diff --git a/framework/runners/field-create.runner.ts b/framework/runners/field-create.runner.ts index 9ed7d55..4cac8dc 100644 --- a/framework/runners/field-create.runner.ts +++ b/framework/runners/field-create.runner.ts @@ -30,13 +30,12 @@ import type { PerfRunContext, PerfRunResult, } from "../types"; -import { PerfRunDiagnosticError } from "../types"; - -type Measurement = { - name: string; - durationMs: number; - result: T; -}; +import { + runFieldAddLifecycle, + seedFieldAddLifecycle, + type FieldAddLifecycleSpec, +} from "./field-add-lifecycle"; +import { type Measurement } from "./record-undo-redo.shared"; type FieldCreateFixture = { tableId: string; @@ -1176,59 +1175,59 @@ const buildSeedCache = (perfCase: PerfCase, config: FieldCreateCaseConfig) => ], }); -export const seedFieldCreateCase = async ( - perfCase: PerfCase, - context: PerfRunContext, -): Promise => { - const config = perfCase.config as FieldCreateCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-seed-${Date.now()}`; - const seedCacheInfo = await buildSeedCache(perfCase, config); - const fixture = await buildFieldCreateFixture( - perfCase, - context, - baseId, - tableName, - config, - seedCacheInfo, - ); - const seedReadyMeasurement = await measureAsync("seedReady", () => - assertSeedReady(fixture, config), - ); - - return buildFieldCreateResult({ - config, - prepareMeasurement: { - name: fixture.seedCacheHit ? "seedRestore" : "seedBuild", - durationMs: 0, - result: fixture, - }, - seedReadyMeasurement, - }); +type FieldCreateLifecycleFixture = FieldCreateFixture & { + // The prepare phase the driver does not emit: in the execute path it is the + // measured "prepareFieldCreate" phase; in the seed path it is a synthetic + // zero-duration "seedBuild"/"seedRestore" marker. Carried on the (mutable) + // fixture so buildResult can rebuild the prepare measurement from the live + // object, after seedReady/backfill have mutated it in place. + prepareName: string; + prepareDurationMs: number; }; -export const runFieldCreateCase = async ( - perfCase: PerfCase, - context: PerfRunContext, -): Promise => { - const config = perfCase.config as FieldCreateCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-${Date.now()}`; - const seedCacheInfo = await buildSeedCache(perfCase, config); - let fixture: FieldCreateFixture | undefined; - let createdFieldIds: string[] = []; - let prepareMeasurement: Measurement | undefined; - let seedReadyMeasurement: - | Measurement>> - | undefined; - let primaryMeasurement: Measurement | undefined; - let readyMeasurement: - | Measurement>> - | undefined; - let verification: FieldCreateVerification | undefined; +type FieldCreateSeedReadyResult = Awaited>; - try { - prepareMeasurement = await measureAsync("prepareFieldCreate", () => +type FieldCreatePrimary = { + primaryMeasurement: Measurement; + readyMeasurement?: Measurement< + Awaited> + >; + verification: FieldCreateVerification; +}; + +// field-create rides the field-add lifecycle as the third member, with the +// widest variation: it seeds an empty (base-fields-only) table, adds N fields in +// one measured trace step (per-field routing), optionally polls until the +// formula columns finish their computed backfill, verifies the created fields +// (and single-select options), then restores the seed by deleting the added +// (non-base) fields. Unlike the prior two members its prepare is a single +// measured phase, so prepareFixture owns that measurement and parks it on the +// fixture; the driver itself is unchanged. +const fieldCreateFieldAddSpec: FieldAddLifecycleSpec< + FieldCreateCaseConfig, + FieldCreateLifecycleFixture, + FieldCreateSeedReadyResult, + FieldCreatePrimary +> = { + prepareFixture: async ({ perfCase, context, baseId, config, seedMode }) => { + const seedCacheInfo = await buildSeedCache(perfCase, config); + if (seedMode) { + const tableName = `${config.tableNamePrefix}-seed-${Date.now()}`; + const fixture = await buildFieldCreateFixture( + perfCase, + context, + baseId, + tableName, + config, + seedCacheInfo, + ); + return Object.assign(fixture, { + prepareName: fixture.seedCacheHit ? "seedRestore" : "seedBuild", + prepareDurationMs: 0, + }); + } + const tableName = `${config.tableNamePrefix}-${Date.now()}`; + const prepareMeasurement = await measureAsync("prepareFieldCreate", () => buildFieldCreateFixture( perfCase, context, @@ -1238,83 +1237,109 @@ export const runFieldCreateCase = async ( seedCacheInfo, ), ); - fixture = prepareMeasurement.result; - seedReadyMeasurement = await measureAsync("seedReady", () => - assertSeedReady(prepareMeasurement.result, config), - ); - const fieldsToCreate = await buildCreateFieldsForTable( - prepareMeasurement.result, - config, - ); - primaryMeasurement = await runFieldCreatePrimary( + return Object.assign(prepareMeasurement.result, { + prepareName: prepareMeasurement.name, + prepareDurationMs: prepareMeasurement.durationMs, + }); + }, + assertSeedReady: ({ fixture, config }) => assertSeedReady(fixture, config), + runPrimary: async ({ perfCase, context, baseId, fixture, config }) => { + const fieldsToCreate = await buildCreateFieldsForTable(fixture, config); + const primaryMeasurement = await runFieldCreatePrimary( perfCase, context, - prepareMeasurement.result, + fixture, config, fieldsToCreate, ); - createdFieldIds = primaryMeasurement.result.fieldIds; + let readyMeasurement: + | Measurement>> + | undefined; if (config.ready) { readyMeasurement = await measureAsync(config.ready.metric, () => waitForComputedFieldsReady( baseId, context, - prepareMeasurement.result, + fixture, config, primaryMeasurement.result, ), ); } - verification = await verifyCreatedFields( - prepareMeasurement.result, + const verification = await verifyCreatedFields( + fixture, config, fieldsToCreate, ); - + return { primaryMeasurement, readyMeasurement, verification }; + }, + buildResult: ({ config, fixture, seedReadyMeasurement, primary, error }) => { + const prepareMeasurement = fixture + ? { + name: fixture.prepareName, + durationMs: fixture.prepareDurationMs, + result: fixture, + } + : undefined; return buildFieldCreateResult({ config, prepareMeasurement, seedReadyMeasurement, - primaryMeasurement, - readyMeasurement, - verification, + primaryMeasurement: primary?.primaryMeasurement, + readyMeasurement: primary?.readyMeasurement, + verification: primary?.verification, + error, }); - } catch (error) { - throw new PerfRunDiagnosticError( - error instanceof Error ? error.message : String(error), - buildFieldCreateResult({ - config, - prepareMeasurement, - seedReadyMeasurement, - primaryMeasurement, - readyMeasurement, - verification, - error, - }), - ); - } finally { - if (fixture?.reusableSeed) { - if (!isExecuteDbIsolated() && createdFieldIds.length > 0) { - try { - for (const fieldId of createdFieldIds) { - await deleteField(fixture.tableId, fieldId); + }, + cleanup: async ({ baseId, fixture, config }) => { + if (isExecuteDbIsolated() || !fixture) { + return; + } + if (fixture.reusableSeed) { + // Restore the reusable seed by deleting the created (non-base) fields — + // the same "base fields only" invariant assertSeedReady enforces before + // the measured create. Re-resolve by name; idempotent, and a no-op when + // the create made nothing. + try { + const baseFieldNames = new Set( + config.baseFields.map((field) => field.name), + ); + const fields = (await getFields(fixture.tableId)) as Array<{ + id: string; + name: string; + }>; + for (const field of fields) { + if (!baseFieldNames.has(field.name)) { + await deleteField(fixture.tableId, field.id); } - } catch (error) { - console.warn( - `Failed to cleanup perf field create fields ${createdFieldIds.join(", ")}`, - error, - ); } - } - } else if (fixture?.tableId && !isExecuteDbIsolated()) { - try { - await permanentDeleteTable(baseId, fixture.tableId); } catch (error) { console.warn( - `Failed to cleanup perf field create table ${fixture.tableId}`, + `Failed to cleanup perf field create fields on ${fixture.tableId}`, error, ); } + return; } - } + try { + await permanentDeleteTable(baseId, fixture.tableId); + } catch (error) { + console.warn( + `Failed to cleanup perf field create table ${fixture.tableId}`, + error, + ); + } + }, }; + +export const seedFieldCreateCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + seedFieldAddLifecycle(perfCase, context, fieldCreateFieldAddSpec); + +export const runFieldCreateCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + runFieldAddLifecycle(perfCase, context, fieldCreateFieldAddSpec); diff --git a/scripts/diff-artifacts.mjs b/scripts/diff-artifacts.mjs index 905d2da..58cdbd5 100644 --- a/scripts/diff-artifacts.mjs +++ b/scripts/diff-artifacts.mjs @@ -280,6 +280,67 @@ const shouldMaskKey = (path, key) => { return true; } + // field-create generated field ids. Each run seeds a fresh table, so every + // created / computed / dependency field gets a new id between two runs of + // unchanged code (confirmed by the field-create baseline A vs B diff). The + // semantic identity stays visible via field names (details.fieldNames, + // createdFields[].name, verifiedFields[].name) and, for formulas, + // details.ready.computedFields[].expectedKind. The ids surface in four shapes: + // + // * details.createdFields[].id, details.ready.computedFields[].id, and + // details.ready.dependencyFields[].id — the field descriptor arrays. + if ( + isArrayIndex(path.at(-1)) && + ["createdFields", "computedFields", "dependencyFields"].includes( + path.at(-2), + ) && + key === "id" + ) { + return true; + } + + // * details.fieldIds — the flat list of generated created-field ids (masked + // whole; the count is redundant with details.fieldNames / createdFields). + if (pathEquals(path, ["details"]) && key === "fieldIds") { + return true; + } + + // * details.verifiedFields[].expression — the compiled formula embeds the + // generated A/B/C field ids; the formula identity stays visible via + // details.ready.computedFields[].expectedKind. + if ( + path.length === 3 && + path[0] === "details" && + path[1] === "verifiedFields" && + isArrayIndex(path[2]) && + key === "expression" + ) { + return true; + } + + // field-create resolves the seeded table's physical name for its computed + // backfill SQL; details.ready.dbTableName embeds the generated table id and + // differs run-to-run on unchanged code (field-create baseline A vs B). + if (pathEquals(path, ["details", "ready"]) && key === "dbTableName") { + return true; + } + + // field-create emits its seed-cache key under details.prepare (seedHash, and + // seedTableName whose suffix is the hash), where the other migrated runners + // nest it under a `cache` object masked above. Same content address: stable + // run-to-run on unchanged code (field-create baseline A vs B), but it moves + // when the runner is refactored — so masking it lets a behavior-preserving + // migration pass the G1 diff, like the cache.seedHash / details.seed.seedHash + // rules. The seed config that also feeds the hash is frozen by the case + // definition (cases/** is not edited in a migration), and the live seed + // identity stays visible via details.tableName / fieldNames / seedRecordCount. + if ( + pathEquals(path, ["details", "prepare"]) && + ["seedHash", "seedTableName"].includes(key) + ) { + return true; + } + return false; }; diff --git a/tasks/runner-migration-tracker.md b/tasks/runner-migration-tracker.md index 40cb0ce..214c0f2 100644 --- a/tasks/runner-migration-tracker.md +++ b/tasks/runner-migration-tracker.md @@ -6,33 +6,34 @@ that uses it, because migrating a runner means re-verifying all of its cases. Status as of 2026-06-20 on `main`. -**Migrated: 21 / 35 runner kinds · 25 / 55 cases.** +**Migrated: 22 / 35 runner kinds · 29 / 55 cases.** ## Migrated (✅ on the driver) -| Runner kind | Driver / where | Cases | Verified | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | --------------------- | -| csv-import | `csv-import-lifecycle.ts` | 3 csv-import cases | ✅ v1+v2 pass (local) | -| field-delete | `field-delete-lifecycle.ts` | field-delete/mixed-10k-delete-19-fields | ✅ v1+v2 pass (local) | -| record-delete | `record-replay-lifecycle.ts` (no setup) | record-delete/delete-1k | ✅ v1+v2 pass (local) | -| record-delete-link | `table-link-lifecycle.ts` (single fixture) | record-delete/link-trash-1k | ✅ v1+v2 pass (local) | -| record-undo | `record-replay-lifecycle.ts` (delete setup) | record-undo/delete-1k | ✅ v1+v2 pass (local) | -| record-redo | `record-replay-lifecycle.ts` (delete+undo setup) | record-redo/delete-1k | ✅ v1+v2 pass (local) | -| table-delete | `table-lifecycle.ts` (sampled archive + restore-back) | table-delete/10k-20f | ✅ v1+v2 pass (local) | -| table-restore | `table-lifecycle.ts` (archive setup + sampled restore) | table-restore/10k-20f | ✅ v1+v2 pass (local) | -| table-delete-link | `table-link-lifecycle.ts` (sampled foreign-table delete) | table-delete/10k-20f-link-detach | ✅ v1+v2 pass (local) | -| table-restore-link | `table-link-lifecycle.ts` (sampled table restore) | table-restore/10k-20f-link-1k | ✅ v1+v2 pass (local) | -| selection-duplicate | `record-duplicate-lifecycle.ts` (stream block duplicate) | record-duplicate/grid-block-duplicate-1k | ✅ v1+v2 pass (local) | -| record-duplicate-single | `record-duplicate-lifecycle.ts` (sequential single dup) | record-duplicate/single-record-sequential-100 | ✅ v1+v2 pass (local) | -| record-update | `record-mutation-lifecycle.ts` (bulk update over seeded rows, record window + restore-or-delete) | record-update/mixed-1k-20fields-bulk-update | ✅ v1+v2 pass (local) | -| record-create | `record-mutation-lifecycle.ts` (bulk insert into empty seed, no window + delete-created-or-drop) | record-create/mixed-1k-20fields-bulk-create | ✅ v1+v2 pass (local) | -| record-reorder | `record-mutation-lifecycle.ts` (block reorder over seeded rows, record window + restore-order-or-drop) | record-reorder/10k-move-last-1k-to-front | ✅ v1+v2 pass (local) | -| field-convert | `field-convert-lifecycle.ts` (convert + readiness, keep-or-delete) | field-convert/10k-multi-select-to-text, field-convert/10k-text-to-formula | ✅ v1+v2 pass (local) | -| field-convert-link | `field-convert-lifecycle.ts` (link convert + readiness, delete host+foreign) | field-convert/10k-link-to-text, field-convert/10k-text-to-link | ✅ v1+v2 pass (local) | -| selection-clear | `record-mutation-lifecycle.ts` (clear-stream over seeded rows, no window, post-op verify, restore-or-delete) | selection-clear/flat-1k-20fields-cell-clear-stream | ✅ v1+v2 pass (local) | -| record-update-link | `record-mutation-lifecycle.ts` (bulk link-cell update over host + linked foreign fixture, no window) | record-update/1k-link-cells-bulk-update | ✅ v1+v2 pass (local) | -| conditional-lookup | `field-add-lifecycle.ts` (add a conditional lookup field over a seeded source + host pair, full-scan readiness, restore-or-delete) | lookup/conditional-10k | ✅ v1+v2 pass (local) | -| field-duplicate | `field-add-lifecycle.ts` (duplicate a lookup field over the conditional-lookup seed, backfill readiness, delete the copy or drop tables) | field-duplicate/conditional-lookup-10k | ✅ v1+v2 pass (local) | +| Runner kind | Driver / where | Cases | Verified | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| csv-import | `csv-import-lifecycle.ts` | 3 csv-import cases | ✅ v1+v2 pass (local) | +| field-delete | `field-delete-lifecycle.ts` | field-delete/mixed-10k-delete-19-fields | ✅ v1+v2 pass (local) | +| record-delete | `record-replay-lifecycle.ts` (no setup) | record-delete/delete-1k | ✅ v1+v2 pass (local) | +| record-delete-link | `table-link-lifecycle.ts` (single fixture) | record-delete/link-trash-1k | ✅ v1+v2 pass (local) | +| record-undo | `record-replay-lifecycle.ts` (delete setup) | record-undo/delete-1k | ✅ v1+v2 pass (local) | +| record-redo | `record-replay-lifecycle.ts` (delete+undo setup) | record-redo/delete-1k | ✅ v1+v2 pass (local) | +| table-delete | `table-lifecycle.ts` (sampled archive + restore-back) | table-delete/10k-20f | ✅ v1+v2 pass (local) | +| table-restore | `table-lifecycle.ts` (archive setup + sampled restore) | table-restore/10k-20f | ✅ v1+v2 pass (local) | +| table-delete-link | `table-link-lifecycle.ts` (sampled foreign-table delete) | table-delete/10k-20f-link-detach | ✅ v1+v2 pass (local) | +| table-restore-link | `table-link-lifecycle.ts` (sampled table restore) | table-restore/10k-20f-link-1k | ✅ v1+v2 pass (local) | +| selection-duplicate | `record-duplicate-lifecycle.ts` (stream block duplicate) | record-duplicate/grid-block-duplicate-1k | ✅ v1+v2 pass (local) | +| record-duplicate-single | `record-duplicate-lifecycle.ts` (sequential single dup) | record-duplicate/single-record-sequential-100 | ✅ v1+v2 pass (local) | +| record-update | `record-mutation-lifecycle.ts` (bulk update over seeded rows, record window + restore-or-delete) | record-update/mixed-1k-20fields-bulk-update | ✅ v1+v2 pass (local) | +| record-create | `record-mutation-lifecycle.ts` (bulk insert into empty seed, no window + delete-created-or-drop) | record-create/mixed-1k-20fields-bulk-create | ✅ v1+v2 pass (local) | +| record-reorder | `record-mutation-lifecycle.ts` (block reorder over seeded rows, record window + restore-order-or-drop) | record-reorder/10k-move-last-1k-to-front | ✅ v1+v2 pass (local) | +| field-convert | `field-convert-lifecycle.ts` (convert + readiness, keep-or-delete) | field-convert/10k-multi-select-to-text, field-convert/10k-text-to-formula | ✅ v1+v2 pass (local) | +| field-convert-link | `field-convert-lifecycle.ts` (link convert + readiness, delete host+foreign) | field-convert/10k-link-to-text, field-convert/10k-text-to-link | ✅ v1+v2 pass (local) | +| selection-clear | `record-mutation-lifecycle.ts` (clear-stream over seeded rows, no window, post-op verify, restore-or-delete) | selection-clear/flat-1k-20fields-cell-clear-stream | ✅ v1+v2 pass (local) | +| record-update-link | `record-mutation-lifecycle.ts` (bulk link-cell update over host + linked foreign fixture, no window) | record-update/1k-link-cells-bulk-update | ✅ v1+v2 pass (local) | +| conditional-lookup | `field-add-lifecycle.ts` (add a conditional lookup field over a seeded source + host pair, full-scan readiness, restore-or-delete) | lookup/conditional-10k | ✅ v1+v2 pass (local) | +| field-duplicate | `field-add-lifecycle.ts` (duplicate a lookup field over the conditional-lookup seed, backfill readiness, delete the copy or drop tables) | field-duplicate/conditional-lookup-10k | ✅ v1+v2 pass (local) | +| field-create | `field-add-lifecycle.ts` (add N fields over a seeded table, per-field routing + computed-backfill readiness, delete created fields or drop table) | field-create/10k-create-5-simple-fields, field-create/10k-create-5-formula-fields, field-create/mixed-10k-create-19-fields, field-create/single-select-1k-options | ✅ v1+v2 pass (local) | ## Not migrated (⬜ legacy `*.runner.ts`) @@ -40,7 +41,6 @@ Status as of 2026-06-20 on `main`. | ------------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | duplicate-base | 3 | duplicate-base/10k-3tables-link-2workflow, duplicate-base/10k-3tables-link-2workflow-stream, export-base/10k-3tables-link-2workflow-stream | | duplicate-table | 2 | duplicate-table/10k-20f, duplicate-table/10k-25f-5formula | -| field-create | 4 | field-create/10k-create-5-simple-fields, field-create/10k-create-5-formula-fields, field-create/mixed-10k-create-19-fields, field-create/single-select-1k-options | | field-update | 1 | field-update/v2-only-10k-select-option-rename-computed-cascade | | form-submit | 1 | form-submit/sequential-200 | | formula-table | 2 | formula/10k-calc, formula/10k-5-concurrent |