diff --git a/framework/runners/conditional-lookup.runner.ts b/framework/runners/conditional-lookup.runner.ts index 12acaee..f6d4969 100644 --- a/framework/runners/conditional-lookup.runner.ts +++ b/framework/runners/conditional-lookup.runner.ts @@ -25,7 +25,12 @@ import type { PerfRunContext, PerfRunResult, } from "../types"; -import { PerfRunDiagnosticError } from "../types"; +import { + runFieldAddLifecycle, + seedFieldAddLifecycle, + type FieldAddLifecycleSpec, +} from "./field-add-lifecycle"; +import { type Measurement } from "./record-undo-redo.shared"; const chunk = (items: T[], size: number) => { const chunks: T[][] = []; @@ -49,12 +54,6 @@ type SeededSampleRecord = { recordId: string; }; -type Measurement = { - name: string; - durationMs: number; - result: T; -}; - export type ConditionalLookupSourceFields = { keyFieldId: string; valueFieldId: string; @@ -1231,104 +1230,52 @@ const buildConditionalLookupCaseResult = ({ }, }); -export const seedConditionalLookupCase = async ( - perfCase: PerfCase, - context: PerfRunContext, -): Promise => { - const config = perfCase.config as ConditionalLookupCaseConfig; - const baseId = globalThis.testConfig.baseId; - const timestamp = Date.now(); - const seedCacheInfo = await buildSeedCacheInfo({ - perfCase, - runner: "conditional-lookup", - fixtureVersion: "conditional-lookup-v1", - seedConfig: getConditionalLookupSeedConfig(config), - seedCodeFiles: [ - new URL(import.meta.url), - new URL("../seed-cache.ts", import.meta.url), - ], - }); - const sourceTableName = seedCacheInfo.enabled - ? buildSeedTableName(seedCacheInfo, "source") - : `${config.sourceTableNamePrefix}-seed-${timestamp}`; - const hostTableName = seedCacheInfo.enabled - ? buildSeedTableName(seedCacheInfo, "host") - : `${config.hostTableNamePrefix}-seed-${timestamp}`; - - assertPermutationConfig(config); - const seedFixture = await buildConditionalLookupSeedFixture( - perfCase, - context, - baseId, - sourceTableName, - hostTableName, - config, - seedCacheInfo, - ); - const seedReadyMeasurement = await measureAsync("seedReady", () => - assertConditionalLookupSeedReady( - seedFixture.sourceTableId, - seedFixture.hostTableId, - seedFixture.sourceFields, - seedFixture.hostFields, - config, - seedFixture.sampleRecords, - ), - ); +type ConditionalLookupSeedReadyResult = Awaited< + ReturnType +>; - return buildConditionalLookupCaseResult({ - config, - sourceTableId: seedFixture.sourceTableId, - sourceTableName: seedFixture.sourceTableName, - hostTableId: seedFixture.hostTableId, - hostTableName: seedFixture.hostTableName, - sourceBatchDurations: seedFixture.sourceBatchDurations, - hostBatchDurations: seedFixture.hostBatchDurations, - sampleRecords: seedFixture.sampleRecords, - createTablesMeasurement: seedFixture.createTablesMeasurement, - seedSourceMeasurement: seedFixture.seedSourceMeasurement, - seedHostMeasurement: seedFixture.seedHostMeasurement, - seedReadyMeasurement, - seedCacheInfo, - seedCacheHit: seedFixture.seedCacheHit, - reusableSeed: seedFixture.reusable, - sourceFields: seedFixture.sourceFields, - hostFields: seedFixture.hostFields, - }); +type ConditionalLookupPrimary = { + createLookupFieldMeasurement: Measurement<{ id: string }>; + fullLookupScanReadyMeasurement: Measurement< + Awaited> + >; + lookupField: { id: string }; }; -export const runConditionalLookupCase = async ( - perfCase: PerfCase, - context: PerfRunContext, -): Promise => { - const config = perfCase.config as ConditionalLookupCaseConfig; - const baseId = globalThis.testConfig.baseId; - const timestamp = Date.now(); - const seedCacheInfo = await buildSeedCacheInfo({ - perfCase, - runner: "conditional-lookup", - fixtureVersion: "conditional-lookup-v1", - seedConfig: getConditionalLookupSeedConfig(config), - seedCodeFiles: [ - new URL(import.meta.url), - new URL("../seed-cache.ts", import.meta.url), - ], - }); - const sourceTableName = seedCacheInfo.enabled - ? buildSeedTableName(seedCacheInfo, "source") - : `${config.sourceTableNamePrefix}-${timestamp}`; - const hostTableName = seedCacheInfo.enabled - ? buildSeedTableName(seedCacheInfo, "host") - : `${config.hostTableNamePrefix}-${timestamp}`; - let sourceTableId = ""; - let hostTableId = ""; - let reusableSeed = false; - let createdLookupFieldId = ""; +// conditional-lookup rides the field-add lifecycle: seed a source + host table +// pair, assert the seed, add one conditional lookup field on the host and wait +// for it to backfill across all rows, then restore the seed by deleting that +// lookup field (or drop both tables when the fixture is not reusable). The +// driver owns the seedReady phase, the diagnostic wrapping, and the cleanup +// invocation; everything below is the conditional-lookup-specific difference. +const conditionalLookupFieldAddSpec: FieldAddLifecycleSpec< + ConditionalLookupCaseConfig, + ConditionalLookupSeedFixture, + ConditionalLookupSeedReadyResult, + ConditionalLookupPrimary +> = { + prepareFixture: async ({ perfCase, context, baseId, config, seedMode }) => { + const timestamp = Date.now(); + const seedCacheInfo = await buildSeedCacheInfo({ + perfCase, + runner: "conditional-lookup", + fixtureVersion: "conditional-lookup-v1", + seedConfig: getConditionalLookupSeedConfig(config), + seedCodeFiles: [ + new URL(import.meta.url), + new URL("../seed-cache.ts", import.meta.url), + ], + }); + const suffix = seedMode ? "-seed-" : "-"; + const sourceTableName = seedCacheInfo.enabled + ? buildSeedTableName(seedCacheInfo, "source") + : `${config.sourceTableNamePrefix}${suffix}${timestamp}`; + const hostTableName = seedCacheInfo.enabled + ? buildSeedTableName(seedCacheInfo, "host") + : `${config.hostTableNamePrefix}${suffix}${timestamp}`; - try { assertPermutationConfig(config); - - const seedFixture = await buildConditionalLookupSeedFixture( + return buildConditionalLookupSeedFixture( perfCase, context, baseId, @@ -1337,163 +1284,128 @@ export const runConditionalLookupCase = async ( config, seedCacheInfo, ); - sourceTableId = seedFixture.sourceTableId; - hostTableId = seedFixture.hostTableId; - reusableSeed = seedFixture.reusable; - const { - sourceTableName: actualSourceTableName, - hostTableName: actualHostTableName, - sourceFields, - hostFields, - sampleRecords, - sourceBatchDurations, - hostBatchDurations, - createTablesMeasurement, - seedSourceMeasurement, - seedHostMeasurement, - seedCacheHit, - } = seedFixture; - let createdLookupField: { id: string } | undefined; - let seedReadyMeasurement: - | Measurement< - Awaited> - > - | undefined; - - try { - seedReadyMeasurement = await measureAsync("seedReady", () => - assertConditionalLookupSeedReady( - sourceTableId, - hostTableId, - sourceFields, - hostFields, - config, - sampleRecords, - ), - ); - - const createLookupFieldMeasurement = await withPerfTraceStep( - context, - perfCase, - "createLookupField", - () => - measureAsync("createLookupField", () => - createField(hostTableId, { - name: config.lookup.name, - type: FieldType.SingleLineText, - isLookup: true, - isConditionalLookup: true, - lookupOptions: { - foreignTableId: sourceTableId, - lookupFieldId: sourceFields.valueFieldId, - filter: { - conjunction: "and", - filterSet: [ - { - fieldId: sourceFields.keyFieldId, - operator: "is", - value: { - type: "field", - fieldId: hostFields.lookupKeyFieldId, - }, - }, - ], - }, - limit: config.lookup.limit, - }, - }), - ), - ); - createdLookupField = createLookupFieldMeasurement.result; - createdLookupFieldId = createdLookupField.id; - - const fullLookupScanReadyMeasurement = await measureAsync( - "fullLookupScanReady", - () => - waitForConditionalLookupFullScan( - hostTableId, - createLookupFieldMeasurement.result.id, + }, + assertSeedReady: ({ fixture, config }) => + assertConditionalLookupSeedReady( + fixture.sourceTableId, + fixture.hostTableId, + fixture.sourceFields, + fixture.hostFields, + config, + fixture.sampleRecords, + ), + runPrimary: async ({ perfCase, context, fixture, config }) => { + const createLookupFieldMeasurement = await withPerfTraceStep( + context, + perfCase, + "createLookupField", + () => + measureAsync("createLookupField", () => + createConditionalLookupField( + fixture.hostTableId, + fixture.sourceTableId, + fixture.sourceFields, + fixture.hostFields, config, - hostFields, ), - ); - - return buildConditionalLookupCaseResult({ - config, - sourceTableId, - sourceTableName: actualSourceTableName, - hostTableId, - hostTableName: actualHostTableName, - sourceBatchDurations, - hostBatchDurations, - sampleRecords, - createTablesMeasurement, - seedSourceMeasurement, - seedHostMeasurement, - seedReadyMeasurement, - createLookupFieldMeasurement, - fullLookupScanReadyMeasurement, - lookupField: createdLookupField, - seedCacheInfo, - seedCacheHit, - reusableSeed, - sourceFields, - hostFields, - }); - } catch (error) { - const diagnosticResult = buildConditionalLookupCaseResult({ - config, - sourceTableId, - sourceTableName: actualSourceTableName, - hostTableId, - hostTableName: actualHostTableName, - sourceBatchDurations, - hostBatchDurations, - sampleRecords, - createTablesMeasurement, - seedSourceMeasurement, - seedHostMeasurement, - seedReadyMeasurement, - lookupField: createdLookupField, - seedCacheInfo, - seedCacheHit, - reusableSeed, - sourceFields, - hostFields, - error, - }); - - throw new PerfRunDiagnosticError( - error instanceof Error ? error.message : String(error), - diagnosticResult, - ); - } - } finally { + ), + ); + const fullLookupScanReadyMeasurement = await measureAsync( + "fullLookupScanReady", + () => + waitForConditionalLookupFullScan( + fixture.hostTableId, + createLookupFieldMeasurement.result.id, + config, + fixture.hostFields, + ), + ); + return { + createLookupFieldMeasurement, + fullLookupScanReadyMeasurement, + lookupField: createLookupFieldMeasurement.result, + }; + }, + buildResult: ({ config, fixture, seedReadyMeasurement, primary, error }) => + buildConditionalLookupCaseResult({ + config, + sourceTableId: fixture?.sourceTableId ?? "", + sourceTableName: fixture?.sourceTableName ?? "", + hostTableId: fixture?.hostTableId ?? "", + hostTableName: fixture?.hostTableName ?? "", + sourceBatchDurations: fixture?.sourceBatchDurations ?? [0], + hostBatchDurations: fixture?.hostBatchDurations ?? [0], + sampleRecords: fixture?.sampleRecords ?? [], + createTablesMeasurement: + fixture?.createTablesMeasurement ?? + createEmptyMeasurement("seedBuildSkipped", undefined), + seedSourceMeasurement: + fixture?.seedSourceMeasurement ?? + createEmptyMeasurement("seedSourceBuildSkipped", undefined), + seedHostMeasurement: + fixture?.seedHostMeasurement ?? + createEmptyMeasurement("seedHostBuildSkipped", undefined), + seedReadyMeasurement, + createLookupFieldMeasurement: primary?.createLookupFieldMeasurement, + fullLookupScanReadyMeasurement: primary?.fullLookupScanReadyMeasurement, + lookupField: primary?.lookupField, + seedCacheInfo: fixture?.seedCacheInfo, + seedCacheHit: fixture?.seedCacheHit, + reusableSeed: fixture?.reusable, + sourceFields: fixture?.sourceFields ?? { + keyFieldId: "", + valueFieldId: "", + }, + hostFields: fixture?.hostFields ?? { + keyFieldId: "", + lookupKeyFieldId: "", + }, + error, + }), + cleanup: async ({ baseId, fixture }) => { // CI execute jobs run on a disposable restored DB copy; cleanup that only - // tidies the durable database is skipped there. - if (isExecuteDbIsolated()) { - // discarded with the database - } else if (reusableSeed) { - if (createdLookupFieldId) { + // tidies the durable database is skipped there. A missing fixture means + // prepare failed before any table existed (it self-cleans on the way out). + if (isExecuteDbIsolated() || !fixture) { + return; + } + if (fixture.reusable) { + // Restore the reusable seed by removing the added lookup field. Re-resolve + // the host fields and drop every non-seed field — idempotent, and a no-op + // when the field-add failed before creating anything. + try { + await cleanupHostLookupFields( + fixture.hostTableId, + await getFields(fixture.hostTableId), + ); + } catch (error) { + console.warn( + `Failed to cleanup perf lookup field on ${fixture.hostTableId}`, + error, + ); + } + return; + } + for (const tableId of [fixture.hostTableId, fixture.sourceTableId]) { + if (tableId) { try { - await deleteField(hostTableId, createdLookupFieldId); + await permanentDeleteTable(baseId, tableId); } catch (error) { - console.warn( - `Failed to cleanup perf lookup field ${createdLookupFieldId}`, - error, - ); - } - } - } else { - for (const tableId of [hostTableId, sourceTableId]) { - if (tableId) { - try { - await permanentDeleteTable(baseId, tableId); - } catch (error) { - console.warn(`Failed to cleanup perf table ${tableId}`, error); - } + console.warn(`Failed to cleanup perf table ${tableId}`, error); } } } - } + }, }; + +export const seedConditionalLookupCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + seedFieldAddLifecycle(perfCase, context, conditionalLookupFieldAddSpec); + +export const runConditionalLookupCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + runFieldAddLifecycle(perfCase, context, conditionalLookupFieldAddSpec); diff --git a/framework/runners/field-add-lifecycle.ts b/framework/runners/field-add-lifecycle.ts new file mode 100644 index 0000000..e78fc60 --- /dev/null +++ b/framework/runners/field-add-lifecycle.ts @@ -0,0 +1,198 @@ +import { measureAsync } from "../metrics"; +import { PerfRunDiagnosticError } from "../types"; +import type { PerfCase, PerfRunContext, PerfRunResult } from "../types"; +import type { Measurement } from "./record-undo-redo.shared"; + +// The lifecycle skeleton shared by the field-add family: seed a populated +// (possibly multi-) table, assert the seed sample state, run one measured +// field-add operation and wait for the new column to backfill, then restore the +// seed by deleting the added field — or drop the fixture table(s). conditional- +// lookup is the first runner kind on it (add a conditional lookup field over a +// seeded source + host pair); field-duplicate and field-create join next, so the +// shared shape is born from one member and proven generic as the family migrates. +// +// The driver owns the repeated protocol: +// prepare(seed) -> seedReady -> measured field-add + readiness -> build result +// (twice: diagnostic catch + success) -> finally restore-or-delete cleanup. +// +// Two deliberate differences from the sibling drivers keep this family honest: +// * Unlike record-mutation-lifecycle, the driver emits NO "prepare" phase. The +// prepare step carries its own create/seed sub-measurements on the fixture, +// and those phase names vary with seed-cache state (seedBuild / createTables +// on a miss, seedRestore on a hit), so the runner owns them. +// * Unlike field-convert-lifecycle, the driver does NOT wrap the primary in a +// single measureAsync(threshold.metric). A field-add runner's primary is +// multi-phase (create the field, then a backfill-readiness scan) feeding a +// computed threshold metric, so runPrimary owns its own trace step(s) and +// measurement(s) and returns the bundle buildResult unpacks. +// +// Cleanup is Class C restore-or-delete: the measured operation only ADDS a field, +// so a reusable seed is restored by deleting that added field while a fresh +// (non-reusable) fixture is dropped. The driver delegates the whole decision to +// the runner's cleanup (which holds the seed-cache + execute-isolation context), +// passing whether the primary was attempted. +// +// Scope note: field-add-family-shaped, not a universal driver. It assumes the +// prepare step carries its own seed measurements (no "prepare" phase), a single +// measured field-add operation against a reusable fixture, and restore-by-delete +// cleanup. A broader abstraction should wait for a family that breaks one of +// those assumptions. + +export type FieldAddLifecyclePrepareArgs = { + perfCase: PerfCase; + context: PerfRunContext; + baseId: string; + config: TConfig; + // True on the seed (prepare-DB) path, false on the measured execute path. Lets + // a runner pick its seed-vs-run table-name suffix; the fixture is otherwise + // opaque to the driver, so it may span more than one table. + seedMode: boolean; +}; + +export type FieldAddLifecycleBuildResultArgs< + TConfig, + TFixture, + TSeedReady, + TPrimary, +> = { + config: TConfig; + fixture?: TFixture; + seedReadyMeasurement?: Measurement; + primary?: TPrimary; + error?: unknown; +}; + +export type FieldAddLifecycleSpec = { + // Build (or restore from the seed cache) the table(s) the field-add runs + // against. Carries its own create/seed measurements on the returned fixture, so + // the driver emits no "prepare" phase. + prepareFixture: ( + args: FieldAddLifecyclePrepareArgs, + ) => Promise; + // Assert the seeded state is in its expected pre-add shape, emitted as the + // `seedReady` phase by the driver. + assertSeedReady: (args: { + baseId: string; + fixture: TFixture; + config: TConfig; + }) => Promise; + // The measured operation: the field-add request(s) (trace-wrapped), routing + // assertion if any, and the backfill-readiness wait(s), each owning its own + // measurement and bundled into the returned primary. The driver does not wrap + // this in a phase — the runner's measurements become the phases and the + // (possibly computed) primary metric in buildResult. + runPrimary: (args: { + perfCase: PerfCase; + context: PerfRunContext; + baseId: string; + fixture: TFixture; + config: TConfig; + }) => Promise; + // Assemble the artifact result. Called once on success and once inside the + // diagnostic-error path (with `error` set and `primary` absent). + buildResult: ( + args: FieldAddLifecycleBuildResultArgs< + TConfig, + TFixture, + TSeedReady, + TPrimary + >, + ) => PerfRunResult; + // Restore the reusable seed by deleting the added field(s), or drop the fixture + // table(s). Runs in `finally`, so it must tolerate an undefined fixture (prepare + // failed). `primaryAttempted` is true once the measured operation began. + cleanup: (args: { + baseId: string; + fixture: TFixture | undefined; + config: TConfig; + primaryAttempted: boolean; + }) => Promise; +}; + +export const seedFieldAddLifecycle = async < + TConfig, + TFixture, + TSeedReady, + TPrimary, +>( + perfCase: PerfCase, + context: PerfRunContext, + spec: FieldAddLifecycleSpec, +): Promise => { + const config = perfCase.config as unknown as TConfig; + const baseId = globalThis.testConfig.baseId; + const fixture = await spec.prepareFixture({ + perfCase, + context, + baseId, + config, + seedMode: true, + }); + const seedReadyMeasurement = await measureAsync("seedReady", () => + spec.assertSeedReady({ baseId, fixture, config }), + ); + + return spec.buildResult({ config, fixture, seedReadyMeasurement }); +}; + +export const runFieldAddLifecycle = async < + TConfig, + TFixture, + TSeedReady, + TPrimary, +>( + perfCase: PerfCase, + context: PerfRunContext, + spec: FieldAddLifecycleSpec, +): Promise => { + const config = perfCase.config as unknown as TConfig; + const baseId = globalThis.testConfig.baseId; + let fixture: TFixture | undefined; + let primaryAttempted = false; + + try { + fixture = await spec.prepareFixture({ + perfCase, + context, + baseId, + config, + seedMode: false, + }); + let seedReadyMeasurement: Measurement | undefined; + let primary: TPrimary | undefined; + + try { + seedReadyMeasurement = await measureAsync("seedReady", () => + spec.assertSeedReady({ baseId, fixture: fixture as TFixture, config }), + ); + primaryAttempted = true; + primary = await spec.runPrimary({ + perfCase, + context, + baseId, + fixture: fixture as TFixture, + config, + }); + } catch (error) { + throw new PerfRunDiagnosticError( + error instanceof Error ? error.message : String(error), + spec.buildResult({ + config, + fixture, + seedReadyMeasurement, + primary, + error, + }), + ); + } + + return spec.buildResult({ + config, + fixture, + seedReadyMeasurement, + primary, + }); + } finally { + await spec.cleanup({ baseId, fixture, config, primaryAttempted }); + } +}; diff --git a/scripts/diff-artifacts.mjs b/scripts/diff-artifacts.mjs index a209c40..905d2da 100644 --- a/scripts/diff-artifacts.mjs +++ b/scripts/diff-artifacts.mjs @@ -85,9 +85,36 @@ const GENERATED_ID_KEYS = new Set([ // A vs B diff). The semantic source identity stays visible via // details.convert.sourceFieldName. "sourceFieldId", + // Generated table / seed-field / record ids in the conditional-lookup family + // details (conditional-lookup, and the field-duplicate seed that reuses it). + // Each run seeds a fresh source + host table pair, so the table ids + // (sourceTableId/hostTableId), the seed field ids in sourceFields/hostFields + // (keyFieldId/valueFieldId/lookupKeyFieldId), and the per-sample host record + // ids differ between two runs of unchanged code (confirmed by the + // conditional-lookup baseline A vs B diff). The semantic lookup proof stays + // visible — verifiedSamples[].rowNumber / sourceRowNumber / actual / expected, + // lookup.name / limit, recordCount, batchSize, and seedHash — only the opaque + // id strings are masked, like the existing recordId / sourceRecordId. + "hostTableId", + "sourceTableId", + "keyFieldId", + "valueFieldId", + "lookupKeyFieldId", + "hostRecordId", ]); -const GENERATED_NAME_KEYS = new Set(["foreignTableName", "tableName"]); +const GENERATED_NAME_KEYS = new Set([ + "foreignTableName", + "tableName", + // Generated source/host table names in the conditional-lookup family details + // (details.sourceTableName/hostTableName and details.seed.*TableName). Locally + // each name carries a Date.now() suffix; in CI it is a content hash — both + // differ run-to-run on unchanged code (confirmed by the conditional-lookup + // baseline A vs B diff). The semantic seed identity stays visible via + // details.seed.seedHash / seedNamePrefix. + "sourceTableName", + "hostTableName", +]); const shouldMaskKey = (path, key) => { if ( @@ -197,6 +224,24 @@ const shouldMaskKey = (path, key) => { return true; } + // conditional-lookup emits its seed-cache key directly under details.seed + // (seedHash / seedHashShort), where every other migrated runner nests it under + // a `cache` object masked by the rule above. Like that key, this is a content + // address that digests both the seed config AND the seed code files: it is + // stable run-to-run on unchanged code (the conditional-lookup baseline A vs B + // artifacts are identical), but it legitimately changes when the runner is + // refactored — so masking it lets a behavior-preserving migration pass the G1 + // diff, exactly as the cache.seedHash rule already does for the other migrated + // runners. The semantic seed identity stays visible via + // details.seed.seedNamePrefix / schemaSignature, details.recordCount / + // batchSize, and the verifiedSamples expected values. + if ( + pathEquals(path, ["details", "seed"]) && + ["seedHash", "seedHashShort"].includes(key) + ) { + return true; + } + if (pathEquals(path, ["details", "seed"]) && key === "maxSeedBatchMs") { return true; } diff --git a/tasks/runner-migration-tracker.md b/tasks/runner-migration-tracker.md index 890af50..cea45ac 100644 --- a/tasks/runner-migration-tracker.md +++ b/tasks/runner-migration-tracker.md @@ -4,39 +4,39 @@ Tracks which runner kinds have been moved onto lifecycle drivers and which are still legacy. Migration is **per runner kind**; a kind's row covers every case that uses it, because migrating a runner means re-verifying all of its cases. -Status as of 2026-06-19 on `main`. +Status as of 2026-06-20 on `main`. -**Migrated: 19 / 35 runner kinds · 23 / 55 cases.** +**Migrated: 20 / 35 runner kinds · 24 / 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) | +| 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) | ## Not migrated (⬜ legacy `*.runner.ts`) | Runner kind | # | Cases | | ------------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| conditional-lookup | 1 | lookup/conditional-10k | | 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 |