diff --git a/.changeset/ready-laws-pull.md b/.changeset/ready-laws-pull.md new file mode 100644 index 00000000..e6ca1877 --- /dev/null +++ b/.changeset/ready-laws-pull.md @@ -0,0 +1,5 @@ +--- +"@webiny/data-transfer": patch +--- + +Fix `addLiveField` in the OS lane and add `fix-live` reconciler command. `OsProcessor.querySourceRecord` now returns decompressed rows, so the published revision's `version` is readable — entries with a draft on top of a published revision correctly get `live: { version }` instead of `live: {}`. Add the `fix-live` command to reconcile already-migrated systems: scans DynamoDB and OpenSearch companion tables, reports changes in JSONL, writes only via conditional `UpdateItem`. Add a command menu (`yarn transfer` with no args), `@clack/prompts`-backed `Prompts`/`UI` abstraction, and `Command` registry. Remove `@inquirer/prompts` — all prompts now go through the abstraction. Update dependencies. diff --git a/.gitignore b/.gitignore index 6aa4e5f5..e3435842 100644 --- a/.gitignore +++ b/.gitignore @@ -59,5 +59,3 @@ __tests__/fixtures/full-table-migrated.json __tests__/fixtures/es-table-migrated.json __tests__/fixtures/os-table-migrated.json .codegraph -.mcp.json -!templates/.mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..d8b4d8cf --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "stdlib": { + "command": "npx", + "args": ["-y", "@webiny/stdlib", "serve"] + }, + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": ["serve", "--mcp"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index a49ec3c1..a4d83620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Use the **codegraph MCP** as the first tool for browsing code. `codegraph_explor **Runtime flow (when deployed):** 1. User writes a single `config.ts`: `createConfig({ source, target, pipeline })`. One file covers DDB, S3, and optional OpenSearch. **User-side custom DI:** `register` callback in `createConfig()` is the primary path (runs before preset loading). `setup.ts` next to the config file is the alternative for larger setups. Both are optional. -2. CLI `transfer` command (no `--config`): the `TransferWizard` selects a project, writes `.env`, then on subsequent runs prompts for a preset and returns `WizardResult { configPath, preset }`. With `--config`: skips wizard, preset passed as `--preset` flag. +2. CLI: `yarn transfer` with no arguments opens a menu over the `Command` registry (`src/commands/registry/`) — entries: `transfer`, `fix-live`. `yarn transfer transfer` (or the legacy `yarn transfer --config … --preset …`) runs the system-to-system transfer: without `--config` the `TransferWizard` selects a project, writes `.env`, then on subsequent runs prompts for a preset and returns `WizardResult { configPath, preset, dryRun }`. `yarn transfer ` still scaffolds (`init`). Prompts go through the `Prompts` / `UI` abstractions (`src/commands/prompts/`, `@clack/prompts`); commands never import a prompt library. Cancel exits 130. 3. Bootstrap loads the config, registers all features (DDB + S3 always; OS conditional on `config.target.opensearch != null`), loads the named preset, spawns worker processes per segment. 4. Each worker runs one or more shards: scans source → for each record, first-match-wins pipeline runs: filters → transformers → each processor's `onEnd?` hook (sequential, array order) → commands accumulate in a pending buffer. Every `tuning.flushEvery` records (default 500) each processor's `execute()` drains its own keys from that buffer (sequential, array order) and the buffer resets — this bounds peak memory to `flushEvery × avg_record_size`. A final flush at shard end drains any remainder. `Commands.unclaimedKeys()` surfaces commands no processor claimed. @@ -29,6 +29,7 @@ Use the **codegraph MCP** as the first tool for browsing code. `codegraph_explor - `docs/design/generic-pipeline-framework.md` — long-term design (pipeline-centric model, merge groups keyed by scanner, first-match-wins). - `docs/superpowers/specs/2026-04-18-*.md` — recent design docs (transformer-library, preset-migration). +- `docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md` — `fix-live` reconciler + CLI command menu (what v6 actually maintains for `live`, certainty rules, UpdateItem-only writes). --- @@ -44,7 +45,7 @@ Everything users import lives in `src/index.ts`: config builder (`createConfig`) ## 3. Project structure -Source lives in `src/` with `cli.ts` entry point, `bootstrap.ts` DI setup, `index.ts` public API. Domain logic is in `src/features/` (one dir per feature), pipeline abstractions in `src/domain/pipeline/`, transform primitives in `src/domain/transform/`, ~30 built-in transformers in `src/transformers/`, and 5 built-in presets in `src/presets/`. Build scripts live in `scripts/features/BuildPackages/` (DI-based, mirrors `@webiny/stdlib`). Build tsconfigs in `config/`. Changeset config in `.changeset/`. CI/CD workflows in `.github/workflows/`. +Source lives in `src/` with `cli.ts` entry point, `bootstrap.ts` DI setup, `index.ts` public API. CLI commands live in `src/commands/` as implementations of the `Command` token (`src/commands/registry/`); the entry `src/cli.ts` registers `registry.list()` with yargs plus a `$0 [folder]` default that preserves the two historical no-command invocations. Domain logic is in `src/features/` (one dir per feature), pipeline abstractions in `src/domain/pipeline/`, transform primitives in `src/domain/transform/`, ~30 built-in transformers in `src/transformers/`, and 5 built-in presets in `src/presets/`. Build scripts live in `scripts/features/BuildPackages/` (DI-based, mirrors `@webiny/stdlib`). Build tsconfigs in `config/`. Changeset config in `.changeset/`. CI/CD workflows in `.github/workflows/`. > Full reference: [Project structure](docs/project-structure.md) @@ -105,10 +106,13 @@ These docs also ship in the published npm package and are referenced from the sc ### Open work +0. **Fix live field + CLI command menu** — **implemented.** Transformer fix, reconciler, DDB/OS runners, clack-based command menu, `fix-live` guided command, all landed and tested. Spec: `docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md`. 1. **First npm publish** — infrastructure is in place (changesets, CI, publish workflow, build scripts). Needs: `NPM_TOKEN` secret in GitHub, first `yarn changeset` to create a version bump, merge to main. 2. **Init scaffolding smoke** — `init` scaffolds from `templates/`. Scaffold output: `config.ts`, `presets/example.ts`, optional `setup.ts`. Do a smoke run to verify a scaffolded project compiles + runs against a live sandbox. 3. **End-to-end AWS smoke** — no test has ever run against real AWS. Day-long sandbox exercise. Catches real issues mocks can't. 4. **Public API audit pass (post-refactor)** — `src/index.ts` grew organically. Re-audit before publish to confirm the surface matches user-authoring intent. `DdbCoreTransformContext` (= Base ∧ DdbProcessorSlice) was added as the narrower alternative to `DdbTransformContext`. +5. **Inquirer removal** — `TransferWizard`, `init` and `initProject` still use `@inquirer/prompts`; migrate them to `Prompts` / `UI` and drop `@inquirer/*` from `package.json`. +6. **`fix-live` OS propagation** — confirm v6's DynamoDB stream handler treats a `data`-only change on the OS companion table as an index update (spec 2026-09-04, open question 1). --- diff --git a/__tests__/commands/dispatchDefault.test.ts b/__tests__/commands/dispatchDefault.test.ts new file mode 100644 index 00000000..adb1c558 --- /dev/null +++ b/__tests__/commands/dispatchDefault.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Command } from "~/commands/registry/abstractions/Command.js"; +import type { CommandRegistry } from "~/commands/registry/abstractions/CommandRegistry.js"; +import { dispatchDefault } from "~/commands/dispatchDefault.js"; + +function fakeRegistry(runs: Record>): CommandRegistry.Interface { + const commands = Object.entries(runs).map( + ([name, run]) => ({ name, description: name, configure: y => y, run }) as Command.Interface + ); + return { + list: () => commands, + menu: () => commands, + get: (name: string) => commands.find(c => c.name === name)! + }; +} + +describe("dispatchDefault", () => { + it("`yarn transfer ` runs init with the folder as project-name", async () => { + const init = vi.fn(async () => 0); + const openMenu = vi.fn(async () => 130); + const code = await dispatchDefault({ + argv: { folder: "my-folder" }, + registry: fakeRegistry({ init, transfer: vi.fn() }), + openMenu + }); + expect(code).toBe(0); + expect(init).toHaveBeenCalledWith({ + folder: "my-folder", + "project-name": "my-folder" + }); + expect(openMenu).not.toHaveBeenCalled(); + }); + + it("`yarn transfer --config --preset` runs the transfer command", async () => { + const transfer = vi.fn(async () => 0); + const argv = { config: "./c.ts", preset: "copy-ddb" }; + const code = await dispatchDefault({ + argv, + registry: fakeRegistry({ init: vi.fn(), transfer }), + openMenu: vi.fn(async () => 130) + }); + expect(code).toBe(0); + expect(transfer).toHaveBeenCalledWith(argv); + }); + + it("`--config` alone still routes to transfer (wizard prompts for the rest)", async () => { + const transfer = vi.fn(async () => 0); + await dispatchDefault({ + argv: { config: "./c.ts" }, + registry: fakeRegistry({ init: vi.fn(), transfer }), + openMenu: vi.fn(async () => 130) + }); + expect(transfer).toHaveBeenCalledOnce(); + }); + + it("no arguments opens the menu and returns its exit code", async () => { + const openMenu = vi.fn(async () => 130); + const code = await dispatchDefault({ + argv: {}, + registry: fakeRegistry({ init: vi.fn(), transfer: vi.fn() }), + openMenu + }); + expect(code).toBe(130); + expect(openMenu).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/commands/fixLive/FixLiveCommand.test.ts b/__tests__/commands/fixLive/FixLiveCommand.test.ts new file mode 100644 index 00000000..5d4c0e5f --- /dev/null +++ b/__tests__/commands/fixLive/FixLiveCommand.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/commands/transfer/wizard/projectDiscovery.ts", () => ({ + discoverProjects: vi.fn(async () => ["acme"]) +})); +vi.mock("~/commands/transfer/wizard/configDiscovery.ts", () => ({ + discoverConfig: vi.fn(async () => "/w/projects/acme/config.ts") +})); + +const CREDS = { accessKeyId: "a", secretAccessKey: "b" }; +const CONFIG = { + source: { + region: "eu-central-1", + credentials: CREDS, + dynamodb: { tableName: "acme-src-ddb" }, + s3: { bucket: "acme-src-s3" } + }, + target: { + region: "us-east-1", + credentials: CREDS, + accountId: "123456789012", + dynamodb: { tableName: "acme-prod-ddb" }, + s3: { bucket: "acme-prod-s3" }, + opensearch: { + endpoint: "https://os.example.com", + tableName: "acme-prod-os", + service: "opensearch" as const, + indexPrefix: "" + } + }, + pipeline: { segments: 4 } +}; + +vi.mock("~/features/MigrationConfig/loadConfig.ts", () => ({ + loadConfig: vi.fn(async () => CONFIG) +})); + +const mockResolve = vi.fn(); +const mockRegisterInstance = vi.fn(); + +vi.mock("~/bootstrap.ts", () => ({ + bootstrap: vi.fn(() => ({ + resolve: mockResolve, + registerInstance: mockRegisterInstance + })) +})); + +import { FixLiveCommand } from "~/commands/fixLive/FixLiveCommand.js"; +import { StubPrompts } from "../prompts/StubPrompts.ts"; +import { StubUI } from "../prompts/StubUI.ts"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { MockChangeReport } from "../../features/FixLive/MockChangeReport.ts"; +import { + SourceDynamoDbClient, + TargetDynamoDbClient +} from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { ChangeReport, DdbLiveFieldRunner, FixLiveState } from "~/features/FixLive/index.js"; +import type { LiveFieldRunner } from "~/features/FixLive/abstractions/LiveFieldRunner.js"; +import { createEmptyStats } from "~/features/FixLive/createEmptyStats.js"; + +const v6Row = { + PK: "T#root#L#en-US#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + _et: "CmsEntries", + _ct: "x", + _md: "x", + data: { modelId: "article", version: 1, status: "draft" } +}; + +const fakeRunner: LiveFieldRunner.Interface = { + async run(options) { + const stats = createEmptyStats(); + stats.scanned = 100; + stats.entries = 10; + stats.changes["missing-live"] = 5; + options.onProgress(stats); + return stats; + } +}; + +const fakeState = { + read: vi.fn(() => null), + pathFor: vi.fn(() => ".transfer/state/fix-live/acme__target.json"), + recordDryRun: vi.fn(), + recordLiveRun: vi.fn() +}; + +beforeEach(() => { + vi.clearAllMocks(); + const targetClient = new MockDynamoDbClient({ + "acme-prod-ddb": [v6Row] as never + }); + const sourceClient = new MockDynamoDbClient({ + "acme-src-ddb": [v6Row] as never + }); + mockResolve.mockImplementation((token: unknown) => { + if (token === TargetDynamoDbClient) { + return targetClient; + } + if (token === SourceDynamoDbClient) { + return sourceClient; + } + if (token === ChangeReport) { + return new MockChangeReport(); + } + if (token === FixLiveState) { + return fakeState; + } + if (token === DdbLiveFieldRunner) { + return fakeRunner; + } + return {}; + }); +}); + +function command( + prompts: StubPrompts, + ui = new StubUI() +): { + cmd: InstanceType; + ui: StubUI; +} { + const cmd = new FixLiveCommand(prompts, ui); + return { cmd, ui }; +} + +describe("FixLiveCommand", () => { + it("cancel at project select → 130", async () => { + const { cmd } = command(new StubPrompts()); + expect(await cmd.run({})).toBe(130); + }); + + it("cancel at system select → 130", async () => { + const { cmd } = command(new StubPrompts({ select: ["acme"] })); + expect(await cmd.run({})).toBe(130); + }); + + it("cancel at system confirm → 130", async () => { + const { cmd } = command(new StubPrompts({ select: ["acme", "target"] })); + expect(await cmd.run({})).toBe(130); + }); + + it("cancel at mode select → 130", async () => { + const { cmd } = command(new StubPrompts({ select: ["acme", "target"], confirm: [true] })); + expect(await cmd.run({})).toBe(130); + }); + + it("--live without a dry run → 1", async () => { + const ui = new StubUI(); + const { cmd } = command(new StubPrompts(), ui); + const code = await cmd.run({ + project: "acme", + system: "target", + live: true, + yes: true + }); + expect(code).toBe(1); + expect(ui.errors[0]).toMatch(/Run a dry run first/); + }); + + it("--yes --dry-run runs, records state, exits 0", async () => { + const prompts = new StubPrompts(); + const ui = new StubUI(); + const { cmd } = command(prompts, ui); + const code = await cmd.run({ + project: "acme", + system: "target", + "dry-run": true, + yes: true, + table: "ddb" + }); + expect(code).toBe(0); + expect(prompts.selectCalls).toHaveLength(0); + expect(prompts.confirmCalls).toHaveLength(0); + expect(fakeState.recordDryRun).toHaveBeenCalledWith( + { project: "acme", system: "target" }, + expect.objectContaining({ changes: 5, skips: 0 }) + ); + expect(ui.outros).toEqual(["Done."]); + }); + + it("--table=os on a system without OpenSearch → 1", async () => { + const ui = new StubUI(); + const { cmd } = command(new StubPrompts(), ui); + const code = await cmd.run({ + project: "acme", + system: "source", + "dry-run": true, + yes: true, + table: "os" + }); + expect(code).toBe(1); + expect(ui.errors[0]).toMatch(/no OpenSearch table/); + }); + + it("--live --yes with state records lastLiveRun", async () => { + fakeState.read.mockReturnValue({ + lastDryRun: { + runId: "0", + at: "2026-09-04T09:12:00.000Z", + changes: 5, + skips: 0 + } + } as never); + const { cmd } = command(new StubPrompts()); + const code = await cmd.run({ + project: "acme", + system: "target", + live: true, + yes: true, + table: "ddb" + }); + expect(code).toBe(0); + expect(fakeState.recordLiveRun).toHaveBeenCalledWith( + { project: "acme", system: "target" }, + expect.objectContaining({ written: 0, conditionFailed: 0 }) + ); + }); +}); diff --git a/__tests__/commands/fixLive/steps/confirmSystem.test.ts b/__tests__/commands/fixLive/steps/confirmSystem.test.ts new file mode 100644 index 00000000..4756e4f8 --- /dev/null +++ b/__tests__/commands/fixLive/steps/confirmSystem.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { confirmSystem, formatSystemSummary } from "~/commands/fixLive/steps/confirmSystem.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; +import { StubUI } from "../../prompts/StubUI.ts"; +import { CONFIG } from "./selectSystem.test.ts"; + +describe("confirmSystem", () => { + it("summary shows endpoint only for target and account id or unknown", () => { + const target = formatSystemSummary("target", CONFIG.target); + expect(target).toContain("os endpoint: https://os.example.com"); + expect(target).toContain("account id: 123456789012"); + const source = formatSystemSummary("source", CONFIG.source); + expect(source).not.toContain("os endpoint"); + expect(source).toContain("os table: none"); + expect(source).toContain("account id: unknown"); + }); + + it("--yes skips the confirm but still prints the note", async () => { + const ui = new StubUI(); + const prompts = new StubPrompts(); + const result = await confirmSystem({ + prompts, + ui, + system: "target", + config: CONFIG.target, + yes: true + }); + expect(result).toEqual({ kind: "ok", value: true }); + expect(ui.notes[0]!.title).toBe("System summary"); + expect(prompts.confirmCalls).toHaveLength(0); + }); + + it("confirm defaults to no; yes → ok, no or cancel → cancelled", async () => { + const yes = new StubPrompts({ confirm: [true] }); + expect( + await confirmSystem({ + prompts: yes, + ui: new StubUI(), + system: "target", + config: CONFIG.target, + yes: false + }) + ).toEqual({ kind: "ok", value: true }); + expect(yes.confirmCalls[0]!.initialValue).toBe(false); + expect(yes.confirmCalls[0]!.message).toBe( + "This is the system whose records will be modified. Continue?" + ); + const no = new StubPrompts({ confirm: [false] }); + expect( + await confirmSystem({ + prompts: no, + ui: new StubUI(), + system: "target", + config: CONFIG.target, + yes: false + }) + ).toEqual({ kind: "cancelled" }); + expect( + await confirmSystem({ + prompts: new StubPrompts(), + ui: new StubUI(), + system: "target", + config: CONFIG.target, + yes: false + }) + ).toEqual({ kind: "cancelled" }); + }); +}); diff --git a/__tests__/commands/fixLive/steps/guardV6.test.ts b/__tests__/commands/fixLive/steps/guardV6.test.ts new file mode 100644 index 00000000..70e296c9 --- /dev/null +++ b/__tests__/commands/fixLive/steps/guardV6.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { guardV6 } from "~/commands/fixLive/steps/guardV6.js"; +import { MockDynamoDbClient } from "../../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { StubUI } from "../../prompts/StubUI.ts"; + +const base = { + _et: "CmsEntries", + _ct: "2026-01-01T00:00:00.000Z", + _md: "2026-01-01T00:00:00.000Z" +}; + +const v6Entry = { + ...base, + PK: "T#root#L#en-US#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { modelId: "article", version: 1, status: "draft" } +}; +const v5Entry = { + ...base, + PK: "T#root#L#en-US#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + modelId: "article", + version: 1, + status: "draft" +}; +const fmFile = { + ...base, + PK: "T#root#L#en-US#CMS#CME#file1", + SK: "L", + TYPE: "cms.entry.l", + data: { modelId: "fmFile", version: 1 } +}; +const settings = { ...base, PK: "T#root#SETTINGS", SK: "A", TYPE: "settings" }; + +const run = (rows: object[]) => + guardV6({ + client: new MockDynamoDbClient({ t: rows as never }), + tableName: "t", + region: "eu-central-1", + ui: new StubUI() + }); + +describe("guardV6", () => { + it("passes on a v6 CMS entry (data object at the root)", async () => { + expect(await run([settings, fmFile, v6Entry])).toEqual({ + kind: "ok", + value: "v6" + }); + }); + + it("refuses a v5 table with the table name and region", async () => { + const result = await run([settings, v5Entry]); + expect(result.kind).toBe("refused"); + expect((result as { message: string }).message).toBe( + 'Table "t" in eu-central-1 holds v5 records. fix-live only runs against migrated v6 systems.' + ); + }); + + it("refuses when no CMS entry is found (internal models do not count)", async () => { + const result = await run([settings, fmFile]); + expect(result.kind).toBe("refused"); + expect((result as { message: string }).message).toBe( + "Could not find a CMS entry record to verify the schema version." + ); + }); + + it("reports the spinner lifecycle", async () => { + const ui = new StubUI(); + await guardV6({ + client: new MockDynamoDbClient({ t: [v6Entry] as never }), + tableName: "t", + region: "r", + ui + }); + expect(ui.spinnerMessages[0]).toBe("Checking schema version…"); + expect(ui.spinnerMessages.at(-1)).toBe("Schema version: v6"); + }); +}); diff --git a/__tests__/commands/fixLive/steps/runTable.test.ts b/__tests__/commands/fixLive/steps/runTable.test.ts new file mode 100644 index 00000000..7eddc36a --- /dev/null +++ b/__tests__/commands/fixLive/steps/runTable.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import type { LiveFieldRunner, ChangeReport } from "~/features/FixLive/index.js"; +import { runTable } from "~/commands/fixLive/steps/runTable.js"; +import { StubUI } from "../../prompts/StubUI.ts"; +import { MockDynamoDbClient } from "../../../services/DynamoDbClient/MockDynamoDbClient.ts"; + +export const STATS: LiveFieldRunner.Stats = { + scanned: 148203, + entries: 31440, + changes: { + "missing-live": 1902, + "empty-live": 201, + "wrong-version": 9, + "stale-live": 6 + }, + skips: { + "no-latest-record": 0, + "invalid-version": 1, + "revision-record-missing": 0, + "revision-version-mismatch": 3, + "latest-status-contradicts-published": 0, + "latest-status-contradicts-unpublished": 0, + "decompress-failed": 0, + "changed-during-run": 0 + }, + written: 0, + conditionFailed: 0 +}; + +export const fakeRunner = (stats: LiveFieldRunner.Stats): LiveFieldRunner.Interface => ({ + async run(options) { + options.onProgress({ ...stats, scanned: 10, entries: 2 }); + options.onProgress(stats); + return stats; + } +}); + +const report = {} as ChangeReport.Interface; +const client = new MockDynamoDbClient(); +const target: LiveFieldRunner.Target = { + client, + tableName: "acme-prod-ddb", + segments: 4 +}; + +describe("runTable", () => { + it("drives the spinner with live counters and returns the stats", async () => { + const ui = new StubUI(); + const result = await runTable({ + table: "ddb", + tableName: "acme-prod-ddb", + region: "eu-central-1", + runner: fakeRunner(STATS), + target, + mode: "dry-run", + report, + ui + }); + expect(result).toEqual({ + table: "ddb", + tableName: "acme-prod-ddb", + region: "eu-central-1", + stats: STATS + }); + expect(ui.spinnerMessages[0]).toBe("Scanning DynamoDB…"); + expect(ui.spinnerMessages).toContain("Scanning DynamoDB… 10 rows / 2 entries"); + expect(ui.spinnerMessages.at(-1)).toBe("DynamoDB scanned: 148 203 rows / 31 440 entries"); + }); + + it("labels the OpenSearch table", async () => { + const ui = new StubUI(); + await runTable({ + table: "os", + tableName: "t", + region: "r", + runner: fakeRunner(STATS), + target: { client, tableName: "t", segments: 1 }, + mode: "live", + report, + ui + }); + expect(ui.spinnerMessages[0]).toBe("Scanning OpenSearch…"); + }); +}); diff --git a/__tests__/commands/fixLive/steps/selectMode.test.ts b/__tests__/commands/fixLive/steps/selectMode.test.ts new file mode 100644 index 00000000..08d5ee85 --- /dev/null +++ b/__tests__/commands/fixLive/steps/selectMode.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { selectMode, NO_DRY_RUN_MESSAGE } from "~/commands/fixLive/steps/selectMode.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; + +const withDryRun = { + lastDryRun: { + runId: "1", + at: "2026-09-04T09:12:00.000Z", + changes: 2118, + skips: 4 + } +}; + +describe("selectMode", () => { + it("--dry-run needs no state", async () => { + expect( + await selectMode({ + prompts: new StubPrompts(), + state: null, + modeArg: "dry-run", + yes: false + }) + ).toEqual({ kind: "ok", value: "dry-run" }); + }); + + it("--live without a dry run is refused with the shared message", async () => { + expect( + await selectMode({ + prompts: new StubPrompts(), + state: null, + modeArg: "live", + yes: false + }) + ).toEqual({ kind: "refused", message: NO_DRY_RUN_MESSAGE }); + }); + + it("--live --yes skips the proceed confirm", async () => { + const prompts = new StubPrompts(); + expect( + await selectMode({ + prompts, + state: withDryRun, + modeArg: "live", + yes: true + }) + ).toEqual({ kind: "ok", value: "live" }); + expect(prompts.confirmCalls).toHaveLength(0); + }); + + it("menu disables live with a hint when there is no state", async () => { + const prompts = new StubPrompts({ select: ["dry-run"] }); + await selectMode({ prompts, state: null, yes: false }); + const live = prompts.selectCalls[0]!.options[1]!; + expect(live.disabled).toBe(true); + expect(live.hint).toBe("run a dry run first"); + expect(prompts.selectCalls[0]!.initialValue).toBe("dry-run"); + }); + + it("live from the menu asks to proceed with the last dry run summary", async () => { + const prompts = new StubPrompts({ select: ["live"], confirm: [true] }); + expect(await selectMode({ prompts, state: withDryRun, yes: false })).toEqual({ + kind: "ok", + value: "live" + }); + expect(prompts.confirmCalls[0]!.message).toMatch( + /^Last dry run: 2 118 changes, 2026-09-04 09:12\. Proceed\?$/ + ); + expect(prompts.confirmCalls[0]!.initialValue).toBe(false); + }); + + it("cancel or decline → cancelled", async () => { + expect( + await selectMode({ prompts: new StubPrompts(), state: withDryRun, yes: false }) + ).toEqual({ kind: "cancelled" }); + expect( + await selectMode({ + prompts: new StubPrompts({ select: ["live"], confirm: [false] }), + state: withDryRun, + yes: false + }) + ).toEqual({ kind: "cancelled" }); + }); +}); diff --git a/__tests__/commands/fixLive/steps/selectProject.test.ts b/__tests__/commands/fixLive/steps/selectProject.test.ts new file mode 100644 index 00000000..d567cdca --- /dev/null +++ b/__tests__/commands/fixLive/steps/selectProject.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("~/commands/transfer/wizard/projectDiscovery.ts", () => ({ + discoverProjects: vi.fn(async () => ["acme", "beta"]) +})); + +import { selectProject } from "~/commands/fixLive/steps/selectProject.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; + +describe("selectProject", () => { + it("uses --project when it exists", async () => { + const prompts = new StubPrompts(); + const result = await selectProject({ prompts, cwd: "/w", projectArg: "beta" }); + expect(result).toEqual({ kind: "ok", value: "beta" }); + expect(prompts.selectCalls).toHaveLength(0); + }); + + it("refuses an unknown --project", async () => { + const result = await selectProject({ + prompts: new StubPrompts(), + cwd: "/w", + projectArg: "x" + }); + expect(result.kind).toBe("refused"); + expect((result as { message: string }).message).toMatch( + /Project "x" not found.*acme, beta/ + ); + }); + + it("prompts and returns the choice", async () => { + const prompts = new StubPrompts({ select: ["acme"] }); + expect(await selectProject({ prompts, cwd: "/w" })).toEqual({ + kind: "ok", + value: "acme" + }); + expect(prompts.selectCalls[0]!.message).toBe("Select a project"); + }); + + it("cancel → cancelled", async () => { + expect(await selectProject({ prompts: new StubPrompts(), cwd: "/w" })).toEqual({ + kind: "cancelled" + }); + }); +}); diff --git a/__tests__/commands/fixLive/steps/selectSystem.test.ts b/__tests__/commands/fixLive/steps/selectSystem.test.ts new file mode 100644 index 00000000..6df0b1dc --- /dev/null +++ b/__tests__/commands/fixLive/steps/selectSystem.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; +import { selectSystem, formatSystemHint } from "~/commands/fixLive/steps/selectSystem.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; + +const CREDS = { accessKeyId: "a", secretAccessKey: "b" }; + +export const CONFIG: MigrationConfig.Interface = { + source: { + region: "eu-central-1", + credentials: CREDS, + dynamodb: { tableName: "acme-src-ddb" }, + s3: { bucket: "acme-src-s3" } + }, + target: { + region: "us-east-1", + credentials: CREDS, + accountId: "123456789012", + dynamodb: { tableName: "acme-prod-ddb" }, + s3: { bucket: "acme-prod-s3" }, + opensearch: { + endpoint: "https://os.example.com", + tableName: "acme-prod-os", + service: "opensearch" as const, + indexPrefix: "" + } + }, + pipeline: { segments: 4 } +}; + +describe("selectSystem", () => { + it("formats the hint with ddb table, region and os table or none", () => { + expect(formatSystemHint(CONFIG.source)).toBe( + "ddb: acme-src-ddb · region: eu-central-1 · os table: none" + ); + expect(formatSystemHint(CONFIG.target)).toBe( + "ddb: acme-prod-ddb · region: us-east-1 · os table: acme-prod-os" + ); + }); + + it("uses --system without prompting", async () => { + const prompts = new StubPrompts(); + expect(await selectSystem({ prompts, config: CONFIG, systemArg: "target" })).toEqual({ + kind: "ok", + value: "target" + }); + expect(prompts.selectCalls).toHaveLength(0); + }); + + it("prompts with hints and returns the choice; cancel → cancelled", async () => { + const prompts = new StubPrompts({ select: ["source"] }); + expect(await selectSystem({ prompts, config: CONFIG })).toEqual({ + kind: "ok", + value: "source" + }); + expect(prompts.selectCalls[0]!.options.map(o => o.hint)).toEqual([ + formatSystemHint(CONFIG.source), + formatSystemHint(CONFIG.target) + ]); + expect(await selectSystem({ prompts: new StubPrompts(), config: CONFIG })).toEqual({ + kind: "cancelled" + }); + }); +}); diff --git a/__tests__/commands/fixLive/steps/summarise.test.ts b/__tests__/commands/fixLive/steps/summarise.test.ts new file mode 100644 index 00000000..7568b1dc --- /dev/null +++ b/__tests__/commands/fixLive/steps/summarise.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { formatSummary, summarise, totalChanges } from "~/commands/fixLive/steps/summarise.js"; +import { StubUI } from "../../prompts/StubUI.ts"; +import { STATS } from "./runTable.test.ts"; + +const results = [ + { + table: "ddb" as const, + tableName: "acme-prod-ddb", + region: "eu-central-1", + stats: STATS + }, + { + table: "os" as const, + tableName: "acme-prod-os", + region: "eu-central-1", + stats: { ...STATS, scanned: 62880 } + } +]; + +describe("formatSummary", () => { + it("renders one block per table with counts and non-zero breakdowns", () => { + const text = formatSummary({ + project: "acme", + system: "target", + mode: "dry-run", + results, + reportPath: ".transfer/1/fix-live-report.jsonl", + statePath: ".transfer/state/fix-live/acme__target.json" + }); + expect(text).toContain("Fix live field — dry run (project: acme, system: target)"); + expect(text).toContain("DynamoDB acme-prod-ddb (eu-central-1)"); + expect(text).toContain("scanned 148 203"); + expect(text).toContain( + "changes 2 118 missing-live 1 902 · empty-live 201 · wrong-version 9 · stale-live 6" + ); + expect(text).toContain( + "skips 4 invalid-version 1 · revision-version-mismatch 3" + ); + expect(text).toContain("OpenSearch acme-prod-os (eu-central-1)"); + expect(text).toContain("Report: .transfer/1/fix-live-report.jsonl"); + expect(text).toContain('Run again and choose "live" to apply these changes.'); + }); + + it("live mode shows written / condition-failed instead of the dry-run hint", () => { + const text = formatSummary({ + project: "acme", + system: "target", + mode: "live", + results: [ + { + ...results[0]!, + stats: { ...STATS, written: 2100, conditionFailed: 18 } + } + ], + reportPath: "r", + statePath: "s" + }); + expect(text).toContain("written 2 100"); + expect(text).toContain("changed during run 18"); + expect(text).not.toContain('choose "live"'); + }); +}); + +describe("summarise", () => { + it("warns when a live run's change count differs from the last dry run", () => { + const ui = new StubUI(); + summarise({ + ui, + project: "acme", + system: "target", + mode: "live", + results, + reportPath: "r", + statePath: "s", + lastDryRun: { + runId: "0", + at: "2026-09-04T09:12:00.000Z", + changes: 2118, + skips: 4 + } + }); + expect(totalChanges(results)).toBe(4236); + expect(ui.warns[0]).toBe("Last dry run reported 2 118 changes, this live run found 4 236."); + expect(ui.notes[0]!.title).toBe("Summary"); + expect(ui.outros).toEqual(["Done."]); + }); +}); diff --git a/__tests__/commands/openMenu.test.ts b/__tests__/commands/openMenu.test.ts new file mode 100644 index 00000000..26d54234 --- /dev/null +++ b/__tests__/commands/openMenu.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Command } from "~/commands/registry/abstractions/Command.js"; +import type { CommandRegistry } from "~/commands/registry/abstractions/CommandRegistry.js"; +import { openMenu } from "~/commands/openMenu.js"; +import { StubPrompts } from "./prompts/StubPrompts.ts"; +import { StubUI } from "./prompts/StubUI.ts"; + +const command = (name: string, run: Command.Interface["run"], hidden?: boolean) => + ({ + name, + description: `${name} desc`, + hidden, + configure: y => y, + run + }) as Command.Interface; + +function registry(commands: Command.Interface[]): CommandRegistry.Interface { + return { + list: () => commands, + menu: () => commands.filter(c => c.hidden !== true), + get: (name: string) => commands.find(c => c.name === name)! + }; +} + +describe("openMenu", () => { + it("offers only non-hidden commands with descriptions as hints", async () => { + const prompts = new StubPrompts({ select: ["transfer"] }); + const transfer = vi.fn(async () => 0); + await openMenu({ + prompts, + ui: new StubUI(), + registry: registry([ + command("transfer", transfer), + command("fix-live", vi.fn()), + command("process-segment", vi.fn(), true) + ]) + }); + expect(prompts.selectCalls[0]!.options).toEqual([ + { value: "transfer", label: "transfer", hint: "transfer desc" }, + { value: "fix-live", label: "fix-live", hint: "fix-live desc" } + ]); + expect(transfer).toHaveBeenCalledWith({}); + }); + + it("returns the chosen command's exit code", async () => { + const code = await openMenu({ + prompts: new StubPrompts({ select: ["fix-live"] }), + ui: new StubUI(), + registry: registry([command("transfer", vi.fn()), command("fix-live", async () => 1)]) + }); + expect(code).toBe(1); + }); + + it("exits 130 on cancel", async () => { + const ui = new StubUI(); + const code = await openMenu({ + prompts: new StubPrompts(), + ui, + registry: registry([command("transfer", vi.fn())]) + }); + expect(code).toBe(130); + expect(ui.cancels).toEqual(["Cancelled."]); + }); +}); diff --git a/__tests__/commands/prompts/ClackPrompts.test.ts b/__tests__/commands/prompts/ClackPrompts.test.ts new file mode 100644 index 00000000..1fe7808f --- /dev/null +++ b/__tests__/commands/prompts/ClackPrompts.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const CANCEL = Symbol("clack:cancel"); + +vi.mock("@clack/prompts", () => ({ + select: vi.fn(), + multiselect: vi.fn(), + confirm: vi.fn(), + text: vi.fn(), + isCancel: (value: unknown) => value === CANCEL +})); + +import * as clack from "@clack/prompts"; +import { ClackPrompts } from "~/commands/prompts/ClackPrompts.js"; + +const mockSelect = vi.mocked(clack.select); +const mockConfirm = vi.mocked(clack.confirm); +const mockText = vi.mocked(clack.text); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("ClackPrompts", () => { + it("select returns the chosen value", async () => { + mockSelect.mockResolvedValue("b"); + const prompts = new ClackPrompts(); + const result = await prompts.select({ + message: "Pick", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" } + ] + }); + expect(result).toBe("b"); + expect(mockSelect).toHaveBeenCalledWith( + expect.objectContaining({ message: "Pick", options: expect.any(Array) }) + ); + }); + + it("select returns null on cancel", async () => { + mockSelect.mockResolvedValue(CANCEL); + const result = await new ClackPrompts().select({ + message: "Pick", + options: [{ value: "a", label: "A" }] + }); + expect(result).toBeNull(); + }); + + it("confirm returns null on cancel and the boolean otherwise", async () => { + mockConfirm.mockResolvedValueOnce(CANCEL).mockResolvedValueOnce(false); + const prompts = new ClackPrompts(); + expect(await prompts.confirm({ message: "Sure?" })).toBeNull(); + expect(await prompts.confirm({ message: "Sure?" })).toBe(false); + }); + + it("text passes validate through and returns null on cancel", async () => { + mockText.mockResolvedValue(CANCEL); + const validate = (value: string) => (value ? undefined : "required"); + expect(await new ClackPrompts().text({ message: "Name", validate })).toBeNull(); + const passed = mockText.mock.calls[0]![0]; + expect(passed.validate).toBeTypeOf("function"); + }); +}); diff --git a/__tests__/commands/prompts/StubPrompts.test.ts b/__tests__/commands/prompts/StubPrompts.test.ts new file mode 100644 index 00000000..b93f49f3 --- /dev/null +++ b/__tests__/commands/prompts/StubPrompts.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { StubPrompts } from "./StubPrompts.ts"; +import { StubUI, StubCancelError } from "./StubUI.ts"; + +describe("StubPrompts", () => { + it("answers in order and cancels when exhausted", async () => { + const prompts = new StubPrompts({ select: ["a"], confirm: [true] }); + expect(await prompts.select({ message: "m", options: [] })).toBe("a"); + expect(await prompts.select({ message: "m", options: [] })).toBeNull(); + expect(await prompts.confirm({ message: "c" })).toBe(true); + expect(await prompts.confirm({ message: "c" })).toBeNull(); + expect(prompts.selectCalls).toHaveLength(2); + }); +}); + +describe("StubUI", () => { + it("exitOnCancel throws on null and passes values through", () => { + const ui = new StubUI(); + expect(ui.exitOnCancel("x")).toBe("x"); + expect(() => ui.exitOnCancel(null)).toThrow(StubCancelError); + expect(ui.cancels).toEqual(["Cancelled."]); + }); +}); diff --git a/__tests__/commands/prompts/StubPrompts.ts b/__tests__/commands/prompts/StubPrompts.ts new file mode 100644 index 00000000..9c0e5967 --- /dev/null +++ b/__tests__/commands/prompts/StubPrompts.ts @@ -0,0 +1,51 @@ +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; + +export interface StubPromptsScript { + select?: (unknown | null)[]; + multiselect?: (unknown[] | null)[]; + confirm?: (boolean | null)[]; + text?: (string | null)[]; +} + +export class StubPrompts implements Prompts.Interface { + private readonly selects: (unknown | null)[]; + private readonly multiselects: (unknown[] | null)[]; + private readonly confirms: (boolean | null)[]; + private readonly texts: (string | null)[]; + + public readonly selectCalls: Prompts.SelectOptions[] = []; + public readonly multiselectCalls: Prompts.MultiselectOptions[] = []; + public readonly confirmCalls: Prompts.ConfirmOptions[] = []; + public readonly textCalls: Prompts.TextOptions[] = []; + + public constructor(script: StubPromptsScript = {}) { + this.selects = [...(script.select ?? [])]; + this.multiselects = [...(script.multiselect ?? [])]; + this.confirms = [...(script.confirm ?? [])]; + this.texts = [...(script.text ?? [])]; + } + + public async select(options: Prompts.SelectOptions): Promise { + this.selectCalls.push(options as Prompts.SelectOptions); + const next = this.selects.shift(); + return next === undefined ? null : (next as T); + } + + public async multiselect(options: Prompts.MultiselectOptions): Promise { + this.multiselectCalls.push(options as Prompts.MultiselectOptions); + const next = this.multiselects.shift(); + return next === undefined ? null : (next as T[]); + } + + public async confirm(options: Prompts.ConfirmOptions): Promise { + this.confirmCalls.push(options); + const next = this.confirms.shift(); + return next === undefined ? null : next; + } + + public async text(options: Prompts.TextOptions): Promise { + this.textCalls.push(options); + const next = this.texts.shift(); + return next === undefined ? null : next; + } +} diff --git a/__tests__/commands/prompts/StubUI.ts b/__tests__/commands/prompts/StubUI.ts new file mode 100644 index 00000000..abcbce73 --- /dev/null +++ b/__tests__/commands/prompts/StubUI.ts @@ -0,0 +1,70 @@ +import type { UI } from "~/commands/prompts/abstractions/UI.js"; + +export class StubCancelError extends Error { + public constructor() { + super("StubUI.exitOnCancel: cancelled"); + this.name = "StubCancelError"; + } +} + +export interface StubNote { + message: string; + title?: string; +} + +export class StubUI implements UI.Interface { + public readonly intros: string[] = []; + public readonly outros: string[] = []; + public readonly notes: StubNote[] = []; + public readonly warns: string[] = []; + public readonly errors: string[] = []; + public readonly cancels: string[] = []; + public readonly spinnerMessages: string[] = []; + + public intro(title: string): void { + this.intros.push(title); + } + + public outro(message: string): void { + this.outros.push(message); + } + + public note(message: string, title?: string): void { + this.notes.push({ message, title }); + } + + public warn(message: string): void { + this.warns.push(message); + } + + public error(message: string): void { + this.errors.push(message); + } + + public cancel(message: string): void { + this.cancels.push(message); + } + + public spinner(): UI.Spinner { + const messages = this.spinnerMessages; + return { + start(message: string): void { + messages.push(message); + }, + message(message: string): void { + messages.push(message); + }, + stop(message: string): void { + messages.push(message); + } + }; + } + + public exitOnCancel(value: T | null): T { + if (value === null) { + this.cancel("Cancelled."); + throw new StubCancelError(); + } + return value; + } +} diff --git a/__tests__/commands/registry/CommandRegistry.test.ts b/__tests__/commands/registry/CommandRegistry.test.ts new file mode 100644 index 00000000..5a57ac95 --- /dev/null +++ b/__tests__/commands/registry/CommandRegistry.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import type { Argv } from "yargs"; +import { Container } from "@webiny/di"; +import { ContainerToken } from "~/base/index.js"; +import { Command } from "~/commands/registry/abstractions/Command.js"; +import { CommandRegistry } from "~/commands/registry/abstractions/CommandRegistry.js"; +import { CommandRegistryFeature } from "~/commands/registry/feature.js"; + +let constructed = 0; + +class VisibleCommandImpl implements Command.Interface { + public readonly name = "visible"; + public readonly description = "A visible command"; + public constructor() { + constructed++; + } + public configure(yargs: Argv): Argv { + return yargs; + } + public async run(): Promise { + return 0; + } +} + +class HiddenCommandImpl implements Command.Interface { + public readonly name = "hidden "; + public readonly description = "A hidden command"; + public readonly hidden = true; + public constructor() { + constructed++; + } + public configure(yargs: Argv): Argv { + return yargs; + } + public async run(): Promise { + return 7; + } +} + +const VisibleCommand = Command.createImplementation({ + implementation: VisibleCommandImpl, + dependencies: [] +}); +const HiddenCommand = Command.createImplementation({ + implementation: HiddenCommandImpl, + dependencies: [] +}); + +function createContainer(): Container { + const container = new Container(); + container.registerInstance(ContainerToken, container); + container.register(VisibleCommand).inSingletonScope(); + container.register(HiddenCommand).inSingletonScope(); + CommandRegistryFeature.register(container); + return container; +} + +describe("CommandRegistry", () => { + it("lists every command in registration order", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(registry.list().map(c => c.name)).toEqual(["visible", "hidden "]); + }); + + it("menu() excludes hidden commands", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(registry.menu().map(c => c.name)).toEqual(["visible"]); + }); + + it("get() matches on the first token of the yargs command string", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(registry.get("hidden").description).toBe("A hidden command"); + }); + + it("get() throws for unknown names", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(() => registry.get("nope")).toThrow(/Unknown command "nope"/); + }); + + it("resolves commands lazily on first access", () => { + constructed = 0; + const registry = createContainer().resolve(CommandRegistry); + expect(constructed).toBe(0); + registry.list(); + registry.list(); + expect(constructed).toBe(2); + }); +}); diff --git a/__tests__/commands/segmentsFilter.test.ts b/__tests__/commands/segmentsFilter.test.ts index 14e5a62d..9437d24c 100644 --- a/__tests__/commands/segmentsFilter.test.ts +++ b/__tests__/commands/segmentsFilter.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { parseSegmentsFilter, resolveSegmentsToRun -} from "../../src/commands/run/segmentsFilter.ts"; +} from "../../src/commands/transfer/segmentsFilter.ts"; describe("parseSegmentsFilter", () => { it("parses a comma-separated list", () => { diff --git a/__tests__/commands/transfer/TransferCommand.test.ts b/__tests__/commands/transfer/TransferCommand.test.ts new file mode 100644 index 00000000..e30b137a --- /dev/null +++ b/__tests__/commands/transfer/TransferCommand.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/commands/transfer/handler.ts", () => ({ handler: vi.fn(async () => undefined) })); +vi.mock("~/commands/transfer/wizard/TransferWizard.ts", () => ({ + TransferWizard: vi.fn() +})); + +import { handler } from "~/commands/transfer/handler.js"; +import { TransferWizard } from "~/commands/transfer/wizard/TransferWizard.js"; +import { TransferCommand } from "~/commands/transfer/TransferCommand.js"; +import { StubPrompts } from "../prompts/StubPrompts.ts"; +import { StubUI } from "../prompts/StubUI.ts"; + +const handlerSpy = vi.mocked(handler); +const MockWizard = vi.mocked(TransferWizard); + +function mockWizardRun(result: unknown): ReturnType { + const run = vi.fn().mockResolvedValue(result); + MockWizard.mockImplementation(function (this: { run: typeof run }) { + this.run = run; + } as never); + return run; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockWizardRun(null); +}); + +describe("TransferCommand", () => { + it("has the yargs name and is visible in the menu", () => { + const command = new TransferCommand(new StubPrompts(), new StubUI()); + expect(command.name).toBe("transfer"); + expect((command as unknown as { hidden?: boolean }).hidden).toBeUndefined(); + }); + + it("--config + --preset skips the wizard and runs the handler", async () => { + const code = await new TransferCommand(new StubPrompts(), new StubUI()).run({ + config: "./p/config.ts", + preset: "copy-ddb", + "dry-run": true, + segments: [1, 3], + "log-level": "warn" + }); + expect(code).toBe(0); + expect(handlerSpy).toHaveBeenCalledWith("./p/config.ts", "copy-ddb", [1, 3], "warn", true); + expect(MockWizard).not.toHaveBeenCalled(); + }); + + it("wizard returning null (env written) exits 0 without running handler", async () => { + mockWizardRun(null); + expect(await new TransferCommand(new StubPrompts(), new StubUI()).run({})).toBe(0); + expect(handlerSpy).not.toHaveBeenCalled(); + }); + + it("wizard result is passed to the handler", async () => { + mockWizardRun({ configPath: "/c.ts", preset: "v5-to-v6-ddb", dryRun: false }); + expect(await new TransferCommand(new StubPrompts(), new StubUI()).run({})).toBe(0); + expect(handlerSpy).toHaveBeenCalledWith( + "/c.ts", + "v5-to-v6-ddb", + undefined, + undefined, + false + ); + }); +}); diff --git a/__tests__/commands/run/wizard/TransferWizard.test.ts b/__tests__/commands/transfer/wizard/TransferWizard.test.ts similarity index 69% rename from __tests__/commands/run/wizard/TransferWizard.test.ts rename to __tests__/commands/transfer/wizard/TransferWizard.test.ts index 27f0ae32..681614d1 100644 --- a/__tests__/commands/run/wizard/TransferWizard.test.ts +++ b/__tests__/commands/transfer/wizard/TransferWizard.test.ts @@ -1,28 +1,28 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Stats } from "node:fs"; -import { TransferWizard } from "../../../../src/commands/run/wizard/TransferWizard.ts"; -import type { RawOutputValues } from "../../../../src/commands/run/wizard/types.ts"; - -vi.mock("../../../../src/commands/run/wizard/projectDiscovery.ts"); -vi.mock("../../../../src/commands/run/wizard/configDiscovery.ts"); -vi.mock("../../../../src/commands/run/wizard/presetDiscovery.ts"); -vi.mock("../../../../src/commands/run/wizard/envWriter.ts"); -vi.mock("../../../../src/commands/run/wizard/sources/WebinyOutputSource.ts"); -vi.mock("../../../../src/commands/run/wizard/sources/PulumiStateSource.ts"); -vi.mock("@inquirer/prompts"); +import { TransferWizard } from "../../../../src/commands/transfer/wizard/TransferWizard.ts"; +import type { RawOutputValues } from "../../../../src/commands/transfer/wizard/types.ts"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; +import { StubUI } from "../../prompts/StubUI.ts"; + +vi.mock("../../../../src/commands/transfer/wizard/projectDiscovery.ts"); +vi.mock("../../../../src/commands/transfer/wizard/configDiscovery.ts"); +vi.mock("../../../../src/commands/transfer/wizard/presetDiscovery.ts"); +vi.mock("../../../../src/commands/transfer/wizard/envWriter.ts"); +vi.mock("../../../../src/commands/transfer/wizard/sources/WebinyOutputSource.ts"); +vi.mock("../../../../src/commands/transfer/wizard/sources/PulumiStateSource.ts"); vi.mock("node:fs/promises"); vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) })); vi.mock("../../../../src/commands/initProject/scaffoldProject.ts", () => ({ scaffoldProject: vi.fn().mockResolvedValue(undefined) })); -import { discoverProjects } from "../../../../src/commands/run/wizard/projectDiscovery.ts"; -import { discoverConfig } from "../../../../src/commands/run/wizard/configDiscovery.ts"; -import { listAvailablePresetsWithDescriptions } from "../../../../src/commands/run/wizard/presetDiscovery.ts"; -import { writeEnv } from "../../../../src/commands/run/wizard/envWriter.ts"; -import { extractFromWebinyOutput } from "../../../../src/commands/run/wizard/sources/WebinyOutputSource.ts"; -import { extractFromPulumiState } from "../../../../src/commands/run/wizard/sources/PulumiStateSource.ts"; -import { input, select } from "@inquirer/prompts"; +import { discoverProjects } from "../../../../src/commands/transfer/wizard/projectDiscovery.ts"; +import { discoverConfig } from "../../../../src/commands/transfer/wizard/configDiscovery.ts"; +import { listAvailablePresetsWithDescriptions } from "../../../../src/commands/transfer/wizard/presetDiscovery.ts"; +import { writeEnv } from "../../../../src/commands/transfer/wizard/envWriter.ts"; +import { extractFromWebinyOutput } from "../../../../src/commands/transfer/wizard/sources/WebinyOutputSource.ts"; +import { extractFromPulumiState } from "../../../../src/commands/transfer/wizard/sources/PulumiStateSource.ts"; import { stat } from "node:fs/promises"; import { scaffoldProject } from "../../../../src/commands/initProject/scaffoldProject.ts"; @@ -32,8 +32,6 @@ const mockListAvailablePresetsWithDescriptions = vi.mocked(listAvailablePresetsW const mockWriteEnv = vi.mocked(writeEnv); const mockExtractFromWebinyOutput = vi.mocked(extractFromWebinyOutput); const mockExtractFromPulumiState = vi.mocked(extractFromPulumiState); -const mockInput = vi.mocked(input); -const mockSelect = vi.mocked(select); const mockStat = vi.mocked(stat); const mockScaffoldProject = vi.mocked(scaffoldProject); @@ -63,10 +61,13 @@ beforeEach(() => { mockScaffoldProject.mockResolvedValue(undefined); }); +function wizard(prompts: StubPrompts, ui = new StubUI()): TransferWizard { + return new TransferWizard(process.cwd(), prompts, ui); +} + describe("TransferWizard", () => { it("env-setup path: writes .env and returns null", async () => { mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { const path = String(p); if (path.endsWith("source.webiny.json") || path.endsWith("target.webiny.json")) { @@ -77,9 +78,9 @@ describe("TransferWizard", () => { mockExtractFromWebinyOutput .mockResolvedValueOnce(SOURCE_VALS) .mockResolvedValueOnce(TARGET_VALS); - mockInput.mockResolvedValue("4"); - const result = await new TransferWizard(process.cwd()).run(); + const prompts = new StubPrompts({ select: ["my-project"], text: ["4"] }); + const result = await wizard(prompts).run(); expect(result).toBeNull(); expect(mockWriteEnv).toHaveBeenCalledOnce(); @@ -88,7 +89,6 @@ describe("TransferWizard", () => { it("re-run path: .env exists, no JSON → finds config.ts, prompts for preset, returns WizardResult", async () => { const CONFIG_PATH = "/projects/my-project/config.ts"; mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValueOnce("my-project").mockResolvedValueOnce("v5-to-v6-ddb"); mockStat.mockImplementation(async (p: unknown) => { if (String(p).endsWith(".env")) { return { size: 100 } as unknown as Stats; @@ -101,15 +101,22 @@ describe("TransferWizard", () => { { name: "v5-to-v6-os", description: "DDB + OpenSearch" } ]); - const result = await new TransferWizard(process.cwd()).run(); + const prompts = new StubPrompts({ + select: ["my-project", "v5-to-v6-ddb"], + confirm: [false] + }); + const result = await wizard(prompts).run(); - expect(result).toEqual({ configPath: CONFIG_PATH, preset: "v5-to-v6-ddb" }); + expect(result).toEqual({ + configPath: CONFIG_PATH, + preset: "v5-to-v6-ddb", + dryRun: false + }); expect(mockWriteEnv).not.toHaveBeenCalled(); }); - it("re-run path: exits with error when no config.ts found in project", async () => { + it("re-run path: throws when no config.ts found in project", async () => { mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { if (String(p).endsWith(".env")) { return { size: 100 } as unknown as Stats; @@ -118,16 +125,12 @@ describe("TransferWizard", () => { }); mockDiscoverConfig.mockResolvedValue(null); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exit"); - }); - await expect(new TransferWizard(process.cwd()).run()).rejects.toThrow("exit"); - exitSpy.mockRestore(); + const prompts = new StubPrompts({ select: ["my-project"] }); + await expect(wizard(prompts).run()).rejects.toThrow(/No config\.ts found/); }); it("writes .env with correct values from webiny output", async () => { mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { const path = String(p); if (path.endsWith("source.webiny.json") || path.endsWith("target.webiny.json")) { @@ -138,9 +141,9 @@ describe("TransferWizard", () => { mockExtractFromWebinyOutput .mockResolvedValueOnce(SOURCE_VALS) .mockResolvedValueOnce(TARGET_VALS); - mockInput.mockResolvedValue("4"); - await new TransferWizard(process.cwd()).run(); + const prompts = new StubPrompts({ select: ["my-project"], text: ["4"] }); + await wizard(prompts).run(); expect(mockWriteEnv).toHaveBeenCalledOnce(); const [, envValues] = mockWriteEnv.mock.calls[0]!; @@ -151,7 +154,6 @@ describe("TransferWizard", () => { it("warns when source and target are in different AWS accounts", async () => { mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { const path = String(p); if (path.endsWith("source.webiny.json") || path.endsWith("target.webiny.json")) { @@ -162,19 +164,17 @@ describe("TransferWizard", () => { mockExtractFromWebinyOutput .mockResolvedValueOnce({ ...SOURCE_VALS, accountId: "111111111111" }) .mockResolvedValueOnce({ ...TARGET_VALS, accountId: "999999999999" }); - mockInput.mockResolvedValue("4"); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await new TransferWizard(process.cwd()).run(); + const prompts = new StubPrompts({ select: ["my-project"], text: ["4"] }); + const ui = new StubUI(); + await wizard(prompts, ui).run(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("111111111111")); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("999999999999")); - warnSpy.mockRestore(); + expect(ui.warns[0]).toContain("111111111111"); + expect(ui.warns[0]).toContain("999999999999"); }); it("does not warn when source and target share the same AWS account", async () => { mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { const path = String(p); if (path.endsWith("source.webiny.json") || path.endsWith("target.webiny.json")) { @@ -185,13 +185,12 @@ describe("TransferWizard", () => { mockExtractFromWebinyOutput .mockResolvedValueOnce({ ...SOURCE_VALS, accountId: "111111111111" }) .mockResolvedValueOnce({ ...TARGET_VALS, accountId: "111111111111" }); - mockInput.mockResolvedValue("4"); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await new TransferWizard(process.cwd()).run(); + const prompts = new StubPrompts({ select: ["my-project"], text: ["4"] }); + const ui = new StubUI(); + await wizard(prompts, ui).run(); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); + expect(ui.warns).toHaveLength(0); }); it("prompts for OS index prefix when OS fields are present", async () => { @@ -206,7 +205,6 @@ describe("TransferWizard", () => { osEndpoint: "https://es.target" }; mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { const path = String(p); if (path.endsWith("source.webiny.json") || path.endsWith("target.webiny.json")) { @@ -217,18 +215,20 @@ describe("TransferWizard", () => { mockExtractFromWebinyOutput .mockResolvedValueOnce(OS_SOURCE) .mockResolvedValueOnce(OS_TARGET); - mockInput.mockResolvedValueOnce("4").mockResolvedValueOnce("v6-"); - await new TransferWizard(process.cwd()).run(); + const prompts = new StubPrompts({ + select: ["my-project"], + text: ["4", "v6-"] + }); + await wizard(prompts).run(); const [, envValues] = mockWriteEnv.mock.calls[0]!; expect(envValues.targetOsIndexPrefix).toBe("v6-"); }); - it("exits with error when no presets are available", async () => { + it("throws when no presets are available", async () => { const CONFIG_PATH = "/projects/my-project/config.ts"; mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { if (String(p).endsWith(".env")) { return { size: 100 } as unknown as Stats; @@ -238,16 +238,12 @@ describe("TransferWizard", () => { mockDiscoverConfig.mockResolvedValue(CONFIG_PATH); mockListAvailablePresetsWithDescriptions.mockResolvedValue([]); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exit"); - }); - await expect(new TransferWizard(process.cwd()).run()).rejects.toThrow("exit"); - exitSpy.mockRestore(); + const prompts = new StubPrompts({ select: ["my-project"] }); + await expect(wizard(prompts).run()).rejects.toThrow(/No presets available/); }); it("throws when same-side files disagree on osTableName", async () => { mockDiscoverProjects.mockResolvedValue(["my-project"]); - mockSelect.mockResolvedValue("my-project"); mockStat.mockImplementation(async (p: unknown) => { const path = String(p); if (path.endsWith("source.webiny.json") || path.endsWith("source.pulumi.json")) { @@ -264,6 +260,13 @@ describe("TransferWizard", () => { osTableName: "wby-es-pulumi" }); - await expect(new TransferWizard(process.cwd()).run()).rejects.toThrow(/osTableName/); + const prompts = new StubPrompts({ select: ["my-project"] }); + await expect(wizard(prompts).run()).rejects.toThrow(/osTableName/); + }); + + it("returns null when the user cancels at project selection", async () => { + mockDiscoverProjects.mockResolvedValue(["my-project"]); + const result = await wizard(new StubPrompts()).run(); + expect(result).toBeNull(); }); }); diff --git a/__tests__/commands/run/wizard/configDiscovery.test.ts b/__tests__/commands/transfer/wizard/configDiscovery.test.ts similarity index 92% rename from __tests__/commands/run/wizard/configDiscovery.test.ts rename to __tests__/commands/transfer/wizard/configDiscovery.test.ts index 41ee7ff1..4d2bad0b 100644 --- a/__tests__/commands/run/wizard/configDiscovery.test.ts +++ b/__tests__/commands/transfer/wizard/configDiscovery.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { discoverConfig } from "../../../../src/commands/run/wizard/configDiscovery.ts"; +import { discoverConfig } from "../../../../src/commands/transfer/wizard/configDiscovery.ts"; describe("discoverConfig", () => { it("returns the resolved path to config.ts when it exists", async () => { diff --git a/__tests__/commands/run/wizard/envWriter.test.ts b/__tests__/commands/transfer/wizard/envWriter.test.ts similarity index 96% rename from __tests__/commands/run/wizard/envWriter.test.ts rename to __tests__/commands/transfer/wizard/envWriter.test.ts index ceed105d..a14d95c1 100644 --- a/__tests__/commands/run/wizard/envWriter.test.ts +++ b/__tests__/commands/transfer/wizard/envWriter.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { writeEnv } from "../../../../src/commands/run/wizard/envWriter.ts"; -import type { EnvValues } from "../../../../src/commands/run/wizard/types.ts"; +import { writeEnv } from "../../../../src/commands/transfer/wizard/envWriter.ts"; +import type { EnvValues } from "../../../../src/commands/transfer/wizard/types.ts"; const SAMPLE_VALUES: EnvValues = { sourceRegion: "eu-central-1", diff --git a/__tests__/commands/run/wizard/presetDiscovery.test.ts b/__tests__/commands/transfer/wizard/presetDiscovery.test.ts similarity index 98% rename from __tests__/commands/run/wizard/presetDiscovery.test.ts rename to __tests__/commands/transfer/wizard/presetDiscovery.test.ts index 9cc91967..9363465a 100644 --- a/__tests__/commands/run/wizard/presetDiscovery.test.ts +++ b/__tests__/commands/transfer/wizard/presetDiscovery.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { listAvailablePresets, listAvailablePresetsWithDescriptions -} from "../../../../src/commands/run/wizard/presetDiscovery.ts"; +} from "../../../../src/commands/transfer/wizard/presetDiscovery.ts"; describe("listAvailablePresets", () => { it("returns built-in preset names (at minimum v5-to-v6-ddb and v5-to-v6-os)", () => { diff --git a/__tests__/commands/run/wizard/projectDiscovery.test.ts b/__tests__/commands/transfer/wizard/projectDiscovery.test.ts similarity index 93% rename from __tests__/commands/run/wizard/projectDiscovery.test.ts rename to __tests__/commands/transfer/wizard/projectDiscovery.test.ts index 53f627b5..19a8a209 100644 --- a/__tests__/commands/run/wizard/projectDiscovery.test.ts +++ b/__tests__/commands/transfer/wizard/projectDiscovery.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { discoverProjects } from "../../../../src/commands/run/wizard/projectDiscovery.ts"; +import { discoverProjects } from "../../../../src/commands/transfer/wizard/projectDiscovery.ts"; let root: string; diff --git a/__tests__/commands/run/wizard/schemas/pulumiState.schema.test.ts b/__tests__/commands/transfer/wizard/schemas/pulumiState.schema.test.ts similarity index 96% rename from __tests__/commands/run/wizard/schemas/pulumiState.schema.test.ts rename to __tests__/commands/transfer/wizard/schemas/pulumiState.schema.test.ts index 5364e366..6dc87375 100644 --- a/__tests__/commands/run/wizard/schemas/pulumiState.schema.test.ts +++ b/__tests__/commands/transfer/wizard/schemas/pulumiState.schema.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { pulumiStateSchema, extractStackOutputs -} from "../../../../../src/commands/run/wizard/schemas/pulumiState.schema.ts"; +} from "../../../../../src/commands/transfer/wizard/schemas/pulumiState.schema.ts"; const VALID_STATE = { version: 3 as const, diff --git a/__tests__/commands/run/wizard/schemas/webinyOutput.schema.test.ts b/__tests__/commands/transfer/wizard/schemas/webinyOutput.schema.test.ts similarity index 98% rename from __tests__/commands/run/wizard/schemas/webinyOutput.schema.test.ts rename to __tests__/commands/transfer/wizard/schemas/webinyOutput.schema.test.ts index 9c1deb08..b48f5a57 100644 --- a/__tests__/commands/run/wizard/schemas/webinyOutput.schema.test.ts +++ b/__tests__/commands/transfer/wizard/schemas/webinyOutput.schema.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { webinyOutputSchema, normalizeOutputs -} from "../../../../../src/commands/run/wizard/schemas/webinyOutput.schema.ts"; +} from "../../../../../src/commands/transfer/wizard/schemas/webinyOutput.schema.ts"; describe("webinyOutputSchema", () => { it("accepts a valid output with elasticsearch prefix", () => { diff --git a/__tests__/commands/run/wizard/sources/PulumiStateSource.test.ts b/__tests__/commands/transfer/wizard/sources/PulumiStateSource.test.ts similarity index 98% rename from __tests__/commands/run/wizard/sources/PulumiStateSource.test.ts rename to __tests__/commands/transfer/wizard/sources/PulumiStateSource.test.ts index e472e08d..dde614d4 100644 --- a/__tests__/commands/run/wizard/sources/PulumiStateSource.test.ts +++ b/__tests__/commands/transfer/wizard/sources/PulumiStateSource.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { extractFromPulumiState } from "../../../../../src/commands/run/wizard/sources/PulumiStateSource.ts"; +import { extractFromPulumiState } from "../../../../../src/commands/transfer/wizard/sources/PulumiStateSource.ts"; const FIXTURES = join(import.meta.dirname, "../../../../fixtures/wizard"); diff --git a/__tests__/commands/run/wizard/sources/WebinyOutputSource.test.ts b/__tests__/commands/transfer/wizard/sources/WebinyOutputSource.test.ts similarity index 97% rename from __tests__/commands/run/wizard/sources/WebinyOutputSource.test.ts rename to __tests__/commands/transfer/wizard/sources/WebinyOutputSource.test.ts index 62a477ac..12aa49b5 100644 --- a/__tests__/commands/run/wizard/sources/WebinyOutputSource.test.ts +++ b/__tests__/commands/transfer/wizard/sources/WebinyOutputSource.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { extractFromWebinyOutput } from "../../../../../src/commands/run/wizard/sources/WebinyOutputSource.ts"; +import { extractFromWebinyOutput } from "../../../../../src/commands/transfer/wizard/sources/WebinyOutputSource.ts"; const FIXTURES = join(import.meta.dirname, "../../../../fixtures/wizard"); diff --git a/__tests__/features/FixLive/ChangeReport.test.ts b/__tests__/features/FixLive/ChangeReport.test.ts new file mode 100644 index 00000000..39f9f613 --- /dev/null +++ b/__tests__/features/FixLive/ChangeReport.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, readFile, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ChangeReport } from "~/features/FixLive/index.js"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; + +describe("ChangeReport", () => { + let originalCwd: string; + let workDir: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + workDir = await realpath(await mkdtemp(join(tmpdir(), "fix-live-report-"))); + process.chdir(workDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + it("appends one JSON line per event under .transfer//fix-live-report.jsonl", async () => { + const report = createFixLiveContainer({ runId: "run-1" }).resolve(ChangeReport); + + report.change({ + table: "ddb", + pk: "T#root#CMS#CME#abc", + sk: "L", + reason: "missing-live", + before: undefined, + after: { version: 2 }, + result: "dry-run" + }); + report.skip({ + table: "ddb", + pk: "T#root#CMS#CME#def", + sk: "REV#0007", + reason: "revision-version-mismatch", + detail: "P.version=7 REV#0007.version=6" + }); + + expect(report.path).toBe(join(workDir, ".transfer", "run-1", "fix-live-report.jsonl")); + const lines = (await readFile(report.path, "utf-8")).trim().split("\n"); + expect(JSON.parse(lines[0]!)).toEqual({ + kind: "change", + table: "ddb", + pk: "T#root#CMS#CME#abc", + sk: "L", + reason: "missing-live", + before: null, + after: { version: 2 }, + result: "dry-run" + }); + expect(JSON.parse(lines[1]!)).toEqual({ + kind: "skip", + table: "ddb", + pk: "T#root#CMS#CME#def", + sk: "REV#0007", + reason: "revision-version-mismatch", + detail: "P.version=7 REV#0007.version=6" + }); + }); +}); diff --git a/__tests__/features/FixLive/DdbLiveFieldRunner.test.ts b/__tests__/features/FixLive/DdbLiveFieldRunner.test.ts new file mode 100644 index 00000000..e3e339ec --- /dev/null +++ b/__tests__/features/FixLive/DdbLiveFieldRunner.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { DdbLiveFieldRunner } from "~/features/FixLive/index.js"; +import type { LiveFieldRunner } from "~/features/FixLive/abstractions/LiveFieldRunner.js"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; +import { MockChangeReport } from "./MockChangeReport.ts"; + +const TABLE = "v6-main"; + +function entry(id: string, sk: string, data: Record, md = "md-1") { + return { + PK: `T#root#CMS#CME#${id}`, + SK: sk, + TYPE: sk === "P" ? "cms.entry.p" : sk === "L" ? "cms.entry.l" : "cms.entry", + _et: "CmsEntries", + _ct: "2026-01-01T00:00:00.000Z", + _md: md, + data: { modelId: "blogPost", entryId: id, ...data } + }; +} + +function seed() { + return [ + entry("a", "L", { version: 3, status: "draft" }), + entry("a", "P", { version: 2, status: "published" }), + entry("a", "REV#0002", { version: 2, status: "published" }), + entry("a", "REV#0003", { version: 3, status: "draft" }), + entry("b", "L", { version: 1, status: "unpublished", live: { version: 1 } }), + entry("b", "REV#0001", { version: 1, live: { version: 1 } }), + entry("c", "L", { version: 1, status: "published" }), + entry("c", "P", { version: 1, status: "published" }), + entry("f", "L", { modelId: "fmFile", version: 1, status: "draft" }), + { + PK: "T#root#PB#P#p1", + SK: "L", + TYPE: "pb.page.l", + _et: "Pb", + _ct: "x", + _md: "x", + data: {} + } + ]; +} + +function run(client: MockDynamoDbClient, mode: LiveFieldRunner.Mode, segments = 2) { + const runner = createFixLiveContainer().resolve(DdbLiveFieldRunner); + const report = new MockChangeReport(); + const progress: number[] = []; + return runner + .run({ + mode, + target: { + client, + tableName: TABLE, + segments, + concurrency: 2, + writeConcurrency: 2 + }, + report, + onProgress: stats => progress.push(stats.scanned) + }) + .then(stats => ({ stats, report, progress })); +} + +describe("DdbLiveFieldRunner", () => { + it("dry run: counts, reports, writes nothing", async () => { + const client = new MockDynamoDbClient({ [TABLE]: seed() }); + const { stats, report, progress } = await run(client, "dry-run"); + + expect(stats.scanned).toBe(5); + expect(stats.entries).toBe(3); + expect(stats.changes).toMatchObject({ "missing-live": 3, "stale-live": 1 }); + expect(stats.skips).toMatchObject({ "revision-record-missing": 1 }); + expect(stats.written).toBe(0); + expect(client.updateCalls).toEqual([]); + expect(report.changes).toHaveLength(4); + expect(report.changes.every(c => c.result === "dry-run" && c.table === "ddb")).toBe(true); + expect(report.skips).toEqual([ + { + table: "ddb", + pk: "T#root#CMS#CME#c", + sk: "REV#0001", + reason: "revision-record-missing", + detail: "P.version=1" + } + ]); + expect(progress.length).toBeGreaterThan(0); + }); + + it("live run: conditional updates on data.live only", async () => { + const client = new MockDynamoDbClient({ [TABLE]: seed() }); + const { stats, report } = await run(client, "live"); + + expect(stats.written).toBe(4); + expect(stats.conditionFailed).toBe(0); + expect(client.updateCalls).toHaveLength(4); + for (const call of client.updateCalls) { + expect(call.request.path).toEqual(["data", "live"]); + expect(call.request.condition).toEqual({ attribute: "_md", equals: "md-1" }); + } + const rows = client.getRecordsForTable(TABLE); + const data = (id: string, sk: string) => + rows.find(r => r.PK === `T#root#CMS#CME#${id}` && r.SK === sk)!.data as Record< + string, + unknown + >; + expect(data("a", "L").live).toEqual({ version: 2 }); + expect(data("a", "P").live).toEqual({ version: 2 }); + expect(data("a", "REV#0002").live).toEqual({ version: 2 }); + expect(data("a", "REV#0003").live).toBeUndefined(); + expect(data("b", "L").live).toBeNull(); + expect(report.changes.every(c => c.result === "written")).toBe(true); + }); + + it("live run: a record changed since read is reported as changed-during-run", async () => { + const rows = seed(); + const client = new MockDynamoDbClient({ [TABLE]: rows }); + const original = client.updateAttribute.bind(client); + client.updateAttribute = async (table, request) => { + if (request.key.PK === "T#root#CMS#CME#a" && request.key.SK === "L") { + rows.find(r => r.PK === request.key.PK && r.SK === "L")!._md = "md-2"; + } + return original(table, request); + }; + + const { stats, report } = await run(client, "live"); + + expect(stats.written).toBe(3); + expect(stats.conditionFailed).toBe(1); + expect(stats.skips["changed-during-run"]).toBe(1); + expect(report.changes.find(c => c.sk === "L" && c.pk.endsWith("#a"))!.result).toBe( + "condition-failed" + ); + expect(report.skips).toContainEqual({ + table: "ddb", + pk: "T#root#CMS#CME#a", + sk: "L", + reason: "changed-during-run", + detail: undefined + }); + }); +}); diff --git a/__tests__/features/FixLive/FixLiveState.test.ts b/__tests__/features/FixLive/FixLiveState.test.ts new file mode 100644 index 00000000..20e40c70 --- /dev/null +++ b/__tests__/features/FixLive/FixLiveState.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, readFile, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FixLiveState } from "~/features/FixLive/index.js"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; + +const KEY = { project: "acme", system: "target" as const }; + +describe("FixLiveState", () => { + let originalCwd: string; + let workDir: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + workDir = await realpath(await mkdtemp(join(tmpdir(), "fix-live-state-"))); + process.chdir(workDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + it("resolves the path under .transfer/state/fix-live", () => { + const state = createFixLiveContainer().resolve(FixLiveState); + expect(state.pathFor(KEY)).toBe( + join(workDir, ".transfer", "state", "fix-live", "acme__target.json") + ); + }); + + it("read returns null when no state exists", () => { + expect(createFixLiveContainer().resolve(FixLiveState).read(KEY)).toBeNull(); + }); + + it("recordDryRun writes lastDryRun; recordLiveRun adds lastLiveRun and keeps lastDryRun", async () => { + const state = createFixLiveContainer().resolve(FixLiveState); + const dry = { + runId: "1", + at: "2026-09-04T09:12:33.000Z", + changes: 2118, + skips: 4 + }; + const live = { ...dry, runId: "2", written: 2110, conditionFailed: 8 }; + + state.recordDryRun(KEY, dry); + expect(state.read(KEY)).toEqual({ lastDryRun: dry }); + + state.recordLiveRun(KEY, live); + expect(state.read(KEY)).toEqual({ lastDryRun: dry, lastLiveRun: live }); + expect(JSON.parse(await readFile(state.pathFor(KEY), "utf-8"))).toEqual({ + lastDryRun: dry, + lastLiveRun: live + }); + }); +}); diff --git a/__tests__/features/FixLive/LiveFieldReconciler.test.ts b/__tests__/features/FixLive/LiveFieldReconciler.test.ts new file mode 100644 index 00000000..5893f76c --- /dev/null +++ b/__tests__/features/FixLive/LiveFieldReconciler.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from "vitest"; +import { LiveFieldReconciler } from "~/features/FixLive/LiveFieldReconciler.js"; +import type { LiveFieldReconciler as Reconciler } from "~/features/FixLive/abstractions/LiveFieldReconciler.js"; + +const PK = "T#root#CMS#CME#abc"; + +function rec(sk: string, data: Record, md = `md-${sk}`): Reconciler.Record { + return { PK, SK: sk, _md: md, data }; +} + +function group(table: Reconciler.Table, ...records: Reconciler.Record[]): Reconciler.Group { + return { pk: PK, table, records: new Map(records.map(r => [r.SK, r])) }; +} + +function decide(table: Reconciler.Table, ...records: Reconciler.Record[]): Reconciler.Decision { + return new LiveFieldReconciler().decide(group(table, ...records)); +} + +const skipReasons = (d: Reconciler.Decision) => d.skips.map(s => s.reason); +const changeSummary = (d: Reconciler.Decision) => d.changes.map(c => `${c.sk}:${c.reason}`).sort(); + +describe("LiveFieldReconciler.decide — skips", () => { + it("no-latest-record when L is absent", () => { + const d = decide("ddb", rec("P", { version: 1, status: "published" })); + expect(skipReasons(d)).toEqual(["no-latest-record"]); + expect(d.changes).toEqual([]); + }); + + it("latest-status-contradicts-unpublished when P is absent but L says published", () => { + const d = decide("ddb", rec("L", { version: 1, status: "published" })); + expect(skipReasons(d)).toEqual(["latest-status-contradicts-unpublished"]); + }); + + it.each([["2"], [0], [-1], [1.5], [null], [undefined]])( + "invalid-version when P.version is %s", + version => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft" }), + rec("P", { version, status: "published" }) + ); + expect(skipReasons(d)).toEqual(["invalid-version"]); + } + ); + + it("latest-status-contradicts-published when L is published but on a different version", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "published" }), + rec("P", { version: 2, status: "published" }), + rec("REV#0002", { version: 2 }) + ); + expect(skipReasons(d)).toEqual(["latest-status-contradicts-published"]); + }); + + it("latest-status-contradicts-published when L has P's version but is not published", () => { + const d = decide( + "ddb", + rec("L", { version: 2, status: "draft" }), + rec("P", { version: 2, status: "published" }), + rec("REV#0002", { version: 2 }) + ); + expect(skipReasons(d)).toEqual(["latest-status-contradicts-published"]); + }); + + it("revision-record-missing on ddb when REV# is absent", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft" }), + rec("P", { version: 2, status: "published" }) + ); + expect(d.skips).toEqual([ + { pk: PK, sk: "REV#0002", reason: "revision-record-missing", detail: "P.version=2" } + ]); + }); + + it("revision-version-mismatch on ddb when REV# carries another version", () => { + const d = decide( + "ddb", + rec("L", { version: 7, status: "published" }), + rec("P", { version: 7, status: "published" }), + rec("REV#0007", { version: 6 }) + ); + expect(skipReasons(d)).toEqual(["revision-version-mismatch"]); + expect(d.skips[0]!.detail).toBe("P.version=7 REV#0007.version=6"); + }); + + it("a skip aborts the whole group — no changes alongside a skip", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft", live: null }), + rec("P", { version: 2, status: "published", live: {} }) + ); + expect(d.skips).toHaveLength(1); + expect(d.changes).toEqual([]); + }); +}); + +describe("LiveFieldReconciler.decide — changes", () => { + it("missing-live on L, P and the published REV# when live is absent or null", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft" }), + rec("P", { version: 2, status: "published", live: null }), + rec("REV#0002", { version: 2, status: "published" }), + rec("REV#0003", { version: 3, status: "draft" }) + ); + expect(changeSummary(d)).toEqual([ + "L:missing-live", + "P:missing-live", + "REV#0002:missing-live" + ]); + for (const change of d.changes) { + expect(change.after).toEqual({ version: 2 }); + expect(change.expectedMd).toBe(`md-${change.sk}`); + } + }); + + it("empty-live when live is {} or has a non-integer version", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft", live: {} }), + rec("P", { version: 2, status: "published", live: { version: "2" } }), + rec("REV#0002", { version: 2, live: { version: 2 } }) + ); + expect(changeSummary(d)).toEqual(["L:empty-live", "P:empty-live"]); + expect(d.changes.find(c => c.sk === "L")!.before).toEqual({}); + }); + + it("wrong-version when live.version differs from P.version", () => { + const d = decide( + "os", + rec("L", { version: 3, status: "draft", live: { version: 1 } }), + rec("P", { version: 2, status: "published", live: { version: 2 } }) + ); + expect(changeSummary(d)).toEqual(["L:wrong-version"]); + }); + + it("stale-live on L only when P is absent and L carries any live value", () => { + const d = decide( + "ddb", + rec("L", { version: 2, status: "unpublished", live: { version: 1 } }), + rec("REV#0001", { version: 1, live: { version: 1 } }), + rec("REV#0002", { version: 2, live: { version: 1 } }) + ); + expect(changeSummary(d)).toEqual(["L:stale-live"]); + expect(d.changes[0]!.after).toBeNull(); + }); + + it("stale-live also normalises {} to null when unpublished", () => { + const d = decide("ddb", rec("L", { version: 1, status: "draft", live: {} })); + expect(changeSummary(d)).toEqual(["L:stale-live"]); + }); + + it("no change when unpublished and live is null or absent", () => { + expect( + decide("ddb", rec("L", { version: 1, status: "draft", live: null })).changes + ).toEqual([]); + expect(decide("ddb", rec("L", { version: 1, status: "draft" })).changes).toEqual([]); + }); + + it("clean group produces neither changes nor skips", () => { + const d = decide( + "ddb", + rec("L", { version: 2, status: "published", live: { version: 2 } }), + rec("P", { version: 2, status: "published", live: { version: 2 } }), + rec("REV#0002", { version: 2, live: { version: 2 } }), + rec("REV#0001", { version: 1, live: { version: 1 } }) + ); + expect(d).toEqual({ changes: [], skips: [] }); + }); + + it("os table skips the REV# checks and never touches REV# records", () => { + const d = decide( + "os", + rec("L", { version: 3, status: "draft" }), + rec("P", { version: 2, status: "published" }) + ); + expect(changeSummary(d)).toEqual(["L:missing-live", "P:missing-live"]); + expect(d.skips).toEqual([]); + }); + + it("other REV# records never appear in changes", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft", live: { version: 2 } }), + rec("P", { version: 2, status: "published", live: { version: 2 } }), + rec("REV#0002", { version: 2, live: { version: 2 } }), + rec("REV#0001", { version: 1, live: {} }), + rec("REV#0003", { version: 3 }) + ); + expect(d.changes).toEqual([]); + }); + + it("single-revision published entry reconciles L, P and REV#0001", () => { + const d = decide( + "ddb", + rec("L", { version: 1, status: "published" }), + rec("P", { version: 1, status: "published" }), + rec("REV#0001", { version: 1, status: "published" }) + ); + expect(changeSummary(d)).toEqual([ + "L:missing-live", + "P:missing-live", + "REV#0001:missing-live" + ]); + }); + + it("pads version >= 10000 as REV#10000 (no truncation)", () => { + const d = decide( + "ddb", + rec("L", { version: 10000, status: "published" }), + rec("P", { version: 10000, status: "published" }), + rec("REV#10000", { version: 10000 }) + ); + expect(d.skips).toEqual([]); + expect(d.changes.map(c => c.sk).sort()).toEqual(["L", "P", "REV#10000"]); + }); +}); diff --git a/__tests__/features/FixLive/MockChangeReport.ts b/__tests__/features/FixLive/MockChangeReport.ts new file mode 100644 index 00000000..47f63b70 --- /dev/null +++ b/__tests__/features/FixLive/MockChangeReport.ts @@ -0,0 +1,15 @@ +import type { ChangeReport } from "~/features/FixLive/abstractions/ChangeReport.js"; + +export class MockChangeReport implements ChangeReport.Interface { + public readonly path = "/dev/null/fix-live-report.jsonl"; + public readonly changes: ChangeReport.Change[] = []; + public readonly skips: ChangeReport.Skip[] = []; + + public change(entry: ChangeReport.Change): void { + this.changes.push(entry); + } + + public skip(entry: ChangeReport.Skip): void { + this.skips.push(entry); + } +} diff --git a/__tests__/features/FixLive/OsLiveFieldRunner.test.ts b/__tests__/features/FixLive/OsLiveFieldRunner.test.ts new file mode 100644 index 00000000..7ef708a6 --- /dev/null +++ b/__tests__/features/FixLive/OsLiveFieldRunner.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { OsLiveFieldRunner } from "~/features/FixLive/index.js"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; +import { MockChangeReport } from "./MockChangeReport.ts"; + +const TABLE = "v6-os"; +const PK = "T#root#L#en-US#CMS#CME#a"; +const INDEX = "root-headless-cms-en-us-blogpost"; + +describe("OsLiveFieldRunner", () => { + it("decompresses, decides, and rewrites only live inside the blob", async () => { + const container = createFixLiveContainer(); + const compression = container.resolve(CompressionHandler); + const latestInner = { + modelId: "blogPost", + version: 3, + status: "draft", + live: {}, + values: { a: "" } + }; + const publishedInner = { + modelId: "blogPost", + version: 2, + status: "published", + live: { version: 2 } + }; + const client = new MockDynamoDbClient({ + [TABLE]: [ + { + PK, + SK: "L", + index: INDEX, + data: await compression.compress(latestInner), + _md: "md-1" + }, + { + PK, + SK: "P", + index: INDEX, + data: await compression.compress(publishedInner), + _md: "md-1" + }, + { + PK: "T#root#L#en-US#CMS#CME#file", + SK: "L", + index: "root-headless-cms-en-us-fmfile", + data: await compression.compress({ + modelId: "fmFile", + version: 1, + status: "draft" + }), + _md: "md-1" + }, + { + PK: "T#root#L#en-US#CMS#CME#corrupt", + SK: "L", + index: INDEX, + data: { compression: "gzip", value: "not-gzip" }, + _md: "md-1" + } + ] + }); + const report = new MockChangeReport(); + + const stats = await container.resolve(OsLiveFieldRunner).run({ + mode: "live", + target: { client, tableName: TABLE, segments: 1 }, + report, + onProgress: () => {} + }); + + expect(stats.scanned).toBe(3); + expect(stats.entries).toBe(2); + expect(stats.changes["empty-live"]).toBe(1); + expect(stats.skips["decompress-failed"]).toBe(1); + expect(stats.written).toBe(1); + + const call = client.updateCalls[0]!; + expect(call.request.key).toEqual({ PK, SK: "L" }); + expect(call.request.path).toEqual(["data"]); + expect(call.request.condition).toEqual({ attribute: "_md", equals: "md-1" }); + const rewritten = await compression.decompress>(call.request.value); + expect(rewritten).toEqual({ ...latestInner, live: { version: 2 } }); + expect(report.changes[0]).toMatchObject({ + table: "os", + sk: "L", + reason: "empty-live", + before: {} + }); + }); +}); diff --git a/__tests__/features/FixLive/fixLiveContainer.ts b/__tests__/features/FixLive/fixLiveContainer.ts new file mode 100644 index 00000000..3066422e --- /dev/null +++ b/__tests__/features/FixLive/fixLiveContainer.ts @@ -0,0 +1,28 @@ +import { Container } from "@webiny/di"; +import { CompressionFeature } from "@webiny/utils/features/compression/feature.js"; +import { ContainerToken } from "~/base/index.js"; +import { TransferContext } from "~/features/TransferLifecycle/abstractions/TransferContext.js"; +import { LoggerFeature } from "~/tools/Logger/index.js"; +import { DirectoryToolFeature } from "~/tools/DirectoryTool/index.js"; +import { FileToolFeature } from "~/tools/FileTool/index.js"; +import { OsRecordDecompressorFeature } from "~/features/OsRecordDecompressor/index.js"; +import { FixLiveFeature } from "~/features/FixLive/index.js"; + +export interface FixLiveContainerOptions { + runId?: string; +} + +export function createFixLiveContainer(options: FixLiveContainerOptions = {}): Container { + const container = new Container(); + container.registerInstance(ContainerToken, container); + container.registerInstance(TransferContext, { + runId: options.runId ?? "fix-live-test-run" + }); + LoggerFeature.register(container, { logLevel: "error", json: false }); + CompressionFeature.register(container); + DirectoryToolFeature.register(container); + FileToolFeature.register(container); + OsRecordDecompressorFeature.register(container); + FixLiveFeature.register(container); + return container; +} diff --git a/__tests__/features/OpenSearchClient/enableRefreshHook.test.ts b/__tests__/features/OpenSearchClient/enableRefreshHook.test.ts index 110b523c..7ef8c29c 100644 --- a/__tests__/features/OpenSearchClient/enableRefreshHook.test.ts +++ b/__tests__/features/OpenSearchClient/enableRefreshHook.test.ts @@ -71,7 +71,8 @@ function makeHarness( writeFileOrThrow: vi.fn(), remove: vi.fn(), copy: vi.fn(), - copyOrThrow: vi.fn() + copyOrThrow: vi.fn(), + appendLineOrThrow: vi.fn() }; const container = new Container(); diff --git a/__tests__/features/OsProcessor/OsProcessor.liveField.test.ts b/__tests__/features/OsProcessor/OsProcessor.liveField.test.ts new file mode 100644 index 00000000..070515f8 --- /dev/null +++ b/__tests__/features/OsProcessor/OsProcessor.liveField.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { createOsContainer } from "../../containers/index.ts"; +import { PipelineRunner } from "~/features/PipelineRunner/index.js"; +import { PipelineBuilderFactory } from "~/features/PipelineBuilderFactory/index.js"; +import { createFilter } from "~/domain/pipeline/index.js"; +import { isCmsEntry } from "~/domain/transform/filters.js"; +import { OsScanner } from "~/features/OsScanner/index.js"; +import { OsProcessor } from "~/features/OsProcessor/index.js"; +import { addLiveField } from "~/transformers/cms/addLiveField.js"; +import { + SourceDynamoDbClient, + TargetDynamoDbClient +} from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; + +const PK = "T#root#L#en-US#CMS#CME#draft-over-published"; +const INDEX = "root-headless-cms-en-us-blogpost"; + +describe("v5-to-v6-os lane — addLiveField on a draft-over-published entry", () => { + it("writes live: { version: 2 } on both L and P documents", async () => { + const container = createOsContainer(); + const compression = container.resolve(CompressionHandler); + const sourceDb = container.resolve(SourceDynamoDbClient) as MockDynamoDbClient; + const now = "2024-01-01T00:00:00.000Z"; + await sourceDb.batchPut("source-os", [ + { + PK, + SK: "L", + index: INDEX, + data: await compression.compress({ + modelId: "blogPost", + entryId: "x", + version: 3, + status: "draft" + }), + _ct: now, + _et: "CmsEntriesElasticsearch", + _md: now + }, + { + PK, + SK: "P", + index: INDEX, + data: await compression.compress({ + modelId: "blogPost", + entryId: "x", + version: 2, + status: "published" + }), + _ct: now, + _et: "CmsEntriesElasticsearch", + _md: now + } + ]); + + const runner = container.resolve(PipelineRunner); + const builder = container.resolve(PipelineBuilderFactory).create({ + name: "CmsEntries", + scanner: OsScanner, + processors: [OsProcessor] + }); + builder.filter(createFilter(isCmsEntry)).use(addLiveField); + runner.register(await builder.build()); + await runner.run(); + + const targetDb = container.resolve(TargetDynamoDbClient) as MockDynamoDbClient; + const written = targetDb.batchPutRecords; + expect(written).toHaveLength(2); + const bySk = new Map(written.map(r => [r.SK, r])); + const latest = await compression.decompress>(bySk.get("L")!.data); + const published = await compression.decompress>( + bySk.get("P")!.data + ); + expect(latest.live).toEqual({ version: 2 }); + expect(published.live).toEqual({ version: 2 }); + }); +}); diff --git a/__tests__/features/OsProcessor/OsProcessor.test.ts b/__tests__/features/OsProcessor/OsProcessor.test.ts index e79251ae..a8bdf2f1 100644 --- a/__tests__/features/OsProcessor/OsProcessor.test.ts +++ b/__tests__/features/OsProcessor/OsProcessor.test.ts @@ -11,13 +11,17 @@ import { TouchedIndexes } from "~/features/TouchedIndexes/index.js"; import { DdbExecutor } from "~/features/DdbExecutor/abstractions/DdbExecutor.js"; import { OpenSearchClient } from "~/services/OpenSearchClient/abstractions/OpenSearchClient.js"; import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { SourceDynamoDbClient } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; import type { OsScanner } from "~/features/OsScanner/index.js"; import type { BaseTransformContext } from "~/features/TransformContext/abstractions/BaseTransformContext.js"; import { OsProcessor } from "~/features/OsProcessor/index.js"; import { MockOpenSearchClient } from "../../services/OpenSearchClient/MockOpenSearchClient.ts"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; interface OsProcessorSlice { putRecord(record: Record): void; + querySourceRecord(pk: string, sk?: string): Promise | null>; + queryTargetRecord(pk: string, sk?: string): Promise | null>; } /** @@ -335,4 +339,45 @@ describe("OsProcessor", () => { expect(entries).toHaveLength(0); }); }); + + describe("querySourceRecord", () => { + it("returns the OS row with data decompressed", async () => { + const container = createOsContainer(); + const compression = container.resolve(CompressionHandler); + const sourceDb = container.resolve(SourceDynamoDbClient) as MockDynamoDbClient; + const compressed = await compression.compress({ + modelId: "blogPost", + version: 2, + status: "published" + }); + await sourceDb.batchPut("source-os", [ + { + PK: "T#root#CMS#CME#q", + SK: "P", + index: "root-headless-cms-en-us-blogpost", + data: compressed, + _ct: "2024-01-01T00:00:00.000Z", + _et: "CmsEntriesElasticsearch", + _md: "2024-01-01T00:00:00.000Z" + } + ]); + const processor = container.resolve(Processor) as OsProcessorInstance & { + extendContext(base: BaseTransformContext.Interface): { + querySourceRecord( + pk: string, + sk?: string + ): Promise | null>; + }; + }; + const { base } = makeBase(makeOsRecord("q", "root-headless-cms-en-us-blogpost")); + + const found = await processor + .extendContext(base) + .querySourceRecord("T#root#CMS#CME#q", "P"); + + expect(found).not.toBeNull(); + expect((found!.data as Record).version).toBe(2); + expect(found!._md).toBe("2024-01-01T00:00:00.000Z"); + }); + }); }); diff --git a/__tests__/integration/fixLive.ddbRunner.test.ts b/__tests__/integration/fixLive.ddbRunner.test.ts new file mode 100644 index 00000000..7cfda1af --- /dev/null +++ b/__tests__/integration/fixLive.ddbRunner.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { DynamoDBClient, CreateTableCommand } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocument, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import { DynamoDbClientImpl } from "~/services/DynamoDbClient/DynamoDbClient.js"; +import type { SourceDynamoDbClient } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { DdbLiveFieldRunner } from "~/features/FixLive/index.js"; +import type { LiveFieldRunner } from "~/features/FixLive/abstractions/LiveFieldRunner.js"; +import { startDynalite, waitForTableActive, type DynaliteInstance } from "./dynalite.ts"; +import { NoopLogger } from "../helpers/NoopLogger.ts"; +import { createFixLiveContainer } from "../features/FixLive/fixLiveContainer.ts"; +import { MockChangeReport } from "../features/FixLive/MockChangeReport.ts"; + +const FAKE_CREDS = { accessKeyId: "test", secretAccessKey: "test" }; +const TABLE = "fix-live-ddb"; +const PK_A = "T#root#CMS#CME#a"; +const PK_B = "T#root#CMS#CME#b"; + +interface SeedRow { + PK: string; + SK: string; + TYPE: string; + _et: string; + _ct: string; + _md: string; + data: Record; +} + +function row(pk: string, sk: string, data: Record): SeedRow { + return { + PK: pk, + SK: sk, + TYPE: sk === "P" ? "cms.entry.p" : sk === "L" ? "cms.entry.l" : "cms.entry", + _et: "CmsEntries", + _ct: "2026-01-01T00:00:00.000Z", + _md: "2026-01-01T00:00:00.000Z", + data: { modelId: "blogPost", values: { emptyString: "" }, ...data } + }; +} + +const SEED: SeedRow[] = [ + row(PK_A, "L", { version: 3, status: "draft" }), + row(PK_A, "P", { version: 2, status: "published" }), + row(PK_A, "REV#0002", { version: 2, status: "published" }), + row(PK_A, "REV#0003", { version: 3, status: "draft" }), + row(PK_B, "L", { version: 1, status: "unpublished", live: { version: 1 } }), + row(PK_B, "REV#0001", { version: 1, live: { version: 1 } }) +]; + +async function createTable(doc: DynamoDBDocument, tableName: string): Promise { + await doc.send( + new CreateTableCommand({ + TableName: tableName, + BillingMode: "PAY_PER_REQUEST", + AttributeDefinitions: [ + { AttributeName: "PK", AttributeType: "S" }, + { AttributeName: "SK", AttributeType: "S" } + ], + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, + { AttributeName: "SK", KeyType: "RANGE" } + ] + }) + ); + await waitForTableActive(doc, tableName); +} + +async function scanAll(doc: DynamoDBDocument, tableName: string): Promise { + const response = await doc.send(new ScanCommand({ TableName: tableName })); + return (response.Items ?? []) as SeedRow[]; +} + +class MdBumpingClient implements SourceDynamoDbClient.Interface { + public constructor( + private readonly inner: SourceDynamoDbClient.Interface, + private readonly doc: DynamoDBDocument, + private readonly targetSk: string + ) {} + + public scan( + tableName: string, + options?: SourceDynamoDbClient.Scan + ) { + return this.inner.scan(tableName, options); + } + public query( + t: string, + pk: string, + sk?: string, + o?: SourceDynamoDbClient.Query + ) { + return this.inner.query(t, pk, sk, o); + } + public queryAll( + t: string, + pk: string, + sk?: string, + o?: SourceDynamoDbClient.Query + ) { + return this.inner.queryAll(t, pk, sk, o); + } + public get(t: string, pk: string, sk: string) { + return this.inner.get(t, pk, sk); + } + public batchPut(t: string, records: T[]) { + return this.inner.batchPut(t, records); + } + public async updateAttribute(tableName: string, request: SourceDynamoDbClient.UpdateRequest) { + if (request.key.SK === this.targetSk) { + await this.doc.update({ + TableName: tableName, + Key: request.key, + UpdateExpression: "SET #md = :md", + ExpressionAttributeNames: { "#md": "_md" }, + ExpressionAttributeValues: { ":md": "2026-09-04T00:00:00.000Z" } + }); + } + return this.inner.updateAttribute(tableName, request); + } +} + +describe("DdbLiveFieldRunner against dynalite", () => { + let instance: DynaliteInstance; + let doc: DynamoDBDocument; + let client: DynamoDbClientImpl; + + beforeAll(async () => { + instance = await startDynalite(); + doc = DynamoDBDocument.from( + new DynamoDBClient({ + endpoint: instance.endpoint, + region: "us-east-1", + credentials: FAKE_CREDS + }) + ); + await createTable(doc, TABLE); + for (const item of SEED) { + await doc.put({ TableName: TABLE, Item: item }); + } + client = new DynamoDbClientImpl( + { + region: "us-east-1", + credentials: FAKE_CREDS, + endpoint: instance.endpoint + }, + new NoopLogger() + ); + }); + + afterAll(async () => { + await instance.stop(); + }); + + function run(mode: LiveFieldRunner.Mode, useClient: SourceDynamoDbClient.Interface = client) { + const report = new MockChangeReport(); + return createFixLiveContainer() + .resolve(DdbLiveFieldRunner) + .run({ + mode, + target: { client: useClient, tableName: TABLE, segments: 2 }, + report, + onProgress: () => {} + }) + .then(stats => ({ stats, report })); + } + + it("dry run reports 4 changes and leaves the table unchanged", async () => { + const before = await scanAll(doc, TABLE); + const { stats, report } = await run("dry-run"); + + expect(stats.scanned).toBe(2); + expect(stats.entries).toBe(2); + expect(stats.changes["missing-live"]).toBe(3); + expect(stats.changes["stale-live"]).toBe(1); + expect(report.changes.map(c => c.result)).toEqual([ + "dry-run", + "dry-run", + "dry-run", + "dry-run" + ]); + expect(await scanAll(doc, TABLE)).toEqual(before); + }); + + it("live run writes data.live only and keeps an empty string byte-identical", async () => { + const { stats } = await run("live"); + + expect(stats.written).toBe(4); + expect(stats.conditionFailed).toBe(0); + const rows = await scanAll(doc, TABLE); + const data = (pk: string, sk: string) => rows.find(r => r.PK === pk && r.SK === sk)!.data; + expect(data(PK_A, "L").live).toEqual({ version: 2 }); + expect(data(PK_A, "P").live).toEqual({ version: 2 }); + expect(data(PK_A, "REV#0002").live).toEqual({ version: 2 }); + expect(data(PK_A, "REV#0003").live).toBeUndefined(); + expect(data(PK_B, "L").live).toBeNull(); + expect((data(PK_A, "L").values as Record).emptyString).toBe(""); + expect(rows.every(r => r._md === "2026-01-01T00:00:00.000Z")).toBe(true); + + const again = await run("dry-run"); + expect(again.report.changes).toEqual([]); + }); + + it("a record whose _md changed between read and write is reported as changed-during-run", async () => { + await doc.update({ + TableName: TABLE, + Key: { PK: PK_A, SK: "P" }, + UpdateExpression: "SET #d.#l = :empty", + ExpressionAttributeNames: { "#d": "data", "#l": "live" }, + ExpressionAttributeValues: { ":empty": {} } + }); + + const { stats, report } = await run("live", new MdBumpingClient(client, doc, "P")); + + expect(stats.changes["empty-live"]).toBe(1); + expect(stats.written).toBe(0); + expect(stats.conditionFailed).toBe(1); + expect(report.skips).toContainEqual({ + table: "ddb", + pk: PK_A, + sk: "P", + reason: "changed-during-run", + detail: undefined + }); + }); +}); diff --git a/__tests__/integration/fixLive.osRunner.test.ts b/__tests__/integration/fixLive.osRunner.test.ts new file mode 100644 index 00000000..97e59439 --- /dev/null +++ b/__tests__/integration/fixLive.osRunner.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { DynamoDBClient, CreateTableCommand } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocument, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { DynamoDbClientImpl } from "~/services/DynamoDbClient/DynamoDbClient.js"; +import { OsLiveFieldRunner } from "~/features/FixLive/index.js"; +import type { LiveFieldRunner } from "~/features/FixLive/abstractions/LiveFieldRunner.js"; +import { startDynalite, waitForTableActive, type DynaliteInstance } from "./dynalite.ts"; +import { NoopLogger } from "../helpers/NoopLogger.ts"; +import { createFixLiveContainer } from "../features/FixLive/fixLiveContainer.ts"; +import { MockChangeReport } from "../features/FixLive/MockChangeReport.ts"; + +const FAKE_CREDS = { accessKeyId: "test", secretAccessKey: "test" }; +const TABLE = "fix-live-os"; +const PK = "T#root#L#en-US#CMS#CME#a"; +const INDEX = "root-headless-cms-en-us-blogpost"; +const MD = "2026-01-01T00:00:00.000Z"; + +interface OsRow { + PK: string; + SK: string; + index: string; + data: unknown; + _ct: string; + _et: string; + _md: string; +} + +const LATEST_INNER = { + modelId: "blogPost", + version: 3, + status: "draft", + live: {}, + values: { s: "" } +}; +const PUBLISHED_INNER = { + modelId: "blogPost", + version: 2, + status: "published", + live: { version: 2 } +}; + +describe("OsLiveFieldRunner against dynalite", () => { + let instance: DynaliteInstance; + let doc: DynamoDBDocument; + let client: DynamoDbClientImpl; + const container = createFixLiveContainer(); + const compression = container.resolve(CompressionHandler); + + beforeAll(async () => { + instance = await startDynalite(); + doc = DynamoDBDocument.from( + new DynamoDBClient({ + endpoint: instance.endpoint, + region: "us-east-1", + credentials: FAKE_CREDS + }) + ); + await doc.send( + new CreateTableCommand({ + TableName: TABLE, + BillingMode: "PAY_PER_REQUEST", + AttributeDefinitions: [ + { AttributeName: "PK", AttributeType: "S" }, + { AttributeName: "SK", AttributeType: "S" } + ], + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, + { AttributeName: "SK", KeyType: "RANGE" } + ] + }) + ); + await waitForTableActive(doc, TABLE); + const rows: OsRow[] = [ + { + PK, + SK: "L", + index: INDEX, + data: await compression.compress(LATEST_INNER), + _ct: MD, + _et: "CmsEntriesElasticsearch", + _md: MD + }, + { + PK, + SK: "P", + index: INDEX, + data: await compression.compress(PUBLISHED_INNER), + _ct: MD, + _et: "CmsEntriesElasticsearch", + _md: MD + } + ]; + for (const item of rows) { + await doc.put({ TableName: TABLE, Item: item }); + } + client = new DynamoDbClientImpl( + { + region: "us-east-1", + credentials: FAKE_CREDS, + endpoint: instance.endpoint + }, + new NoopLogger() + ); + }); + + afterAll(async () => { + await instance.stop(); + }); + + function run(mode: LiveFieldRunner.Mode) { + const report = new MockChangeReport(); + return container + .resolve(OsLiveFieldRunner) + .run({ + mode, + target: { client, tableName: TABLE, segments: 1 }, + report, + onProgress: () => {} + }) + .then(stats => ({ stats, report })); + } + + async function readRows(): Promise { + const response = await doc.send(new ScanCommand({ TableName: TABLE })); + return (response.Items ?? []) as OsRow[]; + } + + it("dry run reports empty-live on L and changes nothing", async () => { + const before = await readRows(); + const { stats, report } = await run("dry-run"); + + expect(stats.entries).toBe(1); + expect(stats.changes["empty-live"]).toBe(1); + expect(report.changes).toEqual([ + expect.objectContaining({ + table: "os", + sk: "L", + reason: "empty-live", + before: {}, + after: { version: 2 }, + result: "dry-run" + }) + ]); + expect(await readRows()).toEqual(before); + }); + + it("live run rewrites the L blob with only live changed and leaves root attributes alone", async () => { + const { stats } = await run("live"); + + expect(stats.written).toBe(1); + const rows = await readRows(); + const latest = rows.find(r => r.SK === "L")!; + const decompressed = await compression.decompress>(latest.data); + expect(decompressed).toEqual({ ...LATEST_INNER, live: { version: 2 } }); + expect(latest._md).toBe(MD); + expect(latest.index).toBe(INDEX); + expect(rows.find(r => r.SK === "P")!.data).toEqual( + await compression.compress(PUBLISHED_INNER) + ); + + const again = await run("dry-run"); + expect(again.report.changes).toEqual([]); + }); +}); diff --git a/__tests__/services/DynamoDbClient/MockDynamoDbClient.test.ts b/__tests__/services/DynamoDbClient/MockDynamoDbClient.test.ts new file mode 100644 index 00000000..02753192 --- /dev/null +++ b/__tests__/services/DynamoDbClient/MockDynamoDbClient.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { MockDynamoDbClient } from "./MockDynamoDbClient.ts"; + +describe("MockDynamoDbClient", () => { + const rows = [ + { PK: "a", SK: "L", _md: "1", data: { live: null } }, + { PK: "a", SK: "P", _md: "1", data: {} }, + { PK: "b", SK: "L", _md: "2", data: {} } + ]; + + it("scan honours sortKeyEquals and limit", async () => { + const client = new MockDynamoDbClient({ t: rows }); + const seen = []; + for await (const row of client.scan("t", { sortKeyEquals: "L", limit: 1 })) { + seen.push(row); + } + expect(seen).toEqual([rows[0]]); + }); + + it("updateAttribute writes a nested path when the condition holds", async () => { + const client = new MockDynamoDbClient({ t: structuredClone(rows) }); + const result = await client.updateAttribute("t", { + key: { PK: "a", SK: "L" }, + path: ["data", "live"], + value: { version: 2 }, + condition: { attribute: "_md", equals: "1" } + }); + expect(result).toBe("written"); + expect((client.getRecordsForTable("t")[0]!.data as Record).live).toEqual({ + version: 2 + }); + }); + + it("updateAttribute returns condition-failed and leaves the record untouched", async () => { + const client = new MockDynamoDbClient({ t: structuredClone(rows) }); + const result = await client.updateAttribute("t", { + key: { PK: "a", SK: "L" }, + path: ["data", "live"], + value: { version: 2 }, + condition: { attribute: "_md", equals: "stale" } + }); + expect(result).toBe("condition-failed"); + expect( + (client.getRecordsForTable("t")[0]!.data as Record).live + ).toBeNull(); + expect(client.updateCalls).toHaveLength(1); + }); +}); diff --git a/__tests__/services/DynamoDbClient/MockDynamoDbClient.ts b/__tests__/services/DynamoDbClient/MockDynamoDbClient.ts index b2effb3f..f6112a43 100644 --- a/__tests__/services/DynamoDbClient/MockDynamoDbClient.ts +++ b/__tests__/services/DynamoDbClient/MockDynamoDbClient.ts @@ -1,12 +1,16 @@ import { SourceDynamoDbClient } from "../../../src/services/DynamoDbClient/abstractions/DynamoDbClient.ts"; import type { BaseRecord } from "../../../src/domain/transform/types/records.ts"; -/** - * Mock implementation of IDynamoDbClient for testing. - */ +export interface MockUpdateCall { + tableName: string; + request: SourceDynamoDbClient.UpdateRequest; + result: SourceDynamoDbClient.UpdateResult; +} + export class MockDynamoDbClient implements SourceDynamoDbClient.Interface { private records: Map = new Map(); public batchPutRecords: SourceDynamoDbClient.Record[] = []; + public updateCalls: MockUpdateCall[] = []; constructor(initialRecords: Record = {}) { for (const [table, records] of Object.entries(initialRecords)) { @@ -19,16 +23,26 @@ export class MockDynamoDbClient implements SourceDynamoDbClient.Interface { options?: SourceDynamoDbClient.Scan ): AsyncIterable { const records = this.records.get(tableName) || []; + let yielded = 0; - if (options && options.segment !== undefined && options.totalSegments) { - for (let i = 0; i < records.length; i++) { - if (i % options.totalSegments === options.segment) { - yield records[i] as T; + for (let i = 0; i < records.length; i++) { + const record = records[i]!; + if (options && options.segment !== undefined && options.totalSegments) { + if (i % options.totalSegments !== options.segment) { + continue; } } - } else { - for (const record of records) { - yield record as T; + if ( + options && + options.sortKeyEquals !== undefined && + record.SK !== options.sortKeyEquals + ) { + continue; + } + yield record as T; + yielded++; + if (options && options.limit !== undefined && yielded >= options.limit) { + return; } } } @@ -82,6 +96,34 @@ export class MockDynamoDbClient implements SourceDynamoDbClient.Interface { this.records.set(tableName, tableRecords); } + async updateAttribute( + tableName: string, + request: SourceDynamoDbClient.UpdateRequest + ): Promise { + const records = this.records.get(tableName) || []; + const record = records.find(r => r.PK === request.key.PK && r.SK === request.key.SK); + const current = record ? record[request.condition.attribute] : undefined; + const holds = JSON.stringify(current) === JSON.stringify(request.condition.equals); + const result: SourceDynamoDbClient.UpdateResult = + record && holds ? "written" : "condition-failed"; + + if (record && holds) { + let cursor = record as Record; + for (let i = 0; i < request.path.length - 1; i++) { + const segment = request.path[i]!; + const next = cursor[segment]; + if (typeof next !== "object" || next === null) { + cursor[segment] = {}; + } + cursor = cursor[segment] as Record; + } + cursor[request.path[request.path.length - 1]!] = request.value; + } + + this.updateCalls.push({ tableName, request, result }); + return result; + } + // Test helpers getRecordsForTable(tableName: string): SourceDynamoDbClient.Record[] { return this.records.get(tableName) || []; @@ -89,5 +131,6 @@ export class MockDynamoDbClient implements SourceDynamoDbClient.Interface { clearRecords(): void { this.batchPutRecords = []; + this.updateCalls = []; } } diff --git a/__tests__/services/DynamoDbClient/scanOptions.test.ts b/__tests__/services/DynamoDbClient/scanOptions.test.ts new file mode 100644 index 00000000..31ef6220 --- /dev/null +++ b/__tests__/services/DynamoDbClient/scanOptions.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import { DynamoDbClientImpl } from "../../../src/services/DynamoDbClient/DynamoDbClient.ts"; +import { NoopLogger } from "../../helpers/NoopLogger.ts"; + +interface SendInput { + input: Record; +} + +function makeClient(): { client: DynamoDbClientImpl; send: ReturnType } { + const client = new DynamoDbClientImpl({ region: "us-east-1" }, new NoopLogger(), { + maxRetries: 0, + initialBackoffMs: 1 + }); + const send = vi.fn(); + vi.spyOn( + (client as unknown as { client: { send: () => unknown } }).client, + "send" + ).mockImplementation(send); + return { client, send }; +} + +describe("DynamoDbClientImpl.scan options", () => { + it("adds FilterExpression SK = :sk when sortKeyEquals is set", async () => { + const { client, send } = makeClient(); + send.mockResolvedValue({ Items: [{ PK: "a", SK: "L" }] }); + + const rows = []; + for await (const row of client.scan("t", { sortKeyEquals: "L" })) { + rows.push(row); + } + + const input = (send.mock.calls[0]![0] as SendInput).input; + expect(input.FilterExpression).toBe("SK = :sk"); + expect(input.ExpressionAttributeValues).toEqual({ ":sk": "L" }); + expect(rows).toHaveLength(1); + }); + + it("stops after `limit` yielded items even when more pages exist", async () => { + const { client, send } = makeClient(); + send.mockResolvedValue({ + Items: [ + { PK: "a", SK: "L" }, + { PK: "b", SK: "L" }, + { PK: "c", SK: "L" } + ], + LastEvaluatedKey: { PK: "c", SK: "L" } + }); + + const rows = []; + for await (const row of client.scan("t", { limit: 2 })) { + rows.push(row); + } + + expect(rows).toHaveLength(2); + expect(send).toHaveBeenCalledTimes(1); + expect((send.mock.calls[0]![0] as SendInput).input.Limit).toBe(2); + }); +}); diff --git a/__tests__/services/DynamoDbClient/updateAttribute.test.ts b/__tests__/services/DynamoDbClient/updateAttribute.test.ts new file mode 100644 index 00000000..bf7b2697 --- /dev/null +++ b/__tests__/services/DynamoDbClient/updateAttribute.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest"; +import { DynamoDbClientImpl } from "../../../src/services/DynamoDbClient/DynamoDbClient.ts"; +import { NoopLogger } from "../../helpers/NoopLogger.ts"; + +interface SendInput { + input: Record; +} + +function makeClient(): { client: DynamoDbClientImpl; send: ReturnType } { + const client = new DynamoDbClientImpl({ region: "us-east-1" }, new NoopLogger(), { + maxRetries: 0, + initialBackoffMs: 1 + }); + const send = vi.fn(); + vi.spyOn( + (client as unknown as { client: { send: () => unknown } }).client, + "send" + ).mockImplementation(send); + return { client, send }; +} + +function conditionalCheckFailed(): Error { + const error = new Error("The conditional request failed"); + error.name = "ConditionalCheckFailedException"; + return error; +} + +describe("DynamoDbClientImpl.updateAttribute", () => { + it("builds a SET path expression with a condition and returns written", async () => { + const { client, send } = makeClient(); + send.mockResolvedValue({}); + + const result = await client.updateAttribute("t", { + key: { PK: "p", SK: "L" }, + path: ["data", "live"], + value: { version: 2 }, + condition: { attribute: "_md", equals: "md-1" } + }); + + expect(result).toBe("written"); + const input = (send.mock.calls[0]![0] as SendInput).input; + expect(input.TableName).toBe("t"); + expect(input.Key).toEqual({ PK: "p", SK: "L" }); + expect(input.UpdateExpression).toBe("SET #p0.#p1 = :v"); + expect(input.ConditionExpression).toBe("#c = :c"); + expect(input.ExpressionAttributeNames).toEqual({ + "#p0": "data", + "#p1": "live", + "#c": "_md" + }); + expect(input.ExpressionAttributeValues).toEqual({ + ":v": { version: 2 }, + ":c": "md-1" + }); + }); + + it("returns condition-failed on ConditionalCheckFailedException without retrying", async () => { + const { client, send } = makeClient(); + send.mockRejectedValue(conditionalCheckFailed()); + + const result = await client.updateAttribute("t", { + key: { PK: "p", SK: "L" }, + path: ["data", "live"], + value: null, + condition: { attribute: "_md", equals: "md-1" } + }); + + expect(result).toBe("condition-failed"); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("propagates every other error", async () => { + const { client, send } = makeClient(); + const error = new Error("boom"); + error.name = "ValidationException"; + send.mockRejectedValue(error); + + await expect( + client.updateAttribute("t", { + key: { PK: "p", SK: "L" }, + path: ["data"], + value: {}, + condition: { attribute: "_md", equals: "x" } + }) + ).rejects.toMatchObject({ name: "ValidationException" }); + }); +}); diff --git a/__tests__/tools/FileTool/FileTool.test.ts b/__tests__/tools/FileTool/FileTool.test.ts index fb3cb3ed..29d2ac4a 100644 --- a/__tests__/tools/FileTool/FileTool.test.ts +++ b/__tests__/tools/FileTool/FileTool.test.ts @@ -143,4 +143,16 @@ describe("FileTool Feature", () => { ).toThrow("Source file not found"); }); }); + + describe("appendLineOrThrow", () => { + it("creates the file and parent directory, then appends one line per call", () => { + const filePath = join(tmpDir, "nested", "report.jsonl"); + const tool = resolve(); + + tool.appendLineOrThrow(filePath, '{"a":1}'); + tool.appendLineOrThrow(filePath, '{"b":2}'); + + expect(readFileSync(filePath, "utf-8")).toBe('{"a":1}\n{"b":2}\n'); + }); + }); }); diff --git a/__tests__/transformers/cms/addLiveField.test.ts b/__tests__/transformers/cms/addLiveField.test.ts index 005a922a..edbda127 100644 --- a/__tests__/transformers/cms/addLiveField.test.ts +++ b/__tests__/transformers/cms/addLiveField.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { addLiveField } from "~/transformers/cms/addLiveField.js"; import { makeFakeDdbCoreContext } from "../fakeContext.ts"; +import { NoopLogger } from "../../helpers/NoopLogger.ts"; const BASE = { PK: "T#root#L#en-US#CMS#CME#CME#abc123", @@ -112,4 +113,86 @@ describe("addLiveField", () => { expect((ctxRev.record.data as Record).live).toBeNull(); expect(ctxRev.querySourceRecord).not.toHaveBeenCalled(); }); + + it("reads version from data when P comes back in the decompressed OS row shape", async () => { + const ctx = makeFakeDdbCoreContext({ + ...BASE, + data: { ...BASE.data, version: 3, status: "draft" } + }); + ctx.querySourceRecord = vi.fn().mockResolvedValue({ + PK: BASE.PK, + SK: "P", + index: "root-headless-cms-en-us-blogpost", + data: { modelId: "blogPost", version: 2, status: "published" }, + _ct: "2024-01-01T00:00:00.000Z", + _et: "CmsEntriesElasticsearch", + _md: "2024-01-01T00:00:00.000Z" + }); + + await addLiveField(ctx); + + expect((ctx.record.data as Record).live).toEqual({ version: 2 }); + }); + + it("never emits { version: undefined } — a raw compressed P row yields live: null and warns", async () => { + const logger = new NoopLogger(); + const ctx = makeFakeDdbCoreContext(BASE, { logger }); + ctx.querySourceRecord = vi.fn().mockResolvedValue({ + PK: BASE.PK, + SK: "P", + index: "root-headless-cms-en-us-blogpost", + data: { compression: "gzip", value: "H4sIAAAAAAAAA6tWKkpNLKlUslIqLcpRqgUAn7mB6RAAAAA=" } + }); + + await addLiveField(ctx); + + expect((ctx.record.data as Record).live).toBeNull(); + expect(logger.entries.some(e => e.level === "warn" && e.message.includes(BASE.PK))).toBe( + true + ); + }); + + it("treats a non-integer P version as no published revision", async () => { + const ctx = makeFakeDdbCoreContext(BASE); + ctx.querySourceRecord = vi.fn().mockResolvedValue({ version: "2" }); + + await addLiveField(ctx); + + expect((ctx.record.data as Record).live).toBeNull(); + }); + + it("queries P for an unpublished L record and sets live: null when none exists", async () => { + const ctx = makeFakeDdbCoreContext({ + ...BASE, + data: { ...BASE.data, version: 4, status: "unpublished" } + }); + ctx.querySourceRecord = vi.fn().mockResolvedValue(null); + + await addLiveField(ctx); + + expect(ctx.querySourceRecord).toHaveBeenCalledWith(BASE.PK, "P"); + expect((ctx.record.data as Record).live).toBeNull(); + }); + + it("live.version is a number whenever live is non-null", async () => { + const shapes: Array | null> = [ + { version: 2 }, + { data: { version: 5 } }, + { version: 0 }, + { version: 1.5 }, + { data: {} }, + null + ]; + for (const shape of shapes) { + const ctx = makeFakeDdbCoreContext(BASE); + ctx.querySourceRecord = vi.fn().mockResolvedValue(shape); + await addLiveField(ctx); + const live = (ctx.record.data as Record).live as { + version: unknown; + } | null; + if (live !== null) { + expect(typeof live.version).toBe("number"); + } + } + }); }); diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 7a07d6a6..deb2ba45 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -6,9 +6,18 @@ yarn install ``` -## Guided setup (recommended) +## Command menu -`yarn transfer` (no `--config`) launches `TransferWizard`. It walks you through: +`yarn transfer` with no arguments opens a menu of available commands: + +- **transfer** — system-to-system transfer (the guided `TransferWizard` below). +- **fix-live** — reconcile the `live` field on a migrated v6 system (see [fix-live](#fix-live)). + +Press Esc / Ctrl+C at any prompt to leave; the process exits with code 130. Every command can also be invoked directly (`yarn transfer transfer`, `yarn transfer fix-live`) and non-interactively with flags — see each section. `yarn transfer --config=… --preset=…` and `yarn transfer ` (scaffold) keep working exactly as before. + +## Guided transfer setup (recommended) + +`yarn transfer` → **transfer** (or `yarn transfer transfer`) launches `TransferWizard`. It walks you through: 1. Selecting a project from `projects/`. 2. Collecting your Webiny output or Pulumi state JSON files and writing `.env`. @@ -79,7 +88,45 @@ yarn transfer --config=./projects/v5-to-v6/config.ts --preset=v5-to-v6-os yarn transfer --config=... --segments=1,3 ``` -Runs only the listed indices. Workers still receive `--total=`, so each shard scans the exact same slice as in a full run. Use after a partial failure to avoid re-scanning the whole table. Parsing + validation live in `src/commands/run/segmentsFilter.ts`. +Runs only the listed indices. Workers still receive `--total=`, so each shard scans the exact same slice as in a full run. Use after a partial failure to avoid re-scanning the whole table. Parsing + validation live in `src/commands/transfer/segmentsFilter.ts`. + +## fix-live + +Repairs the `live` field on CMS entry records of a system that has **already been migrated to v6**. Earlier OpenSearch migrations could leave `live: {}` on the `L` document of entries whose latest revision is a draft on top of an older published revision, so those entries do not show as published. `fix-live` scans the DynamoDB table and, when the system has one, the OpenSearch companion table, and makes `L`, `P` and the published `REV#` record agree with the actual published state. + +### Non-interactive + +```bash +yarn transfer fix-live --project=acme --system=target --dry-run +yarn transfer fix-live --project=acme --system=target --live --yes +yarn transfer fix-live --project=acme --system=target --dry-run --table=ddb +``` + +| Flag | Meaning | +| --- | --- | +| `--project` | Project folder under `projects/` (its `config.ts` is loaded). | +| `--system` | `source` or `target` — the system whose records are modified. | +| `--dry-run` / `--live` | Mutually exclusive. `--live` exits 1 unless a dry run completed for the same project and system. | +| `--yes` | Skip the system confirm and the live-run confirm. | +| `--table` | `ddb` or `os`; default both. The v6 check always runs on the DDB table. | +| `--concurrency` | Scan segments in flight (default 4). Segment count comes from `pipeline.segments`. | + +Exit codes: `0` success, `1` refused or failed (v5 table, missing dry run, unknown project, run error), `130` cancelled. + +### Dry run before live + +A live run is only allowed after a dry run completed for the same project and system. The dry run writes `.transfer/state/fix-live/__.json` with `lastDryRun { runId, at, changes, skips }`; a live run reads it, recomputes everything from scratch (data may have changed), warns when the change count differs, and records `lastLiveRun`. There is no expiry. + +### Report + +Every run writes `.transfer//fix-live-report.jsonl`, one JSON line per change or skip: + +```json +{"kind":"change","table":"ddb","pk":"T#root#CMS#CME#abc","sk":"L","reason":"missing-live","before":null,"after":{"version":2},"result":"dry-run"} +{"kind":"skip","table":"ddb","pk":"T#root#CMS#CME#def","sk":"REV#0007","reason":"revision-version-mismatch","detail":"P.version=7 REV#0007.version=6"} +``` + +`result` is `dry-run`, `written` or `condition-failed`. Change reasons: `missing-live`, `empty-live`, `wrong-version`, `stale-live`. Skip reasons: `no-latest-record`, `invalid-version`, `revision-record-missing`, `revision-version-mismatch`, `latest-status-contradicts-published`, `latest-status-contradicts-unpublished`, `decompress-failed`, `changed-during-run`. A skip means the whole entry was left untouched. ## Scaffolding diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index b9b31df0..95bccb2a 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -70,6 +70,21 @@ Records that match no pipeline are dropped. Check: To transfer everything, add a catch-all pipeline with no filters (registered last). +### Published entries not showing as live after migration + +Entries whose latest revision is a draft on top of an older published revision may have ended up with `live: {}` in the OpenSearch companion table. Run the reconciler against the migrated system — dry run first, then live: + +```bash +yarn transfer fix-live --project= --system=target --dry-run +yarn transfer fix-live --project= --system=target --live +``` + +See [fix-live](commands.md#fix-live). Notes on the report: + +- `changed-during-run` — an editor saved the record between read and write, so the conditional update was refused. Nothing was overwritten; re-run to pick it up. +- `latest-status-contradicts-published` / `latest-status-contradicts-unpublished` — the `L` record's `status` disagrees with the presence or version of `P`. The tool never guesses; inspect the entry in the admin UI and republish or unpublish it, then re-run. +- `revision-record-missing` / `revision-version-mismatch` — `P` points at a revision that does not exist or carries a different version. Same treatment: fix the entry, re-run. + ## Debugging ### Enable snapshot mode diff --git a/docs/handoff/2026-09-04-fix-live-design.md b/docs/handoff/2026-09-04-fix-live-design.md new file mode 100644 index 00000000..80bccd35 --- /dev/null +++ b/docs/handoff/2026-09-04-fix-live-design.md @@ -0,0 +1,37 @@ +# Session Handoff — 2026-09-04 — Fix Live Field Design & Plans + +## What was done + +- **Diagnosed the `live` bug.** Some migrated CMS entries were not marked live. Root cause confirmed in code: `addLiveField` reads `published.version` from the root of the record returned by `ctx.querySourceRecord(PK, "P")`. In the OS preset that source row is the v5 Elasticsearch companion table, where `version` sits inside the gzipped `data` blob, so the read yields `undefined` and the target document ends up with `live: {}`. Hits `L` docs of entries whose latest revision is a draft on top of an older published revision. DDB preset unaffected. +- **Verified v6 `live` semantics** in `webiny-js-next`: v6 maintains `live: { version }` on `L`, `P`, and the published `REV#` only. Other `REV#` records carry best-effort copies that v6 itself leaves stale. The reconciler enforces exactly that invariant and never writes other `REV#` records. +- **Designed and approved** `docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md`: transformer fix, a `fix-live` reconciler command (DDB + OS tables, dry run gate, JSONL report, `UpdateItem`-only writes conditioned on `_md`), and a clack-based CLI command menu with `Prompts`/`UI` abstractions and a `Command` registry. Spec was subagent-reviewed (26 findings) and revised. +- **Wrote two implementation plans:** `docs/superpowers/plans/2026-09-04-fix-live-reconciler.md` (11 tasks) and `docs/superpowers/plans/2026-09-04-cli-command-menu.md` (10 tasks). Reconciled the seam between them: the CLI plan's contract table now names the reconciler plan's real exports (`DdbLiveFieldRunner` / `OsLiveFieldRunner`, `LiveFieldRunner.Target`, `FixLiveState` with `read` / `recordDryRun` / `recordLiveRun`). +- **Shared project MCP config:** `.mcp.json` (stdlib + codegraph) is now tracked; removed from `.gitignore`. +- **Dependencies updated** (`package.json`, `yarn.lock`). +- 4 commits. 722 tests in 110 files pass. Nothing from the spec is implemented yet. + +## Key decisions + +- **Scope of reconciliation:** `L`, `P`, published `REV#` only. Fill missing, clear stale (no `P`), correct wrong version. Any contradiction (e.g. `L` marked published with no `P`, `L` at published version but not marked published, `REV#` missing or version mismatch) is a skip with a reason, never a write. +- **Reads:** scan `L` rows per segment (server-side `SK = L` filter), then `queryAll(PK)` per entry for an authoritative group. No reliance on scan ordering. Chosen over group-by-PK streaming after review found a data-loss hole. +- **Writes:** `UpdateItem` on `data.live` (DDB) or `data` (OS, recompressed blob), conditioned on `_md` unchanged. Never a whole-record put: the document client has `convertEmptyValues: true`, so a put round-trip would corrupt empty strings. `ConditionalCheckFailedException` → `changed-during-run` skip, not retried. +- **v6 guard** on the DDB table (CMS entry `L` row has `data` object at root; v5 is flat). OS table cannot be independently verified, so the OS runner only runs after the DDB guard passed for the same system. +- **Dry run gate:** live run refused without a prior dry run for that project + system (state under `.transfer/state/fix-live/__.json`). Live run recomputes; count difference is a soft warning. +- **Console shows counts only.** Per-record detail goes to `.transfer//fix-live-report.jsonl`. +- **CLI:** `yarn transfer` with no args opens a clack menu over a `Command` registry. Yargs stays for flags. `yarn transfer --config --preset` and `yarn transfer ` must keep working. Exit 130 on cancel. New CLI code lives under `src/commands/` (a `src/cli/` dir would collide with the `src/cli.ts` bin entry). Prompt patterns from `~/private/prijevodi-online-2010/src/cli`, command shape from `~/private/dependency-upgrader/src/cli`. +- **Transformer fix preferred form:** `OsProcessor.querySourceRecord` returns the decompressed row so every OS-lane transformer sees the DDB shape; `addLiveField` adds a positive-integer guard and `cached !== undefined`. + +## Current state + +- Branch: `bruno/feat/fix-target-system-live-property` +- Tests: 722 passed (110 files) +- Typecheck: passing +- Unpushed commits: 4 (branch not on origin) + +## What might come next + +1. Execute `docs/superpowers/plans/2026-09-04-fix-live-reconciler.md` task by task. Task 1 (transformer fix + patch changeset) can ship on its own. +2. Then execute `docs/superpowers/plans/2026-09-04-cli-command-menu.md`. Its Task 8 Step 1 applies the contract-table substitutions before writing the command. +3. Open questions from the spec: confirm v6's DynamoDB stream handler treats a `data`-only change on the OS companion table as an index update; decide whether `fix-live` should report stale `live` on non-published `REV#` as diagnostics. +4. Follow-up after the menu lands: migrate `init` / `initProject` from inquirer to `Prompts` and drop the inquirer dependency. +5. Process note: subagent plan-writing was expensive this session (two agents, ~250k tokens each, partly because of a stop/resume cycle). Prefer writing plans in the main session from the spec, or give agents a hard read budget. diff --git a/docs/hard-won-decisions.md b/docs/hard-won-decisions.md index 94b8a64a..5f32a556 100644 --- a/docs/hard-won-decisions.md +++ b/docs/hard-won-decisions.md @@ -23,9 +23,16 @@ These are one-line summaries. Each links to a spec or PR if fuller context is ne - **Built-in presets are auto-discovered** — `PresetLoader` scans `src/presets/` (relative to its own `import.meta.url`, so dev / installed layouts both work). Convention: **filename === preset name**. Adding a built-in is a file drop, not a code change. Don't reintroduce a hardcoded `BUILT_IN_PRESETS` map or a "register your preset here" registry. - **`v5-to-v6-os` pipeline ordering is load-bearing** — `BackgroundTasks` and `MailerSettings` are blackholed and registered BEFORE `CmsEntries` because both are CMS entries in the OS table (same `TYPE` prefix `cms.entry.*`) and would otherwise be claimed by the catch-all. `FileManagerFiles` must also precede `CmsEntries` for the same reason. Mailer settings are blackholed because v6 stores them in the KV store — the DDB preset handles that migration; the OS record has no v6 target. - **DDB parallel scan guarantees same-PK records land in the same segment** — the scan divides by hash range, so all revisions of the same CMS entry (L, P, REV#...) always go to the same worker. This means an in-process `ctx.cache` keyed by PK is sufficient for per-entry deduplication — no cross-worker shared cache is needed. Queries for sibling records within the same entry are deduplicated by the cache; the first record encountered does the query, subsequent siblings hit the cache. -- **`addLiveField` cache+sentinel pattern** — the transformer uses `ctx.cache` keyed by `ctx.original.PK`. Sentinel value `-1` means "queried, no published revision found" — avoids re-querying. P records skip the query entirely (they ARE the published revision) and populate the cache for siblings. The sentinel must be non-zero (versions start at 1) and truthy (so `if (cached)` correctly identifies a prior miss). Don't use `null` or `undefined` as the sentinel — those are cache misses. +- **`addLiveField` cache+sentinel pattern** — the transformer uses `ctx.cache` keyed by `ctx.original.PK`. Sentinel value `-1` means "queried, no published revision found" — avoids re-querying. P records skip the query entirely (they ARE the published revision) and populate the cache for siblings. The cache check is `cached !== undefined` (`Cache.get` returns `T | undefined`), so the sentinel only needs to be distinct from every valid version (versions start at 1) — it no longer needs to be truthy. Never store `undefined` as a cache value; that is indistinguishable from a miss. `version` is read from the record root, then `data` (the OS lane returns decompressed rows from `OsProcessor.querySourceRecord`), and only a positive integer is accepted — the transformer never emits `{ version: undefined }` (2026-09-04). - **`isModel` guard requires `fields[]`** — `ModelProvider.extractModels` distinguishes model definitions from CMS entry records by requiring `Array.isArray(value.fields)`. Both have a `modelId` field, but only model definitions carry `fields[]`. Without this guard, CMS entry records (which have `modelId` as a reference field) would be loaded as models and crash downstream transformers (`visitFields` would receive `undefined` instead of an array). - **OS transformer context typing** — `createOsTransformer` binds `OsTransformContext.Interface`. `OsScanner.Record` has non-optional `index: string` and `data: Record` — both are always present (OsScanner skips records where decompression fails). Don't add absent-data guards in OS transformers; trust the scanner contract. Test stubs for OS transformers use `makeFakeOsContext` from `__tests__/transformers/fakeContext.ts`. - **PipelineCustomizer extends presets without forking** (2026-06-29) — users implement `PipelineCustomizer.Interface` via `config.register` or `setup.ts` to add filters/transformers to built-in preset pipelines by name. `canUse(pipelineName)` targets pipelines; `configure(builder)` appends to them. The `PipelineBuilderFactory` injects customizers via `[PipelineCustomizer, { multiple: true }]` and passes them to `PipelineBuilder`; `build()` applies matching customizers after the preset's own filters/transformers. `PipelineCustomizerBuilder` is a slim accumulator (`.filter()` + `.use()` only) — users cannot change scanner, processors, hooks, or pipeline-level blackhole. Type erasure to `any` on the slim builder is deliberate (pipeline names are runtime strings; compile-time context typing is impossible). `warnUnmatchedCustomizers(logger)` runs after `preset.configure()` in both handlers. Don't add new builder methods (`.blackhole()`, `.beforeExecuteCommands()`) to the customizer builder — those are pipeline-level concerns owned by the preset. - **Per-record `ctx.blackhole()`** (2026-06-29) — transformers can suppress writes for individual records by calling `ctx.blackhole()`. Same semantics as pipeline-level blackhole: remaining transformers + `onEnd` hooks still run, commands are discarded at the fold step. `isBlackholed` is a closured boolean per `create()` call, not shared state. Runner checks `pipeline.isBlackhole || ctx.isBlackholed`. No undo — once called, the record is blackholed. Use for async guard logic (e.g., query target DDB to skip duplicates). Don't confuse with pipeline-level `.blackhole()` on the builder — that's all-or-nothing. +- **`fix-live` reconciles only the v6-maintained invariant** (2026-09-04) — `L`, `P` and the published `REV#` carry `live: { version }`; other `REV#` records keep a best-effort copy that v6 itself leaves stale, so the reconciler never writes them. Fill missing, clear stale, correct wrong version — nothing else. +- **`fix-live` writes only when certain** (2026-09-04) — any ambiguity (status contradicts `P`, missing/mismatched revision record, decompress failure) is a `skipped` report line for the whole PK, never a partial write. A wrong "fix" is worse than no fix. +- **`fix-live` uses `UpdateItem` with a path expression, never `PutItem`** (2026-09-04) — the document client is built with `convertEmptyValues: true`; a whole-record round-trip would turn every `""` into `NULL` and re-encode numbers. `updateAttribute` leaves untouched attributes byte-identical. +- **`fix-live` scans `L` rows and `queryAll(PK)`s per entry** (2026-09-04) — no reliance on scan ordering or PK locality; one bounded query per entry removes the "group was incomplete" class of bugs. +- **`fix-live` conditions every write on `_md`** (2026-09-04) — `ConditionalCheckFailedException` → `changed-during-run` skip, never retried, never overwrites a fresher record. +- **CLI commands are `Command` implementations behind a lazy registry** (2026-09-04) — one `Cli/Command` token, many implementations; `CommandRegistry` calls `resolveAll` on first use. Command constructors take only `Prompts` / `UI`; the per-project container is built inside `run()`. `hidden: true` keeps a command out of the menu (positional-only commands, the `process-segment` worker) without removing it from `--help`. The `$0 [folder]` default command exists solely for `yarn transfer ` and `yarn transfer --config --preset` compatibility — don't add new behaviour to it; add a command. +- **Prompt libraries stay behind `Prompts` / `UI`** (2026-09-04) — `select` / `confirm` / `text` return `null` on cancel and never exit; commands map `null` to exit 130. Tests use `StubPrompts` / `StubUI` with scripted answers. Only `Clack*.ts` import `@clack/prompts`. - **Processors persist their own state via `afterShard`** (2026-04-21) — the previous `getShardState()` + handler-side collection/serialization was the worker handler pulling state OUT of processors, then writing it. `afterShard({ segment, totalSegments })` inverts the direction: the processor owns its state AND its persistence end-to-end, injecting `TransferContext` / `FileTool` / `DirectoryTool` directly. The `processOsSegment` handler is now identical to `processSegment` (bootstrap → configure → run). Runner fires `afterShard` sequentially in array order after `execute()`, before `warnUnclaimedKeys`. Optional hook — DdbProcessor / S3Processor skip it (no cross-boundary state). When `touchedIndexes` is empty, OsProcessor writes nothing — `EnableRefreshHook` tolerates a missing `.transfer//` dir. Don't reintroduce a handler-side state-collection loop. diff --git a/docs/mcp/guides/pipelineRuntime.md b/docs/mcp/guides/pipelineRuntime.md index 6130574c..87f799e6 100644 --- a/docs/mcp/guides/pipelineRuntime.md +++ b/docs/mcp/guides/pipelineRuntime.md @@ -6,7 +6,7 @@ category: Guides # Pipeline runtime -How records flow through the transfer pipeline at runtime, and the exact ordering of every hook the runner invokes. Source: `docs/guides/pipeline-runtime.md`, `src/features/PipelineRunner/PipelineRunner.ts`, `src/commands/run/handler.ts`, `src/commands/processSegment/handler.ts`. +How records flow through the transfer pipeline at runtime, and the exact ordering of every hook the runner invokes. Source: `docs/guides/pipeline-runtime.md`, `src/features/PipelineRunner/PipelineRunner.ts`, `src/commands/transfer/handler.ts`, `src/commands/processSegment/handler.ts`. ## Merge groups (keyed by scanner) @@ -95,12 +95,12 @@ Net effect: peak memory is bounded to roughly `flushEvery × average_record_size ## Parallelism: segments, shards, and worker processes -`pipeline.segments` (optional in the schema; the orchestrator falls back to `1` if unset — `config.pipeline?.segments || 1` in `src/commands/run/handler.ts`. The example config in `configReference.md` sets `numberFromEnv("SEGMENTS", 4)`, but that `4` is a user-chosen convention, not a schema default) sets **both**: +`pipeline.segments` (optional in the schema; the orchestrator falls back to `1` if unset — `config.pipeline?.segments || 1` in `src/commands/transfer/handler.ts`. The example config in `configReference.md` sets `numberFromEnv("SEGMENTS", 4)`, but that `4` is a user-chosen convention, not a schema default) sets **both**: - how many shards each scanner's `listShards()` reports (`{ segment: i, total: segments }`, passed straight through to DynamoDB's native parallel-`Scan` `Segment`/`TotalSegments` parameters), and - how many **child worker processes** the orchestrator spawns. -The orchestrator (`src/commands/run/handler.ts`) resolves `segmentsToRun` (all segments, or a filtered subset via `--segments=1,3`), then spawns one worker per segment **concurrently**: +The orchestrator (`src/commands/transfer/handler.ts`) resolves `segmentsToRun` (all segments, or a filtered subset via `--segments=1,3`), then spawns one worker per segment **concurrently**: ```typescript const workers = segmentsToRun.map(segment => diff --git a/docs/pino-logger-implementation.md b/docs/pino-logger-implementation.md index a7177549..fa20b72b 100644 --- a/docs/pino-logger-implementation.md +++ b/docs/pino-logger-implementation.md @@ -56,7 +56,7 @@ Controlled by `config.debug.logFile`: ### Run ID -Generated in `src/commands/run/handler.ts` (not `src/cli.ts`). Passed to all worker processes. +Generated in `src/commands/transfer/handler.ts` (not `src/cli.ts`). Passed to all worker processes. ## Gotchas diff --git a/docs/superpowers/plans/2026-09-04-cli-command-menu.md b/docs/superpowers/plans/2026-09-04-cli-command-menu.md new file mode 100644 index 00000000..ef7a9f9d --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-cli-command-menu.md @@ -0,0 +1,3363 @@ +# CLI Command Menu + `fix-live` Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement steps 7–10 of `docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md`: `Prompts` / `UI` abstractions backed by `@clack/prompts`, a `Command` token + `CommandRegistry`, a new `src/cli.ts` that opens a guided menu when invoked with no arguments while keeping `yarn transfer --config --preset` and `yarn transfer ` working, the move of `src/commands/run/` to `src/commands/transfer/`, the `FixLiveCommand` guided flow, guide/AGENTS/hard-won-decision updates, and a `minor` changeset. + +**Architecture:** Everything new lives under `src/commands/` (a `src/cli/` directory would collide with `src/cli.ts` under module resolution). `Command` is one DI token with many implementations, collected by `CommandRegistry` via `container.resolveAll(Command)` on first use so nothing heavy is constructed at CLI start. Commands depend only on `Prompts` and `UI` in their constructors; `FixLiveCommand.run()` builds the per-project container the way `run/handler.ts` does today (`discoverConfig` → `loadConfig` → `bootstrap({ config, runId })`) and resolves `SourceDynamoDbClient` or `TargetDynamoDbClient` for the chosen system. Step modules are plain functions (like `src/commands/init/steps/*.ts`) returning a `StepOutcome` discriminated union (`ok` / `cancelled` / `refused`) so the command maps outcomes to exit codes `0` / `130` / `1` without any `process.exit` in testable code. + +**Contract with the sibling plan (`docs/superpowers/plans/2026-09-04-fix-live-reconciler.md`).** This plan imports the following from `~/features/FixLive/index.js` and does not re-specify them. Given verbatim by the spec: `LiveFieldReconciler` (namespace: `ChangeReason`, `SkipReason`), `LiveFieldRunner` (namespace: `Interface`, `Mode`, `Options { mode, report, onProgress }`, `Stats`), `ChangeReport` (token; `Interface` appends JSONL lines), `FixLiveState` (namespace: `RunSummary`, `LiveRunSummary`, `File`). Expected from the sibling but named here — **Task 8 Step 1 verifies the real names in `src/features/FixLive/index.ts` and adjusts only `runTable.ts` / `FixLiveCommand.ts` imports if they differ**: + +**Reconciled against the sibling plan on 2026-09-04.** The sibling exposes the names below. Code blocks in Tasks 7–8 still use the placeholder names `LiveFieldRunnerFactory` and `FixLiveStateStore`; apply these substitutions in Task 8 Step 1 before writing any file. + +| Real export (from `~/features/FixLive/index.js`) | Real shape | Replaces in this plan | +| --- | --- | --- | +| `FixLiveFeature` | `createFeature`; registered in `bootstrap.ts` by the sibling (its Task 8). No extra `register` call needed. | same name | +| `DdbLiveFieldRunner`, `OsLiveFieldRunner` | two tokens, both `LiveFieldRunner.Interface`. Resolve `container.resolve(table === "ddb" ? DdbLiveFieldRunner : OsLiveFieldRunner)`. | `LiveFieldRunnerFactory` / `runnerFactory.create({...})` | +| `LiveFieldRunner.Options` | `{ mode: LiveFieldRunner.Mode; target: LiveFieldRunner.Target; report: ChangeReport.Interface; onProgress(stats: LiveFieldRunner.Stats): void }` | `runner.run({ mode, report, onProgress })` — add `target` | +| `LiveFieldRunner.Target` | `{ client: SourceDynamoDbClient.Interface; tableName: string; segments: number; concurrency?: number; writeConcurrency?: number }` | the `create({ table, client, tableName, segments, concurrency })` input; `table` is implied by the token | +| `FixLiveState` | token. `read(key: FixLiveState.Key): FixLiveState.File \| null`, `recordDryRun(key, summary: FixLiveState.RunSummary): void`, `recordLiveRun(key, summary: FixLiveState.LiveRunSummary): void`, `pathFor(key): string`. `Key = { project: string; system: "source" \| "target" }`. | `FixLiveStateStore` — `read(project, system)` becomes `read({ project, system })`; `write(project, system, file)` becomes `recordDryRun(key, summary)` or `recordLiveRun(key, summary)` depending on mode; tests mock `recordDryRun` / `recordLiveRun` instead of `write` | +| `ScanOptions.limit`, `ScanOptions.sortKeyEquals` | on `IDynamoDbClient.scan` and `MockDynamoDbClient` (sibling Task 2) | same | + +`runTable.ts` must accept `target: LiveFieldRunner.Target` and forward it in `runner.run(...)`. `FixLiveCommand.run` builds `target` per table: `{ client, tableName, segments: config.pipeline.segments, concurrency: options.concurrency }`. Rename the local `const state = stateStore.read(...)` to `const fixLiveState = container.resolve(FixLiveState); const state = fixLiveState.read(key);` so the later `recordDryRun` / `recordLiveRun` calls have the token in scope. + +**Tech Stack:** TypeScript (nodenext, `~/` alias), `@webiny/di`, `yargs` 18, `@clack/prompts` 1.7.0, Vitest, oxfmt / oxlint / adio. + +## Global Constraints + +Derived from `docs/architecture.md`, `docs/webiny-di-guide.md` §6 and the code under `src/commands/`, `src/features/AccessChecker/`: + +- Types accessed only via namespace (`Prompts.Interface`, `Command.Argv`); abstraction files export `IFoo` + token + `namespace Foo`. `abstractions/index.ts` re-exports tokens only. +- Impl files use the local rename alias `import { Foo as FooAbstraction } from "./abstractions/Foo.ts"`; the impl export reuses the short name (`export const Foo = FooAbstraction.createImplementation({...})`). +- `public` / `private` / `protected` on every class member; `readonly` where applicable. +- Braces always — no single-line `if` / `for`. +- No `reflect-metadata` imports. +- `~/` imports use `.js` extensions; relative imports use `.ts` extensions. In `__tests__/`, `~/` for `src/` imports, relative for test-only infra (`../prompts/StubPrompts.ts`). +- Named `interface` / `type` for every structural shape — no inline `{ ... }` in generic or parameter positions. +- Function-module files camelCase (`selectProject.ts`), class files PascalCase (`FixLiveCommand.ts`). +- Commands never import `@clack/prompts` directly — only `ClackPrompts.ts`, `ClackUI.ts`, `ClackSpinner.ts` do. +- No `process.exit` in step modules or `Command.run`; return exit codes (`EXIT_OK = 0`, `EXIT_FAILURE = 1`, `EXIT_CANCELLED = 130`). The one exception is `UI.exitOnCancel` (spec 3.4). +- Feature names prefixed `Cli/` (existing: `Core/`, `Base/`, `Transfer/`). +- oxfmt formatting (4-space indent under `src/` and `__tests__/`, double quotes, no trailing commas, `printWidth` 100). `yarn`, never `npm`. +- Coverage thresholds (`lines 79 / functions 84 / branches 71 / statements 79`) must not drop; every new module ships a test. `index.ts` / `feature.ts` are excluded from coverage. +- Any CLI behaviour change updates `docs/guides/commands.md` (AGENTS.md §6). +- Commit after each task; run `yarn full` before the final commit (memory: `feedback_run_verification.md`). + +--- + +### Task 1: `Prompts` + `UI` abstractions, clack implementations, test stubs + +**Files:** +- Modify: `package.json` (add `"@clack/prompts": "^1.7.0"` to `dependencies`, alphabetically after `@aws-sdk/credential-providers`) +- Create: `src/commands/exitCodes.ts` +- Create: `src/commands/prompts/abstractions/Prompts.ts` +- Create: `src/commands/prompts/abstractions/UI.ts` +- Create: `src/commands/prompts/abstractions/index.ts` +- Create: `src/commands/prompts/ClackPrompts.ts` +- Create: `src/commands/prompts/ClackSpinner.ts` +- Create: `src/commands/prompts/ClackUI.ts` +- Create: `src/commands/prompts/feature.ts` +- Create: `src/commands/prompts/index.ts` +- Test: `__tests__/commands/prompts/StubPrompts.ts`, `__tests__/commands/prompts/StubUI.ts`, `__tests__/commands/prompts/ClackPrompts.test.ts`, `__tests__/commands/prompts/StubPrompts.test.ts` + +**Interfaces:** +- Consumes: `createAbstraction`, `createFeature` from `~/base/index.js`; `@clack/prompts` +- Produces: `Prompts` / `UI` tokens + namespaces, `PromptsFeature`, `EXIT_*` constants, `StubPrompts` / `StubUI` — used by Tasks 2–8 + +- [ ] **Step 1: Add the dependency** + +```bash +yarn add @clack/prompts@^1.7.0 +``` + +- [ ] **Step 2: Exit codes** + +Create `src/commands/exitCodes.ts`: + +```ts +export const EXIT_OK = 0; +export const EXIT_FAILURE = 1; +/** Conventional "interrupted by user" code — returned on prompt cancel. */ +export const EXIT_CANCELLED = 130; +``` + +- [ ] **Step 3: Abstractions** + +Create `src/commands/prompts/abstractions/Prompts.ts`: + +```ts +import { createAbstraction } from "~/base/index.js"; + +export interface PromptSelectOption { + value: T; + label: string; + hint?: string; + disabled?: boolean; +} + +export interface PromptSelectOptions { + message: string; + options: PromptSelectOption[]; + initialValue?: T; +} + +export interface PromptMultiselectOptions { + message: string; + options: PromptSelectOption[]; + required?: boolean; + initialValues?: T[]; +} + +export interface PromptConfirmOptions { + message: string; + initialValue?: boolean; +} + +export interface PromptTextOptions { + message: string; + placeholder?: string; + defaultValue?: string; + validate?: (value: string) => string | undefined; +} + +/** Every method resolves `null` when the user cancels. Never exits the process. */ +export interface IPrompts { + select(options: PromptSelectOptions): Promise; + multiselect(options: PromptMultiselectOptions): Promise; + confirm(options: PromptConfirmOptions): Promise; + text(options: PromptTextOptions): Promise; +} + +export const Prompts = createAbstraction("Cli/Prompts"); + +export namespace Prompts { + export type Interface = IPrompts; + export type SelectOption = PromptSelectOption; + export type SelectOptions = PromptSelectOptions; + export type MultiselectOptions = PromptMultiselectOptions; + export type ConfirmOptions = PromptConfirmOptions; + export type TextOptions = PromptTextOptions; +} +``` + +Create `src/commands/prompts/abstractions/UI.ts`: + +```ts +import { createAbstraction } from "~/base/index.js"; + +export interface UISpinner { + start(message: string): void; + message(message: string): void; + stop(message: string): void; +} + +export interface IUI { + intro(title: string): void; + outro(message: string): void; + note(message: string, title?: string): void; + warn(message: string): void; + error(message: string): void; + cancel(message: string): void; + spinner(): UISpinner; + /** Prints "Cancelled." and exits 130 when `value` is null; otherwise returns it. */ + exitOnCancel(value: T | null): T; +} + +export const UI = createAbstraction("Cli/UI"); + +export namespace UI { + export type Interface = IUI; + export type Spinner = UISpinner; +} +``` + +Create `src/commands/prompts/abstractions/index.ts`: + +```ts +export { Prompts } from "./Prompts.ts"; +export { UI } from "./UI.ts"; +``` + +- [ ] **Step 4: Failing test for the clack adapter** + +Create `__tests__/commands/prompts/ClackPrompts.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const CANCEL = Symbol("clack:cancel"); + +vi.mock("@clack/prompts", () => ({ + select: vi.fn(), + multiselect: vi.fn(), + confirm: vi.fn(), + text: vi.fn(), + isCancel: (value: unknown) => value === CANCEL +})); + +import * as clack from "@clack/prompts"; +import { ClackPrompts } from "~/commands/prompts/ClackPrompts.js"; + +const mockSelect = vi.mocked(clack.select); +const mockConfirm = vi.mocked(clack.confirm); +const mockText = vi.mocked(clack.text); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("ClackPrompts", () => { + it("select returns the chosen value", async () => { + mockSelect.mockResolvedValue("b"); + const prompts = new ClackPrompts(); + const result = await prompts.select({ + message: "Pick", + options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] + }); + expect(result).toBe("b"); + expect(mockSelect).toHaveBeenCalledWith( + expect.objectContaining({ message: "Pick", options: expect.any(Array) }) + ); + }); + + it("select returns null on cancel", async () => { + mockSelect.mockResolvedValue(CANCEL); + const result = await new ClackPrompts().select({ + message: "Pick", + options: [{ value: "a", label: "A" }] + }); + expect(result).toBeNull(); + }); + + it("confirm returns null on cancel and the boolean otherwise", async () => { + mockConfirm.mockResolvedValueOnce(CANCEL).mockResolvedValueOnce(false); + const prompts = new ClackPrompts(); + expect(await prompts.confirm({ message: "Sure?" })).toBeNull(); + expect(await prompts.confirm({ message: "Sure?" })).toBe(false); + }); + + it("text passes validate through and returns null on cancel", async () => { + mockText.mockResolvedValue(CANCEL); + const validate = (value: string) => (value ? undefined : "required"); + expect(await new ClackPrompts().text({ message: "Name", validate })).toBeNull(); + const passed = mockText.mock.calls[0]![0]; + expect(passed.validate).toBeTypeOf("function"); + }); +}); +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `yarn vitest run __tests__/commands/prompts 2>&1 | tail -20` +Expected: FAIL — cannot resolve `~/commands/prompts/ClackPrompts.js`. + +- [ ] **Step 6: Clack implementations** + +Create `src/commands/prompts/ClackPrompts.ts`: + +```ts +import * as p from "@clack/prompts"; +import { Prompts as PromptsAbstraction } from "./abstractions/Prompts.ts"; + +class ClackPromptsImpl implements PromptsAbstraction.Interface { + public async select(options: PromptsAbstraction.SelectOptions): Promise { + const result = await p.select({ + message: options.message, + options: options.options as p.Option[], + initialValue: options.initialValue + }); + if (p.isCancel(result)) { + return null; + } + return result; + } + + public async multiselect( + options: PromptsAbstraction.MultiselectOptions + ): Promise { + const result = await p.multiselect({ + message: options.message, + options: options.options as p.Option[], + required: options.required ?? false, + initialValues: options.initialValues + }); + if (p.isCancel(result)) { + return null; + } + return result; + } + + public async confirm(options: PromptsAbstraction.ConfirmOptions): Promise { + const result = await p.confirm({ + message: options.message, + initialValue: options.initialValue + }); + if (p.isCancel(result)) { + return null; + } + return result; + } + + public async text(options: PromptsAbstraction.TextOptions): Promise { + const validate = options.validate; + const result = await p.text({ + message: options.message, + placeholder: options.placeholder, + defaultValue: options.defaultValue, + validate: validate ? value => validate(value ?? "") : undefined + }); + if (p.isCancel(result)) { + return null; + } + return result; + } +} + +export const ClackPrompts = PromptsAbstraction.createImplementation({ + implementation: ClackPromptsImpl, + dependencies: [] +}); +``` + +Create `src/commands/prompts/ClackSpinner.ts`: + +```ts +import * as p from "@clack/prompts"; +import type { UI } from "./abstractions/UI.ts"; + +export class ClackSpinner implements UI.Spinner { + private readonly spinner: ReturnType; + + public constructor() { + this.spinner = p.spinner(); + } + + public start(message: string): void { + this.spinner.start(message); + } + + public message(message: string): void { + this.spinner.message(message); + } + + public stop(message: string): void { + this.spinner.stop(message); + } +} +``` + +Create `src/commands/prompts/ClackUI.ts`: + +```ts +import * as p from "@clack/prompts"; +import { UI as UIAbstraction } from "./abstractions/UI.ts"; +import { ClackSpinner } from "./ClackSpinner.ts"; +import { EXIT_CANCELLED } from "~/commands/exitCodes.js"; + +class ClackUIImpl implements UIAbstraction.Interface { + public intro(title: string): void { + p.intro(title); + } + + public outro(message: string): void { + p.outro(message); + } + + public note(message: string, title?: string): void { + p.note(message, title); + } + + public warn(message: string): void { + p.log.warn(message); + } + + public error(message: string): void { + p.log.error(message); + } + + public cancel(message: string): void { + p.cancel(message); + } + + public spinner(): UIAbstraction.Spinner { + return new ClackSpinner(); + } + + public exitOnCancel(value: T | null): T { + if (value === null) { + this.cancel("Cancelled."); + process.exit(EXIT_CANCELLED); + } + return value; + } +} + +export const ClackUI = UIAbstraction.createImplementation({ + implementation: ClackUIImpl, + dependencies: [] +}); +``` + +Create `src/commands/prompts/feature.ts`: + +```ts +import { createFeature } from "~/base/index.js"; +import { ClackPrompts } from "./ClackPrompts.ts"; +import { ClackUI } from "./ClackUI.ts"; + +export const PromptsFeature = createFeature({ + name: "Cli/PromptsFeature", + register(container) { + container.register(ClackPrompts).inSingletonScope(); + container.register(ClackUI).inSingletonScope(); + } +}); +``` + +Create `src/commands/prompts/index.ts`: + +```ts +export { Prompts, UI } from "./abstractions/index.ts"; +export { PromptsFeature } from "./feature.ts"; +``` + +- [ ] **Step 7: Test stubs** + +Create `__tests__/commands/prompts/StubPrompts.ts`: + +```ts +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; + +export interface StubPromptsScript { + select?: (unknown | null)[]; + multiselect?: (unknown[] | null)[]; + confirm?: (boolean | null)[]; + text?: (string | null)[]; +} + +/** + * Scripted prompts. Each method shifts the next queued answer; an exhausted + * queue answers `null` (cancel). Every call's options are recorded. + */ +export class StubPrompts implements Prompts.Interface { + private readonly selects: (unknown | null)[]; + private readonly multiselects: (unknown[] | null)[]; + private readonly confirms: (boolean | null)[]; + private readonly texts: (string | null)[]; + + public readonly selectCalls: Prompts.SelectOptions[] = []; + public readonly multiselectCalls: Prompts.MultiselectOptions[] = []; + public readonly confirmCalls: Prompts.ConfirmOptions[] = []; + public readonly textCalls: Prompts.TextOptions[] = []; + + public constructor(script: StubPromptsScript = {}) { + this.selects = [...(script.select ?? [])]; + this.multiselects = [...(script.multiselect ?? [])]; + this.confirms = [...(script.confirm ?? [])]; + this.texts = [...(script.text ?? [])]; + } + + public async select(options: Prompts.SelectOptions): Promise { + this.selectCalls.push(options as Prompts.SelectOptions); + const next = this.selects.shift(); + return next === undefined ? null : (next as T); + } + + public async multiselect(options: Prompts.MultiselectOptions): Promise { + this.multiselectCalls.push(options as Prompts.MultiselectOptions); + const next = this.multiselects.shift(); + return next === undefined ? null : (next as T[]); + } + + public async confirm(options: Prompts.ConfirmOptions): Promise { + this.confirmCalls.push(options); + const next = this.confirms.shift(); + return next === undefined ? null : next; + } + + public async text(options: Prompts.TextOptions): Promise { + this.textCalls.push(options); + const next = this.texts.shift(); + return next === undefined ? null : next; + } +} +``` + +Create `__tests__/commands/prompts/StubUI.ts`: + +```ts +import type { UI } from "~/commands/prompts/abstractions/UI.js"; + +export class StubCancelError extends Error { + public constructor() { + super("StubUI.exitOnCancel: cancelled"); + this.name = "StubCancelError"; + } +} + +export interface StubNote { + message: string; + title?: string; +} + +/** Records every UI call; `exitOnCancel(null)` throws instead of exiting. */ +export class StubUI implements UI.Interface { + public readonly intros: string[] = []; + public readonly outros: string[] = []; + public readonly notes: StubNote[] = []; + public readonly warns: string[] = []; + public readonly errors: string[] = []; + public readonly cancels: string[] = []; + public readonly spinnerMessages: string[] = []; + + public intro(title: string): void { + this.intros.push(title); + } + + public outro(message: string): void { + this.outros.push(message); + } + + public note(message: string, title?: string): void { + this.notes.push({ message, title }); + } + + public warn(message: string): void { + this.warns.push(message); + } + + public error(message: string): void { + this.errors.push(message); + } + + public cancel(message: string): void { + this.cancels.push(message); + } + + public spinner(): UI.Spinner { + const messages = this.spinnerMessages; + return { + start(message: string): void { + messages.push(message); + }, + message(message: string): void { + messages.push(message); + }, + stop(message: string): void { + messages.push(message); + } + }; + } + + public exitOnCancel(value: T | null): T { + if (value === null) { + this.cancel("Cancelled."); + throw new StubCancelError(); + } + return value; + } +} +``` + +Create `__tests__/commands/prompts/StubPrompts.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { StubPrompts } from "./StubPrompts.ts"; +import { StubUI, StubCancelError } from "./StubUI.ts"; + +describe("StubPrompts", () => { + it("answers in order and cancels when exhausted", async () => { + const prompts = new StubPrompts({ select: ["a"], confirm: [true] }); + expect(await prompts.select({ message: "m", options: [] })).toBe("a"); + expect(await prompts.select({ message: "m", options: [] })).toBeNull(); + expect(await prompts.confirm({ message: "c" })).toBe(true); + expect(await prompts.confirm({ message: "c" })).toBeNull(); + expect(prompts.selectCalls).toHaveLength(2); + }); +}); + +describe("StubUI", () => { + it("exitOnCancel throws on null and passes values through", () => { + const ui = new StubUI(); + expect(ui.exitOnCancel("x")).toBe("x"); + expect(() => ui.exitOnCancel(null)).toThrow(StubCancelError); + expect(ui.cancels).toEqual(["Cancelled."]); + }); +}); +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `yarn vitest run __tests__/commands/prompts 2>&1 | tail -20` +Expected: All PASS. + +- [ ] **Step 9: Commit** + +```bash +git add package.json yarn.lock src/commands/exitCodes.ts src/commands/prompts __tests__/commands/prompts +git commit -m "feat(cli): add Prompts and UI abstractions with @clack/prompts implementations" +``` + +--- + +### Task 2: `Command` token + `CommandRegistry` + +**Files:** +- Create: `src/commands/registry/abstractions/Command.ts` +- Create: `src/commands/registry/abstractions/CommandRegistry.ts` +- Create: `src/commands/registry/abstractions/index.ts` +- Create: `src/commands/registry/CommandRegistry.ts` +- Create: `src/commands/registry/feature.ts` +- Create: `src/commands/registry/index.ts` +- Test: `__tests__/commands/registry/CommandRegistry.test.ts` + +**Interfaces:** +- Consumes: `createAbstraction`, `createFeature`, `ContainerToken` from `~/base/index.js`; `Argv` from `yargs` +- Produces: `Command` token (`Command.Interface`, `Command.Argv`), `CommandRegistry` token (`list()`, `menu()`, `get(name)`), `CommandRegistryFeature` — used by Tasks 3–9 + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/commands/registry/CommandRegistry.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import type { Argv } from "yargs"; +import { Container } from "@webiny/di"; +import { ContainerToken } from "~/base/index.js"; +import { Command } from "~/commands/registry/abstractions/Command.js"; +import { CommandRegistry } from "~/commands/registry/abstractions/CommandRegistry.js"; +import { CommandRegistryFeature } from "~/commands/registry/feature.js"; + +let constructed = 0; + +class VisibleCommandImpl implements Command.Interface { + public readonly name = "visible"; + public readonly description = "A visible command"; + public constructor() { + constructed++; + } + public configure(yargs: Argv): Argv { + return yargs; + } + public async run(): Promise { + return 0; + } +} + +class HiddenCommandImpl implements Command.Interface { + public readonly name = "hidden "; + public readonly description = "A hidden command"; + public readonly hidden = true; + public constructor() { + constructed++; + } + public configure(yargs: Argv): Argv { + return yargs; + } + public async run(): Promise { + return 7; + } +} + +const VisibleCommand = Command.createImplementation({ + implementation: VisibleCommandImpl, + dependencies: [] +}); +const HiddenCommand = Command.createImplementation({ + implementation: HiddenCommandImpl, + dependencies: [] +}); + +function createContainer(): Container { + const container = new Container(); + container.registerInstance(ContainerToken, container); + container.register(VisibleCommand).inSingletonScope(); + container.register(HiddenCommand).inSingletonScope(); + CommandRegistryFeature.register(container); + return container; +} + +describe("CommandRegistry", () => { + it("lists every command in registration order", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(registry.list().map(c => c.name)).toEqual(["visible", "hidden "]); + }); + + it("menu() excludes hidden commands", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(registry.menu().map(c => c.name)).toEqual(["visible"]); + }); + + it("get() matches on the first token of the yargs command string", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(registry.get("hidden").description).toBe("A hidden command"); + }); + + it("get() throws for unknown names", () => { + const registry = createContainer().resolve(CommandRegistry); + expect(() => registry.get("nope")).toThrow(/Unknown command "nope"/); + }); + + it("resolves commands lazily on first access", () => { + constructed = 0; + const registry = createContainer().resolve(CommandRegistry); + expect(constructed).toBe(0); + registry.list(); + registry.list(); + expect(constructed).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn vitest run __tests__/commands/registry 2>&1 | tail -20` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Abstractions** + +Create `src/commands/registry/abstractions/Command.ts`: + +```ts +import type { Argv as YargsArgv } from "yargs"; +import { createAbstraction } from "~/base/index.js"; + +export type CommandArgv = Record; + +export interface ICommand { + /** yargs command string, e.g. "fix-live" or "init ". */ + readonly name: string; + /** Shown in the menu and in `--help`. */ + readonly description: string; + /** Not offered in the interactive menu (still registered with yargs). */ + readonly hidden?: boolean; + configure(yargs: YargsArgv): YargsArgv; + /** Resolves the process exit code. */ + run(argv: CommandArgv): Promise; +} + +export const Command = createAbstraction("Cli/Command"); + +export namespace Command { + export type Interface = ICommand; + export type Argv = CommandArgv; +} +``` + +Create `src/commands/registry/abstractions/CommandRegistry.ts`: + +```ts +import { createAbstraction } from "~/base/index.js"; +import type { Command } from "./Command.ts"; + +export interface ICommandRegistry { + /** Every registered command, registration order. */ + list(): Command.Interface[]; + /** Commands offered in the interactive menu (`hidden !== true`). */ + menu(): Command.Interface[]; + /** Lookup by the first token of the command string ("init" matches "init "). */ + get(name: string): Command.Interface; +} + +export const CommandRegistry = createAbstraction("Cli/CommandRegistry"); + +export namespace CommandRegistry { + export type Interface = ICommandRegistry; +} +``` + +Create `src/commands/registry/abstractions/index.ts`: + +```ts +export { Command } from "./Command.ts"; +export { CommandRegistry } from "./CommandRegistry.ts"; +``` + +- [ ] **Step 4: Implementation + feature** + +Create `src/commands/registry/CommandRegistry.ts`: + +```ts +import type { Container } from "@webiny/di"; +import { ContainerToken } from "~/base/index.js"; +import { Command } from "./abstractions/Command.ts"; +import { CommandRegistry as CommandRegistryAbstraction } from "./abstractions/CommandRegistry.ts"; + +const baseName = (name: string): string => name.split(" ")[0]!; + +/** + * Collects every `Command` implementation. Resolution is deferred to the first + * call so `container.resolve(CommandRegistry)` at CLI start constructs nothing. + * Command constructors must stay cheap (Prompts / UI only); heavy work belongs + * in `run()`. + */ +class CommandRegistryImpl implements CommandRegistryAbstraction.Interface { + private commands: Command.Interface[] | null = null; + + public constructor(private readonly container: Container) {} + + public list(): Command.Interface[] { + if (this.commands === null) { + this.commands = this.container.resolveAll(Command); + } + return this.commands; + } + + public menu(): Command.Interface[] { + return this.list().filter(command => command.hidden !== true); + } + + public get(name: string): Command.Interface { + const found = this.list().find(command => baseName(command.name) === name); + if (!found) { + const known = this.list() + .map(command => baseName(command.name)) + .join(", "); + throw new Error(`Unknown command "${name}". Known commands: ${known}`); + } + return found; + } +} + +export const CommandRegistry = CommandRegistryAbstraction.createImplementation({ + implementation: CommandRegistryImpl, + dependencies: [ContainerToken] +}); +``` + +Create `src/commands/registry/feature.ts`: + +```ts +import { createFeature } from "~/base/index.js"; +import { CommandRegistry } from "./CommandRegistry.ts"; + +export const CommandRegistryFeature = createFeature({ + name: "Cli/CommandRegistryFeature", + register(container) { + container.register(CommandRegistry).inSingletonScope(); + } +}); +``` + +Create `src/commands/registry/index.ts`: + +```ts +export { Command, CommandRegistry } from "./abstractions/index.ts"; +export { CommandRegistryFeature } from "./feature.ts"; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `yarn vitest run __tests__/commands/registry 2>&1 | tail -20` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/registry __tests__/commands/registry +git commit -m "feat(cli): add Command token and CommandRegistry" +``` + +--- + +### Task 3: Move `src/commands/run/` to `src/commands/transfer/` as `TransferCommand` + +**Files:** +- Move: `src/commands/run/**` → `src/commands/transfer/**` (bodies of `handler.ts`, `segmentsFilter.ts`, `wizard/**` unchanged) +- Delete: `src/commands/transfer/register.ts` (after the move) +- Create: `src/commands/transfer/TransferCommand.ts` +- Move: `__tests__/commands/run/**` → `__tests__/commands/transfer/**`; update relative imports there and in `__tests__/commands/segmentsFilter.test.ts` +- Modify: `docs/guides/commands.md` (path on the "Re-running specific shards" line), `docs/project-structure.md`, `docs/pino-logger-implementation.md`, `docs/mcp/guides/pipelineRuntime.md` (path strings only) +- Test: `__tests__/commands/transfer/TransferCommand.test.ts` + +**Interfaces:** +- Consumes: `Command`, `EXIT_*`, `handler`, `TransferWizard`, `parseSegmentsFilter` +- Produces: `TransferCommand` (`name: "transfer"`) — used by Tasks 5 and 6 + +- [ ] **Step 1: Move files** + +```bash +git mv src/commands/run src/commands/transfer +git mv __tests__/commands/run __tests__/commands/transfer +sed -i '' 's#src/commands/run/#src/commands/transfer/#g' \ + __tests__/commands/segmentsFilter.test.ts \ + $(grep -rl "src/commands/run/" __tests__/commands/transfer) +sed -i '' 's#src/commands/run/#src/commands/transfer/#g' \ + docs/guides/commands.md docs/project-structure.md docs/pino-logger-implementation.md docs/mcp/guides/pipelineRuntime.md +``` + +Also in `docs/project-structure.md` rename the tree entry `│ ├── run/ # Main orchestrator ($0)` to `│ ├── transfer/ # System-to-system transfer (TransferCommand)` and replace the `register.ts` line with `│ │ ├── TransferCommand.ts # Command impl; --config+--preset → handler, otherwise TransferWizard.run()`. + +- [ ] **Step 2: Write the failing test** + +Create `__tests__/commands/transfer/TransferCommand.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ExitPromptError } from "@inquirer/core"; + +const handlerSpy = vi.fn(async () => undefined); +const wizardRun = vi.fn(); + +vi.mock("~/commands/transfer/handler.ts", () => ({ handler: handlerSpy })); +vi.mock("~/commands/transfer/wizard/TransferWizard.ts", () => ({ + TransferWizard: class { + public run = wizardRun; + } +})); + +import { TransferCommand } from "~/commands/transfer/TransferCommand.js"; + +beforeEach(() => { + handlerSpy.mockClear(); + wizardRun.mockReset(); +}); + +describe("TransferCommand", () => { + it("has the yargs name and is visible in the menu", () => { + const command = new TransferCommand(); + expect(command.name).toBe("transfer"); + expect(command.hidden).toBeUndefined(); + }); + + it("--config + --preset skips the wizard and runs the handler", async () => { + const code = await new TransferCommand().run({ + config: "./p/config.ts", + preset: "copy-ddb", + "dry-run": true, + segments: [1, 3], + "log-level": "warn" + }); + expect(code).toBe(0); + expect(handlerSpy).toHaveBeenCalledWith("./p/config.ts", "copy-ddb", [1, 3], "warn", true); + expect(wizardRun).not.toHaveBeenCalled(); + }); + + it("wizard returning null (env written) exits 0 without running", async () => { + wizardRun.mockResolvedValue(null); + expect(await new TransferCommand().run({})).toBe(0); + expect(handlerSpy).not.toHaveBeenCalled(); + }); + + it("wizard result is passed to the handler", async () => { + wizardRun.mockResolvedValue({ configPath: "/c.ts", preset: "v5-to-v6-ddb", dryRun: false }); + expect(await new TransferCommand().run({})).toBe(0); + expect(handlerSpy).toHaveBeenCalledWith("/c.ts", "v5-to-v6-ddb", undefined, undefined, false); + }); + + it("inquirer cancel exits 130", async () => { + wizardRun.mockRejectedValue(new ExitPromptError("cancelled")); + expect(await new TransferCommand().run({})).toBe(130); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `yarn vitest run __tests__/commands/transfer/TransferCommand.test.ts 2>&1 | tail -20` +Expected: FAIL — `TransferCommand` module not found. + +- [ ] **Step 4: Create `TransferCommand.ts`, delete `register.ts`** + +Create `src/commands/transfer/TransferCommand.ts` (option definitions copied verbatim from `register.ts`): + +```ts +import type { Argv } from "yargs"; +import { ExitPromptError } from "@inquirer/core"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_CANCELLED, EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; +import { parseSegmentsFilter } from "./segmentsFilter.ts"; +import { TransferWizard } from "./wizard/TransferWizard.ts"; + +class TransferCommandImpl implements CommandAbstraction.Interface { + public readonly name = "transfer"; + public readonly description = "Transfer Webiny data from a source system to a target system"; + + public configure(yargs: Argv): Argv { + return yargs + .option("config", { + type: "string", + demandOption: false, + description: "Path to configuration file" + }) + .option("preset", { + type: "string", + demandOption: false, + description: "Preset name to run" + }) + .option("dry-run", { + type: "boolean", + default: false, + description: "Read source but skip all writes to target" + }) + .option("segments", { + type: "string", + description: + "Comma-separated list of segment indices to run (e.g. `1,3`). " + + "Use to re-run specific shards after a failure. Defaults to all." + }) + .coerce("segments", parseSegmentsFilter) + .option("log-level", { + type: "string", + choices: ["debug", "info", "warn", "error"] as const, + description: "Log level (default: info)" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + const configPath = argv.config as string | undefined; + const preset = argv.preset as string | undefined; + const logLevel = argv["log-level"] as string | undefined; + const dryRun = Boolean(argv["dry-run"]); + const segments = argv.segments as number[] | undefined; + + if (configPath && preset) { + await handler(configPath, preset, segments, logLevel, dryRun); + return EXIT_OK; + } + + const wizard = new TransferWizard(process.cwd()); + try { + const result = await wizard.run(); + if (result === null) { + return EXIT_OK; + } + await handler(result.configPath, result.preset, segments, logLevel, result.dryRun); + return EXIT_OK; + } catch (error) { + if (error instanceof ExitPromptError) { + return EXIT_CANCELLED; + } + throw error; + } + } +} + +export const TransferCommand = CommandAbstraction.createImplementation({ + implementation: TransferCommandImpl, + dependencies: [] +}); +``` + +```bash +git rm src/commands/transfer/register.ts +``` + +`src/commands/index.ts` and `src/cli.ts` are now broken; Task 4 and Task 5 fix them. Type-check is expected to fail until then. + +- [ ] **Step 5: Run the moved tests** + +Run: `yarn vitest run __tests__/commands 2>&1 | tail -20` +Expected: All PASS (wizard tests unchanged apart from paths; `TransferCommand.test.ts` green). + +- [ ] **Step 6: Commit** + +```bash +git add -A src/commands/transfer __tests__/commands docs/guides/commands.md docs/project-structure.md docs/pino-logger-implementation.md docs/mcp/guides/pipelineRuntime.md +git commit -m "refactor(cli): move commands/run to commands/transfer as TransferCommand" +``` + +--- + +### Task 4: Wrap `init`, `init-project`, `process-segment`, `update-skills` as `Command` implementations + +**Files:** +- Create: `src/commands/init/InitCommand.ts`, `src/commands/initProject/InitProjectCommand.ts`, `src/commands/processSegment/ProcessSegmentCommand.ts`, `src/commands/updateSkills/UpdateSkillsCommand.ts` +- Delete: the four `register.ts` files +- Modify: `src/commands/index.ts` +- Test: `__tests__/commands/commandWrappers.test.ts` + +**Interfaces:** +- Consumes: existing `handler` functions (bodies untouched), `Command`, `EXIT_OK` +- Produces: four `Command` implementations, all `hidden: true` (need positionals or are worker/maintenance entry points) — used by Task 5 + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/commands/commandWrappers.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const initHandler = vi.fn(async () => undefined); +const initProjectHandler = vi.fn(async () => undefined); +const processSegmentHandler = vi.fn(async () => undefined); +const updateSkillsHandler = vi.fn(); + +vi.mock("~/commands/init/handler.ts", () => ({ handler: initHandler })); +vi.mock("~/commands/initProject/handler.ts", () => ({ handler: initProjectHandler })); +vi.mock("~/commands/processSegment/handler.ts", () => ({ handler: processSegmentHandler })); +vi.mock("~/commands/updateSkills/handler.ts", () => ({ handler: updateSkillsHandler })); + +import { InitCommand } from "~/commands/init/InitCommand.js"; +import { InitProjectCommand } from "~/commands/initProject/InitProjectCommand.js"; +import { ProcessSegmentCommand } from "~/commands/processSegment/ProcessSegmentCommand.js"; +import { UpdateSkillsCommand } from "~/commands/updateSkills/UpdateSkillsCommand.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("command wrappers", () => { + it("are hidden from the menu and keep their yargs command strings", () => { + expect(new InitCommand().name).toBe("init "); + expect(new InitProjectCommand().name).toBe("init-project "); + expect(new ProcessSegmentCommand().name).toBe("process-segment"); + expect(new UpdateSkillsCommand().name).toBe("update-skills"); + for (const command of [ + new InitCommand(), + new InitProjectCommand(), + new ProcessSegmentCommand(), + new UpdateSkillsCommand() + ]) { + expect(command.hidden).toBe(true); + } + }); + + it("init maps project-name", async () => { + expect(await new InitCommand().run({ "project-name": "my-app" })).toBe(0); + expect(initHandler).toHaveBeenCalledWith({ projectName: "my-app" }); + }); + + it("init-project passes the name", async () => { + expect(await new InitProjectCommand().run({ name: "prod" })).toBe(0); + expect(initProjectHandler).toHaveBeenCalledWith("prod"); + }); + + it("process-segment maps kebab-case flags", async () => { + const code = await new ProcessSegmentCommand().run({ + runId: "r1", + segment: 2, + total: 4, + config: "/c.ts", + preset: "copy-ddb", + "log-level": "info", + "dry-run": true + }); + expect(code).toBe(0); + expect(processSegmentHandler).toHaveBeenCalledWith({ + runId: "r1", + segment: 2, + total: 4, + config: "/c.ts", + preset: "copy-ddb", + logLevel: "info", + dryRun: true + }); + }); + + it("update-skills calls its handler", async () => { + expect(await new UpdateSkillsCommand().run({})).toBe(0); + expect(updateSkillsHandler).toHaveBeenCalledOnce(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `yarn vitest run __tests__/commands/commandWrappers.test.ts 2>&1 | tail -20` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Create the wrappers** + +Create `src/commands/init/InitCommand.ts`: + +```ts +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class InitCommandImpl implements CommandAbstraction.Interface { + public readonly name = "init "; + public readonly description = "Scaffold a new data transfer project"; + // Needs a positional argument — not runnable from the menu. + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs.positional("project-name", { + type: "string", + demandOption: true, + description: "Name of the project directory to create" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + await handler({ projectName: argv["project-name"] as string }); + return EXIT_OK; + } +} + +export const InitCommand = CommandAbstraction.createImplementation({ + implementation: InitCommandImpl, + dependencies: [] +}); +``` + +Create `src/commands/initProject/InitProjectCommand.ts`: + +```ts +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class InitProjectCommandImpl implements CommandAbstraction.Interface { + public readonly name = "init-project "; + public readonly description = "Scaffold a new project in the projects/ directory"; + // Needs a positional argument — not runnable from the menu. + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs.positional("name", { + type: "string", + demandOption: true, + description: "Name of the project folder to create under projects/" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + await handler(argv.name as string); + return EXIT_OK; + } +} + +export const InitProjectCommand = CommandAbstraction.createImplementation({ + implementation: InitProjectCommandImpl, + dependencies: [] +}); +``` + +Create `src/commands/processSegment/ProcessSegmentCommand.ts` (options verbatim from `register.ts`): + +```ts +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class ProcessSegmentCommandImpl implements CommandAbstraction.Interface { + public readonly name = "process-segment"; + public readonly description = + "Process a specific DDB segment (used internally by worker processes)"; + // Worker entry point spawned by the transfer orchestrator — never offered in the menu. + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs + .option("runId", { type: "string", demandOption: true, description: "Run ID" }) + .option("segment", { type: "number", demandOption: true, description: "Segment number" }) + .option("total", { type: "number", demandOption: true, description: "Total segments" }) + .option("config", { type: "string", demandOption: true, description: "Config file path" }) + .option("preset", { + type: "string", + demandOption: true, + description: "Preset name to use for this segment" + }) + .option("log-level", { + type: "string", + choices: ["debug", "info", "warn", "error"] as const, + description: "Log level" + }) + .option("dry-run", { + type: "boolean", + default: false, + description: "Skip all writes to the target system" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + await handler({ + runId: argv.runId as string, + segment: argv.segment as number, + total: argv.total as number, + config: argv.config as string, + preset: argv.preset as string, + logLevel: argv["log-level"] as string | undefined, + dryRun: argv["dry-run"] as boolean | undefined + }); + return EXIT_OK; + } +} + +export const ProcessSegmentCommand = CommandAbstraction.createImplementation({ + implementation: ProcessSegmentCommandImpl, + dependencies: [] +}); +``` + +Create `src/commands/updateSkills/UpdateSkillsCommand.ts`: + +```ts +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class UpdateSkillsCommandImpl implements CommandAbstraction.Interface { + public readonly name = "update-skills"; + public readonly description = + "Update Claude Code skills from the installed @webiny/data-transfer package"; + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs; + } + + public async run(): Promise { + handler(); + return EXIT_OK; + } +} + +export const UpdateSkillsCommand = CommandAbstraction.createImplementation({ + implementation: UpdateSkillsCommandImpl, + dependencies: [] +}); +``` + +```bash +git rm src/commands/init/register.ts src/commands/initProject/register.ts \ + src/commands/processSegment/register.ts src/commands/updateSkills/register.ts +``` + +Replace `src/commands/index.ts` with: + +```ts +export { TransferCommand } from "./transfer/TransferCommand.ts"; +export { InitCommand } from "./init/InitCommand.ts"; +export { InitProjectCommand } from "./initProject/InitProjectCommand.ts"; +export { ProcessSegmentCommand } from "./processSegment/ProcessSegmentCommand.ts"; +export { UpdateSkillsCommand } from "./updateSkills/UpdateSkillsCommand.ts"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `yarn vitest run __tests__/commands/commandWrappers.test.ts 2>&1 | tail -20` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A src/commands __tests__/commands/commandWrappers.test.ts +git commit -m "refactor(cli): wrap init, init-project, process-segment, update-skills as Command implementations" +``` + +--- + +### Task 5: CLI container, menu, default dispatch, new `src/cli.ts` + +**Files:** +- Create: `src/commands/cliContainer.ts`, `src/commands/openMenu.ts`, `src/commands/dispatchDefault.ts` +- Modify: `src/cli.ts` +- Test: `__tests__/commands/openMenu.test.ts`, `__tests__/commands/dispatchDefault.test.ts` + +**Interfaces:** +- Consumes: `PromptsFeature`, `CommandRegistryFeature`, the five `Command` implementations, `Prompts`, `UI` +- Produces: `createCliContainer()`, `openMenu(input)`, `dispatchDefault(input)`; `yarn transfer` → menu, `yarn transfer --config --preset` → transfer, `yarn transfer ` → init + +- [ ] **Step 1: Write the failing tests** + +Create `__tests__/commands/dispatchDefault.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import type { Command } from "~/commands/registry/abstractions/Command.js"; +import type { CommandRegistry } from "~/commands/registry/abstractions/CommandRegistry.js"; +import { dispatchDefault } from "~/commands/dispatchDefault.js"; + +function fakeRegistry(runs: Record>): CommandRegistry.Interface { + const commands = Object.entries(runs).map( + ([name, run]) => ({ name, description: name, configure: y => y, run }) as Command.Interface + ); + return { + list: () => commands, + menu: () => commands, + get: (name: string) => commands.find(c => c.name === name)! + }; +} + +describe("dispatchDefault", () => { + it("`yarn transfer ` runs init with the folder as project-name", async () => { + const init = vi.fn(async () => 0); + const openMenu = vi.fn(async () => 130); + const code = await dispatchDefault({ + argv: { folder: "my-folder" }, + registry: fakeRegistry({ init, transfer: vi.fn() }), + openMenu + }); + expect(code).toBe(0); + expect(init).toHaveBeenCalledWith({ folder: "my-folder", "project-name": "my-folder" }); + expect(openMenu).not.toHaveBeenCalled(); + }); + + it("`yarn transfer --config --preset` runs the transfer command", async () => { + const transfer = vi.fn(async () => 0); + const argv = { config: "./c.ts", preset: "copy-ddb" }; + const code = await dispatchDefault({ + argv, + registry: fakeRegistry({ init: vi.fn(), transfer }), + openMenu: vi.fn(async () => 130) + }); + expect(code).toBe(0); + expect(transfer).toHaveBeenCalledWith(argv); + }); + + it("`--config` alone still routes to transfer (wizard prompts for the rest)", async () => { + const transfer = vi.fn(async () => 0); + await dispatchDefault({ + argv: { config: "./c.ts" }, + registry: fakeRegistry({ init: vi.fn(), transfer }), + openMenu: vi.fn(async () => 130) + }); + expect(transfer).toHaveBeenCalledOnce(); + }); + + it("no arguments opens the menu and returns its exit code", async () => { + const openMenu = vi.fn(async () => 130); + const code = await dispatchDefault({ + argv: {}, + registry: fakeRegistry({ init: vi.fn(), transfer: vi.fn() }), + openMenu + }); + expect(code).toBe(130); + expect(openMenu).toHaveBeenCalledOnce(); + }); +}); +``` + +Create `__tests__/commands/openMenu.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import type { Command } from "~/commands/registry/abstractions/Command.js"; +import type { CommandRegistry } from "~/commands/registry/abstractions/CommandRegistry.js"; +import { openMenu } from "~/commands/openMenu.js"; +import { StubPrompts } from "./prompts/StubPrompts.ts"; +import { StubUI } from "./prompts/StubUI.ts"; + +const command = (name: string, run: Command.Interface["run"], hidden?: boolean) => + ({ name, description: `${name} desc`, hidden, configure: y => y, run }) as Command.Interface; + +function registry(commands: Command.Interface[]): CommandRegistry.Interface { + return { + list: () => commands, + menu: () => commands.filter(c => c.hidden !== true), + get: (name: string) => commands.find(c => c.name === name)! + }; +} + +describe("openMenu", () => { + it("offers only non-hidden commands with descriptions as hints", async () => { + const prompts = new StubPrompts({ select: ["transfer"] }); + const transfer = vi.fn(async () => 0); + await openMenu({ + prompts, + ui: new StubUI(), + registry: registry([ + command("transfer", transfer), + command("fix-live", vi.fn()), + command("process-segment", vi.fn(), true) + ]) + }); + expect(prompts.selectCalls[0]!.options).toEqual([ + { value: "transfer", label: "transfer", hint: "transfer desc" }, + { value: "fix-live", label: "fix-live", hint: "fix-live desc" } + ]); + expect(transfer).toHaveBeenCalledWith({}); + }); + + it("returns the chosen command's exit code", async () => { + const code = await openMenu({ + prompts: new StubPrompts({ select: ["fix-live"] }), + ui: new StubUI(), + registry: registry([command("transfer", vi.fn()), command("fix-live", async () => 1)]) + }); + expect(code).toBe(1); + }); + + it("exits 130 on cancel", async () => { + const ui = new StubUI(); + const code = await openMenu({ + prompts: new StubPrompts(), + ui, + registry: registry([command("transfer", vi.fn())]) + }); + expect(code).toBe(130); + expect(ui.cancels).toEqual(["Cancelled."]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `yarn vitest run __tests__/commands/openMenu.test.ts __tests__/commands/dispatchDefault.test.ts 2>&1 | tail -20` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement** + +Create `src/commands/dispatchDefault.ts`: + +```ts +import type { Command } from "./registry/abstractions/Command.ts"; +import type { CommandRegistry } from "./registry/abstractions/CommandRegistry.ts"; + +export interface DispatchDefaultInput { + argv: Command.Argv; + registry: CommandRegistry.Interface; + openMenu: () => Promise; +} + +/** + * Handler for the `$0 [folder]` default command. Preserves the two historical + * no-command invocations before falling back to the interactive menu: + * `yarn transfer my-folder` → init my-folder + * `yarn transfer --config=… --preset=…` → transfer + */ +export async function dispatchDefault(input: DispatchDefaultInput): Promise { + const { argv, registry, openMenu } = input; + const folder = argv.folder; + if (typeof folder === "string" && folder.length > 0) { + return registry.get("init").run({ ...argv, "project-name": folder }); + } + if (argv.config || argv.preset) { + return registry.get("transfer").run(argv); + } + return openMenu(); +} +``` + +Create `src/commands/openMenu.ts`: + +```ts +import type { Prompts } from "./prompts/abstractions/Prompts.ts"; +import type { UI } from "./prompts/abstractions/UI.ts"; +import type { CommandRegistry } from "./registry/abstractions/CommandRegistry.ts"; +import { EXIT_CANCELLED } from "./exitCodes.ts"; + +export interface OpenMenuInput { + prompts: Prompts.Interface; + ui: UI.Interface; + registry: CommandRegistry.Interface; +} + +export async function openMenu(input: OpenMenuInput): Promise { + const { prompts, ui, registry } = input; + ui.intro("Webiny data transfer"); + const chosen = await prompts.select({ + message: "What do you want to do?", + options: registry.menu().map(command => ({ + value: command.name, + label: command.name, + hint: command.description + })) + }); + if (chosen === null) { + ui.cancel("Cancelled."); + return EXIT_CANCELLED; + } + // Empty argv: the command prompts for everything it needs. + return registry.get(chosen).run({}); +} +``` + +Create `src/commands/cliContainer.ts`: + +```ts +import { Container } from "@webiny/di"; +import { ContainerToken } from "~/base/index.js"; +import { PromptsFeature } from "./prompts/feature.ts"; +import { CommandRegistryFeature } from "./registry/feature.ts"; +import { + TransferCommand, + InitCommand, + InitProjectCommand, + ProcessSegmentCommand, + UpdateSkillsCommand +} from "./index.ts"; + +/** + * Lightweight container for the CLI shell. It knows nothing about a project + * config — commands build the per-project container (`bootstrap`) inside `run()`. + */ +export function createCliContainer(): Container { + const container = new Container(); + container.registerInstance(ContainerToken, container); + PromptsFeature.register(container); + CommandRegistryFeature.register(container); + container.register(TransferCommand).inSingletonScope(); + container.register(InitCommand).inSingletonScope(); + container.register(InitProjectCommand).inSingletonScope(); + container.register(ProcessSegmentCommand).inSingletonScope(); + container.register(UpdateSkillsCommand).inSingletonScope(); + return container; +} +``` + +Replace the section of `src/cli.ts` from `import yargs` to the end (keep the `tsx` register, the `suppressDeprecations` import and the `unhandledRejection` block exactly as they are): + +```ts +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { createCliContainer } from "./commands/cliContainer.ts"; +import { CommandRegistry } from "./commands/registry/index.ts"; +import { Prompts, UI } from "./commands/prompts/index.ts"; +import { openMenu } from "./commands/openMenu.ts"; +import { dispatchDefault } from "./commands/dispatchDefault.ts"; + +// … unhandledRejection block unchanged … + +const container = createCliContainer(); +const registry = container.resolve(CommandRegistry); +const transfer = registry.get("transfer"); + +let cli = yargs(hideBin(process.argv)).scriptName("transfer"); + +for (const command of registry.list()) { + cli = cli.command( + command.name, + command.description, + y => command.configure(y), + async argv => { + process.exitCode = await command.run(argv); + } + ); +} + +// Default command: keeps `yarn transfer ` and `yarn transfer --config --preset` +// working; with no arguments it opens the menu. +cli = cli.command( + "$0 [folder]", + false, + y => + transfer.configure(y).positional("folder", { + type: "string", + description: "Scaffold a new project folder (same as `init `)" + }), + async argv => { + process.exitCode = await dispatchDefault({ + argv, + registry, + openMenu: () => + openMenu({ + prompts: container.resolve(Prompts), + ui: container.resolve(UI), + registry + }) + }); + } +); + +await cli.strict().help().parseAsync(); +``` + +Delete the old `KNOWN_COMMANDS` block and the `registerXCommand` imports. + +- [ ] **Step 4: Run tests and type-check** + +Run: `yarn vitest run __tests__/commands 2>&1 | tail -20 && yarn ts-check` +Expected: All PASS; 0 type errors. + +- [ ] **Step 5: Smoke the three entry paths manually** + +```bash +yarn transfer --help | head -20 # lists transfer, fix-live (after Task 8), init…, process-segment +yarn transfer transfer --help | head -5 # transfer flags +yarn transfer # menu appears; Ctrl+C → exit code 130 (echo $?) +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/cli.ts src/commands/cliContainer.ts src/commands/openMenu.ts src/commands/dispatchDefault.ts __tests__/commands/openMenu.test.ts __tests__/commands/dispatchDefault.test.ts +git commit -m "feat(cli): command menu with backwards-compatible default dispatch" +``` + +--- + +### Task 6: `fix-live` steps — outcome type, `selectProject`, `selectSystem`, `confirmSystem` + +**Files:** +- Create: `src/commands/fixLive/types.ts`, `src/commands/fixLive/steps/outcome.ts`, `src/commands/fixLive/steps/selectProject.ts`, `src/commands/fixLive/steps/selectSystem.ts`, `src/commands/fixLive/steps/confirmSystem.ts` +- Test: `__tests__/commands/fixLive/steps/selectProject.test.ts`, `selectSystem.test.ts`, `confirmSystem.test.ts` + +**Interfaces:** +- Consumes: `Prompts`, `UI`, `discoverProjects` (`~/commands/transfer/wizard/projectDiscovery.js`), `MigrationConfig.Interface` +- Produces: `StepOutcome` + `ok` / `cancelled` / `refused` helpers, `SystemName`, `TableKind`, `SystemConfig`, three step functions — used by Task 8 + +- [ ] **Step 1: Shared types** + +Create `src/commands/fixLive/types.ts`: + +```ts +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; + +export type SystemName = "source" | "target"; +export type TableKind = "ddb" | "os"; +export type SystemConfig = MigrationConfig.Interface["source"] | MigrationConfig.Interface["target"]; +``` + +Create `src/commands/fixLive/steps/outcome.ts`: + +```ts +export interface StepOk { + kind: "ok"; + value: T; +} + +export interface StepCancelled { + kind: "cancelled"; +} + +export interface StepRefused { + kind: "refused"; + message: string; +} + +export type StepOutcome = StepOk | StepCancelled | StepRefused; + +export const ok = (value: T): StepOk => ({ kind: "ok", value }); +export const cancelled = (): StepCancelled => ({ kind: "cancelled" }); +export const refused = (message: string): StepRefused => ({ kind: "refused", message }); +``` + +- [ ] **Step 2: Failing tests** + +Create `__tests__/commands/fixLive/steps/selectProject.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; + +vi.mock("~/commands/transfer/wizard/projectDiscovery.ts", () => ({ + discoverProjects: vi.fn(async () => ["acme", "beta"]) +})); + +import { selectProject } from "~/commands/fixLive/steps/selectProject.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; + +describe("selectProject", () => { + it("uses --project when it exists", async () => { + const prompts = new StubPrompts(); + const result = await selectProject({ prompts, cwd: "/w", projectArg: "beta" }); + expect(result).toEqual({ kind: "ok", value: "beta" }); + expect(prompts.selectCalls).toHaveLength(0); + }); + + it("refuses an unknown --project", async () => { + const result = await selectProject({ prompts: new StubPrompts(), cwd: "/w", projectArg: "x" }); + expect(result.kind).toBe("refused"); + expect((result as { message: string }).message).toMatch(/Project "x" not found.*acme, beta/); + }); + + it("prompts and returns the choice", async () => { + const prompts = new StubPrompts({ select: ["acme"] }); + expect(await selectProject({ prompts, cwd: "/w" })).toEqual({ kind: "ok", value: "acme" }); + expect(prompts.selectCalls[0]!.message).toBe("Select a project"); + }); + + it("cancel → cancelled", async () => { + expect(await selectProject({ prompts: new StubPrompts(), cwd: "/w" })).toEqual({ + kind: "cancelled" + }); + }); +}); +``` + +Create `__tests__/commands/fixLive/steps/selectSystem.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; +import { selectSystem, formatSystemHint } from "~/commands/fixLive/steps/selectSystem.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; + +const CREDS = { accessKeyId: "a", secretAccessKey: "b" }; + +export const CONFIG: MigrationConfig.Interface = { + source: { + region: "eu-central-1", + credentials: CREDS, + dynamodb: { tableName: "acme-src-ddb" }, + s3: { bucket: "acme-src-s3" } + }, + target: { + region: "us-east-1", + credentials: CREDS, + accountId: "123456789012", + dynamodb: { tableName: "acme-prod-ddb" }, + s3: { bucket: "acme-prod-s3" }, + opensearch: { + endpoint: "https://os.example.com", + tableName: "acme-prod-os", + service: "opensearch", + indexPrefix: "" + } + }, + pipeline: { segments: 4 } +}; + +describe("selectSystem", () => { + it("formats the hint with ddb table, region and os table or none", () => { + expect(formatSystemHint(CONFIG.source)).toBe( + "ddb: acme-src-ddb · region: eu-central-1 · os table: none" + ); + expect(formatSystemHint(CONFIG.target)).toBe( + "ddb: acme-prod-ddb · region: us-east-1 · os table: acme-prod-os" + ); + }); + + it("uses --system without prompting", async () => { + const prompts = new StubPrompts(); + expect(await selectSystem({ prompts, config: CONFIG, systemArg: "target" })).toEqual({ + kind: "ok", + value: "target" + }); + expect(prompts.selectCalls).toHaveLength(0); + }); + + it("prompts with hints and returns the choice; cancel → cancelled", async () => { + const prompts = new StubPrompts({ select: ["source"] }); + expect(await selectSystem({ prompts, config: CONFIG })).toEqual({ kind: "ok", value: "source" }); + expect(prompts.selectCalls[0]!.options.map(o => o.hint)).toEqual([ + formatSystemHint(CONFIG.source), + formatSystemHint(CONFIG.target) + ]); + expect(await selectSystem({ prompts: new StubPrompts(), config: CONFIG })).toEqual({ + kind: "cancelled" + }); + }); +}); +``` + +Create `__tests__/commands/fixLive/steps/confirmSystem.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { confirmSystem, formatSystemSummary } from "~/commands/fixLive/steps/confirmSystem.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; +import { StubUI } from "../../prompts/StubUI.ts"; +import { CONFIG } from "./selectSystem.test.ts"; + +describe("confirmSystem", () => { + it("summary shows endpoint only for target and account id or unknown", () => { + const target = formatSystemSummary("target", CONFIG.target); + expect(target).toContain("os endpoint: https://os.example.com"); + expect(target).toContain("account id: 123456789012"); + const source = formatSystemSummary("source", CONFIG.source); + expect(source).not.toContain("os endpoint"); + expect(source).toContain("os table: none"); + expect(source).toContain("account id: unknown"); + }); + + it("--yes skips the confirm but still prints the note", async () => { + const ui = new StubUI(); + const prompts = new StubPrompts(); + const result = await confirmSystem({ prompts, ui, system: "target", config: CONFIG.target, yes: true }); + expect(result).toEqual({ kind: "ok", value: true }); + expect(ui.notes[0]!.title).toBe("System summary"); + expect(prompts.confirmCalls).toHaveLength(0); + }); + + it("confirm defaults to no; yes → ok, no or cancel → cancelled", async () => { + const yes = new StubPrompts({ confirm: [true] }); + expect( + await confirmSystem({ prompts: yes, ui: new StubUI(), system: "target", config: CONFIG.target, yes: false }) + ).toEqual({ kind: "ok", value: true }); + expect(yes.confirmCalls[0]!.initialValue).toBe(false); + expect(yes.confirmCalls[0]!.message).toBe( + "This is the system whose records will be modified. Continue?" + ); + const no = new StubPrompts({ confirm: [false] }); + expect( + await confirmSystem({ prompts: no, ui: new StubUI(), system: "target", config: CONFIG.target, yes: false }) + ).toEqual({ kind: "cancelled" }); + expect( + await confirmSystem({ prompts: new StubPrompts(), ui: new StubUI(), system: "target", config: CONFIG.target, yes: false }) + ).toEqual({ kind: "cancelled" }); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `yarn vitest run __tests__/commands/fixLive 2>&1 | tail -20` +Expected: FAIL — modules not found. + +- [ ] **Step 4: Implement the steps** + +Create `src/commands/fixLive/steps/selectProject.ts`: + +```ts +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import { discoverProjects } from "~/commands/transfer/wizard/projectDiscovery.js"; +import { type StepOutcome, ok, cancelled, refused } from "./outcome.ts"; + +export interface SelectProjectInput { + prompts: Prompts.Interface; + cwd: string; + projectArg?: string; +} + +export async function selectProject(input: SelectProjectInput): Promise> { + const projects = await discoverProjects(input.cwd); + + if (input.projectArg) { + if (!projects.includes(input.projectArg)) { + return refused( + `Project "${input.projectArg}" not found under projects/. Available: ${projects.join(", ") || "none"}` + ); + } + return ok(input.projectArg); + } + + if (projects.length === 0) { + return refused( + "No projects found under projects/. Run `yarn transfer init-project ` first." + ); + } + + const chosen = await input.prompts.select({ + message: "Select a project", + options: projects.map(project => ({ value: project, label: project })) + }); + if (chosen === null) { + return cancelled(); + } + return ok(chosen); +} +``` + +Create `src/commands/fixLive/steps/selectSystem.ts`: + +```ts +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; +import type { SystemConfig, SystemName } from "../types.ts"; +import { type StepOutcome, ok, cancelled } from "./outcome.ts"; + +export interface SelectSystemInput { + prompts: Prompts.Interface; + config: MigrationConfig.Interface; + systemArg?: SystemName; +} + +export function formatSystemHint(system: SystemConfig): string { + const osTable = system.opensearch ? system.opensearch.tableName : "none"; + return `ddb: ${system.dynamodb.tableName} · region: ${system.region} · os table: ${osTable}`; +} + +export async function selectSystem(input: SelectSystemInput): Promise> { + if (input.systemArg) { + return ok(input.systemArg); + } + const chosen = await input.prompts.select({ + message: "Which system?", + options: [ + { value: "source", label: "source", hint: formatSystemHint(input.config.source) }, + { value: "target", label: "target", hint: formatSystemHint(input.config.target) } + ] + }); + if (chosen === null) { + return cancelled(); + } + return ok(chosen); +} +``` + +Create `src/commands/fixLive/steps/confirmSystem.ts`: + +```ts +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { SystemConfig, SystemName } from "../types.ts"; +import { type StepOutcome, ok, cancelled } from "./outcome.ts"; + +export interface ConfirmSystemInput { + prompts: Prompts.Interface; + ui: UI.Interface; + system: SystemName; + config: SystemConfig; + yes: boolean; +} + +export function formatSystemSummary(system: SystemName, config: SystemConfig): string { + const lines = [ + `system: ${system}`, + `region: ${config.region}`, + `ddb table: ${config.dynamodb.tableName}`, + `os table: ${config.opensearch ? config.opensearch.tableName : "none"}` + ]; + // Source systems have no endpoint in the config schema (unified.schema.ts). + if (config.opensearch && "endpoint" in config.opensearch) { + lines.push(`os endpoint: ${config.opensearch.endpoint}`); + } + lines.push(`account id: ${config.accountId ?? "unknown"}`); + return lines.join("\n"); +} + +export async function confirmSystem(input: ConfirmSystemInput): Promise> { + input.ui.note(formatSystemSummary(input.system, input.config), "System summary"); + if (input.yes) { + return ok(true); + } + const answer = await input.prompts.confirm({ + message: "This is the system whose records will be modified. Continue?", + initialValue: false + }); + if (answer !== true) { + return cancelled(); + } + return ok(true); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `yarn vitest run __tests__/commands/fixLive 2>&1 | tail -20` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/fixLive __tests__/commands/fixLive +git commit -m "feat(fix-live): project, system and confirm steps" +``` + +--- + +### Task 7: `fix-live` steps — `guardV6`, `selectMode` + +**Files:** +- Create: `src/commands/fixLive/steps/guardV6.ts`, `src/commands/fixLive/steps/selectMode.ts` +- Test: `__tests__/commands/fixLive/steps/guardV6.test.ts`, `__tests__/commands/fixLive/steps/selectMode.test.ts` + +**Interfaces:** +- Consumes: `SourceDynamoDbClient.Interface` (`scan` with `sortKeyEquals` / `limit` — sibling plan step 2), `isCmsEntry` / `isFmFile` from `~/domain/transform/filters.js`, `FixLiveState.File`, `LiveFieldRunner.Mode` from `~/features/FixLive/index.js` +- Produces: `guardV6(input)`, `selectMode(input)`, `NO_DRY_RUN_MESSAGE` — used by Task 8 + +- [ ] **Step 1: Failing tests** + +Create `__tests__/commands/fixLive/steps/guardV6.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { guardV6 } from "~/commands/fixLive/steps/guardV6.js"; +import { MockDynamoDbClient } from "../../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { StubUI } from "../../prompts/StubUI.ts"; + +const base = { _et: "CmsEntries", _ct: "2026-01-01T00:00:00.000Z", _md: "2026-01-01T00:00:00.000Z" }; + +const v6Entry = { + ...base, + PK: "T#root#L#en-US#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { modelId: "article", version: 1, status: "draft" } +}; +const v5Entry = { + ...base, + PK: "T#root#L#en-US#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + modelId: "article", + version: 1, + status: "draft" +}; +const fmFile = { + ...base, + PK: "T#root#L#en-US#CMS#CME#file1", + SK: "L", + TYPE: "cms.entry.l", + data: { modelId: "fmFile", version: 1 } +}; +const settings = { ...base, PK: "T#root#SETTINGS", SK: "A", TYPE: "settings" }; + +const run = (rows: object[]) => + guardV6({ + client: new MockDynamoDbClient({ t: rows as never }), + tableName: "t", + region: "eu-central-1", + ui: new StubUI() + }); + +describe("guardV6", () => { + it("passes on a v6 CMS entry (data object at the root)", async () => { + expect(await run([settings, fmFile, v6Entry])).toEqual({ kind: "ok", value: "v6" }); + }); + + it("refuses a v5 table with the table name and region", async () => { + const result = await run([settings, v5Entry]); + expect(result.kind).toBe("refused"); + expect((result as { message: string }).message).toBe( + 'Table "t" in eu-central-1 holds v5 records. fix-live only runs against migrated v6 systems.' + ); + }); + + it("refuses when no CMS entry is found (internal models do not count)", async () => { + const result = await run([settings, fmFile]); + expect(result.kind).toBe("refused"); + expect((result as { message: string }).message).toBe( + "Could not find a CMS entry record to verify the schema version." + ); + }); + + it("reports the spinner lifecycle", async () => { + const ui = new StubUI(); + await guardV6({ + client: new MockDynamoDbClient({ t: [v6Entry] as never }), + tableName: "t", + region: "r", + ui + }); + expect(ui.spinnerMessages[0]).toBe("Checking schema version…"); + expect(ui.spinnerMessages.at(-1)).toBe("Schema version: v6"); + }); +}); +``` + +Create `__tests__/commands/fixLive/steps/selectMode.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { selectMode, NO_DRY_RUN_MESSAGE } from "~/commands/fixLive/steps/selectMode.js"; +import { StubPrompts } from "../../prompts/StubPrompts.ts"; + +const withDryRun = { + lastDryRun: { runId: "1", at: "2026-09-04T09:12:00.000Z", changes: 2118, skips: 4 } +}; + +describe("selectMode", () => { + it("--dry-run needs no state", async () => { + expect(await selectMode({ prompts: new StubPrompts(), state: null, modeArg: "dry-run", yes: false })).toEqual({ + kind: "ok", + value: "dry-run" + }); + }); + + it("--live without a dry run is refused with the shared message", async () => { + expect(await selectMode({ prompts: new StubPrompts(), state: null, modeArg: "live", yes: false })).toEqual({ + kind: "refused", + message: NO_DRY_RUN_MESSAGE + }); + }); + + it("--live --yes skips the proceed confirm", async () => { + const prompts = new StubPrompts(); + expect(await selectMode({ prompts, state: withDryRun, modeArg: "live", yes: true })).toEqual({ + kind: "ok", + value: "live" + }); + expect(prompts.confirmCalls).toHaveLength(0); + }); + + it("menu disables live with a hint when there is no state", async () => { + const prompts = new StubPrompts({ select: ["dry-run"] }); + await selectMode({ prompts, state: null, yes: false }); + const live = prompts.selectCalls[0]!.options[1]!; + expect(live.disabled).toBe(true); + expect(live.hint).toBe("run a dry run first"); + expect(prompts.selectCalls[0]!.initialValue).toBe("dry-run"); + }); + + it("live from the menu asks to proceed with the last dry run summary", async () => { + const prompts = new StubPrompts({ select: ["live"], confirm: [true] }); + expect(await selectMode({ prompts, state: withDryRun, yes: false })).toEqual({ kind: "ok", value: "live" }); + expect(prompts.confirmCalls[0]!.message).toMatch(/^Last dry run: 2 118 changes, 2026-09-04 09:12\. Proceed\?$/); + expect(prompts.confirmCalls[0]!.initialValue).toBe(false); + }); + + it("cancel or decline → cancelled", async () => { + expect(await selectMode({ prompts: new StubPrompts(), state: withDryRun, yes: false })).toEqual({ kind: "cancelled" }); + expect( + await selectMode({ prompts: new StubPrompts({ select: ["live"], confirm: [false] }), state: withDryRun, yes: false }) + ).toEqual({ kind: "cancelled" }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `yarn vitest run __tests__/commands/fixLive/steps/guardV6.test.ts __tests__/commands/fixLive/steps/selectMode.test.ts 2>&1 | tail -20` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement `guardV6`** + +Create `src/commands/fixLive/steps/guardV6.ts`: + +```ts +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { SourceDynamoDbClient } from "~/services/DynamoDbClient/index.js"; +import type { BaseRecord } from "~/domain/transform/types/records.js"; +import { isCmsEntry, isFmFile } from "~/domain/transform/filters.js"; +import { formatError } from "~/base/index.js"; +import { type StepOutcome, ok, refused } from "./outcome.ts"; + +export interface GuardV6Input { + client: SourceDynamoDbClient.Interface; + tableName: string; + region: string; + ui: UI.Interface; +} + +const GUARD_SEGMENTS = 4; +const FIRST_PASS_LIMIT = 100; +const MAX_ROWS = 5000; + +export const NO_PROBE_MESSAGE = "Could not find a CMS entry record to verify the schema version."; + +// Same exclusion `addLiveField` applies (fmFile / wbyFmFile never carry `live`). +const isProbeCandidate = (row: BaseRecord): boolean => isCmsEntry(row) && !isFmFile(row); + +const isV6 = (row: BaseRecord): boolean => + typeof row.data === "object" && row.data !== null && !Array.isArray(row.data); + +const isV5 = (row: BaseRecord): boolean => row.data === undefined && typeof row.modelId === "string"; + +async function scanForProbe( + client: SourceDynamoDbClient.Interface, + tableName: string, + limit: number | undefined, + budget: number +): Promise { + let read = 0; + for (let segment = 0; segment < GUARD_SEGMENTS; segment++) { + const rows = client.scan(tableName, { + segment, + totalSegments: GUARD_SEGMENTS, + sortKeyEquals: "L", + limit + }); + for await (const row of rows) { + read++; + if (isProbeCandidate(row)) { + return row; + } + if (read >= budget) { + return null; + } + } + } + return null; +} + +/** + * Runs on the DDB table before the system confirm so nobody is asked to + * confirm a system that will be refused. v6 marker: CMS entry `L` record + * carries a `data` object at the root; v5 keeps fields flat. + */ +export async function guardV6(input: GuardV6Input): Promise> { + const spinner = input.ui.spinner(); + spinner.start("Checking schema version…"); + + let probe: BaseRecord | null; + try { + probe = await scanForProbe( + input.client, + input.tableName, + FIRST_PASS_LIMIT, + GUARD_SEGMENTS * FIRST_PASS_LIMIT + ); + if (!probe) { + probe = await scanForProbe(input.client, input.tableName, undefined, MAX_ROWS); + } + } catch (error) { + spinner.stop("Schema check failed"); + return refused( + `Could not read table "${input.tableName}" in ${input.region}: ${formatError(error, false)}` + ); + } + + if (probe && isV6(probe)) { + spinner.stop("Schema version: v6"); + return ok("v6"); + } + spinner.stop("Schema check failed"); + if (probe && isV5(probe)) { + return refused( + `Table "${input.tableName}" in ${input.region} holds v5 records. fix-live only runs against migrated v6 systems.` + ); + } + return refused(NO_PROBE_MESSAGE); +} +``` + +If `SourceDynamoDbClient.Scan` does not yet have `sortKeyEquals` / `limit`, the sibling plan's step 2 has not landed — stop and land it first; do not add the fields here. + +- [ ] **Step 4: Implement `selectMode`** + +Create `src/commands/fixLive/steps/selectMode.ts`: + +```ts +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import type { FixLiveState, LiveFieldRunner } from "~/features/FixLive/index.js"; +import { formatCount, formatTimestamp } from "./format.ts"; +import { type StepOutcome, ok, cancelled, refused } from "./outcome.ts"; + +export interface SelectModeInput { + prompts: Prompts.Interface; + state: FixLiveState.File | null; + modeArg?: LiveFieldRunner.Mode; + yes: boolean; +} + +export const NO_DRY_RUN_MESSAGE = + "No completed dry run found for this project and system. Run a dry run first."; + +export async function selectMode(input: SelectModeInput): Promise> { + const lastDryRun = input.state?.lastDryRun; + + let mode = input.modeArg; + if (mode === "live" && !lastDryRun) { + return refused(NO_DRY_RUN_MESSAGE); + } + + if (!mode) { + const chosen = await input.prompts.select({ + message: "Run mode", + initialValue: "dry-run", + options: [ + { value: "dry-run", label: "dry run", hint: "report only, nothing is written" }, + { + value: "live", + label: "live", + disabled: !lastDryRun, + hint: lastDryRun + ? `last dry run: ${formatCount(lastDryRun.changes)} changes, ${formatTimestamp(lastDryRun.at)}` + : "run a dry run first" + } + ] + }); + if (chosen === null) { + return cancelled(); + } + mode = chosen; + } + + if (mode === "live" && !input.yes && lastDryRun) { + const proceed = await input.prompts.confirm({ + message: `Last dry run: ${formatCount(lastDryRun.changes)} changes, ${formatTimestamp(lastDryRun.at)}. Proceed?`, + initialValue: false + }); + if (proceed !== true) { + return cancelled(); + } + } + + return ok(mode); +} +``` + +Create `src/commands/fixLive/steps/format.ts`: + +```ts +/** 148203 → "148 203" (thin grouping, matches the summary layout in the spec). */ +export function formatCount(value: number): string { + return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, " "); +} + +/** ISO string → "2026-09-04 09:12" (UTC). */ +export function formatTimestamp(iso: string): string { + return iso.slice(0, 16).replace("T", " "); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `yarn vitest run __tests__/commands/fixLive 2>&1 | tail -20` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/fixLive __tests__/commands/fixLive +git commit -m "feat(fix-live): v6 guard and run-mode steps" +``` + +--- + +### Task 8: `runTable`, `summarise`, `FixLiveCommand`, registration + +**Files:** +- Create: `src/commands/fixLive/steps/runTable.ts`, `src/commands/fixLive/steps/summarise.ts`, `src/commands/fixLive/FixLiveCommand.ts`, `src/commands/fixLive/feature.ts` +- Modify: `src/commands/cliContainer.ts` (register `FixLiveCommandFeature`) +- Test: `__tests__/commands/fixLive/steps/runTable.test.ts`, `__tests__/commands/fixLive/steps/summarise.test.ts`, `__tests__/commands/fixLive/FixLiveCommand.test.ts` + +**Interfaces:** +- Consumes: everything from Tasks 6–7; `bootstrap`, `loadConfig`, `discoverConfig`, `SourceDynamoDbClient` / `TargetDynamoDbClient`, `TransferContext`; from `~/features/FixLive/index.js`: `LiveFieldRunner`, `ChangeReport`, `FixLiveState`, `LiveFieldRunnerFactory`, `FixLiveStateStore` (see contract table) +- Produces: `FixLiveCommand` (`name: "fix-live"`), flags `--project --system --dry-run|--live --yes --table --concurrency --log-level`, exit codes 0 / 1 / 130 + +- [ ] **Step 1: Contract check** + +```bash +cat src/features/FixLive/index.ts +grep -n "FixLive" src/bootstrap.ts +``` + +Confirm the names in the contract table at the top of this plan. If the sibling exposes differently named equivalents, use them in `runTable.ts` / `FixLiveCommand.ts` and update the table. If `bootstrap.ts` does not register `FixLiveFeature`, add `FixLiveFeature.register(container)` immediately after `bootstrap(...)` in `FixLiveCommand.run` (Step 5). + +- [ ] **Step 2: Failing tests for `runTable` and `summarise`** + +Create `__tests__/commands/fixLive/steps/runTable.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import type { LiveFieldRunner, ChangeReport } from "~/features/FixLive/index.js"; +import { runTable } from "~/commands/fixLive/steps/runTable.js"; +import { StubUI } from "../../prompts/StubUI.ts"; + +export const STATS: LiveFieldRunner.Stats = { + scanned: 148203, + entries: 31440, + changes: { "missing-live": 1902, "empty-live": 201, "wrong-version": 9, "stale-live": 6 }, + skips: { + "no-latest-record": 0, + "invalid-version": 1, + "revision-record-missing": 0, + "revision-version-mismatch": 3, + "latest-status-contradicts-published": 0, + "latest-status-contradicts-unpublished": 0, + "decompress-failed": 0, + "changed-during-run": 0 + }, + written: 0, + conditionFailed: 0 +}; + +export const fakeRunner = (stats: LiveFieldRunner.Stats): LiveFieldRunner.Interface => ({ + async run(options) { + options.onProgress({ ...stats, scanned: 10, entries: 2 }); + options.onProgress(stats); + return stats; + } +}); + +const report = {} as ChangeReport.Interface; + +describe("runTable", () => { + it("drives the spinner with live counters and returns the stats", async () => { + const ui = new StubUI(); + const result = await runTable({ + table: "ddb", + tableName: "acme-prod-ddb", + region: "eu-central-1", + runner: fakeRunner(STATS), + mode: "dry-run", + report, + ui + }); + expect(result).toEqual({ table: "ddb", tableName: "acme-prod-ddb", region: "eu-central-1", stats: STATS }); + expect(ui.spinnerMessages[0]).toBe("Scanning DynamoDB…"); + expect(ui.spinnerMessages).toContain("Scanning DynamoDB… 10 rows / 2 entries"); + expect(ui.spinnerMessages.at(-1)).toBe("DynamoDB scanned: 148 203 rows / 31 440 entries"); + }); + + it("labels the OpenSearch table", async () => { + const ui = new StubUI(); + await runTable({ table: "os", tableName: "t", region: "r", runner: fakeRunner(STATS), mode: "live", report, ui }); + expect(ui.spinnerMessages[0]).toBe("Scanning OpenSearch…"); + }); +}); +``` + +Create `__tests__/commands/fixLive/steps/summarise.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { formatSummary, summarise, totalChanges } from "~/commands/fixLive/steps/summarise.js"; +import { StubUI } from "../../prompts/StubUI.ts"; +import { STATS } from "./runTable.test.ts"; + +const results = [ + { table: "ddb" as const, tableName: "acme-prod-ddb", region: "eu-central-1", stats: STATS }, + { table: "os" as const, tableName: "acme-prod-os", region: "eu-central-1", stats: { ...STATS, scanned: 62880 } } +]; + +describe("formatSummary", () => { + it("renders one block per table with counts and non-zero breakdowns", () => { + const text = formatSummary({ + project: "acme", + system: "target", + mode: "dry-run", + results, + reportPath: ".transfer/1/fix-live-report.jsonl", + statePath: ".transfer/state/fix-live/acme__target.json" + }); + expect(text).toContain("Fix live field — dry run (project: acme, system: target)"); + expect(text).toContain("DynamoDB acme-prod-ddb (eu-central-1)"); + expect(text).toContain("scanned 148 203"); + expect(text).toContain("changes 2 118 missing-live 1 902 · empty-live 201 · wrong-version 9 · stale-live 6"); + expect(text).toContain("skips 4 invalid-version 1 · revision-version-mismatch 3"); + expect(text).toContain("OpenSearch acme-prod-os (eu-central-1)"); + expect(text).toContain("Report: .transfer/1/fix-live-report.jsonl"); + expect(text).toContain('Run again and choose "live" to apply these changes.'); + }); + + it("live mode shows written / condition-failed instead of the dry-run hint", () => { + const text = formatSummary({ + project: "acme", + system: "target", + mode: "live", + results: [{ ...results[0]!, stats: { ...STATS, written: 2100, conditionFailed: 18 } }], + reportPath: "r", + statePath: "s" + }); + expect(text).toContain("written 2 100"); + expect(text).toContain("changed during run 18"); + expect(text).not.toContain("choose \"live\""); + }); +}); + +describe("summarise", () => { + it("warns when a live run's change count differs from the last dry run", () => { + const ui = new StubUI(); + summarise({ + ui, + project: "acme", + system: "target", + mode: "live", + results, + reportPath: "r", + statePath: "s", + lastDryRun: { runId: "0", at: "2026-09-04T09:12:00.000Z", changes: 2118, skips: 4 } + }); + expect(totalChanges(results)).toBe(4236); + expect(ui.warns[0]).toBe("Last dry run reported 2 118 changes, this live run found 4 236."); + expect(ui.notes[0]!.title).toBe("Summary"); + expect(ui.outros).toEqual(["Done."]); + }); +}); +``` + +- [ ] **Step 3: Implement `runTable` and `summarise`** + +Create `src/commands/fixLive/steps/runTable.ts`: + +```ts +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { ChangeReport, LiveFieldRunner } from "~/features/FixLive/index.js"; +import type { TableKind } from "../types.ts"; +import { formatCount } from "./format.ts"; + +export interface RunTableInput { + table: TableKind; + tableName: string; + region: string; + runner: LiveFieldRunner.Interface; + mode: LiveFieldRunner.Mode; + report: ChangeReport.Interface; + ui: UI.Interface; +} + +export interface TableRunResult { + table: TableKind; + tableName: string; + region: string; + stats: LiveFieldRunner.Stats; +} + +export const tableLabel = (table: TableKind): string => (table === "ddb" ? "DynamoDB" : "OpenSearch"); + +export async function runTable(input: RunTableInput): Promise { + const label = tableLabel(input.table); + const spinner = input.ui.spinner(); + spinner.start(`Scanning ${label}…`); + + const stats = await input.runner.run({ + mode: input.mode, + report: input.report, + onProgress: progress => { + spinner.message( + `Scanning ${label}… ${formatCount(progress.scanned)} rows / ${formatCount(progress.entries)} entries` + ); + } + }); + + spinner.stop( + `${label} scanned: ${formatCount(stats.scanned)} rows / ${formatCount(stats.entries)} entries` + ); + return { table: input.table, tableName: input.tableName, region: input.region, stats }; +} +``` + +Create `src/commands/fixLive/steps/summarise.ts`: + +```ts +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { FixLiveState, LiveFieldRunner } from "~/features/FixLive/index.js"; +import type { SystemName } from "../types.ts"; +import { formatCount } from "./format.ts"; +import { tableLabel, type TableRunResult } from "./runTable.ts"; + +export interface SummaryInput { + project: string; + system: SystemName; + mode: LiveFieldRunner.Mode; + results: TableRunResult[]; + reportPath: string; + statePath: string; +} + +export interface SummariseInput extends SummaryInput { + ui: UI.Interface; + lastDryRun?: FixLiveState.RunSummary; +} + +const sum = (counts: Record): number => + Object.values(counts).reduce((total, count) => total + count, 0); + +const breakdown = (counts: Record): string => + Object.entries(counts) + .filter(([, count]) => count > 0) + .map(([reason, count]) => `${reason} ${formatCount(count)}`) + .join(" · "); + +const row = (label: string, value: number, detail = ""): string => { + const line = ` ${label.padEnd(14)} ${formatCount(value).padStart(9)}`; + return detail ? `${line} ${detail}` : line; +}; + +export const totalChanges = (results: TableRunResult[]): number => + results.reduce((total, result) => total + sum(result.stats.changes), 0); + +export const totalSkips = (results: TableRunResult[]): number => + results.reduce((total, result) => total + sum(result.stats.skips), 0); + +export function formatSummary(input: SummaryInput): string { + const modeLabel = input.mode === "dry-run" ? "dry run" : "live run"; + const lines: string[] = [ + `Fix live field — ${modeLabel} (project: ${input.project}, system: ${input.system})`, + "" + ]; + for (const result of input.results) { + const { stats } = result; + lines.push(` ${tableLabel(result.table)} ${result.tableName} (${result.region})`); + lines.push(row("scanned", stats.scanned)); + lines.push(row("cms entries", stats.entries)); + lines.push(row("changes", sum(stats.changes), breakdown(stats.changes))); + lines.push(row("skips", sum(stats.skips), breakdown(stats.skips))); + if (input.mode === "live") { + lines.push(row("written", stats.written)); + lines.push(row("changed during run", stats.conditionFailed)); + } + lines.push(""); + } + lines.push(`Report: ${input.reportPath}`); + lines.push(`State: ${input.statePath}`); + if (input.mode === "dry-run") { + lines.push(""); + lines.push('Run again and choose "live" to apply these changes.'); + } + return lines.join("\n"); +} + +export function summarise(input: SummariseInput): void { + if (input.mode === "live" && input.lastDryRun) { + const found = totalChanges(input.results); + if (found !== input.lastDryRun.changes) { + input.ui.warn( + `Last dry run reported ${formatCount(input.lastDryRun.changes)} changes, this live run found ${formatCount(found)}.` + ); + } + } + input.ui.note(formatSummary(input), "Summary"); + input.ui.outro("Done."); +} +``` + +Run: `yarn vitest run __tests__/commands/fixLive/steps 2>&1 | tail -20` — expected: all PASS. + +- [ ] **Step 4: Failing test for `FixLiveCommand`** + +Create `__tests__/commands/fixLive/FixLiveCommand.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { FixLiveState } from "~/features/FixLive/index.js"; +import { SourceDynamoDbClient, TargetDynamoDbClient } from "~/services/DynamoDbClient/index.js"; +import { TransferContext } from "~/features/TransferLifecycle/index.js"; +import { ChangeReport, FixLiveStateStore, LiveFieldRunnerFactory } from "~/features/FixLive/index.js"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { StubPrompts } from "../prompts/StubPrompts.ts"; +import { StubUI } from "../prompts/StubUI.ts"; +import { CONFIG } from "./steps/selectSystem.test.ts"; +import { STATS, fakeRunner } from "./steps/runTable.test.ts"; + +const resolveMap = new Map(); +const registerInstance = vi.fn(); + +vi.mock("~/commands/transfer/wizard/projectDiscovery.ts", () => ({ + discoverProjects: vi.fn(async () => ["acme"]) +})); +vi.mock("~/commands/transfer/wizard/configDiscovery.ts", () => ({ + discoverConfig: vi.fn(async () => "/w/projects/acme/config.ts") +})); +vi.mock("~/features/MigrationConfig/loadConfig.ts", () => ({ + loadConfig: vi.fn(async () => CONFIG) +})); +vi.mock("~/bootstrap.ts", () => ({ + bootstrap: vi.fn(() => ({ + resolve: (token: unknown) => resolveMap.get(token), + registerInstance + })) +})); + +import { FixLiveCommand } from "~/commands/fixLive/FixLiveCommand.js"; + +const v6Row = { + PK: "T#root#L#en-US#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + _et: "CmsEntries", + _ct: "x", + _md: "x", + data: { modelId: "article", version: 1, status: "draft" } +}; +const v5Row = { ...v6Row, data: undefined, modelId: "article", version: 1 }; + +let stateFile: FixLiveState.File | null; +const stateWrite = vi.fn(); +const factoryCreate = vi.fn(() => fakeRunner(STATS)); + +beforeEach(() => { + resolveMap.clear(); + registerInstance.mockReset(); + stateWrite.mockReset(); + factoryCreate.mockClear(); + stateFile = null; + resolveMap.set(SourceDynamoDbClient, new MockDynamoDbClient({ "acme-src-ddb": [v5Row] as never })); + resolveMap.set(TargetDynamoDbClient, new MockDynamoDbClient({ "acme-prod-ddb": [v6Row] as never })); + resolveMap.set(ChangeReport, {}); + resolveMap.set(FixLiveStateStore, { read: () => stateFile, write: stateWrite }); + resolveMap.set(LiveFieldRunnerFactory, { create: factoryCreate }); +}); + +const command = (prompts: StubPrompts, ui = new StubUI()) => new FixLiveCommand(prompts, ui); + +describe("FixLiveCommand", () => { + it("cancel at project select → 130", async () => { + expect(await command(new StubPrompts()).run({})).toBe(130); + }); + + it("cancel at system select → 130", async () => { + expect(await command(new StubPrompts({ select: ["acme"] })).run({})).toBe(130); + }); + + it("v5 system is refused before the confirm → 1", async () => { + const prompts = new StubPrompts({ select: ["acme", "source"], confirm: [true] }); + const ui = new StubUI(); + expect(await command(prompts, ui).run({})).toBe(1); + expect(prompts.confirmCalls).toHaveLength(0); + expect(ui.errors[0]).toMatch(/holds v5 records/); + }); + + it("cancel at the system confirm → 130", async () => { + expect(await command(new StubPrompts({ select: ["acme", "target"] })).run({})).toBe(130); + }); + + it("cancel at the mode select → 130", async () => { + const prompts = new StubPrompts({ select: ["acme", "target"], confirm: [true] }); + expect(await command(prompts).run({})).toBe(130); + }); + + it("--live without a dry run → 1 with the refusal message", async () => { + const ui = new StubUI(); + const code = await command(new StubPrompts(), ui).run({ + project: "acme", + system: "target", + live: true, + yes: true + }); + expect(code).toBe(1); + expect(ui.errors[0]).toMatch(/Run a dry run first/); + expect(factoryCreate).not.toHaveBeenCalled(); + }); + + it("--yes --dry-run runs both tables, writes lastDryRun, asks nothing", async () => { + const prompts = new StubPrompts(); + const ui = new StubUI(); + const code = await command(prompts, ui).run({ + project: "acme", + system: "target", + "dry-run": true, + yes: true, + concurrency: 4 + }); + expect(code).toBe(0); + expect(prompts.confirmCalls).toHaveLength(0); + expect(prompts.selectCalls).toHaveLength(0); + expect(factoryCreate.mock.calls.map(([input]) => (input as { table: string }).table)).toEqual(["ddb", "os"]); + expect(factoryCreate.mock.calls[0]![0]).toMatchObject({ + table: "ddb", + tableName: "acme-prod-ddb", + segments: 4, + concurrency: 4 + }); + expect(registerInstance).toHaveBeenCalledWith(TransferContext, expect.objectContaining({ dryRun: true })); + expect(stateWrite).toHaveBeenCalledWith( + "acme", + "target", + expect.objectContaining({ lastDryRun: expect.objectContaining({ changes: 4236, skips: 8 }) }) + ); + expect(ui.outros).toEqual(["Done."]); + }); + + it("--table=ddb restricts to one table", async () => { + await command(new StubPrompts()).run({ project: "acme", system: "target", "dry-run": true, yes: true, table: "ddb" }); + expect(factoryCreate).toHaveBeenCalledOnce(); + }); + + it("--table=os on a system without OpenSearch → 1", async () => { + resolveMap.set(SourceDynamoDbClient, new MockDynamoDbClient({ "acme-src-ddb": [v6Row] as never })); + const ui = new StubUI(); + expect( + await command(new StubPrompts(), ui).run({ project: "acme", system: "source", "dry-run": true, yes: true, table: "os" }) + ).toBe(1); + expect(ui.errors[0]).toMatch(/no OpenSearch table/); + }); + + it("--live --yes with state writes lastLiveRun", async () => { + stateFile = { lastDryRun: { runId: "0", at: "2026-09-04T09:12:00.000Z", changes: 4236, skips: 8 } }; + const code = await command(new StubPrompts()).run({ project: "acme", system: "target", live: true, yes: true }); + expect(code).toBe(0); + expect(stateWrite).toHaveBeenCalledWith( + "acme", + "target", + expect.objectContaining({ + lastDryRun: stateFile.lastDryRun, + lastLiveRun: expect.objectContaining({ written: 0, conditionFailed: 0 }) + }) + ); + }); +}); +``` + +- [ ] **Step 5: Implement `FixLiveCommand` and register it** + +Create `src/commands/fixLive/FixLiveCommand.ts`: + +```ts +import type { Argv } from "yargs"; +import { join, resolve } from "node:path"; +import type { Container } from "@webiny/di"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import { UI } from "~/commands/prompts/abstractions/UI.js"; +import { EXIT_CANCELLED, EXIT_FAILURE, EXIT_OK } from "~/commands/exitCodes.js"; +import { discoverConfig } from "~/commands/transfer/wizard/configDiscovery.js"; +import { bootstrap } from "~/bootstrap.js"; +import { formatError } from "~/base/index.js"; +import { loadConfig } from "~/features/MigrationConfig/loadConfig.js"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; +import { TransferContext } from "~/features/TransferLifecycle/index.js"; +import { SourceDynamoDbClient, TargetDynamoDbClient } from "~/services/DynamoDbClient/index.js"; +import { + ChangeReport, + FixLiveStateStore, + LiveFieldRunnerFactory, + type FixLiveState, + type LiveFieldRunner +} from "~/features/FixLive/index.js"; +import type { SystemConfig, SystemName, TableKind } from "./types.ts"; +import type { StepCancelled, StepRefused } from "./steps/outcome.ts"; +import { selectProject } from "./steps/selectProject.ts"; +import { selectSystem } from "./steps/selectSystem.ts"; +import { guardV6 } from "./steps/guardV6.ts"; +import { confirmSystem } from "./steps/confirmSystem.ts"; +import { selectMode } from "./steps/selectMode.ts"; +import { runTable, type TableRunResult } from "./steps/runTable.ts"; +import { summarise, totalChanges, totalSkips } from "./steps/summarise.ts"; + +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface FixLiveOptions { + project?: string; + system?: SystemName; + mode?: LiveFieldRunner.Mode; + yes: boolean; + table?: TableKind; + concurrency: number; + logLevel?: LogLevel; +} + +const DEFAULT_CONCURRENCY = 4; + +function parseOptions(argv: CommandAbstraction.Argv): FixLiveOptions { + let mode: LiveFieldRunner.Mode | undefined; + if (argv.live === true) { + mode = "live"; + } else if (argv["dry-run"] === true) { + mode = "dry-run"; + } + return { + project: argv.project as string | undefined, + system: argv.system as SystemName | undefined, + mode, + yes: argv.yes === true, + table: argv.table as TableKind | undefined, + concurrency: typeof argv.concurrency === "number" ? argv.concurrency : DEFAULT_CONCURRENCY, + logLevel: argv["log-level"] as LogLevel | undefined + }; +} + +function resolveTables(restriction: TableKind | undefined, system: SystemConfig): TableKind[] { + if (restriction) { + return [restriction]; + } + return system.opensearch ? ["ddb", "os"] : ["ddb"]; +} + +class FixLiveCommandImpl implements CommandAbstraction.Interface { + public readonly name = "fix-live"; + public readonly description = + "Reconcile the `live` field on CMS entries of an already migrated v6 system"; + + public constructor( + private readonly prompts: Prompts.Interface, + private readonly ui: UI.Interface + ) {} + + public configure(yargs: Argv): Argv { + return yargs + .option("project", { type: "string", description: "Project folder under projects/" }) + .option("system", { + type: "string", + choices: ["source", "target"] as const, + description: "Which system of the project to reconcile" + }) + .option("dry-run", { type: "boolean", description: "Report changes without writing" }) + .option("live", { + type: "boolean", + description: "Apply changes (requires a completed dry run for the same project and system)" + }) + .conflicts("dry-run", "live") + .option("yes", { type: "boolean", default: false, description: "Skip confirmations" }) + .option("table", { + type: "string", + choices: ["ddb", "os"] as const, + description: "Restrict to one table (default: both)" + }) + .option("concurrency", { + type: "number", + default: DEFAULT_CONCURRENCY, + description: "Scan segments in flight" + }) + .option("log-level", { + type: "string", + choices: ["debug", "info", "warn", "error"] as const, + description: "Log level (default: from config)" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + const options = parseOptions(argv); + const cwd = process.cwd(); + + const project = await selectProject({ prompts: this.prompts, cwd, projectArg: options.project }); + if (project.kind !== "ok") { + return this.finish(project); + } + + const configPath = await discoverConfig(resolve(join(cwd, "projects", project.value))); + if (!configPath) { + return this.refuse(`No config.ts found in projects/${project.value}/.`); + } + + const runId = String(Date.now()); + let config: MigrationConfig.Interface; + let container: Container; + try { + config = await loadConfig(configPath); + container = bootstrap({ config, runId, logLevel: options.logLevel ?? config.debug?.logLevel }); + } catch (error) { + return this.refuse(formatError(error, false)); + } + + const system = await selectSystem({ prompts: this.prompts, config, systemArg: options.system }); + if (system.kind !== "ok") { + return this.finish(system); + } + const systemConfig: SystemConfig = config[system.value]; + const client = + system.value === "source" + ? container.resolve(SourceDynamoDbClient) + : container.resolve(TargetDynamoDbClient); + + if (options.table === "os" && !systemConfig.opensearch) { + return this.refuse(`System "${system.value}" has no OpenSearch table configured.`); + } + + // Guard first: nobody confirms a system that will be refused. The OS table + // is only reconciled after this DDB guard passed for the same system. + const guard = await guardV6({ + client, + tableName: systemConfig.dynamodb.tableName, + region: systemConfig.region, + ui: this.ui + }); + if (guard.kind !== "ok") { + return this.finish(guard); + } + + const confirmed = await confirmSystem({ + prompts: this.prompts, + ui: this.ui, + system: system.value, + config: systemConfig, + yes: options.yes + }); + if (confirmed.kind !== "ok") { + return this.finish(confirmed); + } + + const stateStore = container.resolve(FixLiveStateStore); + const state = stateStore.read(project.value, system.value); + const mode = await selectMode({ prompts: this.prompts, state, modeArg: options.mode, yes: options.yes }); + if (mode.kind !== "ok") { + return this.finish(mode); + } + + container.registerInstance(TransferContext, { runId, dryRun: mode.value === "dry-run" }); + const report = container.resolve(ChangeReport); + const runnerFactory = container.resolve(LiveFieldRunnerFactory); + const segments = config.pipeline?.segments || 1; + + const results: TableRunResult[] = []; + try { + for (const table of resolveTables(options.table, systemConfig)) { + const tableName = + table === "ddb" ? systemConfig.dynamodb.tableName : systemConfig.opensearch!.tableName; + const runner = runnerFactory.create({ + table, + client, + tableName, + segments, + concurrency: options.concurrency + }); + results.push( + await runTable({ + table, + tableName, + region: systemConfig.region, + runner, + mode: mode.value, + report, + ui: this.ui + }) + ); + } + } catch (error) { + // State is written only when a run completes without an unhandled error. + return this.refuse(`fix-live failed: ${formatError(error, false)}`); + } + + stateStore.write(project.value, system.value, this.nextState(state, mode.value, runId, results)); + + summarise({ + ui: this.ui, + project: project.value, + system: system.value, + mode: mode.value, + results, + reportPath: join(".transfer", runId, "fix-live-report.jsonl"), + statePath: join(".transfer", "state", "fix-live", `${project.value}__${system.value}.json`), + lastDryRun: state?.lastDryRun + }); + return EXIT_OK; + } + + private nextState( + previous: FixLiveState.File | null, + mode: LiveFieldRunner.Mode, + runId: string, + results: TableRunResult[] + ): FixLiveState.File { + const summary: FixLiveState.RunSummary = { + runId, + at: new Date().toISOString(), + changes: totalChanges(results), + skips: totalSkips(results) + }; + if (mode === "dry-run") { + return { ...previous, lastDryRun: summary }; + } + return { + ...previous, + lastLiveRun: { + ...summary, + written: results.reduce((total, result) => total + result.stats.written, 0), + conditionFailed: results.reduce((total, result) => total + result.stats.conditionFailed, 0) + } + }; + } + + private finish(outcome: StepCancelled | StepRefused): number { + if (outcome.kind === "cancelled") { + this.ui.cancel("Cancelled."); + return EXIT_CANCELLED; + } + return this.refuse(outcome.message); + } + + private refuse(message: string): number { + this.ui.error(message); + return EXIT_FAILURE; + } +} + +export const FixLiveCommand = CommandAbstraction.createImplementation({ + implementation: FixLiveCommandImpl, + dependencies: [Prompts, UI] +}); +``` + +Create `src/commands/fixLive/feature.ts`: + +```ts +import { createFeature } from "~/base/index.js"; +import { FixLiveCommand } from "./FixLiveCommand.ts"; + +export const FixLiveCommandFeature = createFeature({ + name: "Cli/FixLiveCommandFeature", + register(container) { + container.register(FixLiveCommand).inSingletonScope(); + } +}); +``` + +In `src/commands/cliContainer.ts` add `import { FixLiveCommandFeature } from "./fixLive/feature.ts";` and, after the `TransferCommand` registration line, `FixLiveCommandFeature.register(container);` (menu order: transfer, fix-live, then the hidden ones). + +- [ ] **Step 6: Run tests, type-check, smoke** + +Run: `yarn vitest run __tests__/commands 2>&1 | tail -30 && yarn ts-check` +Expected: All PASS; 0 errors. + +```bash +yarn transfer fix-live --help +yarn transfer fix-live --project=nope --system=target --dry-run; echo "exit=$?" # exit=1, "Project "nope" not found" +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/commands/fixLive src/commands/cliContainer.ts __tests__/commands/fixLive +git commit -m "feat(fix-live): FixLiveCommand guided flow and non-interactive flags" +``` + +--- + +### Task 9: Guides, `AGENTS.md`, hard-won decisions, project structure + +**Files:** +- Modify: `docs/guides/commands.md`, `docs/guides/troubleshooting.md`, `docs/project-structure.md`, `AGENTS.md`, `docs/hard-won-decisions.md` + +**Interfaces:** +- Consumes: behaviour from Tasks 1–8 +- Produces: user docs for the menu and `fix-live`; agent guidance for the new layout + +- [ ] **Step 1: `docs/guides/commands.md`** + +Replace the `## Guided setup (recommended)` heading and its first paragraph with: + +```markdown +## Command menu + +`yarn transfer` with no arguments opens a menu of available commands: + +- **transfer** — system-to-system transfer (the guided `TransferWizard` below). +- **fix-live** — reconcile the `live` field on a migrated v6 system (see [fix-live](#fix-live)). + +Press Esc / Ctrl+C at any prompt to leave; the process exits with code 130. Every command can also be invoked directly (`yarn transfer transfer`, `yarn transfer fix-live`) and non-interactively with flags — see each section. `yarn transfer --config=… --preset=…` and `yarn transfer ` (scaffold) keep working exactly as before. + +## Guided transfer setup (recommended) + +`yarn transfer` → **transfer** (or `yarn transfer transfer`) launches `TransferWizard`. It walks you through: +``` + +Append before `## Scaffolding`: + +```markdown +## fix-live + +Repairs the `live` field on CMS entry records of a system that has **already been migrated to v6**. Earlier OpenSearch migrations could leave `live: {}` on the `L` document of entries whose latest revision is a draft on top of an older published revision, so those entries do not show as published. `fix-live` scans the DynamoDB table and, when the system has one, the OpenSearch companion table, and makes `L`, `P` and the published `REV#` record agree with the actual published state. + +### Guided flow + +``` +yarn transfer fix-live +◆ Select a project projects/* +◆ Which system? source | target — hint shows DDB table, region, OS table +│ Checking schema version… refuses v5 tables +◇ System summary region, DDB table, OS table, OS endpoint (target only), account id +◆ This is the system whose records will be modified. Continue? (default: no) +◆ Run mode dry run (default) | live — live is disabled until a dry run completed +│ Scanning DynamoDB… 148 203 rows / 31 440 entries +│ Scanning OpenSearch… 62 880 rows / 31 440 entries +◇ Summary +``` + +### Non-interactive + +```bash +yarn transfer fix-live --project=acme --system=target --dry-run +yarn transfer fix-live --project=acme --system=target --live --yes +yarn transfer fix-live --project=acme --system=target --dry-run --table=ddb +``` + +| Flag | Meaning | +| --- | --- | +| `--project` | Project folder under `projects/` (its `config.ts` is loaded). | +| `--system` | `source` or `target` — the system whose records are modified. | +| `--dry-run` / `--live` | Mutually exclusive. `--live` exits 1 unless a dry run completed for the same project and system. | +| `--yes` | Skip the system confirm and the live-run confirm. | +| `--table` | `ddb` or `os`; default both. The v6 check always runs on the DDB table. | +| `--concurrency` | Scan segments in flight (default 4). Segment count comes from `pipeline.segments`. | + +Exit codes: `0` success, `1` refused or failed (v5 table, missing dry run, unknown project, run error), `130` cancelled. + +### Dry run before live + +A live run is only allowed after a dry run completed for the same project and system. The dry run writes `.transfer/state/fix-live/__.json` with `lastDryRun { runId, at, changes, skips }`; a live run reads it, recomputes everything from scratch (data may have changed), warns when the change count differs, and records `lastLiveRun`. There is no expiry. + +### Report + +Every run writes `.transfer//fix-live-report.jsonl`, one JSON line per change or skip: + +```json +{"kind":"change","table":"ddb","pk":"T#root#CMS#CME#abc","sk":"L","reason":"missing-live","before":null,"after":{"version":2},"result":"dry-run"} +{"kind":"skip","table":"ddb","pk":"T#root#CMS#CME#def","sk":"REV#0007","reason":"revision-version-mismatch","detail":"P.version=7 REV#0007.version=6"} +``` + +`result` is `dry-run`, `written` or `condition-failed`. Change reasons: `missing-live`, `empty-live`, `wrong-version`, `stale-live`. Skip reasons: `no-latest-record`, `invalid-version`, `revision-record-missing`, `revision-version-mismatch`, `latest-status-contradicts-published`, `latest-status-contradicts-unpublished`, `decompress-failed`, `changed-during-run`. A skip means the whole entry was left untouched. + +### What is and is not reconciled + +- Reconciled: `data.live` on `L`, `P` and the published `REV#` of every CMS entry, in both tables. Only that attribute is written (`UpdateItem` with a path expression, conditioned on `_md` being unchanged since the read). +- Not reconciled: `live` on non-published `REV#` records (v6 itself leaves those stale), File Manager files (`fmFile`, `wbyFmFile` never carry `live`), anything that is not a CMS entry, and the OpenSearch index itself — the companion table is patched and v6's stream indexer picks the change up. +``` + +- [ ] **Step 2: `docs/guides/troubleshooting.md`** + +Insert before `## Debugging`: + +```markdown +### Published entries not showing as live after migration + +Entries whose latest revision is a draft on top of an older published revision may have ended up with `live: {}` in the OpenSearch companion table. Run the reconciler against the migrated system — dry run first, then live: + +```bash +yarn transfer fix-live --project= --system=target --dry-run +yarn transfer fix-live --project= --system=target --live +``` + +See [fix-live](commands.md#fix-live). Notes on the report: + +- `changed-during-run` — an editor saved the record between read and write, so the conditional update was refused. Nothing was overwritten; re-run to pick it up. +- `latest-status-contradicts-published` / `latest-status-contradicts-unpublished` — the `L` record's `status` disagrees with the presence or version of `P`. The tool never guesses; inspect the entry in the admin UI and republish or unpublish it, then re-run. +- `revision-record-missing` / `revision-version-mismatch` — `P` points at a revision that does not exist or carries a different version. Same treatment: fix the entry, re-run. +``` + +- [ ] **Step 3: `docs/project-structure.md`** + +Under `├── commands/` add (keeping the `transfer/` entry from Task 3): + +``` +│ ├── exitCodes.ts # EXIT_OK / EXIT_FAILURE / EXIT_CANCELLED (130) +│ ├── cliContainer.ts # Light DI container for the CLI shell (Prompts, UI, registry, commands) +│ ├── openMenu.ts # `yarn transfer` with no args → select over registry.menu() +│ ├── dispatchDefault.ts # `$0 [folder]` handler: → init, --config/--preset → transfer, else menu +│ ├── registry/ # Command token (Cli/Command) + CommandRegistry (lazy resolveAll) +│ ├── prompts/ # Prompts + UI abstractions; ClackPrompts / ClackUI / ClackSpinner (@clack/prompts) +│ ├── fixLive/ # FixLiveCommand + steps/ (selectProject, selectSystem, guardV6, +│ │ # confirmSystem, selectMode, runTable, summarise) — function modules +``` + +and add `│ ├── XCommand.ts` mentions to `init/`, `initProject/`, `processSegment/`, `updateSkills/` lines ("`register.ts` replaced by a `Command` implementation; handlers unchanged"). Under `__tests__` note `__tests__/commands/prompts/StubPrompts.ts` / `StubUI.ts`. + +- [ ] **Step 4: `AGENTS.md`** + +§1 "Runtime flow", replace item 2 with: + +```markdown +2. CLI: `yarn transfer` with no arguments opens a menu over the `Command` registry (`src/commands/registry/`) — entries: `transfer`, `fix-live`. `yarn transfer transfer` (or the legacy `yarn transfer --config … --preset …`) runs the system-to-system transfer: without `--config` the `TransferWizard` selects a project, writes `.env`, then on subsequent runs prompts for a preset and returns `WizardResult { configPath, preset, dryRun }`. `yarn transfer ` still scaffolds (`init`). Prompts go through the `Prompts` / `UI` abstractions (`src/commands/prompts/`, `@clack/prompts`); commands never import a prompt library. Cancel exits 130. +``` + +§8 "Open work", append: + +```markdown +5. **Inquirer removal** — `TransferWizard`, `init` and `initProject` still use `@inquirer/prompts`; migrate them to `Prompts` / `UI` and drop `@inquirer/*` from `package.json`. +6. **`fix-live` OS propagation** — confirm v6's DynamoDB stream handler treats a `data`-only change on the OS companion table as an index update (spec 2026-09-04, open question 1). +``` + +§3, after the first sentence, add: "CLI commands live in `src/commands/` as implementations of the `Command` token (`src/commands/registry/`); the entry `src/cli.ts` registers `registry.list()` with yargs plus a `$0 [folder]` default that preserves the two historical no-command invocations." + +- [ ] **Step 5: `docs/hard-won-decisions.md`** + +Append: + +```markdown +- **`fix-live` reconciles only the v6-maintained invariant** (2026-09-04) — `L`, `P` and the published `REV#` carry `live: { version }`; other `REV#` records keep a best-effort copy that v6 itself leaves stale, so the reconciler never writes them. Fill missing, clear stale, correct wrong version — nothing else. +- **`fix-live` writes only when certain** (2026-09-04) — any ambiguity (status contradicts `P`, missing/mismatched revision record, decompress failure) is a `skipped` report line for the whole PK, never a partial write. A wrong "fix" is worse than no fix. +- **`fix-live` uses `UpdateItem` with a path expression, never `PutItem`** (2026-09-04) — the document client is built with `convertEmptyValues: true`; a whole-record round-trip would turn every `""` into `NULL` and re-encode numbers. `updateAttribute` leaves untouched attributes byte-identical. +- **`fix-live` scans `L` rows and `queryAll(PK)`s per entry** (2026-09-04) — no reliance on scan ordering or PK locality; one bounded query per entry removes the "group was incomplete" class of bugs. +- **`fix-live` conditions every write on `_md`** (2026-09-04) — `ConditionalCheckFailedException` → `changed-during-run` skip, never retried, never overwrites a fresher record. +- **CLI commands are `Command` implementations behind a lazy registry** (2026-09-04) — one `Cli/Command` token, many implementations; `CommandRegistry` calls `resolveAll` on first use. Command constructors take only `Prompts` / `UI`; the per-project container is built inside `run()`. `hidden: true` keeps a command out of the menu (positional-only commands, the `process-segment` worker) without removing it from `--help`. The `$0 [folder]` default command exists solely for `yarn transfer ` and `yarn transfer --config --preset` compatibility — don't add new behaviour to it; add a command. +- **Prompt libraries stay behind `Prompts` / `UI`** (2026-09-04) — `select` / `confirm` / `text` return `null` on cancel and never exit; commands map `null` to exit 130. Tests use `StubPrompts` / `StubUI` with scripted answers. Only `Clack*.ts` import `@clack/prompts`. +``` + +Amend the `addLiveField cache+sentinel pattern` entry (skip if the transformer-fix plan already did): replace "The sentinel must be non-zero (versions start at 1) and truthy (so `if (cached)` correctly identifies a prior miss). Don't use `null` or `undefined` as the sentinel — those are cache misses." with "The cache check is `cached !== undefined` (`Cache.get` returns `T | undefined`), so the sentinel only has to be distinguishable from a real version; `undefined` is never cached because the transformer never produces it (`resolvePublishedVersion` accepts only positive integers)." + +- [ ] **Step 6: Commit** + +```bash +git add docs/guides/commands.md docs/guides/troubleshooting.md docs/project-structure.md AGENTS.md docs/hard-won-decisions.md +git commit -m "docs: command menu, fix-live guide, troubleshooting, agent guidance" +``` + +--- + +### Task 10: Changeset + full verification + +**Files:** +- Create: `.changeset/guided-command-menu.md` +- All files from Tasks 1–9 + +**Interfaces:** +- Consumes: all prior tasks +- Produces: a `minor` release entry and a clean tree + +- [ ] **Step 1: Changeset** + +Create `.changeset/guided-command-menu.md`: + +```markdown +--- +"@webiny/data-transfer": minor +--- + +Add a command menu: `yarn transfer` with no arguments now lists available commands (`transfer`, `fix-live`) via `@clack/prompts`; `yarn transfer --config --preset` and `yarn transfer ` behave as before. Add the `fix-live` command that reconciles the `live` field on CMS entries of an already migrated v6 system (DynamoDB table and OpenSearch companion table), with a mandatory dry run, a JSONL change report under `.transfer//`, and non-interactive flags (`--project --system --dry-run|--live --yes --table --concurrency`). Prompts now go through `Prompts` / `UI` abstractions; cancelling any prompt exits 130. +``` + +- [ ] **Step 2: Run the full verification suite** + +```bash +yarn npm audit && yarn format:fix && yarn ts-check && yarn test:coverage && yarn lint && yarn check:imports +``` + +Expected: no audit suggestions; formatter changes only whitespace (re-stage them); 0 type errors; all tests green with thresholds met; 0 lint errors; adio reports `@clack/prompts` as used and nothing missing. + +- [ ] **Step 3: Manual smoke of the four entry paths** + +```bash +yarn transfer --help +yarn transfer # menu → Esc → echo $? prints 130 +yarn transfer --config=./projects/v5-to-v6/config.ts --preset=copy-ddb --dry-run # transfer path (needs .env) +yarn transfer fix-live --project=v5-to-v6 --system=target --live; echo $? # 1 without a dry run +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "chore: changeset for command menu and fix-live" +``` diff --git a/docs/superpowers/plans/2026-09-04-fix-live-reconciler.md b/docs/superpowers/plans/2026-09-04-fix-live-reconciler.md new file mode 100644 index 00000000..89deab16 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-fix-live-reconciler.md @@ -0,0 +1,3105 @@ +# Fix Live Field Reconciler Implementation Plan (Steps 1–6) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the root-cause fix in `addLiveField` for the OS lane, and build the `fix-live` reconciler core — `LiveFieldReconciler` (pure decision logic), `DdbLiveFieldRunner` / `OsLiveFieldRunner` (scan → group → decide → conditional `UpdateItem`), `ChangeReport` (JSONL audit trail), and `FixLiveState` (dry-run gate) — plus the `IDynamoDbClient` / `FileTool` primitives they need. This covers **Implementation order steps 1–6** of `docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md`. The CLI menu, `Prompts`/`UI`, `FixLiveCommand`, the v6 guard step, and guide updates are a sibling plan; everything here is consumable by that command through the abstractions defined in Task 4. + +**Architecture:** New feature directory `src/features/FixLive/` following the standard feature layout (`abstractions/` with one file per token + an `index.ts` of const tokens only, impl classes, `feature.ts`, `index.ts`). Runners share one `ILiveFieldRunner` interface exposed through **two tokens** — `DdbLiveFieldRunner` and `OsLiveFieldRunner` — mirroring how `SourceDynamoDbClient` / `TargetDynamoDbClient` share `IDynamoDbClient`, so a command can resolve each explicitly. The system-specific client and table name are passed at call time via `LiveFieldRunner.Options.target` (the runner is stateless; bootstrap binds Source/Target clients, the command picks one). Both runners extend an abstract `BaseLiveFieldRunner` that owns the scan/query/decide/write loop; subclasses supply `acceptsRow`, `prepareGroup` (OS: decompress) and `buildWrite` (DDB: `["data","live"]`; OS: recompressed `["data"]`). `ChangeReport` writes under `.transfer//fix-live-report.jsonl` via `TransferContext.runId` exactly like `DroppedRecordLog`. `FixLiveState` writes `.transfer/state/fix-live/__.json`. The transformer fix takes the spec's **preferred form**: `OsProcessor.querySourceRecord` / `queryTargetRecord` return the row with `data` decompressed (same shape `OsScanner` yields), using the already-registered `OsRecordDecompressor`; `addLiveField` reads `version` from the root, then `data`, and accepts only a positive integer. The fallback (decompress inside `addLiveField` via `ctx.compressionHandler`) is noted in Task 1 but not taken — the only OS-lane caller of `querySourceRecord` is `addLiveField`, so the processor contract change has minimal blast radius. + +**Tech Stack:** TypeScript (ESM, `~/` alias), `@webiny/di`, `@webiny/aws-sdk/client-dynamodb` (`UpdateCommand`, `ScanCommand` re-exported from `@aws-sdk/lib-dynamodb`), `@webiny/utils` `CompressionHandler`, vitest, dynalite, oxfmt, oxlint. + +## Global Constraints + +Derived from `docs/architecture.md`, `docs/testing.md`, and the observed code style: + +- DI via `createAbstraction(name)` + `Abstraction.createImplementation({ implementation, dependencies })`; features register via `createFeature({ name, register(container) })` and `container.register(Impl).inSingletonScope()`. +- Feature layout: `abstractions/.ts` (interface + token + namespace), `abstractions/index.ts` (**const tokens only**, no type exports), `.ts` (Impl class + `createImplementation`), `feature.ts`, `index.ts`. +- Types are consumed through namespaces (`LiveFieldReconciler.Interface`, `LiveFieldRunner.Options`), never by importing interfaces directly from abstractions. Every structural shape gets a named `interface`/`type` — no inline `{ ... }` in generic or parameter positions. +- `public`/`private`/`protected` on every class member. Braces always (`curly: error`). No `reflect-metadata` import. +- Imports: cross-module via `~/path/file.js`; intra-feature relative via `./file.ts`. Tests import `src` via `~/…js` and test-only infra via relative `../x.ts`. +- File names: PascalCase for class/abstraction modules, camelCase for function modules (`createEmptyStats.ts`, `runConcurrently.ts`). +- AWS imports through `@webiny/aws-sdk/client-dynamodb/index.js` (exception already documented: `QueryCommand` from `@aws-sdk/lib-dynamodb`). All DDB calls go through `executeWithRetry`; `ConditionalCheckFailedException` is not in `isRetryableAwsError`'s retryable set, so it is never retried — map it to a result, do not swallow anything else. +- Only `data.live` is ever written, via `UpdateItem` path expressions. Never `PutItem` a whole record (the document client has `convertEmptyValues: true`). +- Logging via `Logger.Interface` (`ctx.logger` / injected `Logger`), never `console.*`. +- Files under `.transfer/` are resolved as `join(process.cwd(), ".transfer", runId, …)` (DroppedRecordLog pattern). +- Tests live in `__tests__/` mirroring `src/`. Unit tests use `MockDynamoDbClient` and `NoopLogger` (`__tests__/helpers/NoopLogger.ts`); real-SDK tests use `startDynalite()` + `waitForTableActive()` from `__tests__/integration/dynalite.ts`. Coverage thresholds (lines 79 / functions 84 / branches 71 / statements 79) are enforced — new code ships with tests. +- Public API (`src/index.ts`) does not change. +- Formatting: oxfmt (`printWidth` 100, 4 spaces in `src/` and `__tests__/`, double quotes, no trailing commas, `arrowParens: avoid`). `yarn`, never `npm`. +- Unused parameters are prefixed with `_` (oxlint `no-unused-vars` is a warning and `--deny-warnings` is on). +- Verification before commit: `yarn npm audit && yarn format:fix && yarn ts-check && yarn test:coverage && yarn lint && yarn check:imports`. + +--- + +### Task 1: Transformer fix — decompressed OS lookups, integer guard, cache check + +**Files:** +- Modify: `src/features/OsProcessor/OsProcessor.ts` +- Modify: `src/transformers/cms/addLiveField.ts` +- Modify: `docs/hard-won-decisions.md` (cache-sentinel entry) +- Create: `.changeset/fix-live-field-os-lane.md` +- Test: `__tests__/transformers/cms/addLiveField.test.ts` (extend), `__tests__/features/OsProcessor/OsProcessor.test.ts` (extend), `__tests__/features/OsProcessor/OsProcessor.liveField.test.ts` (create) + +**Interfaces:** +- Consumes: `OsRecordDecompressor.Interface` (`~/features/OsRecordDecompressor/abstractions/OsRecordDecompressor.js`), `CompressionHandler`, `MockDynamoDbClient.batchPut` for seeding. +- Produces: `OsProcessor` slice `querySourceRecord`/`queryTargetRecord` return `{ ...row, data: }`; `addLiveField` never emits `{ version: undefined }`. + +- [ ] **Step 1: Add failing `addLiveField` tests** + +Append to `__tests__/transformers/cms/addLiveField.test.ts` (add `import { NoopLogger } from "../../helpers/NoopLogger.ts";` at the top): + +```typescript + it("reads version from data when P comes back in the decompressed OS row shape", async () => { + const ctx = makeFakeDdbCoreContext({ + ...BASE, + data: { ...BASE.data, version: 3, status: "draft" } + }); + ctx.querySourceRecord = vi.fn().mockResolvedValue({ + PK: BASE.PK, + SK: "P", + index: "root-headless-cms-en-us-blogpost", + data: { modelId: "blogPost", version: 2, status: "published" }, + _ct: "2024-01-01T00:00:00.000Z", + _et: "CmsEntriesElasticsearch", + _md: "2024-01-01T00:00:00.000Z" + }); + + await addLiveField(ctx); + + expect((ctx.record.data as Record).live).toEqual({ version: 2 }); + }); + + it("never emits { version: undefined } — a raw compressed P row yields live: null and warns", async () => { + const logger = new NoopLogger(); + const ctx = makeFakeDdbCoreContext(BASE, { logger }); + ctx.querySourceRecord = vi.fn().mockResolvedValue({ + PK: BASE.PK, + SK: "P", + index: "root-headless-cms-en-us-blogpost", + data: { compression: "gzip", value: "H4sIAAAAAAAAA6tWKkpNLKlUslIqLcpRqgUAn7mB6RAAAAA=" } + }); + + await addLiveField(ctx); + + expect((ctx.record.data as Record).live).toBeNull(); + expect(logger.entries.some(e => e.level === "warn" && e.message.includes(BASE.PK))).toBe(true); + }); + + it("treats a non-integer P version as no published revision", async () => { + const ctx = makeFakeDdbCoreContext(BASE); + ctx.querySourceRecord = vi.fn().mockResolvedValue({ version: "2" }); + + await addLiveField(ctx); + + expect((ctx.record.data as Record).live).toBeNull(); + }); + + it("queries P for an unpublished L record and sets live: null when none exists", async () => { + const ctx = makeFakeDdbCoreContext({ + ...BASE, + data: { ...BASE.data, version: 4, status: "unpublished" } + }); + ctx.querySourceRecord = vi.fn().mockResolvedValue(null); + + await addLiveField(ctx); + + expect(ctx.querySourceRecord).toHaveBeenCalledWith(BASE.PK, "P"); + expect((ctx.record.data as Record).live).toBeNull(); + }); + + it("live.version is a number whenever live is non-null", async () => { + const shapes: Array | null> = [ + { version: 2 }, + { data: { version: 5 } }, + { version: 0 }, + { version: 1.5 }, + { data: {} }, + null + ]; + for (const shape of shapes) { + const ctx = makeFakeDdbCoreContext(BASE); + ctx.querySourceRecord = vi.fn().mockResolvedValue(shape); + await addLiveField(ctx); + const live = (ctx.record.data as Record).live as { version: unknown } | null; + if (live !== null) { + expect(typeof live.version).toBe("number"); + } + } + }); +``` + +- [ ] **Step 2: Add failing `OsProcessor.querySourceRecord` test** + +Append a new `describe` inside `__tests__/features/OsProcessor/OsProcessor.test.ts` (add `import { SourceDynamoDbClient } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js";` and `import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts";`): + +```typescript + describe("querySourceRecord", () => { + it("returns the OS row with data decompressed", async () => { + const container = createOsContainer(); + const compression = container.resolve(CompressionHandler); + const sourceDb = container.resolve(SourceDynamoDbClient) as MockDynamoDbClient; + const compressed = await compression.compress({ modelId: "blogPost", version: 2, status: "published" }); + await sourceDb.batchPut("source-os", [ + { + PK: "T#root#CMS#CME#q", + SK: "P", + index: "root-headless-cms-en-us-blogpost", + data: compressed, + _ct: "2024-01-01T00:00:00.000Z", + _et: "CmsEntriesElasticsearch", + _md: "2024-01-01T00:00:00.000Z" + } + ]); + const processor = container.resolve(Processor) as OsProcessorInstance & { + extendContext(base: BaseTransformContext.Interface): { + querySourceRecord(pk: string, sk?: string): Promise | null>; + }; + }; + const { base } = makeBase(makeOsRecord("q", "root-headless-cms-en-us-blogpost")); + + const found = await processor.extendContext(base).querySourceRecord("T#root#CMS#CME#q", "P"); + + expect(found).not.toBeNull(); + expect((found!.data as Record).version).toBe(2); + expect(found!._md).toBe("2024-01-01T00:00:00.000Z"); + }); + }); +``` + +- [ ] **Step 3: Add failing OS-lane pipeline test (stands in for the OS golden)** + +There is no OS-preset golden harness; this mock-container end-to-end test over `OsScanner + OsProcessor + addLiveField` with real gzip is the regression guard. Create `__tests__/features/OsProcessor/OsProcessor.liveField.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { createOsContainer } from "../../containers/index.ts"; +import { PipelineRunner } from "~/features/PipelineRunner/index.js"; +import { PipelineBuilderFactory } from "~/features/PipelineBuilderFactory/index.js"; +import { createFilter } from "~/domain/pipeline/index.js"; +import { isCmsEntry } from "~/domain/transform/filters.js"; +import { OsScanner } from "~/features/OsScanner/index.js"; +import { OsProcessor } from "~/features/OsProcessor/index.js"; +import { addLiveField } from "~/transformers/cms/addLiveField.js"; +import { + SourceDynamoDbClient, + TargetDynamoDbClient +} from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; + +const PK = "T#root#L#en-US#CMS#CME#draft-over-published"; +const INDEX = "root-headless-cms-en-us-blogpost"; + +describe("v5-to-v6-os lane — addLiveField on a draft-over-published entry", () => { + it("writes live: { version: 2 } on both L and P documents", async () => { + const container = createOsContainer(); + const compression = container.resolve(CompressionHandler); + const sourceDb = container.resolve(SourceDynamoDbClient) as MockDynamoDbClient; + const now = "2024-01-01T00:00:00.000Z"; + await sourceDb.batchPut("source-os", [ + { + PK, + SK: "L", + index: INDEX, + data: await compression.compress({ modelId: "blogPost", entryId: "x", version: 3, status: "draft" }), + _ct: now, + _et: "CmsEntriesElasticsearch", + _md: now + }, + { + PK, + SK: "P", + index: INDEX, + data: await compression.compress({ modelId: "blogPost", entryId: "x", version: 2, status: "published" }), + _ct: now, + _et: "CmsEntriesElasticsearch", + _md: now + } + ]); + + const runner = container.resolve(PipelineRunner); + const builder = container.resolve(PipelineBuilderFactory).create({ + name: "CmsEntries", + scanner: OsScanner, + processors: [OsProcessor] + }); + builder.filter(createFilter(isCmsEntry)).use(addLiveField); + runner.register(await builder.build()); + await runner.run(); + + const targetDb = container.resolve(TargetDynamoDbClient) as MockDynamoDbClient; + const written = targetDb.batchPutRecords; + expect(written).toHaveLength(2); + const bySk = new Map(written.map(r => [r.SK, r])); + const latest = await compression.decompress>(bySk.get("L")!.data); + const published = await compression.decompress>(bySk.get("P")!.data); + expect(latest.live).toEqual({ version: 2 }); + expect(published.live).toEqual({ version: 2 }); + }); +}); +``` + +Run: `yarn vitest run __tests__/transformers/cms/addLiveField.test.ts __tests__/features/OsProcessor` — expect the new tests to fail. + +- [ ] **Step 4: Make `OsProcessor` return decompressed rows** + +In `src/features/OsProcessor/OsProcessor.ts`: + +Add the import: + +```typescript +import { OsRecordDecompressor } from "~/features/OsRecordDecompressor/abstractions/OsRecordDecompressor.js"; +``` + +Append a constructor parameter after `indexConfigurationResolver`: + +```typescript + private readonly indexConfigurationResolver: IndexConfigurationResolver.Interface, + private readonly decompressor: OsRecordDecompressor.Interface +``` + +Replace the two query helpers inside `extendContext` (keep `putRecord` as is): + +```typescript + const decompressRow = (row: OsRecordDecompressor.Compressed): Promise> => + this.decompressRow(row); + return { + putRecord(record: Record) { + base.addCommand(PutRecord.create({ table: targetTable, record })); + }, + async querySourceRecord = Record>( + pk: string, + sk?: string + ): Promise { + const results = await sourceDb.query(sourceTable, pk, sk); + const first = results[0]; + if (!first) { + return null; + } + return (await decompressRow(first)) as unknown as T; + }, + async queryTargetRecord = Record>( + pk: string, + sk?: string + ): Promise { + const results = await targetDb.query(targetTable, pk, sk); + const first = results[0]; + if (!first) { + return null; + } + return (await decompressRow(first)) as unknown as T; + } + }; +``` + +Add a private method (next to `buildGzippedPuts`): + +```typescript + /** + * OS companion rows carry `data: { compression, value }`. OsScanner hands + * transformers the row with `data` decompressed; the query helpers return + * the same shape so a P lookup reads like a scanned row. Rows the + * decompressor cannot handle (no `index`, no `compression`, corrupt blob) + * are returned unchanged — callers guard what they read. + */ + private async decompressRow( + row: OsRecordDecompressor.Compressed + ): Promise> { + const decompressed = await this.decompressor.decompress(row); + if (decompressed === null) { + return { ...row }; + } + return { ...row, data: decompressed }; + } +``` + +Append `OsRecordDecompressor` to the `dependencies` array of `OsProcessor` (after `IndexConfigurationResolver`). + +- [ ] **Step 5: Rewrite `addLiveField`** + +Replace `src/transformers/cms/addLiveField.ts` with: + +```typescript +import { createTransformer } from "~/transformers/createTransformer.js"; +import type { DdbCoreTransformContext } from "~/features/TransformContext/abstractions/contextAliases.js"; +import type { BaseRecord } from "~/domain/transform/types/records.js"; + +// Cache sentinel: "queried, no published revision". Versions start at 1, so -1 +// can never collide with a real version. The cache check is `!== undefined`, +// so the sentinel does not need to be truthy — only distinct from every version. +const NO_PUBLISHED_REVISION = -1; + +const INTERNAL_MODELS = new Set(["fmfile", "wbyfmfile"]); + +export const addLiveField = createTransformer>( + "addLiveField", + async ctx => { + const data = ctx.record.data as Record | undefined; + if (!data) { + return; + } + + const modelId = data.modelId as string | undefined; + if (!modelId || INTERNAL_MODELS.has(modelId.toLowerCase())) { + return; + } + + const publishedVersion = await resolvePublishedVersion(ctx); + data.live = publishedVersion === null ? null : { version: publishedVersion }; + } +); + +/** + * Reads `version` from either lane shape: + * - DDB lane (v5 main table): `version` at the record root. + * - OS lane: `OsProcessor.querySourceRecord` returns the row with `data` + * decompressed, so `version` lives under `data`. + * Anything that is not a positive integer counts as "no version". + */ +function readPositiveIntegerVersion(record: Record): number | null { + const nested = record.data as Record | undefined; + const raw = record.version !== undefined ? record.version : nested?.version; + if (typeof raw === "number" && Number.isInteger(raw) && raw > 0) { + return raw; + } + return null; +} + +async function resolvePublishedVersion( + ctx: DdbCoreTransformContext.Interface +): Promise { + const cacheKey = `live:${ctx.original.PK}`; + + const cached = ctx.cache.get(cacheKey); + if (cached !== undefined) { + return cached === NO_PUBLISHED_REVISION ? null : cached; + } + + // This record IS the published revision — no query needed. + // P record: always the published revision by definition. + // L record with status "published": L and P point to the same revision. + const data = ctx.record.data as Record; + const originalSK = ctx.original.SK; + const isPublishedRevision = + originalSK === "P" || (originalSK === "L" && data.status === "published"); + + if (isPublishedRevision) { + const version = readPositiveIntegerVersion(data); + if (version === null) { + ctx.logger.warn( + `addLiveField: ${ctx.original.PK} ${originalSK} is the published revision but has no positive integer version — writing live: null` + ); + ctx.cache.set(cacheKey, NO_PUBLISHED_REVISION); + return null; + } + ctx.cache.set(cacheKey, version); + return version; + } + + ctx.logger.debug(`Querying for published revision of ${ctx.original.PK}...`); + const published = await ctx.querySourceRecord(ctx.original.PK, "P"); + if (!published) { + ctx.cache.set(cacheKey, NO_PUBLISHED_REVISION); + return null; + } + + const version = readPositiveIntegerVersion(published); + if (version === null) { + ctx.logger.warn( + `addLiveField: P record for ${ctx.original.PK} has no positive integer version — writing live: null` + ); + ctx.cache.set(cacheKey, NO_PUBLISHED_REVISION); + return null; + } + + ctx.cache.set(cacheKey, version); + return version; +} +``` + +Fallback (not taken): if the processor contract change is judged too invasive, detect `published.data?.compression` inside `addLiveField` and call `ctx.compressionHandler.decompress(published.data)` before `readPositiveIntegerVersion`. `published.data?.version` alone is **not** a fallback — `data` is `{ compression, value }` on the raw row. + +- [ ] **Step 6: Amend the hard-won decision** + +In `docs/hard-won-decisions.md`, replace the `addLiveField` cache+sentinel bullet with: + +```markdown +- **`addLiveField` cache+sentinel pattern** — the transformer uses `ctx.cache` keyed by `ctx.original.PK`. Sentinel value `-1` means "queried, no published revision found" — avoids re-querying. P records skip the query entirely (they ARE the published revision) and populate the cache for siblings. The cache check is `cached !== undefined` (`Cache.get` returns `T | undefined`), so the sentinel only needs to be distinct from every valid version (versions start at 1) — it no longer needs to be truthy. Never store `undefined` as a cache value; that is indistinguishable from a miss. `version` is read from the record root, then `data` (the OS lane returns decompressed rows from `OsProcessor.querySourceRecord`), and only a positive integer is accepted — the transformer never emits `{ version: undefined }` (2026-09-04). +``` + +- [ ] **Step 7: Changeset (patch)** + +Create `.changeset/fix-live-field-os-lane.md` (equivalent to `yarn changeset` → patch): + +```markdown +--- +"@webiny/data-transfer": patch +--- + +Fix `addLiveField` in the OS lane: `OsProcessor.querySourceRecord` / `queryTargetRecord` now return the companion-table row with `data` decompressed, so the published revision's `version` is readable and `live: { version }` is written correctly for draft-over-published entries. `live` is only ever `{ version: }` or `null`; the cache check no longer relies on truthiness. +``` + +- [ ] **Step 8: Verify** + +```bash +yarn vitest run __tests__/transformers/cms/addLiveField.test.ts __tests__/features/OsProcessor __tests__/integration/pipeline.preset.test.ts +``` + +All green; the DDB golden must be unchanged (no `UPDATE_EXPECTED`). + +--- + +### Task 2: `IDynamoDbClient.updateAttribute` + `ScanOptions.limit` / `sortKeyEquals` + mock support + +**Files:** +- Modify: `src/services/DynamoDbClient/abstractions/DynamoDbClient.ts` +- Modify: `src/services/DynamoDbClient/DynamoDbClient.ts` +- Modify: `__tests__/services/DynamoDbClient/MockDynamoDbClient.ts` +- Test: `__tests__/services/DynamoDbClient/scanOptions.test.ts`, `__tests__/services/DynamoDbClient/updateAttribute.test.ts`, `__tests__/services/DynamoDbClient/MockDynamoDbClient.test.ts` + +**Interfaces:** +- Consumes: `UpdateCommand`, `ScanCommand` from `@webiny/aws-sdk/client-dynamodb/index.js`; `executeWithRetry`. +- Produces: `SourceDynamoDbClient.UpdateRequest`, `SourceDynamoDbClient.UpdateResult`, extended `ScanOptions`. + +- [ ] **Step 1: Failing tests for the real client (spy on `client.send`)** + +`__tests__/services/DynamoDbClient/scanOptions.test.ts`: + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { DynamoDbClientImpl } from "../../../src/services/DynamoDbClient/DynamoDbClient.ts"; +import { NoopLogger } from "../../helpers/NoopLogger.ts"; + +interface SendInput { + input: Record; +} + +function makeClient(): { client: DynamoDbClientImpl; send: ReturnType } { + const client = new DynamoDbClientImpl({ region: "us-east-1" }, new NoopLogger(), { + maxRetries: 0, + initialBackoffMs: 1 + }); + const send = vi.fn(); + vi.spyOn((client as unknown as { client: { send: () => unknown } }).client, "send").mockImplementation(send); + return { client, send }; +} + +describe("DynamoDbClientImpl.scan options", () => { + it("adds FilterExpression SK = :sk when sortKeyEquals is set", async () => { + const { client, send } = makeClient(); + send.mockResolvedValue({ Items: [{ PK: "a", SK: "L" }] }); + + const rows = []; + for await (const row of client.scan("t", { sortKeyEquals: "L" })) { + rows.push(row); + } + + const input = (send.mock.calls[0]![0] as SendInput).input; + expect(input.FilterExpression).toBe("SK = :sk"); + expect(input.ExpressionAttributeValues).toEqual({ ":sk": "L" }); + expect(rows).toHaveLength(1); + }); + + it("stops after `limit` yielded items even when more pages exist", async () => { + const { client, send } = makeClient(); + send.mockResolvedValue({ + Items: [ + { PK: "a", SK: "L" }, + { PK: "b", SK: "L" }, + { PK: "c", SK: "L" } + ], + LastEvaluatedKey: { PK: "c", SK: "L" } + }); + + const rows = []; + for await (const row of client.scan("t", { limit: 2 })) { + rows.push(row); + } + + expect(rows).toHaveLength(2); + expect(send).toHaveBeenCalledTimes(1); + expect((send.mock.calls[0]![0] as SendInput).input.Limit).toBe(2); + }); +}); +``` + +`__tests__/services/DynamoDbClient/updateAttribute.test.ts`: + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { DynamoDbClientImpl } from "../../../src/services/DynamoDbClient/DynamoDbClient.ts"; +import { NoopLogger } from "../../helpers/NoopLogger.ts"; + +interface SendInput { + input: Record; +} + +function makeClient(): { client: DynamoDbClientImpl; send: ReturnType } { + const client = new DynamoDbClientImpl({ region: "us-east-1" }, new NoopLogger(), { + maxRetries: 0, + initialBackoffMs: 1 + }); + const send = vi.fn(); + vi.spyOn((client as unknown as { client: { send: () => unknown } }).client, "send").mockImplementation(send); + return { client, send }; +} + +function conditionalCheckFailed(): Error { + const error = new Error("The conditional request failed"); + error.name = "ConditionalCheckFailedException"; + return error; +} + +describe("DynamoDbClientImpl.updateAttribute", () => { + it("builds a SET path expression with a condition and returns written", async () => { + const { client, send } = makeClient(); + send.mockResolvedValue({}); + + const result = await client.updateAttribute("t", { + key: { PK: "p", SK: "L" }, + path: ["data", "live"], + value: { version: 2 }, + condition: { attribute: "_md", equals: "md-1" } + }); + + expect(result).toBe("written"); + const input = (send.mock.calls[0]![0] as SendInput).input; + expect(input.TableName).toBe("t"); + expect(input.Key).toEqual({ PK: "p", SK: "L" }); + expect(input.UpdateExpression).toBe("SET #p0.#p1 = :v"); + expect(input.ConditionExpression).toBe("#c = :c"); + expect(input.ExpressionAttributeNames).toEqual({ "#p0": "data", "#p1": "live", "#c": "_md" }); + expect(input.ExpressionAttributeValues).toEqual({ ":v": { version: 2 }, ":c": "md-1" }); + }); + + it("returns condition-failed on ConditionalCheckFailedException without retrying", async () => { + const { client, send } = makeClient(); + send.mockRejectedValue(conditionalCheckFailed()); + + const result = await client.updateAttribute("t", { + key: { PK: "p", SK: "L" }, + path: ["data", "live"], + value: null, + condition: { attribute: "_md", equals: "md-1" } + }); + + expect(result).toBe("condition-failed"); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("propagates every other error", async () => { + const { client, send } = makeClient(); + const error = new Error("boom"); + error.name = "ValidationException"; + send.mockRejectedValue(error); + + await expect( + client.updateAttribute("t", { + key: { PK: "p", SK: "L" }, + path: ["data"], + value: {}, + condition: { attribute: "_md", equals: "x" } + }) + ).rejects.toMatchObject({ name: "ValidationException" }); + }); +}); +``` + +- [ ] **Step 2: Extend the abstraction** + +In `src/services/DynamoDbClient/abstractions/DynamoDbClient.ts` replace `ScanOptions` and add the update types: + +```typescript +export interface ScanOptions { + segment?: number; + totalSegments?: number; + /** Maximum number of items yielded by the generator (also sent as page `Limit`). */ + limit?: number; + /** Server-side `FilterExpression SK = :sk`. Does not reduce consumed capacity. */ + sortKeyEquals?: string; +} + +export interface UpdateAttributeKey { + PK: string; + SK: string; +} + +export interface UpdateAttributeCondition { + attribute: string; + equals: unknown; +} + +export interface UpdateAttributeRequest { + key: UpdateAttributeKey; + /** Attribute path, e.g. ["data", "live"]. */ + path: string[]; + /** Marshalled as-is; `null` allowed. */ + value: unknown; + condition: UpdateAttributeCondition; +} + +export type UpdateAttributeResult = "written" | "condition-failed"; +``` + +Add to `IDynamoDbClient` after `batchPut`: + +```typescript + /** + * Conditional `UpdateItem` that sets exactly one attribute path. Returns + * "condition-failed" when the condition does not hold; every other error + * propagates through the retry wrapper. + */ + updateAttribute(tableName: string, request: UpdateAttributeRequest): Promise; +``` + +Add to **both** namespaces (`SourceDynamoDbClient`, `TargetDynamoDbClient`): + +```typescript + export type UpdateRequest = UpdateAttributeRequest; + export type UpdateResult = UpdateAttributeResult; +``` + +- [ ] **Step 3: Implement in `DynamoDbClientImpl`** + +Add `UpdateCommand` to the `@webiny/aws-sdk/client-dynamodb/index.js` import list. Replace the `scan` body: + +```typescript + public async *scan( + tableName: string, + options?: SourceDynamoDbClient.Scan + ): AsyncIterable { + let lastEvaluatedKey: Record | undefined; + let yielded = 0; + const limit = options ? options.limit : undefined; + const sortKeyEquals = options ? options.sortKeyEquals : undefined; + + do { + const command = new ScanCommand({ + TableName: tableName, + Segment: options ? options.segment : undefined, + TotalSegments: options ? options.totalSegments : undefined, + ExclusiveStartKey: lastEvaluatedKey, + Limit: limit, + FilterExpression: sortKeyEquals !== undefined ? "SK = :sk" : undefined, + ExpressionAttributeValues: + sortKeyEquals !== undefined ? { ":sk": sortKeyEquals } : undefined + }); + + const response = await this.executeWithRetry(async () => { + return await this.client.send(command); + }); + + if (response.Items) { + for (const item of response.Items) { + yield item as T; + yielded++; + if (limit !== undefined && yielded >= limit) { + return; + } + } + } + + lastEvaluatedKey = response.LastEvaluatedKey; + } while (lastEvaluatedKey); + } +``` + +Add after `batchPut`: + +```typescript + public async updateAttribute( + tableName: string, + request: SourceDynamoDbClient.UpdateRequest + ): Promise { + const names: Record = {}; + const pathExpression = request.path + .map((segment, index) => { + const placeholder = `#p${index}`; + names[placeholder] = segment; + return placeholder; + }) + .join("."); + names["#c"] = request.condition.attribute; + + const command = new UpdateCommand({ + TableName: tableName, + Key: request.key, + UpdateExpression: `SET ${pathExpression} = :v`, + ConditionExpression: "#c = :c", + ExpressionAttributeNames: names, + ExpressionAttributeValues: { ":v": request.value, ":c": request.condition.equals } + }); + + try { + await this.executeWithRetry(async () => { + return await this.client.send(command); + }); + return "written"; + } catch (error) { + if (isConditionalCheckFailed(error)) { + return "condition-failed"; + } + throw error; + } + } +``` + +Add a module-level helper (below the constants): + +```typescript +function isConditionalCheckFailed(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const { name } = error as { name?: unknown }; + return name === "ConditionalCheckFailedException"; +} +``` + +- [ ] **Step 4: Mock client support + tests** + +Replace `__tests__/services/DynamoDbClient/MockDynamoDbClient.ts` with: + +```typescript +import { SourceDynamoDbClient } from "../../../src/services/DynamoDbClient/abstractions/DynamoDbClient.ts"; +import type { BaseRecord } from "../../../src/domain/transform/types/records.ts"; + +export interface MockUpdateCall { + tableName: string; + request: SourceDynamoDbClient.UpdateRequest; + result: SourceDynamoDbClient.UpdateResult; +} + +/** + * Mock implementation of IDynamoDbClient for testing. `scan` shards + * round-robin by index (not by hash range) — group records via `queryAll`. + */ +export class MockDynamoDbClient implements SourceDynamoDbClient.Interface { + private records: Map = new Map(); + public batchPutRecords: SourceDynamoDbClient.Record[] = []; + public updateCalls: MockUpdateCall[] = []; + + constructor(initialRecords: Record = {}) { + for (const [table, records] of Object.entries(initialRecords)) { + this.records.set(table, records); + } + } + + async *scan( + tableName: string, + options?: SourceDynamoDbClient.Scan + ): AsyncIterable { + const records = this.records.get(tableName) || []; + let yielded = 0; + + for (let i = 0; i < records.length; i++) { + const record = records[i]!; + if (options && options.segment !== undefined && options.totalSegments) { + if (i % options.totalSegments !== options.segment) { + continue; + } + } + if (options && options.sortKeyEquals !== undefined && record.SK !== options.sortKeyEquals) { + continue; + } + yield record as T; + yielded++; + if (options && options.limit !== undefined && yielded >= options.limit) { + return; + } + } + } + + async query( + tableName: string, + pk: string, + sk?: string, + _options?: SourceDynamoDbClient.Query + ): Promise { + const records = this.records.get(tableName) || []; + + return records.filter(record => { + if (record.PK !== pk) { + return false; + } + if (sk && record.SK !== sk) { + return false; + } + return true; + }) as T[]; + } + + async get( + tableName: string, + pk: string, + sk: string + ): Promise { + const records = this.records.get(tableName) || []; + const found = records.find(r => r.PK === pk && r.SK === sk); + return (found as T) ?? null; + } + + async queryAll( + tableName: string, + pk: string, + sk?: string, + options?: SourceDynamoDbClient.Query + ): Promise { + return this.query(tableName, pk, sk, options); + } + + async batchPut( + tableName: string, + records: T[] + ): Promise { + this.batchPutRecords.push(...records); + + const tableRecords = this.records.get(tableName) || []; + tableRecords.push(...records); + this.records.set(tableName, tableRecords); + } + + async updateAttribute( + tableName: string, + request: SourceDynamoDbClient.UpdateRequest + ): Promise { + const records = this.records.get(tableName) || []; + const record = records.find(r => r.PK === request.key.PK && r.SK === request.key.SK); + const current = record ? record[request.condition.attribute] : undefined; + const holds = JSON.stringify(current) === JSON.stringify(request.condition.equals); + const result: SourceDynamoDbClient.UpdateResult = record && holds ? "written" : "condition-failed"; + + if (record && holds) { + let cursor = record as Record; + for (let i = 0; i < request.path.length - 1; i++) { + const segment = request.path[i]!; + const next = cursor[segment]; + if (typeof next !== "object" || next === null) { + cursor[segment] = {}; + } + cursor = cursor[segment] as Record; + } + cursor[request.path[request.path.length - 1]!] = request.value; + } + + this.updateCalls.push({ tableName, request, result }); + return result; + } + + // Test helpers + getRecordsForTable(tableName: string): SourceDynamoDbClient.Record[] { + return this.records.get(tableName) || []; + } + + clearRecords(): void { + this.batchPutRecords = []; + this.updateCalls = []; + } +} +``` + +Create `__tests__/services/DynamoDbClient/MockDynamoDbClient.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { MockDynamoDbClient } from "./MockDynamoDbClient.ts"; + +describe("MockDynamoDbClient", () => { + const rows = [ + { PK: "a", SK: "L", _md: "1", data: { live: null } }, + { PK: "a", SK: "P", _md: "1", data: {} }, + { PK: "b", SK: "L", _md: "2", data: {} } + ]; + + it("scan honours sortKeyEquals and limit", async () => { + const client = new MockDynamoDbClient({ t: rows }); + const seen = []; + for await (const row of client.scan("t", { sortKeyEquals: "L", limit: 1 })) { + seen.push(row); + } + expect(seen).toEqual([rows[0]]); + }); + + it("updateAttribute writes a nested path when the condition holds", async () => { + const client = new MockDynamoDbClient({ t: structuredClone(rows) }); + const result = await client.updateAttribute("t", { + key: { PK: "a", SK: "L" }, + path: ["data", "live"], + value: { version: 2 }, + condition: { attribute: "_md", equals: "1" } + }); + expect(result).toBe("written"); + expect((client.getRecordsForTable("t")[0]!.data as Record).live).toEqual({ version: 2 }); + }); + + it("updateAttribute returns condition-failed and leaves the record untouched", async () => { + const client = new MockDynamoDbClient({ t: structuredClone(rows) }); + const result = await client.updateAttribute("t", { + key: { PK: "a", SK: "L" }, + path: ["data", "live"], + value: { version: 2 }, + condition: { attribute: "_md", equals: "stale" } + }); + expect(result).toBe("condition-failed"); + expect((client.getRecordsForTable("t")[0]!.data as Record).live).toBeNull(); + expect(client.updateCalls).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 5: Verify** + +```bash +yarn vitest run __tests__/services/DynamoDbClient && yarn ts-check +``` + +--- + +### Task 3: `FileTool.appendLineOrThrow` + +**Files:** +- Modify: `src/tools/FileTool/abstractions/FileTool.ts`, `src/tools/FileTool/FileTool.ts` +- Test: `__tests__/tools/FileTool/FileTool.test.ts` (extend) + +**Interfaces:** +- Produces: `FileTool.Interface.appendLineOrThrow(path, line)` — creates parent dir, appends `line + "\n"`. + +- [ ] **Step 1: Failing test** + +Append a `describe` in `__tests__/tools/FileTool/FileTool.test.ts`: + +```typescript + describe("appendLineOrThrow", () => { + it("creates the file and parent directory, then appends one line per call", () => { + const filePath = join(tmpDir, "nested", "report.jsonl"); + const tool = resolve(); + + tool.appendLineOrThrow(filePath, '{"a":1}'); + tool.appendLineOrThrow(filePath, '{"b":2}'); + + expect(readFileSync(filePath, "utf-8")).toBe('{"a":1}\n{"b":2}\n'); + }); + }); +``` + +- [ ] **Step 2: Implement** + +Add to `IFileTool` (after `writeFileOrThrow`): + +```typescript + /** Appends `line` plus a trailing newline, creating the parent directory. */ + appendLineOrThrow(path: string, line: string): void; +``` + +In `src/tools/FileTool/FileTool.ts` add `appendFileSync` to the `node:fs` import and the method: + +```typescript + public appendLineOrThrow(path: string, line: string): void { + this.directoryTool.create(dirname(path)); + appendFileSync(path, `${line}\n`, "utf-8"); + } +``` + +- [ ] **Step 3: Verify** + +```bash +yarn vitest run __tests__/tools/FileTool && yarn ts-check +``` + +--- + +### Task 4: FixLive abstractions + `LiveFieldReconciler` + +**Files:** +- Create: `src/features/FixLive/abstractions/LiveFieldReconciler.ts`, `LiveFieldRunner.ts`, `ChangeReport.ts`, `FixLiveState.ts`, `index.ts` +- Create: `src/features/FixLive/LiveFieldReconciler.ts`, `src/features/FixLive/feature.ts`, `src/features/FixLive/index.ts` +- Test: `__tests__/features/FixLive/LiveFieldReconciler.test.ts` + +**Interfaces:** +- Consumes: `DatabaseRecord`, `SourceDynamoDbClient.Interface`. +- Produces: tokens `LiveFieldReconciler`, `DdbLiveFieldRunner`, `OsLiveFieldRunner`, `ChangeReport`, `FixLiveState`; namespaces per spec §2.2 / §2.3 / §2.5 (the contract the sibling command plan consumes). + +- [ ] **Step 1: Write the abstractions (the contract)** + +`src/features/FixLive/abstractions/LiveFieldReconciler.ts`: + +```typescript +import { createAbstraction } from "~/base/index.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; + +export type LiveFieldTable = "ddb" | "os"; + +/** + * What decide() reads: PK/SK for addressing, `_md` for the write condition, + * `data.live` / `data.status` / `data.version` for the decision. OS rows have + * no root TYPE, so this is deliberately narrower than BaseRecord. OS records + * are already decompressed when they reach the reconciler. + */ +export interface ReconcilableRecord extends DatabaseRecord { + _md: string; + data: Record; +} + +export interface LiveFieldGroup { + pk: string; + table: LiveFieldTable; + /** Keyed by SK. */ + records: Map; +} + +export interface LiveFieldValue { + version: number; +} + +export type LiveFieldChangeReason = "missing-live" | "empty-live" | "wrong-version" | "stale-live"; + +export type LiveFieldSkipReason = + | "no-latest-record" + | "invalid-version" + | "revision-record-missing" + | "revision-version-mismatch" + | "latest-status-contradicts-published" + | "latest-status-contradicts-unpublished" + | "decompress-failed" + | "changed-during-run"; // emitted by the writer, not by decide() + +export interface LiveFieldChange { + pk: string; + sk: string; + /** Current data.live, verbatim. */ + before: unknown; + after: LiveFieldValue | null; + reason: LiveFieldChangeReason; + /** _md at read time, for the write condition. */ + expectedMd: string; +} + +export interface LiveFieldSkip { + pk: string; + sk?: string; + reason: LiveFieldSkipReason; + detail?: string; +} + +export interface LiveFieldDecision { + changes: LiveFieldChange[]; + skips: LiveFieldSkip[]; +} + +export interface ILiveFieldReconciler { + /** Deterministic, synchronous, no I/O. The runner guarantees a complete group. */ + decide(group: LiveFieldGroup): LiveFieldDecision; +} + +export const LiveFieldReconciler = createAbstraction("FixLive/Reconciler"); + +export namespace LiveFieldReconciler { + export type Interface = ILiveFieldReconciler; + export type Table = LiveFieldTable; + export type Record = ReconcilableRecord; + export type Group = LiveFieldGroup; + export type LiveValue = LiveFieldValue; + export type Change = LiveFieldChange; + export type Skip = LiveFieldSkip; + export type Decision = LiveFieldDecision; + export type ChangeReason = LiveFieldChangeReason; + export type SkipReason = LiveFieldSkipReason; +} +``` + +`src/features/FixLive/abstractions/ChangeReport.ts`: + +```typescript +import { createAbstraction } from "~/base/index.js"; +import type { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; + +export type ChangeReportResult = "dry-run" | "written" | "condition-failed"; + +export interface ChangeReportChange { + table: LiveFieldReconciler.Table; + pk: string; + sk: string; + reason: LiveFieldReconciler.ChangeReason; + before: unknown; + after: LiveFieldReconciler.LiveValue | null; + result: ChangeReportResult; +} + +export interface ChangeReportSkip { + table: LiveFieldReconciler.Table; + pk: string; + sk?: string; + reason: LiveFieldReconciler.SkipReason; + detail?: string; +} + +export interface IChangeReport { + /** Absolute path of the JSONL file. */ + readonly path: string; + change(entry: ChangeReportChange): void; + skip(entry: ChangeReportSkip): void; +} + +export const ChangeReport = createAbstraction("FixLive/ChangeReport"); + +export namespace ChangeReport { + export type Interface = IChangeReport; + export type Result = ChangeReportResult; + export type Change = ChangeReportChange; + export type Skip = ChangeReportSkip; +} +``` + +`src/features/FixLive/abstractions/LiveFieldRunner.ts`: + +```typescript +import { createAbstraction } from "~/base/index.js"; +import type { SourceDynamoDbClient } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import type { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; +import type { ChangeReport } from "./ChangeReport.ts"; + +export type LiveFieldRunMode = "dry-run" | "live"; + +/** The system being reconciled. The command resolves Source/Target client by `--system`. */ +export interface LiveFieldRunTarget { + client: SourceDynamoDbClient.Interface; + tableName: string; + /** `pipeline.segments` from the project config. */ + segments: number; + /** Segments in flight. Default 4. */ + concurrency?: number; + /** Writes in flight per segment. Default 8. */ + writeConcurrency?: number; +} + +export interface LiveFieldRunStats { + scanned: number; + entries: number; + changes: Record; + skips: Record; + written: number; + conditionFailed: number; +} + +export interface LiveFieldRunOptions { + mode: LiveFieldRunMode; + target: LiveFieldRunTarget; + report: ChangeReport.Interface; + onProgress(stats: LiveFieldRunStats): void; +} + +export interface ILiveFieldRunner { + run(options: LiveFieldRunOptions): Promise; +} + +export const DdbLiveFieldRunner = createAbstraction("FixLive/DdbRunner"); +export const OsLiveFieldRunner = createAbstraction("FixLive/OsRunner"); + +export namespace LiveFieldRunner { + export type Interface = ILiveFieldRunner; + export type Mode = LiveFieldRunMode; + export type Target = LiveFieldRunTarget; + export type Options = LiveFieldRunOptions; + export type Stats = LiveFieldRunStats; +} +``` + +`src/features/FixLive/abstractions/FixLiveState.ts`: + +```typescript +import { createAbstraction } from "~/base/index.js"; + +export interface FixLiveRunSummary { + runId: string; + /** ISO timestamp. */ + at: string; + changes: number; + skips: number; +} + +export interface FixLiveLiveRunSummary extends FixLiveRunSummary { + written: number; + conditionFailed: number; +} + +export interface FixLiveStateFile { + lastDryRun?: FixLiveRunSummary; + lastLiveRun?: FixLiveLiveRunSummary; +} + +export interface FixLiveStateKey { + project: string; + system: "source" | "target"; +} + +export interface IFixLiveState { + pathFor(key: FixLiveStateKey): string; + read(key: FixLiveStateKey): FixLiveStateFile | null; + recordDryRun(key: FixLiveStateKey, summary: FixLiveRunSummary): void; + recordLiveRun(key: FixLiveStateKey, summary: FixLiveLiveRunSummary): void; +} + +export const FixLiveState = createAbstraction("FixLive/State"); + +export namespace FixLiveState { + export type Interface = IFixLiveState; + export type Key = FixLiveStateKey; + export type RunSummary = FixLiveRunSummary; + export type LiveRunSummary = FixLiveLiveRunSummary; + export type File = FixLiveStateFile; +} +``` + +`src/features/FixLive/abstractions/index.ts`: + +```typescript +export { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; +export { DdbLiveFieldRunner, OsLiveFieldRunner } from "./LiveFieldRunner.ts"; +export { ChangeReport } from "./ChangeReport.ts"; +export { FixLiveState } from "./FixLiveState.ts"; +``` + +- [ ] **Step 2: Exhaustive failing tests for `decide`** + +`__tests__/features/FixLive/LiveFieldReconciler.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { LiveFieldReconciler } from "~/features/FixLive/LiveFieldReconciler.js"; +import type { LiveFieldReconciler as Reconciler } from "~/features/FixLive/abstractions/LiveFieldReconciler.js"; + +const PK = "T#root#CMS#CME#abc"; + +function rec(sk: string, data: Record, md = `md-${sk}`): Reconciler.Record { + return { PK, SK: sk, _md: md, data }; +} + +function group(table: Reconciler.Table, ...records: Reconciler.Record[]): Reconciler.Group { + return { pk: PK, table, records: new Map(records.map(r => [r.SK, r])) }; +} + +function decide(table: Reconciler.Table, ...records: Reconciler.Record[]): Reconciler.Decision { + return new LiveFieldReconciler().decide(group(table, ...records)); +} + +const skipReasons = (d: Reconciler.Decision) => d.skips.map(s => s.reason); +const changeSummary = (d: Reconciler.Decision) => d.changes.map(c => `${c.sk}:${c.reason}`).sort(); + +describe("LiveFieldReconciler.decide — skips", () => { + it("no-latest-record when L is absent", () => { + const d = decide("ddb", rec("P", { version: 1, status: "published" })); + expect(skipReasons(d)).toEqual(["no-latest-record"]); + expect(d.changes).toEqual([]); + }); + + it("latest-status-contradicts-unpublished when P is absent but L says published", () => { + const d = decide("ddb", rec("L", { version: 1, status: "published" })); + expect(skipReasons(d)).toEqual(["latest-status-contradicts-unpublished"]); + }); + + it.each([["2"], [0], [-1], [1.5], [null], [undefined]])( + "invalid-version when P.version is %s", + version => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft" }), + rec("P", { version, status: "published" }) + ); + expect(skipReasons(d)).toEqual(["invalid-version"]); + } + ); + + it("latest-status-contradicts-published when L is published but on a different version", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "published" }), + rec("P", { version: 2, status: "published" }), + rec("REV#0002", { version: 2 }) + ); + expect(skipReasons(d)).toEqual(["latest-status-contradicts-published"]); + }); + + it("latest-status-contradicts-published when L has P's version but is not published", () => { + const d = decide( + "ddb", + rec("L", { version: 2, status: "draft" }), + rec("P", { version: 2, status: "published" }), + rec("REV#0002", { version: 2 }) + ); + expect(skipReasons(d)).toEqual(["latest-status-contradicts-published"]); + }); + + it("revision-record-missing on ddb when REV# is absent", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft" }), + rec("P", { version: 2, status: "published" }) + ); + expect(d.skips).toEqual([ + { pk: PK, sk: "REV#0002", reason: "revision-record-missing", detail: "P.version=2" } + ]); + }); + + it("revision-version-mismatch on ddb when REV# carries another version", () => { + const d = decide( + "ddb", + rec("L", { version: 7, status: "draft" }), + rec("P", { version: 7, status: "published" }), + rec("REV#0007", { version: 6 }) + ); + expect(skipReasons(d)).toEqual(["revision-version-mismatch"]); + expect(d.skips[0]!.detail).toBe("P.version=7 REV#0007.version=6"); + }); + + it("a skip aborts the whole group — no changes alongside a skip", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft", live: null }), + rec("P", { version: 2, status: "published", live: {} }) + ); + expect(d.skips).toHaveLength(1); + expect(d.changes).toEqual([]); + }); +}); + +describe("LiveFieldReconciler.decide — changes", () => { + it("missing-live on L, P and the published REV# when live is absent or null", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft" }), + rec("P", { version: 2, status: "published", live: null }), + rec("REV#0002", { version: 2, status: "published" }), + rec("REV#0003", { version: 3, status: "draft" }) + ); + expect(changeSummary(d)).toEqual(["L:missing-live", "P:missing-live", "REV#0002:missing-live"]); + for (const change of d.changes) { + expect(change.after).toEqual({ version: 2 }); + expect(change.expectedMd).toBe(`md-${change.sk}`); + } + }); + + it("empty-live when live is {} or has a non-integer version", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft", live: {} }), + rec("P", { version: 2, status: "published", live: { version: "2" } }), + rec("REV#0002", { version: 2, live: { version: 2 } }) + ); + expect(changeSummary(d)).toEqual(["L:empty-live", "P:empty-live"]); + expect(d.changes.find(c => c.sk === "L")!.before).toEqual({}); + }); + + it("wrong-version when live.version differs from P.version", () => { + const d = decide( + "os", + rec("L", { version: 3, status: "draft", live: { version: 1 } }), + rec("P", { version: 2, status: "published", live: { version: 2 } }) + ); + expect(changeSummary(d)).toEqual(["L:wrong-version"]); + }); + + it("stale-live on L only when P is absent and L carries any live value", () => { + const d = decide( + "ddb", + rec("L", { version: 2, status: "unpublished", live: { version: 1 } }), + rec("REV#0001", { version: 1, live: { version: 1 } }), + rec("REV#0002", { version: 2, live: { version: 1 } }) + ); + expect(changeSummary(d)).toEqual(["L:stale-live"]); + expect(d.changes[0]!.after).toBeNull(); + }); + + it("stale-live also normalises {} to null when unpublished", () => { + const d = decide("ddb", rec("L", { version: 1, status: "draft", live: {} })); + expect(changeSummary(d)).toEqual(["L:stale-live"]); + }); + + it("no change when unpublished and live is null or absent", () => { + expect(decide("ddb", rec("L", { version: 1, status: "draft", live: null })).changes).toEqual([]); + expect(decide("ddb", rec("L", { version: 1, status: "draft" })).changes).toEqual([]); + }); + + it("clean group produces neither changes nor skips", () => { + const d = decide( + "ddb", + rec("L", { version: 2, status: "published", live: { version: 2 } }), + rec("P", { version: 2, status: "published", live: { version: 2 } }), + rec("REV#0002", { version: 2, live: { version: 2 } }), + rec("REV#0001", { version: 1, live: { version: 1 } }) + ); + expect(d).toEqual({ changes: [], skips: [] }); + }); + + it("os table skips the REV# checks and never touches REV# records", () => { + const d = decide( + "os", + rec("L", { version: 3, status: "draft" }), + rec("P", { version: 2, status: "published" }) + ); + expect(changeSummary(d)).toEqual(["L:missing-live", "P:missing-live"]); + expect(d.skips).toEqual([]); + }); + + it("other REV# records never appear in changes", () => { + const d = decide( + "ddb", + rec("L", { version: 3, status: "draft", live: { version: 2 } }), + rec("P", { version: 2, status: "published", live: { version: 2 } }), + rec("REV#0002", { version: 2, live: { version: 2 } }), + rec("REV#0001", { version: 1, live: {} }), + rec("REV#0003", { version: 3 }) + ); + expect(d.changes).toEqual([]); + }); + + it("single-revision published entry reconciles L, P and REV#0001", () => { + const d = decide( + "ddb", + rec("L", { version: 1, status: "published" }), + rec("P", { version: 1, status: "published" }), + rec("REV#0001", { version: 1, status: "published" }) + ); + expect(changeSummary(d)).toEqual(["L:missing-live", "P:missing-live", "REV#0001:missing-live"]); + }); + + it("pads version >= 10000 as REV#10000 (no truncation)", () => { + const d = decide( + "ddb", + rec("L", { version: 10000, status: "published" }), + rec("P", { version: 10000, status: "published" }), + rec("REV#10000", { version: 10000 }) + ); + expect(d.skips).toEqual([]); + expect(d.changes.map(c => c.sk).sort()).toEqual(["L", "P", "REV#10000"]); + }); +}); +``` + +- [ ] **Step 3: Implement the reconciler** + +`src/features/FixLive/LiveFieldReconciler.ts`: + +```typescript +import { LiveFieldReconciler as LiveFieldReconcilerAbstraction } from "./abstractions/LiveFieldReconciler.ts"; + +export type { ILiveFieldReconciler } from "./abstractions/LiveFieldReconciler.js"; + +const LATEST_SK = "L"; +const PUBLISHED_SK = "P"; +const PUBLISHED_STATUS = "published"; + +type SkipWithoutPk = Omit; + +class LiveFieldReconcilerImpl implements LiveFieldReconcilerAbstraction.Interface { + public decide(group: LiveFieldReconcilerAbstraction.Group): LiveFieldReconcilerAbstraction.Decision { + const latest = group.records.get(LATEST_SK); + if (!latest) { + return this.skip(group, { reason: "no-latest-record" }); + } + const published = group.records.get(PUBLISHED_SK); + if (!published) { + return this.decideUnpublished(group, latest); + } + return this.decidePublished(group, latest, published); + } + + private decideUnpublished( + group: LiveFieldReconcilerAbstraction.Group, + latest: LiveFieldReconcilerAbstraction.Record + ): LiveFieldReconcilerAbstraction.Decision { + if (latest.data.status === PUBLISHED_STATUS) { + return this.skip(group, { + sk: LATEST_SK, + reason: "latest-status-contradicts-unpublished", + detail: "P missing while L.status=published" + }); + } + return { changes: this.reconcile(group.pk, latest, null), skips: [] }; + } + + private decidePublished( + group: LiveFieldReconcilerAbstraction.Group, + latest: LiveFieldReconcilerAbstraction.Record, + published: LiveFieldReconcilerAbstraction.Record + ): LiveFieldReconcilerAbstraction.Decision { + const version = published.data.version; + if (!isPositiveInteger(version)) { + return this.skip(group, { + sk: PUBLISHED_SK, + reason: "invalid-version", + detail: `P.version=${String(version)}` + }); + } + + const latestVersion = latest.data.version; + const latestStatus = latest.data.status; + if (latestStatus === PUBLISHED_STATUS && latestVersion !== version) { + return this.skip(group, { + sk: LATEST_SK, + reason: "latest-status-contradicts-published", + detail: `L.status=published L.version=${String(latestVersion)} P.version=${version}` + }); + } + if (latestVersion === version && latestStatus !== PUBLISHED_STATUS) { + return this.skip(group, { + sk: LATEST_SK, + reason: "latest-status-contradicts-published", + detail: `L.version=P.version=${version} but L.status=${String(latestStatus)}` + }); + } + + const targets: LiveFieldReconcilerAbstraction.Record[] = [latest, published]; + if (group.table === "ddb") { + const revisionSk = `REV#${padVersion(version)}`; + const revision = group.records.get(revisionSk); + if (!revision) { + return this.skip(group, { + sk: revisionSk, + reason: "revision-record-missing", + detail: `P.version=${version}` + }); + } + if (revision.data.version !== version) { + return this.skip(group, { + sk: revisionSk, + reason: "revision-version-mismatch", + detail: `P.version=${version} ${revisionSk}.version=${String(revision.data.version)}` + }); + } + targets.push(revision); + } + + const expected: LiveFieldReconcilerAbstraction.LiveValue = { version }; + const changes = targets.flatMap(record => this.reconcile(group.pk, record, expected)); + return { changes, skips: [] }; + } + + private reconcile( + pk: string, + record: LiveFieldReconcilerAbstraction.Record, + expected: LiveFieldReconcilerAbstraction.LiveValue | null + ): LiveFieldReconcilerAbstraction.Change[] { + const live = record.data.live; + const base = { pk, sk: record.SK, before: live, expectedMd: record._md }; + + if (expected === null) { + if (live === undefined || live === null) { + return []; + } + return [{ ...base, after: null, reason: "stale-live" }]; + } + if (live === undefined || live === null) { + return [{ ...base, after: expected, reason: "missing-live" }]; + } + const current = readLiveVersion(live); + if (current === null) { + return [{ ...base, after: expected, reason: "empty-live" }]; + } + if (current !== expected.version) { + return [{ ...base, after: expected, reason: "wrong-version" }]; + } + return []; + } + + private skip( + group: LiveFieldReconcilerAbstraction.Group, + skip: SkipWithoutPk + ): LiveFieldReconcilerAbstraction.Decision { + return { changes: [], skips: [{ pk: group.pk, ...skip }] }; + } +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +/** v6 `zeroPad`: 4 digits minimum, never truncated (10000 → "10000"). */ +function padVersion(version: number): string { + return String(version).padStart(4, "0"); +} + +function readLiveVersion(live: unknown): number | null { + if (typeof live !== "object" || live === null) { + return null; + } + const { version } = live as Record; + return isPositiveInteger(version) ? version : null; +} + +export const LiveFieldReconciler = LiveFieldReconcilerAbstraction.createImplementation({ + implementation: LiveFieldReconcilerImpl, + dependencies: [] +}); +``` + +- [ ] **Step 4: Feature + index (grown in later tasks)** + +`src/features/FixLive/feature.ts`: + +```typescript +import { createFeature } from "~/base/index.js"; +import { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; + +export const FixLiveFeature = createFeature({ + name: "FixLive/FixLiveFeature", + register(container) { + container.register(LiveFieldReconciler).inSingletonScope(); + } +}); +``` + +`src/features/FixLive/index.ts`: + +```typescript +export { + LiveFieldReconciler, + DdbLiveFieldRunner, + OsLiveFieldRunner, + ChangeReport, + FixLiveState +} from "./abstractions/index.ts"; +export { FixLiveFeature } from "./feature.ts"; +``` + +- [ ] **Step 5: Verify** + +```bash +yarn vitest run __tests__/features/FixLive/LiveFieldReconciler.test.ts && yarn ts-check +``` + +--- + +### Task 5: `ChangeReport` (JSONL) + test container + +**Files:** +- Create: `src/features/FixLive/ChangeReport.ts` +- Modify: `src/features/FixLive/feature.ts` +- Create: `__tests__/features/FixLive/fixLiveContainer.ts`, `__tests__/features/FixLive/MockChangeReport.ts` +- Test: `__tests__/features/FixLive/ChangeReport.test.ts` + +**Interfaces:** +- Consumes: `TransferContext.runId`, `FileTool.appendLineOrThrow`. +- Produces: `ChangeReport` implementation writing `.transfer//fix-live-report.jsonl`; `MockChangeReport` for runner tests. + +- [ ] **Step 1: Test container + mock report** + +`__tests__/features/FixLive/fixLiveContainer.ts`: + +```typescript +import { Container } from "@webiny/di"; +import { CompressionFeature } from "@webiny/utils/features/compression/feature.js"; +import { ContainerToken } from "~/base/index.js"; +import { TransferContext } from "~/features/TransferLifecycle/abstractions/TransferContext.js"; +import { LoggerFeature } from "~/tools/Logger/index.js"; +import { DirectoryToolFeature } from "~/tools/DirectoryTool/index.js"; +import { FileToolFeature } from "~/tools/FileTool/index.js"; +import { OsRecordDecompressorFeature } from "~/features/OsRecordDecompressor/index.js"; +import { FixLiveFeature } from "~/features/FixLive/index.js"; + +export interface FixLiveContainerOptions { + runId?: string; +} + +/** Minimal container for FixLive tests — no pipeline, no source/target clients. */ +export function createFixLiveContainer(options: FixLiveContainerOptions = {}): Container { + const container = new Container(); + container.registerInstance(ContainerToken, container); + container.registerInstance(TransferContext, { runId: options.runId ?? "fix-live-test-run" }); + LoggerFeature.register(container, { logLevel: "error", json: false }); + CompressionFeature.register(container); + DirectoryToolFeature.register(container); + FileToolFeature.register(container); + OsRecordDecompressorFeature.register(container); + FixLiveFeature.register(container); + return container; +} +``` + +`__tests__/features/FixLive/MockChangeReport.ts`: + +```typescript +import type { ChangeReport } from "~/features/FixLive/abstractions/ChangeReport.js"; + +export class MockChangeReport implements ChangeReport.Interface { + public readonly path = "/dev/null/fix-live-report.jsonl"; + public readonly changes: ChangeReport.Change[] = []; + public readonly skips: ChangeReport.Skip[] = []; + + public change(entry: ChangeReport.Change): void { + this.changes.push(entry); + } + + public skip(entry: ChangeReport.Skip): void { + this.skips.push(entry); + } +} +``` + +- [ ] **Step 2: Failing test** + +`__tests__/features/FixLive/ChangeReport.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ChangeReport } from "~/features/FixLive/index.js"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; + +describe("ChangeReport", () => { + let originalCwd: string; + let workDir: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + workDir = await mkdtemp(join(tmpdir(), "fix-live-report-")); + process.chdir(workDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + it("appends one JSON line per event under .transfer//fix-live-report.jsonl", async () => { + const report = createFixLiveContainer({ runId: "run-1" }).resolve(ChangeReport); + + report.change({ + table: "ddb", + pk: "T#root#CMS#CME#abc", + sk: "L", + reason: "missing-live", + before: undefined, + after: { version: 2 }, + result: "dry-run" + }); + report.skip({ + table: "ddb", + pk: "T#root#CMS#CME#def", + sk: "REV#0007", + reason: "revision-version-mismatch", + detail: "P.version=7 REV#0007.version=6" + }); + + expect(report.path).toBe(join(workDir, ".transfer", "run-1", "fix-live-report.jsonl")); + const lines = (await readFile(report.path, "utf-8")).trim().split("\n"); + expect(JSON.parse(lines[0]!)).toEqual({ + kind: "change", + table: "ddb", + pk: "T#root#CMS#CME#abc", + sk: "L", + reason: "missing-live", + before: null, + after: { version: 2 }, + result: "dry-run" + }); + expect(JSON.parse(lines[1]!)).toEqual({ + kind: "skip", + table: "ddb", + pk: "T#root#CMS#CME#def", + sk: "REV#0007", + reason: "revision-version-mismatch", + detail: "P.version=7 REV#0007.version=6" + }); + }); +}); +``` + +- [ ] **Step 3: Implement** + +`src/features/FixLive/ChangeReport.ts`: + +```typescript +import { join } from "node:path"; +import { ChangeReport as ChangeReportAbstraction } from "./abstractions/ChangeReport.ts"; +import { TransferContext } from "~/features/TransferLifecycle/abstractions/TransferContext.js"; +import { FileTool } from "~/tools/FileTool/abstractions/FileTool.js"; + +export type { IChangeReport } from "./abstractions/ChangeReport.js"; + +const REPORT_FILE_NAME = "fix-live-report.jsonl"; + +interface ChangeLine extends ChangeReportAbstraction.Change { + kind: "change"; +} + +interface SkipLine extends ChangeReportAbstraction.Skip { + kind: "skip"; +} + +type ReportLine = ChangeLine | SkipLine; + +/** + * Appends one JSON line per event as it happens, so the file is a valid + * audit trail even when the run is interrupted. + */ +class JsonlChangeReportImpl implements ChangeReportAbstraction.Interface { + public readonly path: string; + + public constructor( + transferContext: TransferContext.Interface, + private readonly fileTool: FileTool.Interface + ) { + this.path = join(process.cwd(), ".transfer", transferContext.runId, REPORT_FILE_NAME); + } + + public change(entry: ChangeReportAbstraction.Change): void { + this.append({ + kind: "change", + table: entry.table, + pk: entry.pk, + sk: entry.sk, + reason: entry.reason, + before: entry.before === undefined ? null : entry.before, + after: entry.after, + result: entry.result + }); + } + + public skip(entry: ChangeReportAbstraction.Skip): void { + this.append({ + kind: "skip", + table: entry.table, + pk: entry.pk, + sk: entry.sk, + reason: entry.reason, + detail: entry.detail + }); + } + + private append(line: ReportLine): void { + this.fileTool.appendLineOrThrow(this.path, JSON.stringify(line)); + } +} + +export const ChangeReport = ChangeReportAbstraction.createImplementation({ + implementation: JsonlChangeReportImpl, + dependencies: [TransferContext, FileTool] +}); +``` + +Add to `feature.ts`: `import { ChangeReport } from "./ChangeReport.ts";` and `container.register(ChangeReport).inSingletonScope();`. + +- [ ] **Step 4: Verify** + +```bash +yarn vitest run __tests__/features/FixLive/ChangeReport.test.ts && yarn ts-check +``` + +--- + +### Task 6: `FixLiveState` store + +**Files:** +- Create: `src/features/FixLive/FixLiveState.ts` +- Modify: `src/features/FixLive/feature.ts` +- Test: `__tests__/features/FixLive/FixLiveState.test.ts` + +**Interfaces:** +- Consumes: `FileTool` (`exists`, `readFileOrThrow`, `writeFileOrThrow`). +- Produces: `FixLiveState` implementation over `.transfer/state/fix-live/__.json`. + +- [ ] **Step 1: Failing test** + +```typescript +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FixLiveState } from "~/features/FixLive/index.js"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; + +const KEY = { project: "acme", system: "target" as const }; + +describe("FixLiveState", () => { + let originalCwd: string; + let workDir: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + workDir = await mkdtemp(join(tmpdir(), "fix-live-state-")); + process.chdir(workDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + it("resolves the path under .transfer/state/fix-live", () => { + const state = createFixLiveContainer().resolve(FixLiveState); + expect(state.pathFor(KEY)).toBe(join(workDir, ".transfer", "state", "fix-live", "acme__target.json")); + }); + + it("read returns null when no state exists", () => { + expect(createFixLiveContainer().resolve(FixLiveState).read(KEY)).toBeNull(); + }); + + it("recordDryRun writes lastDryRun; recordLiveRun adds lastLiveRun and keeps lastDryRun", async () => { + const state = createFixLiveContainer().resolve(FixLiveState); + const dry = { runId: "1", at: "2026-09-04T09:12:33.000Z", changes: 2118, skips: 4 }; + const live = { ...dry, runId: "2", written: 2110, conditionFailed: 8 }; + + state.recordDryRun(KEY, dry); + expect(state.read(KEY)).toEqual({ lastDryRun: dry }); + + state.recordLiveRun(KEY, live); + expect(state.read(KEY)).toEqual({ lastDryRun: dry, lastLiveRun: live }); + expect(JSON.parse(await readFile(state.pathFor(KEY), "utf-8"))).toEqual({ + lastDryRun: dry, + lastLiveRun: live + }); + }); +}); +``` + +- [ ] **Step 2: Implement** + +`src/features/FixLive/FixLiveState.ts`: + +```typescript +import { join } from "node:path"; +import { FixLiveState as FixLiveStateAbstraction } from "./abstractions/FixLiveState.ts"; +import { FileTool } from "~/tools/FileTool/abstractions/FileTool.js"; + +export type { IFixLiveState } from "./abstractions/FixLiveState.js"; + +class FixLiveStateImpl implements FixLiveStateAbstraction.Interface { + public constructor(private readonly fileTool: FileTool.Interface) {} + + public pathFor(key: FixLiveStateAbstraction.Key): string { + return join(process.cwd(), ".transfer", "state", "fix-live", `${key.project}__${key.system}.json`); + } + + public read(key: FixLiveStateAbstraction.Key): FixLiveStateAbstraction.File | null { + const path = this.pathFor(key); + if (!this.fileTool.exists(path)) { + return null; + } + return JSON.parse(this.fileTool.readFileOrThrow(path)) as FixLiveStateAbstraction.File; + } + + public recordDryRun( + key: FixLiveStateAbstraction.Key, + summary: FixLiveStateAbstraction.RunSummary + ): void { + this.write(key, { ...(this.read(key) ?? {}), lastDryRun: summary }); + } + + public recordLiveRun( + key: FixLiveStateAbstraction.Key, + summary: FixLiveStateAbstraction.LiveRunSummary + ): void { + this.write(key, { ...(this.read(key) ?? {}), lastLiveRun: summary }); + } + + private write(key: FixLiveStateAbstraction.Key, file: FixLiveStateAbstraction.File): void { + this.fileTool.writeFileOrThrow(this.pathFor(key), `${JSON.stringify(file, null, 2)}\n`); + } +} + +export const FixLiveState = FixLiveStateAbstraction.createImplementation({ + implementation: FixLiveStateImpl, + dependencies: [FileTool] +}); +``` + +Add to `feature.ts`: `import { FixLiveState } from "./FixLiveState.ts";` and `container.register(FixLiveState).inSingletonScope();`. + +- [ ] **Step 3: Verify** + +```bash +yarn vitest run __tests__/features/FixLive && yarn ts-check +``` + +--- + +### Task 7: `BaseLiveFieldRunner` + `DdbLiveFieldRunner` + bootstrap wiring + +**Files:** +- Create: `src/features/FixLive/createEmptyStats.ts`, `src/features/FixLive/runConcurrently.ts`, `src/features/FixLive/cmsEntryGuards.ts`, `src/features/FixLive/BaseLiveFieldRunner.ts`, `src/features/FixLive/DdbLiveFieldRunner.ts` +- Modify: `src/features/FixLive/feature.ts`, `src/bootstrap.ts` +- Test: `__tests__/features/FixLive/DdbLiveFieldRunner.test.ts` + +**Interfaces:** +- Consumes: `LiveFieldReconciler`, `Logger`, `SourceDynamoDbClient.Interface` (`scan` with `sortKeyEquals`, `queryAll`, `updateAttribute`), `isCmsEntry`. +- Produces: `DdbLiveFieldRunner` token bound; `createEmptyStats()`; `runConcurrently()`. + +- [ ] **Step 1: Failing unit test against `MockDynamoDbClient`** + +`__tests__/features/FixLive/DdbLiveFieldRunner.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { DdbLiveFieldRunner } from "~/features/FixLive/index.js"; +import type { LiveFieldRunner } from "~/features/FixLive/abstractions/LiveFieldRunner.js"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; +import { MockChangeReport } from "./MockChangeReport.ts"; + +const TABLE = "v6-main"; + +function entry(id: string, sk: string, data: Record, md = "md-1") { + return { + PK: `T#root#CMS#CME#${id}`, + SK: sk, + TYPE: sk === "P" ? "cms.entry.p" : sk === "L" ? "cms.entry.l" : "cms.entry", + _et: "CmsEntries", + _ct: "2026-01-01T00:00:00.000Z", + _md: md, + data: { modelId: "blogPost", entryId: id, ...data } + }; +} + +function seed() { + return [ + // draft over published, live missing everywhere → 3 changes + entry("a", "L", { version: 3, status: "draft" }), + entry("a", "P", { version: 2, status: "published" }), + entry("a", "REV#0002", { version: 2, status: "published" }), + entry("a", "REV#0003", { version: 3, status: "draft" }), + // unpublished with stale live → 1 change + entry("b", "L", { version: 1, status: "unpublished", live: { version: 1 } }), + entry("b", "REV#0001", { version: 1, live: { version: 1 } }), + // published, REV# missing → skip + entry("c", "L", { version: 1, status: "published" }), + entry("c", "P", { version: 1, status: "published" }), + // file manager row → ignored + entry("f", "L", { modelId: "fmFile", version: 1, status: "draft" }), + // not a CMS entry → scanned only + { PK: "T#root#PB#P#p1", SK: "L", TYPE: "pb.page.l", _et: "Pb", _ct: "x", _md: "x", data: {} } + ]; +} + +function run(client: MockDynamoDbClient, mode: LiveFieldRunner.Mode, segments = 2) { + const runner = createFixLiveContainer().resolve(DdbLiveFieldRunner); + const report = new MockChangeReport(); + const progress: number[] = []; + return runner + .run({ + mode, + target: { client, tableName: TABLE, segments, concurrency: 2, writeConcurrency: 2 }, + report, + onProgress: stats => progress.push(stats.scanned) + }) + .then(stats => ({ stats, report, progress })); +} + +describe("DdbLiveFieldRunner", () => { + it("dry run: counts, reports, writes nothing", async () => { + const client = new MockDynamoDbClient({ [TABLE]: seed() }); + const { stats, report, progress } = await run(client, "dry-run"); + + expect(stats.scanned).toBe(5); + expect(stats.entries).toBe(3); + expect(stats.changes).toMatchObject({ "missing-live": 3, "stale-live": 1 }); + expect(stats.skips).toMatchObject({ "revision-record-missing": 1 }); + expect(stats.written).toBe(0); + expect(client.updateCalls).toEqual([]); + expect(report.changes).toHaveLength(4); + expect(report.changes.every(c => c.result === "dry-run" && c.table === "ddb")).toBe(true); + expect(report.skips).toEqual([ + { + table: "ddb", + pk: "T#root#CMS#CME#c", + sk: "REV#0001", + reason: "revision-record-missing", + detail: "P.version=1" + } + ]); + expect(progress.length).toBeGreaterThan(0); + }); + + it("live run: conditional updates on data.live only", async () => { + const client = new MockDynamoDbClient({ [TABLE]: seed() }); + const { stats, report } = await run(client, "live"); + + expect(stats.written).toBe(4); + expect(stats.conditionFailed).toBe(0); + expect(client.updateCalls).toHaveLength(4); + for (const call of client.updateCalls) { + expect(call.request.path).toEqual(["data", "live"]); + expect(call.request.condition).toEqual({ attribute: "_md", equals: "md-1" }); + } + const rows = client.getRecordsForTable(TABLE); + const data = (id: string, sk: string) => + rows.find(r => r.PK === `T#root#CMS#CME#${id}` && r.SK === sk)!.data as Record; + expect(data("a", "L").live).toEqual({ version: 2 }); + expect(data("a", "P").live).toEqual({ version: 2 }); + expect(data("a", "REV#0002").live).toEqual({ version: 2 }); + expect(data("a", "REV#0003").live).toBeUndefined(); + expect(data("b", "L").live).toBeNull(); + expect(report.changes.every(c => c.result === "written")).toBe(true); + }); + + it("live run: a record changed since read is reported as changed-during-run", async () => { + const rows = seed(); + const client = new MockDynamoDbClient({ [TABLE]: rows }); + const original = client.updateAttribute.bind(client); + client.updateAttribute = async (table, request) => { + if (request.key.PK === "T#root#CMS#CME#a" && request.key.SK === "L") { + rows.find(r => r.PK === request.key.PK && r.SK === "L")!._md = "md-2"; + } + return original(table, request); + }; + + const { stats, report } = await run(client, "live"); + + expect(stats.written).toBe(3); + expect(stats.conditionFailed).toBe(1); + expect(stats.skips["changed-during-run"]).toBe(1); + expect(report.changes.find(c => c.sk === "L" && c.pk.endsWith("#a"))!.result).toBe("condition-failed"); + expect(report.skips).toContainEqual({ + table: "ddb", + pk: "T#root#CMS#CME#a", + sk: "L", + reason: "changed-during-run", + detail: undefined + }); + }); +}); +``` + +- [ ] **Step 2: Helpers** + +`src/features/FixLive/createEmptyStats.ts`: + +```typescript +import type { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import type { LiveFieldRunner } from "./abstractions/LiveFieldRunner.ts"; + +export const CHANGE_REASONS: readonly LiveFieldReconciler.ChangeReason[] = [ + "missing-live", + "empty-live", + "wrong-version", + "stale-live" +]; + +export const SKIP_REASONS: readonly LiveFieldReconciler.SkipReason[] = [ + "no-latest-record", + "invalid-version", + "revision-record-missing", + "revision-version-mismatch", + "latest-status-contradicts-published", + "latest-status-contradicts-unpublished", + "decompress-failed", + "changed-during-run" +]; + +export function createEmptyStats(): LiveFieldRunner.Stats { + const changes = Object.fromEntries(CHANGE_REASONS.map(reason => [reason, 0])) as Record< + LiveFieldReconciler.ChangeReason, + number + >; + const skips = Object.fromEntries(SKIP_REASONS.map(reason => [reason, 0])) as Record< + LiveFieldReconciler.SkipReason, + number + >; + return { scanned: 0, entries: 0, changes, skips, written: 0, conditionFailed: 0 }; +} +``` + +`src/features/FixLive/runConcurrently.ts`: + +```typescript +/** + * Runs `fn` over `items` with at most `limit` promises in flight. The first + * rejection propagates; work already started keeps running to completion. + */ +export async function runConcurrently( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const queue = [...items]; + const size = Math.max(1, Math.min(limit, queue.length)); + const workers: Promise[] = []; + + for (let i = 0; i < size; i++) { + workers.push( + (async () => { + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + await fn(next); + } + })() + ); + } + + await Promise.all(workers); +} +``` + +`src/features/FixLive/cmsEntryGuards.ts`: + +```typescript +import { isCmsEntry } from "~/domain/transform/filters.js"; +import type { BaseRecord } from "~/domain/transform/types/records.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; + +// Mirrors INTERNAL_MODELS in src/transformers/cms/addLiveField.ts. File Manager +// has no publishing, so its rows never receive `live` and are never reconciled. +const INTERNAL_MODELS = new Set(["fmfile", "wbyfmfile"]); + +/** isCmsEntry matches on TYPE prefix or PK containing "#CMS#CME#"; both work on raw rows. */ +export function isCmsEntryRow(row: DatabaseRecord): boolean { + return isCmsEntry(row as BaseRecord); +} + +export function isInternalModel(modelId: unknown): boolean { + return typeof modelId === "string" && INTERNAL_MODELS.has(modelId.toLowerCase()); +} + +/** Root first (v5 shape), then `data` (v6 shape). */ +export function readModelId(record: DatabaseRecord): unknown { + if (record.modelId !== undefined) { + return record.modelId; + } + const data = record.data as Record | undefined; + return data?.modelId; +} +``` + +- [ ] **Step 3: Base runner** + +`src/features/FixLive/BaseLiveFieldRunner.ts`: + +```typescript +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import type { Logger } from "~/tools/Logger/abstractions/Logger.js"; +import type { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import type { LiveFieldRunner } from "./abstractions/LiveFieldRunner.ts"; +import type { ChangeReport } from "./abstractions/ChangeReport.ts"; +import { createEmptyStats } from "./createEmptyStats.ts"; +import { runConcurrently } from "./runConcurrently.ts"; +import { isCmsEntryRow } from "./cmsEntryGuards.ts"; + +const DEFAULT_SEGMENT_CONCURRENCY = 4; +const DEFAULT_WRITE_CONCURRENCY = 8; +const LATEST_SK = "L"; +const MD_ATTRIBUTE = "_md"; + +export interface ReadyGroup { + kind: "ready"; + records: Map; +} + +/** Not a reconcilable entry (e.g. File Manager row) — counted as scanned only. */ +export interface IgnoredGroup { + kind: "ignored"; +} + +export interface SkippedGroup { + kind: "skipped"; + reason: LiveFieldReconciler.SkipReason; + detail?: string; +} + +export type GroupPreparation = ReadyGroup | IgnoredGroup | SkippedGroup; + +export interface AttributeWrite { + path: string[]; + value: unknown; +} + +interface SegmentRun { + segment: number; + totalSegments: number; +} + +/** + * Scan → group → decide → write loop shared by the DDB and OS runners. + * Scans `L` rows per segment, queries the full PK for the authoritative + * group (no reliance on scan ordering), and conditions every write on `_md`. + */ +export abstract class BaseLiveFieldRunner implements LiveFieldRunner.Interface { + protected abstract readonly table: LiveFieldReconciler.Table; + + protected constructor( + protected readonly reconciler: LiveFieldReconciler.Interface, + protected readonly logger: Logger.Interface + ) {} + + /** Cheap gate on the scanned L row before queryAll. */ + protected abstract acceptsRow(row: DatabaseRecord): boolean; + + /** Turns the queryAll result into reconciler records (OS: decompress). */ + protected abstract prepareGroup(pk: string, rows: DatabaseRecord[]): Promise; + + /** UpdateItem path + value for one change. */ + protected abstract buildWrite( + change: LiveFieldReconciler.Change, + record: LiveFieldReconciler.Record + ): Promise; + + public async run(options: LiveFieldRunner.Options): Promise { + const stats = createEmptyStats(); + const segments: SegmentRun[] = []; + for (let segment = 0; segment < options.target.segments; segment++) { + segments.push({ segment, totalSegments: options.target.segments }); + } + const concurrency = options.target.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY; + + await runConcurrently(segments, concurrency, run => this.runSegment(run, options, stats)); + + options.onProgress(stats); + return stats; + } + + private async runSegment( + run: SegmentRun, + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats + ): Promise { + const { client, tableName } = options.target; + const rows = client.scan(tableName, { + segment: run.segment, + totalSegments: run.totalSegments, + sortKeyEquals: LATEST_SK + }); + + for await (const row of rows) { + stats.scanned++; + if (!isCmsEntryRow(row) || !this.acceptsRow(row)) { + options.onProgress(stats); + continue; + } + + const groupRows = await client.queryAll(tableName, row.PK); + const prepared = await this.prepareGroup(row.PK, groupRows); + if (prepared.kind === "ignored") { + options.onProgress(stats); + continue; + } + + stats.entries++; + if (prepared.kind === "skipped") { + this.recordSkip(options, stats, { + pk: row.PK, + sk: LATEST_SK, + reason: prepared.reason, + detail: prepared.detail + }); + options.onProgress(stats); + continue; + } + + const decision = this.reconciler.decide({ + pk: row.PK, + table: this.table, + records: prepared.records + }); + for (const skip of decision.skips) { + this.recordSkip(options, stats, skip); + } + await this.applyChanges(decision.changes, prepared.records, options, stats); + options.onProgress(stats); + } + + this.logger.debug( + `fix-live[${this.table}]: segment ${run.segment + 1}/${run.totalSegments} done — ${stats.scanned} rows scanned so far` + ); + } + + private async applyChanges( + changes: LiveFieldReconciler.Change[], + records: Map, + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats + ): Promise { + for (const change of changes) { + stats.changes[change.reason]++; + } + if (options.mode === "dry-run") { + for (const change of changes) { + options.report.change(this.toReportChange(change, "dry-run")); + } + return; + } + const writeConcurrency = options.target.writeConcurrency ?? DEFAULT_WRITE_CONCURRENCY; + await runConcurrently(changes, writeConcurrency, change => + this.write(change, records, options, stats) + ); + } + + private async write( + change: LiveFieldReconciler.Change, + records: Map, + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats + ): Promise { + const record = records.get(change.sk); + if (!record) { + throw new Error( + `fix-live: decide() emitted a change for ${change.pk} ${change.sk}, which is not in the group` + ); + } + const { path, value } = await this.buildWrite(change, record); + const result = await options.target.client.updateAttribute(options.target.tableName, { + key: { PK: change.pk, SK: change.sk }, + path, + value, + condition: { attribute: MD_ATTRIBUTE, equals: change.expectedMd } + }); + + if (result === "written") { + stats.written++; + options.report.change(this.toReportChange(change, "written")); + return; + } + stats.conditionFailed++; + options.report.change(this.toReportChange(change, "condition-failed")); + this.recordSkip(options, stats, { pk: change.pk, sk: change.sk, reason: "changed-during-run" }); + } + + private recordSkip( + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats, + skip: LiveFieldReconciler.Skip + ): void { + stats.skips[skip.reason]++; + options.report.skip({ + table: this.table, + pk: skip.pk, + sk: skip.sk, + reason: skip.reason, + detail: skip.detail + }); + } + + private toReportChange( + change: LiveFieldReconciler.Change, + result: ChangeReport.Result + ): ChangeReport.Change { + return { + table: this.table, + pk: change.pk, + sk: change.sk, + reason: change.reason, + before: change.before, + after: change.after, + result + }; + } +} +``` + +- [ ] **Step 4: DDB runner** + +`src/features/FixLive/DdbLiveFieldRunner.ts`: + +```typescript +import { DdbLiveFieldRunner as DdbLiveFieldRunnerAbstraction } from "./abstractions/LiveFieldRunner.ts"; +import { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import { Logger } from "~/tools/Logger/abstractions/Logger.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { BaseLiveFieldRunner, type AttributeWrite, type GroupPreparation } from "./BaseLiveFieldRunner.ts"; +import { isInternalModel, readModelId } from "./cmsEntryGuards.ts"; + +export type { ILiveFieldRunner } from "./abstractions/LiveFieldRunner.js"; + +class DdbLiveFieldRunnerImpl extends BaseLiveFieldRunner { + protected readonly table: LiveFieldReconciler.Table = "ddb"; + + public constructor(reconciler: LiveFieldReconciler.Interface, logger: Logger.Interface) { + super(reconciler, logger); + } + + protected acceptsRow(row: DatabaseRecord): boolean { + return !isInternalModel(readModelId(row)); + } + + protected async prepareGroup(_pk: string, rows: DatabaseRecord[]): Promise { + const records = new Map(); + for (const row of rows) { + records.set(row.SK, toReconcilable(row)); + } + return { kind: "ready", records }; + } + + protected async buildWrite(change: LiveFieldReconciler.Change): Promise { + return { path: ["data", "live"], value: change.after }; + } +} + +function toReconcilable(row: DatabaseRecord): LiveFieldReconciler.Record { + const data = row.data; + return { + ...row, + // v6 always writes _md; an absent value can never satisfy the write condition. + _md: typeof row._md === "string" ? row._md : "", + data: typeof data === "object" && data !== null ? (data as Record) : {} + }; +} + +export const DdbLiveFieldRunner = DdbLiveFieldRunnerAbstraction.createImplementation({ + implementation: DdbLiveFieldRunnerImpl, + dependencies: [LiveFieldReconciler, Logger] +}); +``` + +- [ ] **Step 5: Register** + +`feature.ts`: add `import { DdbLiveFieldRunner } from "./DdbLiveFieldRunner.ts";` and `container.register(DdbLiveFieldRunner).inSingletonScope();`. + +`src/bootstrap.ts`: add `import { FixLiveFeature } from "~/features/FixLive/index.js";` and, after `AccessCheckerFeature.register(container);`, add `FixLiveFeature.register(container);`. + +- [ ] **Step 6: Verify** + +```bash +yarn vitest run __tests__/features/FixLive && yarn ts-check && yarn lint +``` + +--- + +### Task 8: `DdbLiveFieldRunner` dynalite integration test + +**Files:** +- Test: `__tests__/integration/fixLive.ddbRunner.test.ts` + +**Interfaces:** +- Consumes: `startDynalite`, `waitForTableActive`, `DynamoDbClientImpl` (constructed directly with `endpoint`, like `failedBatchLogging.test.ts`), `createFixLiveContainer`, `MockChangeReport`. + +- [ ] **Step 1: Write the test** + +```typescript +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { DynamoDBClient, CreateTableCommand } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocument, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import { DynamoDbClientImpl } from "~/services/DynamoDbClient/DynamoDbClient.js"; +import type { SourceDynamoDbClient } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { DdbLiveFieldRunner } from "~/features/FixLive/index.js"; +import type { LiveFieldRunner } from "~/features/FixLive/abstractions/LiveFieldRunner.js"; +import { startDynalite, waitForTableActive, type DynaliteInstance } from "./dynalite.ts"; +import { NoopLogger } from "../helpers/NoopLogger.ts"; +import { createFixLiveContainer } from "../features/FixLive/fixLiveContainer.ts"; +import { MockChangeReport } from "../features/FixLive/MockChangeReport.ts"; + +const FAKE_CREDS = { accessKeyId: "test", secretAccessKey: "test" }; +const TABLE = "fix-live-ddb"; +const PK_A = "T#root#CMS#CME#a"; +const PK_B = "T#root#CMS#CME#b"; + +interface SeedRow { + PK: string; + SK: string; + TYPE: string; + _et: string; + _ct: string; + _md: string; + data: Record; +} + +function row(pk: string, sk: string, data: Record): SeedRow { + return { + PK: pk, + SK: sk, + TYPE: sk === "P" ? "cms.entry.p" : sk === "L" ? "cms.entry.l" : "cms.entry", + _et: "CmsEntries", + _ct: "2026-01-01T00:00:00.000Z", + _md: "2026-01-01T00:00:00.000Z", + data: { modelId: "blogPost", values: { emptyString: "" }, ...data } + }; +} + +const SEED: SeedRow[] = [ + row(PK_A, "L", { version: 3, status: "draft" }), + row(PK_A, "P", { version: 2, status: "published" }), + row(PK_A, "REV#0002", { version: 2, status: "published" }), + row(PK_A, "REV#0003", { version: 3, status: "draft" }), + row(PK_B, "L", { version: 1, status: "unpublished", live: { version: 1 } }), + row(PK_B, "REV#0001", { version: 1, live: { version: 1 } }) +]; + +async function createTable(doc: DynamoDBDocument, tableName: string): Promise { + await doc.send( + new CreateTableCommand({ + TableName: tableName, + BillingMode: "PAY_PER_REQUEST", + AttributeDefinitions: [ + { AttributeName: "PK", AttributeType: "S" }, + { AttributeName: "SK", AttributeType: "S" } + ], + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, + { AttributeName: "SK", KeyType: "RANGE" } + ] + }) + ); + await waitForTableActive(doc, tableName); +} + +async function scanAll(doc: DynamoDBDocument, tableName: string): Promise { + const response = await doc.send(new ScanCommand({ TableName: tableName })); + return (response.Items ?? []) as SeedRow[]; +} + +/** Test hook: bumps `_md` on one key right before the runner's write reaches DynamoDB. */ +class MdBumpingClient implements SourceDynamoDbClient.Interface { + public constructor( + private readonly inner: SourceDynamoDbClient.Interface, + private readonly doc: DynamoDBDocument, + private readonly targetSk: string + ) {} + + public scan(tableName: string, options?: SourceDynamoDbClient.Scan) { + return this.inner.scan(tableName, options); + } + public query(t: string, pk: string, sk?: string, o?: SourceDynamoDbClient.Query) { + return this.inner.query(t, pk, sk, o); + } + public queryAll(t: string, pk: string, sk?: string, o?: SourceDynamoDbClient.Query) { + return this.inner.queryAll(t, pk, sk, o); + } + public get(t: string, pk: string, sk: string) { + return this.inner.get(t, pk, sk); + } + public batchPut(t: string, records: T[]) { + return this.inner.batchPut(t, records); + } + public async updateAttribute(tableName: string, request: SourceDynamoDbClient.UpdateRequest) { + if (request.key.SK === this.targetSk) { + await this.doc.update({ + TableName: tableName, + Key: request.key, + UpdateExpression: "SET #md = :md", + ExpressionAttributeNames: { "#md": "_md" }, + ExpressionAttributeValues: { ":md": "2026-09-04T00:00:00.000Z" } + }); + } + return this.inner.updateAttribute(tableName, request); + } +} + +describe("DdbLiveFieldRunner against dynalite", () => { + let instance: DynaliteInstance; + let doc: DynamoDBDocument; + let client: DynamoDbClientImpl; + + beforeAll(async () => { + instance = await startDynalite(); + doc = DynamoDBDocument.from( + new DynamoDBClient({ endpoint: instance.endpoint, region: "us-east-1", credentials: FAKE_CREDS }) + ); + await createTable(doc, TABLE); + for (const item of SEED) { + await doc.put({ TableName: TABLE, Item: item }); + } + client = new DynamoDbClientImpl( + { region: "us-east-1", credentials: FAKE_CREDS, endpoint: instance.endpoint }, + new NoopLogger() + ); + }); + + afterAll(async () => { + await instance.stop(); + }); + + function run(mode: LiveFieldRunner.Mode, useClient: SourceDynamoDbClient.Interface = client) { + const report = new MockChangeReport(); + return createFixLiveContainer() + .resolve(DdbLiveFieldRunner) + .run({ + mode, + target: { client: useClient, tableName: TABLE, segments: 2 }, + report, + onProgress: () => {} + }) + .then(stats => ({ stats, report })); + } + + it("dry run reports 4 changes and leaves the table unchanged", async () => { + const before = await scanAll(doc, TABLE); + const { stats, report } = await run("dry-run"); + + expect(stats.scanned).toBe(2); + expect(stats.entries).toBe(2); + expect(stats.changes["missing-live"]).toBe(3); + expect(stats.changes["stale-live"]).toBe(1); + expect(report.changes.map(c => c.result)).toEqual(["dry-run", "dry-run", "dry-run", "dry-run"]); + expect(await scanAll(doc, TABLE)).toEqual(before); + }); + + it("live run writes data.live only and keeps an empty string byte-identical", async () => { + const { stats } = await run("live"); + + expect(stats.written).toBe(4); + expect(stats.conditionFailed).toBe(0); + const rows = await scanAll(doc, TABLE); + const data = (pk: string, sk: string) => rows.find(r => r.PK === pk && r.SK === sk)!.data; + expect(data(PK_A, "L").live).toEqual({ version: 2 }); + expect(data(PK_A, "P").live).toEqual({ version: 2 }); + expect(data(PK_A, "REV#0002").live).toEqual({ version: 2 }); + expect(data(PK_A, "REV#0003").live).toBeUndefined(); + expect(data(PK_B, "L").live).toBeNull(); + expect((data(PK_A, "L").values as Record).emptyString).toBe(""); + expect(rows.every(r => r._md === "2026-01-01T00:00:00.000Z")).toBe(true); + + const again = await run("dry-run"); + expect(again.report.changes).toEqual([]); + }); + + it("a record whose _md changed between read and write is reported as changed-during-run", async () => { + await doc.update({ + TableName: TABLE, + Key: { PK: PK_A, SK: "P" }, + UpdateExpression: "SET #d.#l = :empty", + ExpressionAttributeNames: { "#d": "data", "#l": "live" }, + ExpressionAttributeValues: { ":empty": {} } + }); + + const { stats, report } = await run("live", new MdBumpingClient(client, doc, "P")); + + expect(stats.changes["empty-live"]).toBe(1); + expect(stats.written).toBe(0); + expect(stats.conditionFailed).toBe(1); + expect(report.skips).toContainEqual({ + table: "ddb", + pk: PK_A, + sk: "P", + reason: "changed-during-run", + detail: undefined + }); + }); +}); +``` + +- [ ] **Step 2: Verify** + +```bash +yarn vitest run __tests__/integration/fixLive.ddbRunner.test.ts +``` + +--- + +### Task 9: `OsLiveFieldRunner` + unit test + +**Files:** +- Create: `src/features/FixLive/OsLiveFieldRunner.ts` +- Modify: `src/features/FixLive/feature.ts` +- Test: `__tests__/features/FixLive/OsLiveFieldRunner.test.ts` + +**Interfaces:** +- Consumes: `OsRecordDecompressor.Interface`, `CompressionHandler.Interface`. +- Produces: `OsLiveFieldRunner` token bound; writes `path: ["data"]` with the recompressed blob. + +- [ ] **Step 1: Failing unit test** + +```typescript +import { describe, it, expect } from "vitest"; +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { OsLiveFieldRunner } from "~/features/FixLive/index.js"; +import { MockDynamoDbClient } from "../../services/DynamoDbClient/MockDynamoDbClient.ts"; +import { createFixLiveContainer } from "./fixLiveContainer.ts"; +import { MockChangeReport } from "./MockChangeReport.ts"; + +const TABLE = "v6-os"; +const PK = "T#root#L#en-US#CMS#CME#a"; +const INDEX = "root-headless-cms-en-us-blogpost"; + +describe("OsLiveFieldRunner", () => { + it("decompresses, decides, and rewrites only live inside the blob", async () => { + const container = createFixLiveContainer(); + const compression = container.resolve(CompressionHandler); + const latestInner = { modelId: "blogPost", version: 3, status: "draft", live: {}, values: { a: "" } }; + const publishedInner = { modelId: "blogPost", version: 2, status: "published", live: { version: 2 } }; + const client = new MockDynamoDbClient({ + [TABLE]: [ + { PK, SK: "L", index: INDEX, data: await compression.compress(latestInner), _md: "md-1" }, + { PK, SK: "P", index: INDEX, data: await compression.compress(publishedInner), _md: "md-1" }, + { + PK: "T#root#L#en-US#CMS#CME#file", + SK: "L", + index: "root-headless-cms-en-us-fmfile", + data: await compression.compress({ modelId: "fmFile", version: 1, status: "draft" }), + _md: "md-1" + }, + { + PK: "T#root#L#en-US#CMS#CME#corrupt", + SK: "L", + index: INDEX, + data: { compression: "gzip", value: "not-gzip" }, + _md: "md-1" + } + ] + }); + const report = new MockChangeReport(); + + const stats = await container.resolve(OsLiveFieldRunner).run({ + mode: "live", + target: { client, tableName: TABLE, segments: 1 }, + report, + onProgress: () => {} + }); + + expect(stats.scanned).toBe(3); + expect(stats.entries).toBe(2); + expect(stats.changes["empty-live"]).toBe(1); + expect(stats.skips["decompress-failed"]).toBe(1); + expect(stats.written).toBe(1); + + const call = client.updateCalls[0]!; + expect(call.request.key).toEqual({ PK, SK: "L" }); + expect(call.request.path).toEqual(["data"]); + expect(call.request.condition).toEqual({ attribute: "_md", equals: "md-1" }); + const rewritten = await compression.decompress>(call.request.value); + expect(rewritten).toEqual({ ...latestInner, live: { version: 2 } }); + expect(report.changes[0]).toMatchObject({ table: "os", sk: "L", reason: "empty-live", before: {} }); + }); +}); +``` + +- [ ] **Step 2: Implement** + +`src/features/FixLive/OsLiveFieldRunner.ts`: + +```typescript +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { OsLiveFieldRunner as OsLiveFieldRunnerAbstraction } from "./abstractions/LiveFieldRunner.ts"; +import { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import { OsRecordDecompressor } from "~/features/OsRecordDecompressor/abstractions/OsRecordDecompressor.js"; +import { Logger } from "~/tools/Logger/abstractions/Logger.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { BaseLiveFieldRunner, type AttributeWrite, type GroupPreparation } from "./BaseLiveFieldRunner.ts"; +import { isInternalModel } from "./cmsEntryGuards.ts"; + +export type { ILiveFieldRunner } from "./abstractions/LiveFieldRunner.js"; + +const LATEST_SK = "L"; + +class OsLiveFieldRunnerImpl extends BaseLiveFieldRunner { + protected readonly table: LiveFieldReconciler.Table = "os"; + + public constructor( + reconciler: LiveFieldReconciler.Interface, + logger: Logger.Interface, + private readonly decompressor: OsRecordDecompressor.Interface, + private readonly compression: CompressionHandler.Interface + ) { + super(reconciler, logger); + } + + /** modelId lives inside the blob — the internal-model check happens after decompression. */ + protected acceptsRow(_row: DatabaseRecord): boolean { + return true; + } + + protected async prepareGroup(_pk: string, rows: DatabaseRecord[]): Promise { + const records = new Map(); + for (const row of rows) { + const data = await this.decompressRow(row); + if (data === null) { + return { kind: "skipped", reason: "decompress-failed", detail: `SK=${row.SK}` }; + } + records.set(row.SK, { + ...row, + _md: typeof row._md === "string" ? row._md : "", + data + }); + } + const latest = records.get(LATEST_SK); + if (latest && isInternalModel(latest.data.modelId)) { + return { kind: "ignored" }; + } + return { kind: "ready", records }; + } + + /** + * The whole blob is one attribute, so it is replaced as a unit. Its + * decompressed content differs from what was read only in `live`. + */ + protected async buildWrite( + change: LiveFieldReconciler.Change, + record: LiveFieldReconciler.Record + ): Promise { + const data = { ...record.data, live: change.after }; + const compressed = await this.compression.compress(data); + return { path: ["data"], value: compressed }; + } + + private async decompressRow(row: DatabaseRecord): Promise | null> { + try { + return await this.decompressor.decompress(row as OsRecordDecompressor.Compressed); + } catch (error) { + this.logger.warn(`fix-live[os]: failed to decompress ${row.PK} ${row.SK}: ${String(error)}`); + return null; + } + } +} + +export const OsLiveFieldRunner = OsLiveFieldRunnerAbstraction.createImplementation({ + implementation: OsLiveFieldRunnerImpl, + dependencies: [LiveFieldReconciler, Logger, OsRecordDecompressor, CompressionHandler] +}); +``` + +`feature.ts`: add `import { OsLiveFieldRunner } from "./OsLiveFieldRunner.ts";` and `container.register(OsLiveFieldRunner).inSingletonScope();`. Final `feature.ts` registers, in order: `LiveFieldReconciler`, `ChangeReport`, `FixLiveState`, `DdbLiveFieldRunner`, `OsLiveFieldRunner`. + +- [ ] **Step 3: Verify** + +```bash +yarn vitest run __tests__/features/FixLive && yarn ts-check && yarn lint +``` + +--- + +### Task 10: `OsLiveFieldRunner` dynalite integration test + +**Files:** +- Test: `__tests__/integration/fixLive.osRunner.test.ts` + +**Interfaces:** +- Consumes: same harness as Task 8 plus `CompressionHandler` resolved from `createFixLiveContainer()`. + +- [ ] **Step 1: Write the test** + +```typescript +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { DynamoDBClient, CreateTableCommand } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocument, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { DynamoDbClientImpl } from "~/services/DynamoDbClient/DynamoDbClient.js"; +import { OsLiveFieldRunner } from "~/features/FixLive/index.js"; +import type { LiveFieldRunner } from "~/features/FixLive/abstractions/LiveFieldRunner.js"; +import { startDynalite, waitForTableActive, type DynaliteInstance } from "./dynalite.ts"; +import { NoopLogger } from "../helpers/NoopLogger.ts"; +import { createFixLiveContainer } from "../features/FixLive/fixLiveContainer.ts"; +import { MockChangeReport } from "../features/FixLive/MockChangeReport.ts"; + +const FAKE_CREDS = { accessKeyId: "test", secretAccessKey: "test" }; +const TABLE = "fix-live-os"; +const PK = "T#root#L#en-US#CMS#CME#a"; +const INDEX = "root-headless-cms-en-us-blogpost"; +const MD = "2026-01-01T00:00:00.000Z"; + +interface OsRow { + PK: string; + SK: string; + index: string; + data: unknown; + _ct: string; + _et: string; + _md: string; +} + +const LATEST_INNER = { modelId: "blogPost", version: 3, status: "draft", live: {}, values: { s: "" } }; +const PUBLISHED_INNER = { modelId: "blogPost", version: 2, status: "published", live: { version: 2 } }; + +describe("OsLiveFieldRunner against dynalite", () => { + let instance: DynaliteInstance; + let doc: DynamoDBDocument; + let client: DynamoDbClientImpl; + const container = createFixLiveContainer(); + const compression = container.resolve(CompressionHandler); + + beforeAll(async () => { + instance = await startDynalite(); + doc = DynamoDBDocument.from( + new DynamoDBClient({ endpoint: instance.endpoint, region: "us-east-1", credentials: FAKE_CREDS }) + ); + await doc.send( + new CreateTableCommand({ + TableName: TABLE, + BillingMode: "PAY_PER_REQUEST", + AttributeDefinitions: [ + { AttributeName: "PK", AttributeType: "S" }, + { AttributeName: "SK", AttributeType: "S" } + ], + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, + { AttributeName: "SK", KeyType: "RANGE" } + ] + }) + ); + await waitForTableActive(doc, TABLE); + const rows: OsRow[] = [ + { PK, SK: "L", index: INDEX, data: await compression.compress(LATEST_INNER), _ct: MD, _et: "CmsEntriesElasticsearch", _md: MD }, + { PK, SK: "P", index: INDEX, data: await compression.compress(PUBLISHED_INNER), _ct: MD, _et: "CmsEntriesElasticsearch", _md: MD } + ]; + for (const item of rows) { + await doc.put({ TableName: TABLE, Item: item }); + } + client = new DynamoDbClientImpl( + { region: "us-east-1", credentials: FAKE_CREDS, endpoint: instance.endpoint }, + new NoopLogger() + ); + }); + + afterAll(async () => { + await instance.stop(); + }); + + function run(mode: LiveFieldRunner.Mode) { + const report = new MockChangeReport(); + return container + .resolve(OsLiveFieldRunner) + .run({ mode, target: { client, tableName: TABLE, segments: 1 }, report, onProgress: () => {} }) + .then(stats => ({ stats, report })); + } + + async function readRows(): Promise { + const response = await doc.send(new ScanCommand({ TableName: TABLE })); + return (response.Items ?? []) as OsRow[]; + } + + it("dry run reports empty-live on L and changes nothing", async () => { + const before = await readRows(); + const { stats, report } = await run("dry-run"); + + expect(stats.entries).toBe(1); + expect(stats.changes["empty-live"]).toBe(1); + expect(report.changes).toEqual([ + expect.objectContaining({ table: "os", sk: "L", reason: "empty-live", before: {}, after: { version: 2 }, result: "dry-run" }) + ]); + expect(await readRows()).toEqual(before); + }); + + it("live run rewrites the L blob with only live changed and leaves root attributes alone", async () => { + const { stats } = await run("live"); + + expect(stats.written).toBe(1); + const rows = await readRows(); + const latest = rows.find(r => r.SK === "L")!; + const decompressed = await compression.decompress>(latest.data); + expect(decompressed).toEqual({ ...LATEST_INNER, live: { version: 2 } }); + expect(latest._md).toBe(MD); + expect(latest.index).toBe(INDEX); + expect(rows.find(r => r.SK === "P")!.data).toEqual(await compression.compress(PUBLISHED_INNER)); + + const again = await run("dry-run"); + expect(again.report.changes).toEqual([]); + }); +}); +``` + +Note: the `P` blob equality assertion holds because gzip output is deterministic for identical input on one platform and the runner never rewrites `P` here (it was already correct). + +- [ ] **Step 2: Verify** + +```bash +yarn vitest run __tests__/integration/fixLive.osRunner.test.ts +``` + +--- + +### Task 11: Full verification + +**Files:** +- No new files. Fix whatever the checks surface. + +- [ ] **Step 1: Run the full check set** + +```bash +yarn npm audit && yarn format:fix && yarn ts-check && yarn test:coverage && yarn lint && yarn check:imports +``` + +Expect: no audit suggestions, 0 type errors, all tests green with coverage thresholds met, 0 lint errors, 0 import errors. + +- [ ] **Step 2: Confirm the golden is untouched and the public API is unchanged** + +```bash +git status --short __tests__/data src/index.ts +``` + +Neither `__tests__/data/small-one.expected.json` nor `src/index.ts` may appear. + +- [ ] **Step 3: Review `git status`** + +Expected changed/added: `src/features/OsProcessor/OsProcessor.ts`, `src/transformers/cms/addLiveField.ts`, `src/services/DynamoDbClient/**`, `src/tools/FileTool/**`, `src/features/FixLive/**`, `src/bootstrap.ts`, `docs/hard-won-decisions.md`, `.changeset/fix-live-field-os-lane.md`, and the tests listed per task. Commit only when asked; the sibling plan (command menu, `FixLiveCommand`, v6 guard step, guides) builds on the `DdbLiveFieldRunner` / `OsLiveFieldRunner` / `ChangeReport` / `FixLiveState` tokens defined in Task 4. diff --git a/docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md b/docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md new file mode 100644 index 00000000..d345cfdd --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-fix-live-field-and-command-menu-design.md @@ -0,0 +1,574 @@ +# Fix Live Field Reconciler & CLI Command Menu — Design + +**Status:** Approved 2026-09-04 (revised after review). Plans: `docs/superpowers/plans/2026-09-04-fix-live-reconciler.md`, `docs/superpowers/plans/2026-09-04-cli-command-menu.md`. +**Date:** 2026-09-04 +**Builds on:** `docs/superpowers/specs/2026-04-22-os-transfer-preset-design.md` (`addLiveField`, `OsProcessor`), `docs/superpowers/specs/2026-05-08-guided-env-setup-design.md` (`TransferWizard`, projects dir), `docs/superpowers/specs/2026-04-23-dropped-record-log-design.md` (`.transfer//` file layout), `docs/superpowers/specs/2026-04-19-aws-retry-design.md`. + +--- + +## Goal + +Two deliverables, shipped together: + +1. **`fix-live` command** — a reconciler that scans an already-migrated v6 system and makes every CMS entry's `live` field consistent with its published state. Covers the DynamoDB table always, and the OpenSearch companion table when the system has one. Dry run first, live run only after a dry run has completed. +2. **CLI command menu** — `yarn transfer` with no arguments opens a guided menu over a registry of commands. Initial entries: the existing system-to-system transfer and the new `fix-live`. Prompts move from inquirer to `@clack/prompts` behind an abstraction so commands never touch a prompt library directly. + +A third, much smaller change lands first and independently: the root-cause fix in `addLiveField` so future OS migrations stop producing the damage the reconciler repairs. + +--- + +## Background + +### The bug + +`live` is `{ version: number } | null` on v6 CMS entry records. It tells v6 which revision is published without reading `P`. + +`addLiveField` (`src/transformers/cms/addLiveField.ts`) computes it during migration. For records that are not themselves the published revision it calls `ctx.querySourceRecord(PK, "P")` and reads `published.version` from the **root** of the returned record. + +- **DDB preset:** the source is the v5 main table. `version` is at the root. Correct. +- **OS preset:** the source is the v5 Elasticsearch companion table. `OsProcessor.querySourceRecord` (`src/features/OsProcessor/OsProcessor.ts:89-95`) returns the raw row: `{ PK, SK, index, data: { compression: "gzip", value }, _ct, _et, _md }`. `version` is inside the gzipped blob. The read returns `undefined`, so `data.live = { version: undefined }`, which JSON serialisation turns into `live: {}`. The entry is not live in the target OS index. + +The exact scenario is an entry whose latest revision is a draft on top of an older published revision. The `P` document is correct (short-circuit path), the `L` document, which v6 lists, is wrong. Side effect: `undefined` is never cached (truthiness check on `cache.get`), so every affected record re-queries the source. + +### What v6 actually maintains + +Verified in `webiny-js-next`: + +- `live` is declared entry-level: "Is this CMS Entry live (no matter the revision)" (`packages/api-headless-cms/src/types/types.ts:447`). +- **Publish** sets `live: { version }` on the record being published (`CreatePublishEntryDataFactory.ts:66`). The DDB storage operation writes it to that revision's `REV#` and to `P`, and, when publishing an older revision, rewrites `L` and the latest `REV#` with the entry-level meta fields (`api-headless-cms-ddb/src/operations/entry/index.ts:1140-1215`). The previously published `REV#` is rewritten with `status: unpublished` but keeps its **old** `live`. +- **Unpublish** sets `live: null` (`CreateUnpublishEntryDataFactory.ts:33`) and deletes `P`. +- **New revision / update** copies `live: originalEntry.live` (`CreateEntryRevisionFromDataFactory.ts:199`, `UpdateEntryDataFactory.ts:125`). + +So the invariant v6 keeps is: **`L`, `P`, and the published `REV#` carry `live: { version: published }`**. Every other `REV#` carries a best-effort copy that v6 itself leaves stale. The reconciler enforces exactly the maintained invariant and does not touch the other `REV#` records, because there is no authoritative value to write there. + +Confirmed facts from the discussion: + +- The status value is `"published"`. +- An `L` record **may** legitimately carry `status: "published"`. The admin UI reads `L` only and needs to know. +- File Manager has no publishing. FM records never get `live`. That is correct behaviour, not a gap. + +--- + +## Scope + +### In scope + +- **Transformer fix.** `addLiveField` reads `version` from the decompressed source record when the source is OS-shaped. Cache check no longer relies on truthiness. New OS-shaped fixture and a draft-over-published test. +- **`LiveFieldReconciler`** — pure decision logic over all records of one PK. Exhaustively unit tested. +- **`FixLiveCommand`** — the guided flow: project → system → v6 guard → metadata confirm → dry-run/live choice → run → summary. +- **DDB runner** — parallel segment scan for `L` records, one `queryAll(PK)` per entry, conditional `UpdateItem` writes. +- **OS runner** — same shape over the OS companion table, with decompress → patch → recompress. +- **Dry-run state** under `.transfer/state/`, and a JSONL change report per run under `.transfer//`. +- **Command registry + clack menu**, `Prompts` and `UI` abstractions with stub implementations, existing `TransferWizard` moved under the registry. Existing `yarn transfer --config --preset` and `yarn transfer ` invocations keep working. +- **`IDynamoDbClient` additions:** `updateAttribute` (conditional `UpdateItem`), `ScanOptions.limit`, `ScanOptions.sortKeyEquals`. +- Guides updated: `commands.md`, `troubleshooting.md`. + +### Out of scope + +- Reconciling `live` on `REV#` records other than the published one (see "What v6 actually maintains"). +- Reconciling anything other than `live`, or any record that is not a CMS entry. FM, ACO, Form Builder untouched. +- Fixing the v6 OpenSearch **index** directly. The OS companion table is the write path; v6's indexer picks up table changes through its stream. See open question 2. +- Multi-process worker orchestration. Single process, parallel segments. +- Replacing inquirer inside `init` / `initProject`. They keep working and can migrate to `Prompts` later. +- Rollback. Every write is idempotent; re-running is the recovery path. + +--- + +## Decisions + +| # | Decision | Rationale | +| --- | --- | --- | +| 1 | Detect v6 on the DDB table and refuse otherwise. Marker: CMS entry `L` record has a `data` object at the root. v5 keeps fields flat at the root. The OS table cannot be independently verified (v5 and v6 companion rows share the same outer and inner shape), so the OS runner runs only after the DDB guard passed for the same system. | Only v6 has `live`. Running against v5 would inject a foreign field. | +| 2 | OS table is patched in place, self-contained. `P` is looked up in the OS table, not derived from DDB. | Keeps each table's run independent. | +| 3 | Reconcile `L`, `P`, and the published `REV#` in both directions: fill missing, clear stale, correct wrong version. A clean dry run means those three records are in sync for every entry. Other `REV#` records are never written. | Matches the invariant v6 maintains. Writing anything else would exceed what the system of record guarantees. | +| 4 | Write only when certain. Any ambiguity is a `skipped` line with a reason, never a write. | Repair tool. A wrong "fix" is worse than no fix. | +| 5 | Live run recomputes from scratch. Dry run is a review gate, not a plan to replay. Soft warning when counts differ. No expiry. | Data may change between runs. | +| 6 | Console: spinner + summary counts only. JSONL report: every change and skip. | Tens of thousands of records. Console must stay readable. | +| 7 | Single process. Parallel DynamoDB scan segments from `pipeline.segments` in the project config, bounded by `--concurrency`. One report writer. | Repair tool does not need worker plumbing. | +| 8 | Only `data.live` changes. Writes use `UpdateItem` with a path expression, never a whole-record `PutItem`. | The document client is built with `convertEmptyValues: true` (`DynamoDbClient.ts:63-67`); a `PutItem` round-trip would turn every `""` into `NULL` and re-encode numbers. `UpdateItem` leaves untouched attributes byte-identical by DynamoDB's own guarantee. | +| 9 | Scan for `L` records only, then `queryAll(PK)` per entry to build the authoritative group. No reliance on scan ordering or PK locality. | DynamoDB does not document scan ordering. One query per entry is a bounded, predictable cost and removes an entire class of "group was incomplete" errors. | +| 10 | Condition every write on `_md` being unchanged since the read. `ConditionalCheckFailedException` is reported as `changed-during-run`, never retried. | Editors may publish during a run. Never overwrite a fresher record. | +| 11 | System selection shows region, DDB table, OS table, and account id, then a separate confirm. Source systems have no OS endpoint in the config schema (`unified.schema.ts:11-13`), so the endpoint is shown only for target. | A project can have two v6 systems (prod → dev copy). The v6 guard alone does not catch picking the wrong v6. | +| 12 | Unit tests on the pure decision function plus one dynalite integration test per table type. | Query grouping, conditional updates, and OS recompression cannot be covered by unit tests. | +| 13 | Command shape from `dependency-upgrader`, prompt abstractions from `prijevodi-online`. Yargs stays for flags. Exit 130 on cancel. | Non-interactive use must keep working for CI. | + +--- + +## Part 1 — Transformer fix + +### `addLiveField` + +Preferred: make `OsProcessor.querySourceRecord` return the **decompressed** record (via the already-injected `OsRecordDecompressor`) so every OS-lane transformer sees the same shape a DDB-lane transformer would. `addLiveField` then only needs an integer guard on `published.version`. + +Fallback, if changing the processor contract is judged too invasive: inside `addLiveField`, detect the compressed shape (`published.data?.compression`) and decompress with `ctx.compressionHandler.decompress` (available on `BaseTransformContext`, `BaseTransformContext.ts:19`) before reading `version`. Note that `published.data?.version` does **not** work as a fallback on its own, because `data` is `{ compression, value }` on the raw row. + +In both variants `resolvePublishedVersion` accepts only a positive integer. Anything else logs at `warn` with the PK, caches `NO_PUBLISHED_REVISION`, and yields `live: null`. The transformer never emits `{ version: undefined }`. + +Cache: replace `if (cached)` with `if (cached !== undefined)` (`Cache.get` returns `T | undefined`, `src/tools/Cache/abstractions/Cache.ts:4`). Update the "sentinel must be truthy" entry in `docs/hard-won-decisions.md` accordingly. + +### Tests + +- `__tests__/transformers/cms/addLiveField.test.ts`: OS-shaped source mock (gzipped `data`, no root `version`) for an `L` draft with `P` at version 2 → `live.version === 2`. +- Assert `live.version` is a number whenever `live` is non-null. +- Add a `status: "unpublished"` `L` case. +- Integration: `v5-to-v6-os` end to end with an `L` draft + `P` pair; expected OS document has `live: { version: 2 }`. Extend the golden expectation in `pipeline.preset.test.ts` or a sibling. + +--- + +## Part 2 — `fix-live` reconciler + +### 2.1 Record model + +DDB table, within one PK: + +| SK | Meaning | Present when | +| --- | --- | --- | +| `L` | Latest revision, what the admin UI lists | Always | +| `P` | Published revision | Entry is published | +| `REV#NNNN` | One per revision, `NNNN` = `String(version).padStart(4, "0")` (v6 `zeroPad`, so version 10000 → `REV#10000`) | One per revision | + +`data.version` is a positive integer on every record. `data.status` on `L` is `"draft"`, `"unpublished"`, or `"published"`. + +OS companion table: only `L` and `P`, each `{ PK, SK, index, data: { compression, value }, _ct, _et, _md }`. There is no root `TYPE`; it is inside the blob. `value` gzips the same `data` object the DDB record carries. + +**CMS entry detection.** `isCmsEntry` (`src/domain/transform/filters.ts:40`) also matches File Manager rows (PK `#CMS#CME#`, modelId `fmFile` / `wbyFmFile`). The reconciler applies the same exclusion `addLiveField` uses (`INTERNAL_MODELS`, `addLiveField.ts:7`). Everything else `isCmsEntry` matches (ACO search records, background tasks stored as CMS entries, mailer settings) is a regular CMS entry in v6 that received `live` during migration, and is reconciled like any other. + +### 2.2 `LiveFieldReconciler` (pure) + +```ts +export interface ILiveFieldReconciler { + decide(group: LiveFieldReconciler.Group): LiveFieldReconciler.Decision; +} + +export const LiveFieldReconciler = createAbstraction("FixLive/Reconciler"); + +export namespace LiveFieldReconciler { + export type Interface = ILiveFieldReconciler; + + export interface Group { + pk: string; + table: "ddb" | "os"; + records: Map; // keyed by SK; OS records already decompressed + } + + export interface LiveValue { + version: number; + } + + export interface Change { + pk: string; + sk: string; + before: unknown; // current data.live, verbatim + after: LiveValue | null; + reason: ChangeReason; + expectedMd: string; // _md at read time, for the write condition + } + + export interface Skip { + pk: string; + sk?: string; + reason: SkipReason; + detail?: string; + } + + export interface Decision { + changes: Change[]; + skips: Skip[]; + } + + export type ChangeReason = "missing-live" | "empty-live" | "wrong-version" | "stale-live"; + + export type SkipReason = + | "no-latest-record" + | "invalid-version" + | "revision-record-missing" + | "revision-version-mismatch" + | "latest-status-contradicts-published" + | "latest-status-contradicts-unpublished" + | "decompress-failed" + | "changed-during-run"; // emitted by the writer, not by decide() +} +``` + +`decide` is deterministic, synchronous, and performs no I/O. The runner guarantees the group is complete (see 2.3) before calling it. + +#### Decision algorithm + +``` +L = records["L"]; P = records["P"] +if no L → skip no-latest-record + +if no P: + if L.data.status === "published" → skip latest-status-contradicts-unpublished + expected = null + reconcile(L, expected) # only L; REV# records untouched +else: + v = P.data.version + if v not a positive integer → skip invalid-version + if L.data.status === "published" and L.data.version !== v + → skip latest-status-contradicts-published + if L.data.version === v and L.data.status !== "published" + → skip latest-status-contradicts-published + if table === "ddb": + rev = records["REV#" + pad(v)] + if no rev → skip revision-record-missing + if rev.data.version !== v → skip revision-version-mismatch + expected = { version: v } + reconcile(L, expected); reconcile(P, expected) + if table === "ddb": reconcile(rev, expected) + +reconcile(record, expected): + live = record.data.live + if expected === null: + if live is undefined or null → no change + else → change stale-live (after = null) # covers {} and {version} + else: + if live is undefined or null → change missing-live + if live is not an object with an integer version + → change empty-live + if live.version !== expected.version → change wrong-version + else → no change +``` + +A skip aborts the whole group: no partial writes for a PK. + +Only `data.live` is ever changed. Every `Change` carries `expectedMd` from the record as read so the writer can build its condition. + +### 2.3 Runners + +One abstraction, two implementations: + +```ts +export interface ILiveFieldRunner { + run(options: LiveFieldRunner.Options): Promise; +} + +export namespace LiveFieldRunner { + export type Interface = ILiveFieldRunner; + export type Mode = "dry-run" | "live"; + + export interface Options { + mode: Mode; + report: ChangeReport.Interface; + onProgress(stats: Stats): void; + } + + export interface Stats { + scanned: number; + entries: number; + changes: Record; + skips: Record; + written: number; + conditionFailed: number; + } +} +``` + +| Runner | Table | Read | Write | +| --- | --- | --- | --- | +| `DdbLiveFieldRunner` | `.dynamodb.tableName` | Scan `L` rows per segment, `queryAll(PK)` per entry | `updateAttribute` per change | +| `OsLiveFieldRunner` | `.opensearch.tableName` | Same, then decompress `data` on every record | Recompress, `updateAttribute` per change | + +The runner receives the `IDynamoDbClient` instance for the chosen system. Bootstrap already binds `SourceDynamoDbClient` and `TargetDynamoDbClient`; `FixLiveCommand` resolves the one matching `--system` and passes it in. Project config is loaded the same way `run/handler.ts` does today: `discoverConfig` → `loadConfig` → `bootstrap({ config, runId })`. + +#### Read path + +Per segment, with `--concurrency` segments in flight (default 4): + +1. `client.scan(table, { segment, totalSegments, sortKeyEquals: "L" })`. The filter is server-side (`FilterExpression SK = :l`); it does not reduce consumed read capacity but does cut transfer and per-item work. +2. Skip rows where `!isCmsEntry(row)` or the modelId is in `INTERNAL_MODELS`. Count as `scanned`, not as `entries`. +3. `client.queryAll(table, row.PK)` → the authoritative group. Count as `entries`. +4. OS only: decompress `data` on every record in the group. Any failure → `skip decompress-failed` for the whole PK. +5. `reconciler.decide(group)`. Append every `Change` and `Skip` to the report. In `live` mode hand changes to the writer. + +Memory is bounded by the largest single group, which is one entry's revision count. + +#### Write path + +New method on `IDynamoDbClient`: + +```ts +updateAttribute(tableName: string, request: UpdateAttributeRequest): Promise; + +export interface UpdateAttributeRequest { + key: { PK: string; SK: string }; + path: string[]; // e.g. ["data", "live"] + value: unknown; // marshalled as-is; null allowed + condition: { attribute: string; equals: unknown }; +} + +export type UpdateAttributeResult = "written" | "condition-failed"; +``` + +Implemented with `UpdateCommand`, `UpdateExpression: "SET #p0.#p1 = :v"`, `ConditionExpression: "#c = :c"`. `ConditionalCheckFailedException` returns `"condition-failed"`; every other error propagates through the existing `executeWithRetry` wrapper. `ConditionalCheckFailedException` is not in the retryable set (`isRetryableAwsError.ts`), so it is never retried. + +- **DDB:** `path: ["data", "live"]`, `value: change.after`. +- **OS:** the runner re-serialises the decompressed `data` with only `live` replaced, compresses it with `CompressionHandler.compress`, and writes `path: ["data"]`, `value: `. The whole blob is replaced because it is one attribute; its decompressed content differs from what was read only in `live`. All root attributes other than `data` are untouched. + +Condition in both cases: `{ attribute: "_md", equals: change.expectedMd }`. A `"condition-failed"` result is appended as `skip changed-during-run` and counted in `conditionFailed`. + +Writes run with a bounded concurrency (default 8). + +### 2.4 v6 guard + +Runs on the DDB table before anything else, before the system confirm so the user is not asked to confirm a system that will be refused: + +1. `client.scan(table, { segment, totalSegments, sortKeyEquals: "L", limit: 100 })` across up to 4 segments; take the first `isCmsEntry` row that is not an internal model. +2. If none found, continue scanning until one is found or 5 000 rows have been read. +3. `data` is an object at the root → v6, proceed. +4. `data` absent and `modelId` present at the root → v5. Refuse: "Table `` in `` holds v5 records. `fix-live` only runs against migrated v6 systems." +5. Neither → refuse: "Could not find a CMS entry record to verify the schema version." + +The OS runner is enabled only when this guard passed for the same system in the same invocation. + +### 2.5 State and report + +``` +.transfer/ + state/ + fix-live/ + __.json + / + fix-live-report.jsonl + logs/orchestrator.log +``` + +```ts +export namespace FixLiveState { + export interface RunSummary { + runId: string; + at: string; // ISO + changes: number; + skips: number; + } + export interface LiveRunSummary extends RunSummary { + written: number; + conditionFailed: number; + } + export interface File { + lastDryRun?: RunSummary; + lastLiveRun?: LiveRunSummary; + } +} +``` + +The state file is written only when a dry run completes without an unhandled error. The live run reads it, refuses if `lastDryRun` is absent, and after completion sets `lastLiveRun`. + +`ChangeReport` appends one JSON line per event as produced, so the file is a valid audit trail even if the run is interrupted. `FileTool` currently offers only `writeFileOrThrow`; add `appendLineOrThrow` (or an append stream) as part of this work. + +```json +{"kind":"change","table":"ddb","pk":"T#root#CMS#CME#abc","sk":"L","reason":"missing-live","before":null,"after":{"version":2},"result":"dry-run"} +{"kind":"change","table":"os","pk":"T#root#CMS#CME#abc","sk":"P","reason":"empty-live","before":{},"after":{"version":2},"result":"written"} +{"kind":"skip","table":"ddb","pk":"T#root#CMS#CME#def","sk":"REV#0007","reason":"revision-version-mismatch","detail":"P.version=7 REV#0007.version=6"} +{"kind":"skip","table":"ddb","pk":"T#root#CMS#CME#ghi","sk":"L","reason":"changed-during-run"} +``` + +`result` is `"dry-run"`, `"written"`, or `"condition-failed"`. + +Console summary: + +``` +Fix live field — dry run (project: acme, system: target) + + DynamoDB acme-prod-ddb (eu-central-1) + scanned 148 203 + cms entries 31 440 + changes 2 118 missing-live 1 902 · empty-live 201 · wrong-version 9 · stale-live 6 + skips 4 revision-version-mismatch 3 · invalid-version 1 + + OpenSearch acme-prod-os (eu-central-1) + scanned 62 880 + cms entries 31 440 + changes 2 103 empty-live 2 094 · stale-live 9 + skips 0 + +Report: .transfer/20260904-091233/fix-live-report.jsonl +State: .transfer/state/fix-live/acme__target.json + +Run again and choose "live" to apply these changes. +``` + +On a live run, if the recomputed change count differs from `lastDryRun.changes`, print a warning with both numbers before the final confirm. It does not block. + +### 2.6 Guided flow + +``` +◆ Select a project projects/* via discoverProjects() +◆ Which system? source | target +│ hint: "ddb: · region: · os table: " +│ Checking schema version… spinner; refuse on v5 +◇ System summary note(): region, DDB table, OS table, OS endpoint (target only), account id +◆ This is the system whose records will be modified. Continue? default: no +◆ Run mode dry run (default) | live +│ live disabled with hint "run a dry run first" when no state +│ (live only) Last dry run: 2 118 changes, 2026-09-04 09:12. Proceed? default: no +│ Scanning DynamoDB… 148 203 rows / 31 440 entries spinner with live counter +│ Scanning OpenSearch… 62 880 rows / 31 440 entries +◇ Summary as above +└ Done. +``` + +Non-interactive: + +``` +yarn transfer fix-live --project=acme --system=target --dry-run +yarn transfer fix-live --project=acme --system=target --live --yes +yarn transfer fix-live --project=acme --system=target --dry-run --table=ddb +``` + +`--live` without a state file exits 1 with the same message the menu shows. `--yes` skips both confirms. `--table=ddb|os` restricts to one table; default is both. + +--- + +## Part 3 — Command menu and prompt abstractions + +### 3.1 Layout + +`src/cli.ts` is the bin entry (`package.json` `bin`) and must stay. A sibling `src/cli/` directory would collide with it under module resolution, so the new pieces live under the existing `src/commands/`: + +``` +src/commands/ + registry/ + abstractions/Command.ts # Command token + Command.Interface + CommandRegistry.ts + feature.ts + prompts/ + abstractions/Prompts.ts + abstractions/UI.ts + ClackPrompts.ts + ClackUI.ts + feature.ts + transfer/ # today's `run/` — TransferWizard + handler, unchanged bodies + fixLive/ + FixLiveCommand.ts + feature.ts + steps/ + selectProject.ts + selectSystem.ts + guardV6.ts + confirmSystem.ts + selectMode.ts + runTable.ts + summarise.ts + init/ initProject/ processSegment/ updateSkills/ # untouched +__tests__/commands/prompts/ + StubPrompts.ts, StubUI.ts +``` + +Step modules export functions, matching `src/commands/init/steps/*.ts`. + +### 3.2 `Command` + +```ts +export interface ICommand { + readonly name: string; // yargs command, e.g. "fix-live" + readonly description: string; // menu + --help + readonly hidden?: boolean; // processSegment: not in the menu + configure(yargs: Argv): Argv; + run(argv: Command.Argv): Promise; // exit code +} + +export const Command = createAbstraction("Cli/Command"); + +export namespace Command { + export type Interface = ICommand; + export type Argv = Record; +} +``` + +Commands are implementations of the same `Command` token, collected with `[Command, { multiple: true }]`, mirroring how processors share one token elsewhere. `CommandRegistry` resolves lazily so only the chosen command's dependencies are constructed. + +### 3.3 Entry and backwards compatibility + +```ts +const registry = container.resolve(CommandRegistry); +let cli = yargs(hideBin(process.argv)).scriptName("transfer"); +for (const command of registry.list()) { + cli = cli.command(command.name, command.description, y => command.configure(y), async argv => { + process.exitCode = await command.run(argv); + }); +} +cli = cli.command("$0 [folder]", false, y => transfer.configure(y), async argv => { + if (argv.folder) { // `yarn transfer my-folder` → init (today's behaviour) + process.exitCode = await registry.get("init").run(argv); + return; + } + if (argv.config || argv.preset) { // `yarn transfer --config --preset` → transfer (today's behaviour) + process.exitCode = await registry.get("transfer").run(argv); + return; + } + process.exitCode = await openMenu(container, registry); +}); +await cli.strict().help().parseAsync(); +``` + +`openMenu` shows `ui.intro`, a `prompts.select` over non-hidden commands with `hint = description`, exits 130 on cancel, and runs the chosen command with empty argv so it prompts for everything. + +Documented invocations in `docs/guides/commands.md` (`yarn transfer --config=… --preset=…`, `yarn transfer `) continue to work unchanged. + +### 3.4 Prompt abstractions + +`Prompts.Interface` offers `select`, `multiselect`, `confirm`, `text`; each returns `T | null`, `null` on cancel, and never exits. `UI.Interface` offers `intro`, `outro`, `note`, `cancel`, `spinner`, and `exitOnCancel(value: T | null): T`, which calls `cancel("Cancelled.")` and `process.exit(130)` on `null`. Stubs in `__tests__/commands/prompts/` queue scripted answers. + +Inquirer remains a dependency until `init` and `initProject` migrate. `ExitPromptError` handling stays inside those two commands. + +--- + +## Public API impact + +None. Nothing in `src/index.ts` changes. `IDynamoDbClient` gains `updateAttribute` and `ScanOptions` gains `limit` and `sortKeyEquals`; these are internal service abstractions. + +--- + +## Documentation + +- `docs/guides/commands.md`: "Command menu" section; `fix-live` section with guided flow, flags, state file, report format, the dry-run-before-live rule, and what is and is not reconciled. +- `docs/guides/troubleshooting.md`: "Published entries not showing as live after migration" → `fix-live`; note on `changed-during-run` and the contradiction skips. +- `AGENTS.md` §1 runtime flow: mention the menu. §8 open work: inquirer removal follow-up. +- `docs/hard-won-decisions.md`: add decisions 3, 4, 8, 9, 10; amend the cache-sentinel entry. + +--- + +## Testing strategy + +### Unit + +- `LiveFieldReconciler.decide`: one test per branch, every `SkipReason` and `ChangeReason`, `table: "os"` skipping the `REV#` checks, other `REV#` records never appearing in `changes`, `{}` normalised in both directions, single-revision entries, version ≥ 10000 padding. +- `addLiveField`: DDB shape, OS shape, missing, non-integer. +- `CommandRegistry`: list, get, hidden filtering, lazy resolution. +- `FixLiveCommand` with `StubPrompts` / `StubUI`: cancel at each step → 130, live refused without state, `--yes` skips confirms, `--table` restriction. +- `updateAttribute` against `MockDynamoDbClient`: written vs condition-failed. **Note:** `MockDynamoDbClient.scan` shards round-robin by index (`MockDynamoDbClient.ts:17-33`). That is fine for this design since grouping uses `queryAll`, but the mock needs `sortKeyEquals` and `limit` support. + +### Integration (dynalite) + +- **DDB runner:** seed entries covering every outcome. Dry run → report lines match, table unchanged. Live run → table state and `result` values match. Mutate one record's `_md` between read and write via a test hook → `condition-failed`. Assert a non-`live` attribute containing `""` survives byte-identical. +- **OS runner:** seed gzipped `L`/`P` documents, same assertions, plus the written `data` decompresses to the read object with only `live` changed. +- **v6 guard:** a v5-shaped table is refused; an OS-only run without the DDB guard is refused. + +### Golden + +- Extend the OS preset expectation with `live` on a draft-over-published pair. + +--- + +## Implementation order + +1. Transformer fix + tests. Own changeset (`patch`). +2. `IDynamoDbClient.updateAttribute`, `ScanOptions.limit` / `sortKeyEquals`, mock client support, `FileTool.appendLineOrThrow`. +3. `LiveFieldReconciler` + exhaustive unit tests. +4. `ChangeReport` + `FixLiveState` store. +5. `DdbLiveFieldRunner` + dynalite test. +6. `OsLiveFieldRunner` + dynalite test. +7. `Prompts`, `UI`, clack implementations, stubs. +8. `Command`, `CommandRegistry`, new entry wiring, move `run/` to `transfer/`. +9. `FixLiveCommand` and steps. +10. Guides, `AGENTS.md`, hard-won decisions. Changeset (`minor`). + +Steps 1 to 6 do not depend on 7 to 9 and can be exercised through a plain yargs command before the menu exists. + +--- + +## Open questions + +1. The OS companion table update relies on v6's DynamoDB stream to propagate into the OpenSearch index. Confirm the stream handler treats a `data`-only change as an index update. If not, a follow-up "touch" mechanism is needed. +2. Whether `fix-live` should also offer to report, without writing, the stale `live` copies on non-published `REV#` records, purely as diagnostics. Not needed for correctness; skipped unless asked for. diff --git a/package.json b/package.json index a38479c8..1a810cb4 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,8 @@ "author": "Webiny", "license": "MIT", "dependencies": { - "@aws-sdk/credential-providers": "^3.1117.0", - "@inquirer/core": "^12.0.0", - "@inquirer/prompts": "^8.6.0", + "@aws-sdk/credential-providers": "^3.1126.0", + "@clack/prompts": "^1.7.0", "@modelcontextprotocol/sdk": "^1.30.0", "@opensearch-project/opensearch": "3.6.0", "@types/node": "^24.13.3", @@ -72,29 +71,30 @@ "jsdom": "^30.0.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", - "sharp": "^0.35.3", - "tsx": "^4.23.12", + "sharp": "^0.35.4", + "tsx": "^4.23.13", "typescript": "^7.0.2", "yargs": "^18.1.0", - "zod": "^4.4.3" + "zod": "^4.5.4" }, "devDependencies": { - "@aws-sdk/client-dynamodb": "^3.1117.0", - "@aws-sdk/client-s3": "^3.1117.0", - "@aws-sdk/lib-dynamodb": "^3.1117.0", + "@aws-sdk/client-dynamodb": "^3.1126.0", + "@aws-sdk/client-s3": "^3.1126.0", + "@aws-sdk/lib-dynamodb": "^3.1126.0", "@changesets/cli": "^2.31.1", "@faker-js/faker": "^10.6.0", "@smithy/util-stream": "^4.8.2", "@types/jsdom": "^30.0.0", "@types/yargs": "^17.0.35", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^5.0.0", "adio": "^3.0.1", "aws-sdk-client-mock": "^4.1.0", "dynalite": "^4.0.0", - "oxfmt": "^0.65.0", - "oxlint": "^1.80.0", - "verdaccio": "^6.10.0", - "vitest": "^4.1.11" + "oxfmt": "^0.66.0", + "oxlint": "^1.81.0", + "verdaccio": "^6.10.2", + "vite": "^8.2.2", + "vitest": "^5.0.0" }, "engines": { "node": ">=24.0.0" diff --git a/src/bootstrap.ts b/src/bootstrap.ts index f43ecaef..eb513fe5 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -36,6 +36,7 @@ import { AccessCheckerFeature } from "~/features/AccessChecker/index.js"; import { DroppedRecordLogFeature } from "~/features/DroppedRecordLog/index.js"; import { TransferredRecordLogFeature } from "~/features/TransferredRecordLog/index.js"; import { CompressionFeature } from "@webiny/utils/features/compression/feature.js"; +import { FixLiveFeature } from "~/features/FixLive/index.js"; export interface BootstrapOptions { config: MigrationConfig.Interface; @@ -133,6 +134,7 @@ export function bootstrap(options: BootstrapOptions): Container { OsScannerFeature.register(container); OsProcessorFeature.register(container); AccessCheckerFeature.register(container); + FixLiveFeature.register(container); return container; } diff --git a/src/cli.ts b/src/cli.ts index 32f44745..1ff91d3b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,23 +2,15 @@ import { register } from "tsx/esm/api"; register(); -// Install the deprecation filter FIRST so it's in place before any -// import pulls in @webiny/lexical-* (the DEP0151 source). ESM imports -// are evaluated in order within a module; this one must stay on top. import "./utils/suppressDeprecations.ts"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { - registerRunCommand, - registerInitCommand, - registerInitProjectCommand, - registerProcessSegmentCommand, - registerUpdateSkillsCommand -} from "./commands/index.ts"; +import { createCliContainer } from "./commands/cliContainer.ts"; +import { CommandRegistry } from "./commands/registry/index.ts"; +import { Prompts, UI } from "./commands/prompts/index.ts"; +import { openMenu } from "./commands/openMenu.ts"; +import { dispatchDefault } from "./commands/dispatchDefault.ts"; -// Last-resort safety net: any promise rejection that escapes all try-catch -// blocks (e.g. from SDK internals during a backoff sleep) is caught here so -// the process always exits with code 1 rather than crashing silently. process.on("unhandledRejection", (reason: unknown) => { const lines: string[] = ["Fatal: unhandled rejection"]; if (reason instanceof Error) { @@ -38,18 +30,43 @@ process.on("unhandledRejection", (reason: unknown) => { process.exit(1); }); -const KNOWN_COMMANDS = new Set(["init", "init-project", "run", "process-segment", "update-skills"]); +const container = createCliContainer(); +const registry = container.resolve(CommandRegistry); +const transfer = registry.get("transfer"); -let cli = yargs(hideBin(process.argv)); -cli = registerInitCommand(cli); -cli = registerInitProjectCommand(cli); -cli = registerRunCommand(cli); -cli = registerProcessSegmentCommand(cli); -cli = registerUpdateSkillsCommand(cli); +let cli = yargs(hideBin(process.argv)).scriptName("transfer"); -const firstArg = process.argv[2]; -if (firstArg && !firstArg.startsWith("-") && !KNOWN_COMMANDS.has(firstArg)) { - cli.parse(["init", ...process.argv.slice(2)]); -} else { - cli.help().parse(); +for (const command of registry.list()) { + cli = cli.command( + command.name, + command.description, + y => command.configure(y), + async argv => { + process.exitCode = await command.run(argv); + } + ); } + +cli = cli.command( + "$0 [folder]", + false, + y => + transfer.configure(y).positional("folder", { + type: "string", + description: "Scaffold a new project folder (same as `init `)" + }), + async argv => { + process.exitCode = await dispatchDefault({ + argv, + registry, + openMenu: () => + openMenu({ + prompts: container.resolve(Prompts), + ui: container.resolve(UI), + registry + }) + }); + } +); + +await cli.strict().help().parseAsync(); diff --git a/src/commands/cliContainer.ts b/src/commands/cliContainer.ts new file mode 100644 index 00000000..1eb761cb --- /dev/null +++ b/src/commands/cliContainer.ts @@ -0,0 +1,26 @@ +import { Container } from "@webiny/di"; +import { ContainerToken } from "~/base/index.js"; +import { PromptsFeature } from "./prompts/feature.ts"; +import { CommandRegistryFeature } from "./registry/feature.ts"; +import { + TransferCommand, + InitCommand, + InitProjectCommand, + ProcessSegmentCommand, + UpdateSkillsCommand +} from "./index.ts"; +import { FixLiveCommandFeature } from "./fixLive/feature.ts"; + +export function createCliContainer(): Container { + const container = new Container(); + container.registerInstance(ContainerToken, container); + PromptsFeature.register(container); + CommandRegistryFeature.register(container); + container.register(TransferCommand).inSingletonScope(); + FixLiveCommandFeature.register(container); + container.register(InitCommand).inSingletonScope(); + container.register(InitProjectCommand).inSingletonScope(); + container.register(ProcessSegmentCommand).inSingletonScope(); + container.register(UpdateSkillsCommand).inSingletonScope(); + return container; +} diff --git a/src/commands/dispatchDefault.ts b/src/commands/dispatchDefault.ts new file mode 100644 index 00000000..81fc40e3 --- /dev/null +++ b/src/commands/dispatchDefault.ts @@ -0,0 +1,20 @@ +import type { Command } from "./registry/abstractions/Command.ts"; +import type { CommandRegistry } from "./registry/abstractions/CommandRegistry.ts"; + +export interface DispatchDefaultInput { + argv: Command.Argv; + registry: CommandRegistry.Interface; + openMenu: () => Promise; +} + +export async function dispatchDefault(input: DispatchDefaultInput): Promise { + const { argv, registry, openMenu } = input; + const folder = argv.folder; + if (typeof folder === "string" && folder.length > 0) { + return registry.get("init").run({ ...argv, "project-name": folder }); + } + if (argv.config || argv.preset) { + return registry.get("transfer").run(argv); + } + return openMenu(); +} diff --git a/src/commands/exitCodes.ts b/src/commands/exitCodes.ts new file mode 100644 index 00000000..d1f18266 --- /dev/null +++ b/src/commands/exitCodes.ts @@ -0,0 +1,3 @@ +export const EXIT_OK = 0; +export const EXIT_FAILURE = 1; +export const EXIT_CANCELLED = 130; diff --git a/src/commands/fixLive/FixLiveCommand.ts b/src/commands/fixLive/FixLiveCommand.ts new file mode 100644 index 00000000..a5b1a45b --- /dev/null +++ b/src/commands/fixLive/FixLiveCommand.ts @@ -0,0 +1,305 @@ +import type { Argv } from "yargs"; +import { join, resolve } from "node:path"; +import type { Container } from "@webiny/di"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import { UI } from "~/commands/prompts/abstractions/UI.js"; +import { EXIT_CANCELLED, EXIT_FAILURE, EXIT_OK } from "~/commands/exitCodes.js"; +import { discoverConfig } from "~/commands/transfer/wizard/configDiscovery.js"; +import { bootstrap } from "~/bootstrap.js"; +import { formatError } from "~/base/index.js"; +import { loadConfig } from "~/features/MigrationConfig/loadConfig.js"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; +import { TransferContext } from "~/features/TransferLifecycle/index.js"; +import { SourceDynamoDbClient, TargetDynamoDbClient } from "~/services/DynamoDbClient/index.js"; +import { + ChangeReport, + DdbLiveFieldRunner, + OsLiveFieldRunner, + FixLiveState, + type LiveFieldRunner +} from "~/features/FixLive/index.js"; +import type { SystemConfig, SystemName, TableKind } from "./types.ts"; +import type { StepCancelled, StepRefused } from "./steps/outcome.ts"; +import { selectProject } from "./steps/selectProject.ts"; +import { selectSystem } from "./steps/selectSystem.ts"; +import { guardV6 } from "./steps/guardV6.ts"; +import { confirmSystem } from "./steps/confirmSystem.ts"; +import { selectMode } from "./steps/selectMode.ts"; +import { runTable, type TableRunResult } from "./steps/runTable.ts"; +import { summarise, totalChanges, totalSkips } from "./steps/summarise.ts"; + +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface FixLiveOptions { + project?: string; + system?: SystemName; + mode?: LiveFieldRunner.Mode; + yes: boolean; + table?: TableKind; + concurrency: number; + logLevel?: LogLevel; +} + +const DEFAULT_CONCURRENCY = 4; + +function parseOptions(argv: CommandAbstraction.Argv): FixLiveOptions { + let mode: LiveFieldRunner.Mode | undefined; + if (argv.live === true) { + mode = "live"; + } else if (argv["dry-run"] === true) { + mode = "dry-run"; + } + return { + project: argv.project as string | undefined, + system: argv.system as SystemName | undefined, + mode, + yes: argv.yes === true, + table: argv.table as TableKind | undefined, + concurrency: typeof argv.concurrency === "number" ? argv.concurrency : DEFAULT_CONCURRENCY, + logLevel: argv["log-level"] as LogLevel | undefined + }; +} + +function resolveTables(restriction: TableKind | undefined, system: SystemConfig): TableKind[] { + if (restriction) { + return [restriction]; + } + return system.opensearch ? ["ddb", "os"] : ["ddb"]; +} + +class FixLiveCommandImpl implements CommandAbstraction.Interface { + public readonly name = "fix-live"; + public readonly description = + "Reconcile the `live` field on CMS entries of an already migrated v6 system"; + + public constructor( + private readonly prompts: Prompts.Interface, + private readonly ui: UI.Interface + ) {} + + public configure(yargs: Argv): Argv { + return yargs + .option("project", { + type: "string", + description: "Project folder under projects/" + }) + .option("system", { + type: "string", + choices: ["source", "target"] as const, + description: "Which system of the project to reconcile" + }) + .option("dry-run", { + type: "boolean", + description: "Report changes without writing" + }) + .option("live", { + type: "boolean", + description: + "Apply changes (requires a completed dry run for the same project and system)" + }) + .conflicts("dry-run", "live") + .option("yes", { + type: "boolean", + default: false, + description: "Skip confirmations" + }) + .option("table", { + type: "string", + choices: ["ddb", "os"] as const, + description: "Restrict to one table (default: both)" + }) + .option("concurrency", { + type: "number", + default: DEFAULT_CONCURRENCY, + description: "Scan segments in flight" + }) + .option("log-level", { + type: "string", + choices: ["debug", "info", "warn", "error"] as const, + description: "Log level (default: from config)" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + const options = parseOptions(argv); + const cwd = process.cwd(); + + const project = await selectProject({ + prompts: this.prompts, + cwd, + projectArg: options.project + }); + if (project.kind !== "ok") { + return this.finish(project); + } + + const configPath = await discoverConfig(resolve(join(cwd, "projects", project.value))); + if (!configPath) { + return this.refuse(`No config.ts found in projects/${project.value}/.`); + } + + const runId = String(Date.now()); + let config: MigrationConfig.Interface; + let container: Container; + try { + config = await loadConfig(configPath); + container = bootstrap({ + config, + runId, + logLevel: options.logLevel ?? config.debug?.logLevel + }); + } catch (error) { + return this.refuse(formatError(error, false)); + } + + const system = await selectSystem({ + prompts: this.prompts, + config, + systemArg: options.system + }); + if (system.kind !== "ok") { + return this.finish(system); + } + const systemConfig: SystemConfig = config[system.value]; + const client = + system.value === "source" + ? container.resolve(SourceDynamoDbClient) + : container.resolve(TargetDynamoDbClient); + + if (options.table === "os" && !systemConfig.opensearch) { + return this.refuse(`System "${system.value}" has no OpenSearch table configured.`); + } + + const guard = await guardV6({ + client, + tableName: systemConfig.dynamodb.tableName, + region: systemConfig.region, + ui: this.ui + }); + if (guard.kind !== "ok") { + return this.finish(guard); + } + + const confirmed = await confirmSystem({ + prompts: this.prompts, + ui: this.ui, + system: system.value, + config: systemConfig, + yes: options.yes + }); + if (confirmed.kind !== "ok") { + return this.finish(confirmed); + } + + const fixLiveState = container.resolve(FixLiveState); + const stateKey = { project: project.value, system: system.value }; + const state = fixLiveState.read(stateKey); + const mode = await selectMode({ + prompts: this.prompts, + state, + modeArg: options.mode, + yes: options.yes + }); + if (mode.kind !== "ok") { + return this.finish(mode); + } + + container.registerInstance(TransferContext, { + runId, + dryRun: mode.value === "dry-run" + }); + const report = container.resolve(ChangeReport); + const segments = config.pipeline?.segments || 1; + + const results: TableRunResult[] = []; + try { + for (const table of resolveTables(options.table, systemConfig)) { + const tableName = + table === "ddb" + ? systemConfig.dynamodb.tableName + : systemConfig.opensearch!.tableName; + const runner = + table === "ddb" + ? container.resolve(DdbLiveFieldRunner) + : container.resolve(OsLiveFieldRunner); + const target: LiveFieldRunner.Target = { + client, + tableName, + segments, + concurrency: options.concurrency + }; + results.push( + await runTable({ + table, + tableName, + region: systemConfig.region, + runner, + target, + mode: mode.value, + report, + ui: this.ui + }) + ); + } + } catch (error) { + return this.refuse(`fix-live failed: ${formatError(error, false)}`); + } + + if (mode.value === "dry-run") { + fixLiveState.recordDryRun(stateKey, { + runId, + at: new Date().toISOString(), + changes: totalChanges(results), + skips: totalSkips(results) + }); + } else { + fixLiveState.recordLiveRun(stateKey, { + runId, + at: new Date().toISOString(), + changes: totalChanges(results), + skips: totalSkips(results), + written: results.reduce((total, result) => total + result.stats.written, 0), + conditionFailed: results.reduce( + (total, result) => total + result.stats.conditionFailed, + 0 + ) + }); + } + + summarise({ + ui: this.ui, + project: project.value, + system: system.value, + mode: mode.value, + results, + reportPath: join(".transfer", runId, "fix-live-report.jsonl"), + statePath: join( + ".transfer", + "state", + "fix-live", + `${project.value}__${system.value}.json` + ), + lastDryRun: state?.lastDryRun + }); + return EXIT_OK; + } + + private finish(outcome: StepCancelled | StepRefused): number { + if (outcome.kind === "cancelled") { + this.ui.cancel("Cancelled."); + return EXIT_CANCELLED; + } + return this.refuse(outcome.message); + } + + private refuse(message: string): number { + this.ui.error(message); + return EXIT_FAILURE; + } +} + +export const FixLiveCommand = CommandAbstraction.createImplementation({ + implementation: FixLiveCommandImpl, + dependencies: [Prompts, UI] +}); diff --git a/src/commands/fixLive/feature.ts b/src/commands/fixLive/feature.ts new file mode 100644 index 00000000..c62bce24 --- /dev/null +++ b/src/commands/fixLive/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "~/base/index.js"; +import { FixLiveCommand } from "./FixLiveCommand.ts"; + +export const FixLiveCommandFeature = createFeature({ + name: "Cli/FixLiveCommandFeature", + register(container) { + container.register(FixLiveCommand).inSingletonScope(); + } +}); diff --git a/src/commands/fixLive/steps/confirmSystem.ts b/src/commands/fixLive/steps/confirmSystem.ts new file mode 100644 index 00000000..27cdadaa --- /dev/null +++ b/src/commands/fixLive/steps/confirmSystem.ts @@ -0,0 +1,41 @@ +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { SystemConfig, SystemName } from "../types.ts"; +import { type StepOutcome, ok, cancelled } from "./outcome.ts"; + +export interface ConfirmSystemInput { + prompts: Prompts.Interface; + ui: UI.Interface; + system: SystemName; + config: SystemConfig; + yes: boolean; +} + +export function formatSystemSummary(system: SystemName, config: SystemConfig): string { + const lines = [ + `system: ${system}`, + `region: ${config.region}`, + `ddb table: ${config.dynamodb.tableName}`, + `os table: ${config.opensearch ? config.opensearch.tableName : "none"}` + ]; + if (config.opensearch && "endpoint" in config.opensearch) { + lines.push(`os endpoint: ${config.opensearch.endpoint}`); + } + lines.push(`account id: ${config.accountId ?? "unknown"}`); + return lines.join("\n"); +} + +export async function confirmSystem(input: ConfirmSystemInput): Promise> { + input.ui.note(formatSystemSummary(input.system, input.config), "System summary"); + if (input.yes) { + return ok(true); + } + const answer = await input.prompts.confirm({ + message: "This is the system whose records will be modified. Continue?", + initialValue: false + }); + if (answer !== true) { + return cancelled(); + } + return ok(true); +} diff --git a/src/commands/fixLive/steps/format.ts b/src/commands/fixLive/steps/format.ts new file mode 100644 index 00000000..92a7c91e --- /dev/null +++ b/src/commands/fixLive/steps/format.ts @@ -0,0 +1,7 @@ +export function formatCount(value: number): string { + return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, " "); +} + +export function formatTimestamp(iso: string): string { + return iso.slice(0, 16).replace("T", " "); +} diff --git a/src/commands/fixLive/steps/guardV6.ts b/src/commands/fixLive/steps/guardV6.ts new file mode 100644 index 00000000..0e3f605e --- /dev/null +++ b/src/commands/fixLive/steps/guardV6.ts @@ -0,0 +1,89 @@ +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { SourceDynamoDbClient } from "~/services/DynamoDbClient/index.js"; +import type { BaseRecord } from "~/domain/transform/types/records.js"; +import { isCmsEntry, isFmFile } from "~/domain/transform/filters.js"; +import { formatError } from "~/base/index.js"; +import { type StepOutcome, ok, refused } from "./outcome.ts"; + +export interface GuardV6Input { + client: SourceDynamoDbClient.Interface; + tableName: string; + region: string; + ui: UI.Interface; +} + +const GUARD_SEGMENTS = 4; +const FIRST_PASS_LIMIT = 100; +const MAX_ROWS = 5000; + +export const NO_PROBE_MESSAGE = "Could not find a CMS entry record to verify the schema version."; + +const isProbeCandidate = (row: BaseRecord): boolean => isCmsEntry(row) && !isFmFile(row); + +const isV6 = (row: BaseRecord): boolean => + typeof row.data === "object" && row.data !== null && !Array.isArray(row.data); + +const isV5 = (row: BaseRecord): boolean => + row.data === undefined && typeof row.modelId === "string"; + +async function scanForProbe( + client: SourceDynamoDbClient.Interface, + tableName: string, + limit: number | undefined, + budget: number +): Promise { + let read = 0; + for (let segment = 0; segment < GUARD_SEGMENTS; segment++) { + const rows = client.scan(tableName, { + segment, + totalSegments: GUARD_SEGMENTS, + sortKeyEquals: "L", + limit + }); + for await (const row of rows) { + read++; + if (isProbeCandidate(row)) { + return row; + } + if (read >= budget) { + return null; + } + } + } + return null; +} + +export async function guardV6(input: GuardV6Input): Promise> { + const spinner = input.ui.spinner(); + spinner.start("Checking schema version…"); + + let probe: BaseRecord | null; + try { + probe = await scanForProbe( + input.client, + input.tableName, + FIRST_PASS_LIMIT, + GUARD_SEGMENTS * FIRST_PASS_LIMIT + ); + if (!probe) { + probe = await scanForProbe(input.client, input.tableName, undefined, MAX_ROWS); + } + } catch (error) { + spinner.stop("Schema check failed"); + return refused( + `Could not read table "${input.tableName}" in ${input.region}: ${formatError(error, false)}` + ); + } + + if (probe && isV6(probe)) { + spinner.stop("Schema version: v6"); + return ok("v6"); + } + spinner.stop("Schema check failed"); + if (probe && isV5(probe)) { + return refused( + `Table "${input.tableName}" in ${input.region} holds v5 records. fix-live only runs against migrated v6 systems.` + ); + } + return refused(NO_PROBE_MESSAGE); +} diff --git a/src/commands/fixLive/steps/outcome.ts b/src/commands/fixLive/steps/outcome.ts new file mode 100644 index 00000000..6576cab9 --- /dev/null +++ b/src/commands/fixLive/steps/outcome.ts @@ -0,0 +1,19 @@ +export interface StepOk { + kind: "ok"; + value: T; +} + +export interface StepCancelled { + kind: "cancelled"; +} + +export interface StepRefused { + kind: "refused"; + message: string; +} + +export type StepOutcome = StepOk | StepCancelled | StepRefused; + +export const ok = (value: T): StepOk => ({ kind: "ok", value }); +export const cancelled = (): StepCancelled => ({ kind: "cancelled" }); +export const refused = (message: string): StepRefused => ({ kind: "refused", message }); diff --git a/src/commands/fixLive/steps/runTable.ts b/src/commands/fixLive/steps/runTable.ts new file mode 100644 index 00000000..51956ad3 --- /dev/null +++ b/src/commands/fixLive/steps/runTable.ts @@ -0,0 +1,52 @@ +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { ChangeReport, LiveFieldRunner } from "~/features/FixLive/index.js"; +import type { TableKind } from "../types.ts"; +import { formatCount } from "./format.ts"; + +export interface RunTableInput { + table: TableKind; + tableName: string; + region: string; + runner: LiveFieldRunner.Interface; + target: LiveFieldRunner.Target; + mode: LiveFieldRunner.Mode; + report: ChangeReport.Interface; + ui: UI.Interface; +} + +export interface TableRunResult { + table: TableKind; + tableName: string; + region: string; + stats: LiveFieldRunner.Stats; +} + +export const tableLabel = (table: TableKind): string => + table === "ddb" ? "DynamoDB" : "OpenSearch"; + +export async function runTable(input: RunTableInput): Promise { + const label = tableLabel(input.table); + const spinner = input.ui.spinner(); + spinner.start(`Scanning ${label}…`); + + const stats = await input.runner.run({ + mode: input.mode, + target: input.target, + report: input.report, + onProgress: progress => { + spinner.message( + `Scanning ${label}… ${formatCount(progress.scanned)} rows / ${formatCount(progress.entries)} entries` + ); + } + }); + + spinner.stop( + `${label} scanned: ${formatCount(stats.scanned)} rows / ${formatCount(stats.entries)} entries` + ); + return { + table: input.table, + tableName: input.tableName, + region: input.region, + stats + }; +} diff --git a/src/commands/fixLive/steps/selectMode.ts b/src/commands/fixLive/steps/selectMode.ts new file mode 100644 index 00000000..9e253e48 --- /dev/null +++ b/src/commands/fixLive/steps/selectMode.ts @@ -0,0 +1,63 @@ +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import type { FixLiveState, LiveFieldRunner } from "~/features/FixLive/index.js"; +import { formatCount, formatTimestamp } from "./format.ts"; +import { type StepOutcome, ok, cancelled, refused } from "./outcome.ts"; + +export interface SelectModeInput { + prompts: Prompts.Interface; + state: FixLiveState.File | null; + modeArg?: LiveFieldRunner.Mode; + yes: boolean; +} + +export const NO_DRY_RUN_MESSAGE = + "No completed dry run found for this project and system. Run a dry run first."; + +export async function selectMode( + input: SelectModeInput +): Promise> { + const lastDryRun = input.state?.lastDryRun; + + let mode = input.modeArg; + if (mode === "live" && !lastDryRun) { + return refused(NO_DRY_RUN_MESSAGE); + } + + if (!mode) { + const chosen = await input.prompts.select({ + message: "Run mode", + initialValue: "dry-run", + options: [ + { + value: "dry-run", + label: "dry run", + hint: "report only, nothing is written" + }, + { + value: "live", + label: "live", + disabled: !lastDryRun, + hint: lastDryRun + ? `last dry run: ${formatCount(lastDryRun.changes)} changes, ${formatTimestamp(lastDryRun.at)}` + : "run a dry run first" + } + ] + }); + if (chosen === null) { + return cancelled(); + } + mode = chosen; + } + + if (mode === "live" && !input.yes && lastDryRun) { + const proceed = await input.prompts.confirm({ + message: `Last dry run: ${formatCount(lastDryRun.changes)} changes, ${formatTimestamp(lastDryRun.at)}. Proceed?`, + initialValue: false + }); + if (proceed !== true) { + return cancelled(); + } + } + + return ok(mode); +} diff --git a/src/commands/fixLive/steps/selectProject.ts b/src/commands/fixLive/steps/selectProject.ts new file mode 100644 index 00000000..72442377 --- /dev/null +++ b/src/commands/fixLive/steps/selectProject.ts @@ -0,0 +1,37 @@ +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import { discoverProjects } from "~/commands/transfer/wizard/projectDiscovery.js"; +import { type StepOutcome, ok, cancelled, refused } from "./outcome.ts"; + +export interface SelectProjectInput { + prompts: Prompts.Interface; + cwd: string; + projectArg?: string; +} + +export async function selectProject(input: SelectProjectInput): Promise> { + const projects = await discoverProjects(input.cwd); + + if (input.projectArg) { + if (!projects.includes(input.projectArg)) { + return refused( + `Project "${input.projectArg}" not found under projects/. Available: ${projects.join(", ") || "none"}` + ); + } + return ok(input.projectArg); + } + + if (projects.length === 0) { + return refused( + "No projects found under projects/. Run `yarn transfer init-project ` first." + ); + } + + const chosen = await input.prompts.select({ + message: "Select a project", + options: projects.map(project => ({ value: project, label: project })) + }); + if (chosen === null) { + return cancelled(); + } + return ok(chosen); +} diff --git a/src/commands/fixLive/steps/selectSystem.ts b/src/commands/fixLive/steps/selectSystem.ts new file mode 100644 index 00000000..b4f2bd48 --- /dev/null +++ b/src/commands/fixLive/steps/selectSystem.ts @@ -0,0 +1,40 @@ +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; +import type { SystemConfig, SystemName } from "../types.ts"; +import { type StepOutcome, ok, cancelled } from "./outcome.ts"; + +export interface SelectSystemInput { + prompts: Prompts.Interface; + config: MigrationConfig.Interface; + systemArg?: SystemName; +} + +export function formatSystemHint(system: SystemConfig): string { + const osTable = system.opensearch ? system.opensearch.tableName : "none"; + return `ddb: ${system.dynamodb.tableName} · region: ${system.region} · os table: ${osTable}`; +} + +export async function selectSystem(input: SelectSystemInput): Promise> { + if (input.systemArg) { + return ok(input.systemArg); + } + const chosen = await input.prompts.select({ + message: "Which system?", + options: [ + { + value: "source", + label: "source", + hint: formatSystemHint(input.config.source) + }, + { + value: "target", + label: "target", + hint: formatSystemHint(input.config.target) + } + ] + }); + if (chosen === null) { + return cancelled(); + } + return ok(chosen); +} diff --git a/src/commands/fixLive/steps/summarise.ts b/src/commands/fixLive/steps/summarise.ts new file mode 100644 index 00000000..78175bdf --- /dev/null +++ b/src/commands/fixLive/steps/summarise.ts @@ -0,0 +1,80 @@ +import type { UI } from "~/commands/prompts/abstractions/UI.js"; +import type { FixLiveState, LiveFieldRunner } from "~/features/FixLive/index.js"; +import type { SystemName } from "../types.ts"; +import { formatCount } from "./format.ts"; +import { tableLabel, type TableRunResult } from "./runTable.ts"; + +export interface SummaryInput { + project: string; + system: SystemName; + mode: LiveFieldRunner.Mode; + results: TableRunResult[]; + reportPath: string; + statePath: string; +} + +export interface SummariseInput extends SummaryInput { + ui: UI.Interface; + lastDryRun?: FixLiveState.RunSummary; +} + +const sum = (counts: Record): number => + Object.values(counts).reduce((total, count) => total + count, 0); + +const breakdown = (counts: Record): string => + Object.entries(counts) + .filter(([, count]) => count > 0) + .map(([reason, count]) => `${reason} ${formatCount(count)}`) + .join(" · "); + +const row = (label: string, value: number, detail = ""): string => { + const line = ` ${label.padEnd(14)} ${formatCount(value).padStart(9)}`; + return detail ? `${line} ${detail}` : line; +}; + +export const totalChanges = (results: TableRunResult[]): number => + results.reduce((total, result) => total + sum(result.stats.changes), 0); + +export const totalSkips = (results: TableRunResult[]): number => + results.reduce((total, result) => total + sum(result.stats.skips), 0); + +export function formatSummary(input: SummaryInput): string { + const modeLabel = input.mode === "dry-run" ? "dry run" : "live run"; + const lines: string[] = [ + `Fix live field — ${modeLabel} (project: ${input.project}, system: ${input.system})`, + "" + ]; + for (const result of input.results) { + const { stats } = result; + lines.push(` ${tableLabel(result.table)} ${result.tableName} (${result.region})`); + lines.push(row("scanned", stats.scanned)); + lines.push(row("cms entries", stats.entries)); + lines.push(row("changes", sum(stats.changes), breakdown(stats.changes))); + lines.push(row("skips", sum(stats.skips), breakdown(stats.skips))); + if (input.mode === "live") { + lines.push(row("written", stats.written)); + lines.push(row("changed during run", stats.conditionFailed)); + } + lines.push(""); + } + lines.push(`Report: ${input.reportPath}`); + lines.push(`State: ${input.statePath}`); + if (input.mode === "dry-run") { + lines.push(""); + lines.push('Run again and choose "live" to apply these changes.'); + } + return lines.join("\n"); +} + +export function summarise(input: SummariseInput): void { + if (input.mode === "live" && input.lastDryRun) { + const found = totalChanges(input.results); + if (found !== input.lastDryRun.changes) { + input.ui.warn( + `Last dry run reported ${formatCount(input.lastDryRun.changes)} changes, this live run found ${formatCount(found)}.` + ); + } + } + input.ui.note(formatSummary(input), "Summary"); + input.ui.outro("Done."); +} diff --git a/src/commands/fixLive/types.ts b/src/commands/fixLive/types.ts new file mode 100644 index 00000000..bfb7a4a3 --- /dev/null +++ b/src/commands/fixLive/types.ts @@ -0,0 +1,7 @@ +import type { MigrationConfig } from "~/features/MigrationConfig/index.js"; + +export type SystemName = "source" | "target"; +export type TableKind = "ddb" | "os"; +export type SystemConfig = + | MigrationConfig.Interface["source"] + | MigrationConfig.Interface["target"]; diff --git a/src/commands/index.ts b/src/commands/index.ts index 43601535..a8a8711f 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,5 +1,5 @@ -export { registerRunCommand } from "./run/register.ts"; -export { registerInitCommand } from "./init/register.ts"; -export { registerInitProjectCommand } from "./initProject/register.ts"; -export { registerProcessSegmentCommand } from "./processSegment/register.ts"; -export { registerUpdateSkillsCommand } from "./updateSkills/register.ts"; +export { TransferCommand } from "./transfer/TransferCommand.ts"; +export { InitCommand } from "./init/InitCommand.ts"; +export { InitProjectCommand } from "./initProject/InitProjectCommand.ts"; +export { ProcessSegmentCommand } from "./processSegment/ProcessSegmentCommand.ts"; +export { UpdateSkillsCommand } from "./updateSkills/UpdateSkillsCommand.ts"; diff --git a/src/commands/init/InitCommand.ts b/src/commands/init/InitCommand.ts new file mode 100644 index 00000000..a51062f7 --- /dev/null +++ b/src/commands/init/InitCommand.ts @@ -0,0 +1,28 @@ +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class InitCommandImpl implements CommandAbstraction.Interface { + public readonly name = "init "; + public readonly description = "Scaffold a new data transfer project"; + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs.positional("project-name", { + type: "string", + demandOption: true, + description: "Name of the project directory to create" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + await handler({ projectName: argv["project-name"] as string }); + return EXIT_OK; + } +} + +export const InitCommand = CommandAbstraction.createImplementation({ + implementation: InitCommandImpl, + dependencies: [] +}); diff --git a/src/commands/init/register.ts b/src/commands/init/register.ts deleted file mode 100644 index 1c2e51aa..00000000 --- a/src/commands/init/register.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Argv } from "yargs"; -import { handler } from "./handler.ts"; - -export function registerInitCommand(yargs: Argv): Argv { - return yargs.command( - "init ", - "Scaffold a new data transfer project", - yargs => { - return yargs.positional("project-name", { - type: "string", - demandOption: true, - description: "Name of the project directory to create" - }); - }, - async argv => { - await handler({ - projectName: argv["project-name"] as string - }); - } - ); -} diff --git a/src/commands/initProject/InitProjectCommand.ts b/src/commands/initProject/InitProjectCommand.ts new file mode 100644 index 00000000..1cc44bd2 --- /dev/null +++ b/src/commands/initProject/InitProjectCommand.ts @@ -0,0 +1,28 @@ +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class InitProjectCommandImpl implements CommandAbstraction.Interface { + public readonly name = "init-project "; + public readonly description = "Scaffold a new project in the projects/ directory"; + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs.positional("name", { + type: "string", + demandOption: true, + description: "Name of the project folder to create under projects/" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + await handler(argv.name as string); + return EXIT_OK; + } +} + +export const InitProjectCommand = CommandAbstraction.createImplementation({ + implementation: InitProjectCommandImpl, + dependencies: [] +}); diff --git a/src/commands/initProject/register.ts b/src/commands/initProject/register.ts deleted file mode 100644 index c420d894..00000000 --- a/src/commands/initProject/register.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Argv } from "yargs"; -import { handler } from "./handler.ts"; - -export function registerInitProjectCommand(yargs: Argv): Argv { - return yargs.command( - "init-project ", - "Scaffold a new project in the projects/ directory", - yargs => { - return yargs.positional("name", { - type: "string", - demandOption: true, - description: "Name of the project folder to create under projects/" - }); - }, - async argv => { - await handler(argv.name as string); - } - ); -} diff --git a/src/commands/openMenu.ts b/src/commands/openMenu.ts new file mode 100644 index 00000000..169c4c55 --- /dev/null +++ b/src/commands/openMenu.ts @@ -0,0 +1,28 @@ +import type { Prompts } from "./prompts/abstractions/Prompts.ts"; +import type { UI } from "./prompts/abstractions/UI.ts"; +import type { CommandRegistry } from "./registry/abstractions/CommandRegistry.ts"; +import { EXIT_CANCELLED } from "./exitCodes.ts"; + +export interface OpenMenuInput { + prompts: Prompts.Interface; + ui: UI.Interface; + registry: CommandRegistry.Interface; +} + +export async function openMenu(input: OpenMenuInput): Promise { + const { prompts, ui, registry } = input; + ui.intro("Webiny data transfer"); + const chosen = await prompts.select({ + message: "What do you want to do?", + options: registry.menu().map(command => ({ + value: command.name, + label: command.name, + hint: command.description + })) + }); + if (chosen === null) { + ui.cancel("Cancelled."); + return EXIT_CANCELLED; + } + return registry.get(chosen).run({}); +} diff --git a/src/commands/processSegment/ProcessSegmentCommand.ts b/src/commands/processSegment/ProcessSegmentCommand.ts new file mode 100644 index 00000000..dbb0abbe --- /dev/null +++ b/src/commands/processSegment/ProcessSegmentCommand.ts @@ -0,0 +1,64 @@ +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class ProcessSegmentCommandImpl implements CommandAbstraction.Interface { + public readonly name = "process-segment"; + public readonly description = + "Process a specific DDB segment (used internally by worker processes)"; + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs + .option("runId", { type: "string", demandOption: true, description: "Run ID" }) + .option("segment", { + type: "number", + demandOption: true, + description: "Segment number" + }) + .option("total", { + type: "number", + demandOption: true, + description: "Total segments" + }) + .option("config", { + type: "string", + demandOption: true, + description: "Config file path" + }) + .option("preset", { + type: "string", + demandOption: true, + description: "Preset name to use for this segment" + }) + .option("log-level", { + type: "string", + choices: ["debug", "info", "warn", "error"] as const, + description: "Log level" + }) + .option("dry-run", { + type: "boolean", + default: false, + description: "Skip all writes to the target system" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + await handler({ + runId: argv.runId as string, + segment: argv.segment as number, + total: argv.total as number, + config: argv.config as string, + preset: argv.preset as string, + logLevel: argv["log-level"] as string | undefined, + dryRun: argv["dry-run"] as boolean | undefined + }); + return EXIT_OK; + } +} + +export const ProcessSegmentCommand = CommandAbstraction.createImplementation({ + implementation: ProcessSegmentCommandImpl, + dependencies: [] +}); diff --git a/src/commands/processSegment/register.ts b/src/commands/processSegment/register.ts deleted file mode 100644 index 5677f80e..00000000 --- a/src/commands/processSegment/register.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Argv } from "yargs"; -import { handler } from "./handler.ts"; - -export function registerProcessSegmentCommand(yargs: Argv): Argv { - return yargs.command( - "process-segment", - "Process a specific DDB segment (used internally by worker processes)", - yargs => { - return yargs - .option("runId", { type: "string", demandOption: true, description: "Run ID" }) - .option("segment", { - type: "number", - demandOption: true, - description: "Segment number" - }) - .option("total", { - type: "number", - demandOption: true, - description: "Total segments" - }) - .option("config", { - type: "string", - demandOption: true, - description: "Config file path" - }) - .option("preset", { - type: "string", - demandOption: true, - description: "Preset name to use for this segment" - }) - .option("log-level", { - type: "string", - choices: ["debug", "info", "warn", "error"] as const, - description: "Log level" - }) - .option("dry-run", { - type: "boolean", - default: false, - description: "Skip all writes to the target system" - }); - }, - async argv => { - await handler({ - ...argv, - logLevel: argv["log-level"] as string | undefined, - preset: argv.preset, - dryRun: argv["dry-run"] - }); - } - ); -} diff --git a/src/commands/prompts/ClackPrompts.ts b/src/commands/prompts/ClackPrompts.ts new file mode 100644 index 00000000..4f863f67 --- /dev/null +++ b/src/commands/prompts/ClackPrompts.ts @@ -0,0 +1,61 @@ +import * as p from "@clack/prompts"; +import { Prompts as PromptsAbstraction } from "./abstractions/Prompts.ts"; + +class ClackPromptsImpl implements PromptsAbstraction.Interface { + public async select(options: PromptsAbstraction.SelectOptions): Promise { + const result = await p.select({ + message: options.message, + options: options.options as p.Option[], + initialValue: options.initialValue + }); + if (p.isCancel(result)) { + return null; + } + return result; + } + + public async multiselect( + options: PromptsAbstraction.MultiselectOptions + ): Promise { + const result = await p.multiselect({ + message: options.message, + options: options.options as p.Option[], + required: options.required ?? false, + initialValues: options.initialValues + }); + if (p.isCancel(result)) { + return null; + } + return result; + } + + public async confirm(options: PromptsAbstraction.ConfirmOptions): Promise { + const result = await p.confirm({ + message: options.message, + initialValue: options.initialValue + }); + if (p.isCancel(result)) { + return null; + } + return result; + } + + public async text(options: PromptsAbstraction.TextOptions): Promise { + const validate = options.validate; + const result = await p.text({ + message: options.message, + placeholder: options.placeholder, + defaultValue: options.defaultValue, + validate: validate ? value => validate(value ?? "") : undefined + }); + if (p.isCancel(result)) { + return null; + } + return result; + } +} + +export const ClackPrompts = PromptsAbstraction.createImplementation({ + implementation: ClackPromptsImpl, + dependencies: [] +}); diff --git a/src/commands/prompts/ClackSpinner.ts b/src/commands/prompts/ClackSpinner.ts new file mode 100644 index 00000000..007ef3ac --- /dev/null +++ b/src/commands/prompts/ClackSpinner.ts @@ -0,0 +1,22 @@ +import * as p from "@clack/prompts"; +import type { UI } from "./abstractions/UI.ts"; + +export class ClackSpinner implements UI.Spinner { + private readonly spinner: ReturnType; + + public constructor() { + this.spinner = p.spinner(); + } + + public start(message: string): void { + this.spinner.start(message); + } + + public message(message: string): void { + this.spinner.message(message); + } + + public stop(message: string): void { + this.spinner.stop(message); + } +} diff --git a/src/commands/prompts/ClackUI.ts b/src/commands/prompts/ClackUI.ts new file mode 100644 index 00000000..0fb6d17e --- /dev/null +++ b/src/commands/prompts/ClackUI.ts @@ -0,0 +1,47 @@ +import * as p from "@clack/prompts"; +import { UI as UIAbstraction } from "./abstractions/UI.ts"; +import { ClackSpinner } from "./ClackSpinner.ts"; +import { EXIT_CANCELLED } from "~/commands/exitCodes.js"; + +class ClackUIImpl implements UIAbstraction.Interface { + public intro(title: string): void { + p.intro(title); + } + + public outro(message: string): void { + p.outro(message); + } + + public note(message: string, title?: string): void { + p.note(message, title); + } + + public warn(message: string): void { + p.log.warn(message); + } + + public error(message: string): void { + p.log.error(message); + } + + public cancel(message: string): void { + p.cancel(message); + } + + public spinner(): UIAbstraction.Spinner { + return new ClackSpinner(); + } + + public exitOnCancel(value: T | null): T { + if (value === null) { + this.cancel("Cancelled."); + process.exit(EXIT_CANCELLED); + } + return value; + } +} + +export const ClackUI = UIAbstraction.createImplementation({ + implementation: ClackUIImpl, + dependencies: [] +}); diff --git a/src/commands/prompts/abstractions/Prompts.ts b/src/commands/prompts/abstractions/Prompts.ts new file mode 100644 index 00000000..7471877e --- /dev/null +++ b/src/commands/prompts/abstractions/Prompts.ts @@ -0,0 +1,51 @@ +import { createAbstraction } from "~/base/index.js"; + +export interface PromptSelectOption { + value: T; + label: string; + hint?: string; + disabled?: boolean; +} + +export interface PromptSelectOptions { + message: string; + options: PromptSelectOption[]; + initialValue?: T; +} + +export interface PromptMultiselectOptions { + message: string; + options: PromptSelectOption[]; + required?: boolean; + initialValues?: T[]; +} + +export interface PromptConfirmOptions { + message: string; + initialValue?: boolean; +} + +export interface PromptTextOptions { + message: string; + placeholder?: string; + defaultValue?: string; + validate?: (value: string) => string | undefined; +} + +export interface IPrompts { + select(options: PromptSelectOptions): Promise; + multiselect(options: PromptMultiselectOptions): Promise; + confirm(options: PromptConfirmOptions): Promise; + text(options: PromptTextOptions): Promise; +} + +export const Prompts = createAbstraction("Cli/Prompts"); + +export namespace Prompts { + export type Interface = IPrompts; + export type SelectOption = PromptSelectOption; + export type SelectOptions = PromptSelectOptions; + export type MultiselectOptions = PromptMultiselectOptions; + export type ConfirmOptions = PromptConfirmOptions; + export type TextOptions = PromptTextOptions; +} diff --git a/src/commands/prompts/abstractions/UI.ts b/src/commands/prompts/abstractions/UI.ts new file mode 100644 index 00000000..5def0467 --- /dev/null +++ b/src/commands/prompts/abstractions/UI.ts @@ -0,0 +1,25 @@ +import { createAbstraction } from "~/base/index.js"; + +export interface UISpinner { + start(message: string): void; + message(message: string): void; + stop(message: string): void; +} + +export interface IUI { + intro(title: string): void; + outro(message: string): void; + note(message: string, title?: string): void; + warn(message: string): void; + error(message: string): void; + cancel(message: string): void; + spinner(): UISpinner; + exitOnCancel(value: T | null): T; +} + +export const UI = createAbstraction("Cli/UI"); + +export namespace UI { + export type Interface = IUI; + export type Spinner = UISpinner; +} diff --git a/src/commands/prompts/abstractions/index.ts b/src/commands/prompts/abstractions/index.ts new file mode 100644 index 00000000..7c332082 --- /dev/null +++ b/src/commands/prompts/abstractions/index.ts @@ -0,0 +1,2 @@ +export { Prompts } from "./Prompts.ts"; +export { UI } from "./UI.ts"; diff --git a/src/commands/prompts/feature.ts b/src/commands/prompts/feature.ts new file mode 100644 index 00000000..76b47d1d --- /dev/null +++ b/src/commands/prompts/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "~/base/index.js"; +import { ClackPrompts } from "./ClackPrompts.ts"; +import { ClackUI } from "./ClackUI.ts"; + +export const PromptsFeature = createFeature({ + name: "Cli/PromptsFeature", + register(container) { + container.register(ClackPrompts).inSingletonScope(); + container.register(ClackUI).inSingletonScope(); + } +}); diff --git a/src/commands/prompts/index.ts b/src/commands/prompts/index.ts new file mode 100644 index 00000000..4444646c --- /dev/null +++ b/src/commands/prompts/index.ts @@ -0,0 +1,2 @@ +export { Prompts, UI } from "./abstractions/index.ts"; +export { PromptsFeature } from "./feature.ts"; diff --git a/src/commands/registry/CommandRegistry.ts b/src/commands/registry/CommandRegistry.ts new file mode 100644 index 00000000..682372b5 --- /dev/null +++ b/src/commands/registry/CommandRegistry.ts @@ -0,0 +1,39 @@ +import type { Container } from "@webiny/di"; +import { ContainerToken } from "~/base/index.js"; +import { Command } from "./abstractions/Command.ts"; +import { CommandRegistry as CommandRegistryAbstraction } from "./abstractions/CommandRegistry.ts"; + +const baseName = (name: string): string => name.split(" ")[0]!; + +class CommandRegistryImpl implements CommandRegistryAbstraction.Interface { + private commands: Command.Interface[] | null = null; + + public constructor(private readonly container: Container) {} + + public list(): Command.Interface[] { + if (this.commands === null) { + this.commands = this.container.resolveAll(Command); + } + return this.commands; + } + + public menu(): Command.Interface[] { + return this.list().filter(command => command.hidden !== true); + } + + public get(name: string): Command.Interface { + const found = this.list().find(command => baseName(command.name) === name); + if (!found) { + const known = this.list() + .map(command => baseName(command.name)) + .join(", "); + throw new Error(`Unknown command "${name}". Known commands: ${known}`); + } + return found; + } +} + +export const CommandRegistry = CommandRegistryAbstraction.createImplementation({ + implementation: CommandRegistryImpl, + dependencies: [ContainerToken] +}); diff --git a/src/commands/registry/abstractions/Command.ts b/src/commands/registry/abstractions/Command.ts new file mode 100644 index 00000000..05e8f492 --- /dev/null +++ b/src/commands/registry/abstractions/Command.ts @@ -0,0 +1,19 @@ +import type { Argv as YargsArgv } from "yargs"; +import { createAbstraction } from "~/base/index.js"; + +export type CommandArgv = Record; + +export interface ICommand { + readonly name: string; + readonly description: string; + readonly hidden?: boolean; + configure(yargs: YargsArgv): YargsArgv; + run(argv: CommandArgv): Promise; +} + +export const Command = createAbstraction("Cli/Command"); + +export namespace Command { + export type Interface = ICommand; + export type Argv = CommandArgv; +} diff --git a/src/commands/registry/abstractions/CommandRegistry.ts b/src/commands/registry/abstractions/CommandRegistry.ts new file mode 100644 index 00000000..534d1b0c --- /dev/null +++ b/src/commands/registry/abstractions/CommandRegistry.ts @@ -0,0 +1,14 @@ +import { createAbstraction } from "~/base/index.js"; +import type { Command } from "./Command.ts"; + +export interface ICommandRegistry { + list(): Command.Interface[]; + menu(): Command.Interface[]; + get(name: string): Command.Interface; +} + +export const CommandRegistry = createAbstraction("Cli/CommandRegistry"); + +export namespace CommandRegistry { + export type Interface = ICommandRegistry; +} diff --git a/src/commands/registry/abstractions/index.ts b/src/commands/registry/abstractions/index.ts new file mode 100644 index 00000000..61b63821 --- /dev/null +++ b/src/commands/registry/abstractions/index.ts @@ -0,0 +1,2 @@ +export { Command } from "./Command.ts"; +export { CommandRegistry } from "./CommandRegistry.ts"; diff --git a/src/commands/registry/feature.ts b/src/commands/registry/feature.ts new file mode 100644 index 00000000..eba41ceb --- /dev/null +++ b/src/commands/registry/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "~/base/index.js"; +import { CommandRegistry } from "./CommandRegistry.ts"; + +export const CommandRegistryFeature = createFeature({ + name: "Cli/CommandRegistryFeature", + register(container) { + container.register(CommandRegistry).inSingletonScope(); + } +}); diff --git a/src/commands/registry/index.ts b/src/commands/registry/index.ts new file mode 100644 index 00000000..7325972f --- /dev/null +++ b/src/commands/registry/index.ts @@ -0,0 +1,2 @@ +export { Command, CommandRegistry } from "./abstractions/index.ts"; +export { CommandRegistryFeature } from "./feature.ts"; diff --git a/src/commands/run/register.ts b/src/commands/run/register.ts deleted file mode 100644 index 68e2ab49..00000000 --- a/src/commands/run/register.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Argv } from "yargs"; -import { handler } from "./handler.ts"; -import { parseSegmentsFilter } from "./segmentsFilter.ts"; -import { TransferWizard } from "./wizard/TransferWizard.ts"; -import { ExitPromptError } from "@inquirer/core"; - -export function registerRunCommand(yargs: Argv): Argv { - return yargs.command( - "$0", - "Transfer Webiny data using a configuration file", - yargs => { - return yargs - .option("config", { - type: "string", - demandOption: false, - description: "Path to configuration file" - }) - .option("preset", { - type: "string", - demandOption: false, - description: "Preset name to run" - }) - .option("dry-run", { - type: "boolean", - default: false, - description: "Read source but skip all writes to target" - }) - .option("segments", { - type: "string", - description: - "Comma-separated list of segment indices to run (e.g. `1,3`). " + - "Use to re-run specific shards after a failure. Defaults to all." - }) - .coerce("segments", parseSegmentsFilter) - .option("log-level", { - type: "string", - choices: ["debug", "info", "warn", "error"] as const, - description: "Log level (default: info)" - }); - }, - async argv => { - const configPath = argv.config as string | undefined; - const preset = argv.preset as string | undefined; - const logLevel = argv["log-level"] as string | undefined; - const dryRun = argv["dry-run"] as boolean; - - if (configPath && preset) { - await handler(configPath, preset, argv.segments, logLevel, dryRun); - return; - } - - const wizard = new TransferWizard(process.cwd()); - try { - const result = await wizard.run(); - if (result === null) { - process.exit(0); - } - await handler( - result.configPath, - result.preset, - argv.segments, - logLevel, - result.dryRun - ); - } catch (err) { - if (err instanceof ExitPromptError) { - process.exit(0); - } - throw err; - } - } - ); -} diff --git a/src/commands/transfer/TransferCommand.ts b/src/commands/transfer/TransferCommand.ts new file mode 100644 index 00000000..431a67d5 --- /dev/null +++ b/src/commands/transfer/TransferCommand.ts @@ -0,0 +1,75 @@ +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import { UI } from "~/commands/prompts/abstractions/UI.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; +import { parseSegmentsFilter } from "./segmentsFilter.ts"; +import { TransferWizard } from "./wizard/TransferWizard.ts"; + +class TransferCommandImpl implements CommandAbstraction.Interface { + public readonly name = "transfer"; + public readonly description = "Transfer Webiny data from a source system to a target system"; + + public constructor( + private readonly prompts: Prompts.Interface, + private readonly ui: UI.Interface + ) {} + + public configure(yargs: Argv): Argv { + return yargs + .option("config", { + type: "string", + demandOption: false, + description: "Path to configuration file" + }) + .option("preset", { + type: "string", + demandOption: false, + description: "Preset name to run" + }) + .option("dry-run", { + type: "boolean", + default: false, + description: "Read source but skip all writes to target" + }) + .option("segments", { + type: "string", + description: + "Comma-separated list of segment indices to run (e.g. `1,3`). " + + "Use to re-run specific shards after a failure. Defaults to all." + }) + .coerce("segments", parseSegmentsFilter) + .option("log-level", { + type: "string", + choices: ["debug", "info", "warn", "error"] as const, + description: "Log level (default: info)" + }); + } + + public async run(argv: CommandAbstraction.Argv): Promise { + const configPath = argv.config as string | undefined; + const preset = argv.preset as string | undefined; + const logLevel = argv["log-level"] as string | undefined; + const dryRun = Boolean(argv["dry-run"]); + const segments = argv.segments as number[] | undefined; + + if (configPath && preset) { + await handler(configPath, preset, segments, logLevel, dryRun); + return EXIT_OK; + } + + const wizard = new TransferWizard(process.cwd(), this.prompts, this.ui); + const result = await wizard.run(); + if (result === null) { + return EXIT_OK; + } + await handler(result.configPath, result.preset, segments, logLevel, result.dryRun); + return EXIT_OK; + } +} + +export const TransferCommand = CommandAbstraction.createImplementation({ + implementation: TransferCommandImpl, + dependencies: [Prompts, UI] +}); diff --git a/src/commands/run/handler.ts b/src/commands/transfer/handler.ts similarity index 100% rename from src/commands/run/handler.ts rename to src/commands/transfer/handler.ts diff --git a/src/commands/run/segmentsFilter.ts b/src/commands/transfer/segmentsFilter.ts similarity index 100% rename from src/commands/run/segmentsFilter.ts rename to src/commands/transfer/segmentsFilter.ts diff --git a/src/commands/run/wizard/TransferWizard.ts b/src/commands/transfer/wizard/TransferWizard.ts similarity index 66% rename from src/commands/run/wizard/TransferWizard.ts rename to src/commands/transfer/wizard/TransferWizard.ts index c1f5270a..a48d9754 100644 --- a/src/commands/run/wizard/TransferWizard.ts +++ b/src/commands/transfer/wizard/TransferWizard.ts @@ -2,7 +2,8 @@ import { join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { stat } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { select, input, confirm } from "@inquirer/prompts"; +import type { Prompts } from "~/commands/prompts/abstractions/Prompts.js"; +import type { UI } from "~/commands/prompts/abstractions/UI.js"; import { discoverProjects } from "./projectDiscovery.ts"; import { discoverConfig } from "./configDiscovery.ts"; import { listAvailablePresetsWithDescriptions } from "./presetDiscovery.ts"; @@ -46,7 +47,6 @@ async function resolveRawValues( return pulumiVals; } - // Both present — check for conflicts on all fields const conflicts: string[] = []; for (const key of [ "region", @@ -66,7 +66,6 @@ async function resolveRawValues( ); } - // Consistent — prefer webiny, but fill in fields from pulumi if webiny lacks them return { region: webinyVals!.region, primaryDynamodbTableName: webinyVals!.primaryDynamodbTableName, @@ -78,51 +77,54 @@ async function resolveRawValues( }; } -function printInstructions(projectDir: string): void { +function buildInstructionsText(projectDir: string): string { const rel = relative(process.cwd(), projectDir); - console.log(` -To populate your .env, you need output from both your source and target Webiny systems. - -Option A — Webiny CLI output (recommended): - In your source system project: yarn webiny output core --json > ${rel}/source.webiny.json - In your target system project: yarn webiny output core --json > ${rel}/target.webiny.json - -Option B — Pulumi state file (use when you don't have Webiny CLI access): - Copy the Pulumi state file from your source system to: ${rel}/source.pulumi.json - Copy the Pulumi state file from your target system to: ${rel}/target.pulumi.json - State files are at: .pulumi/apps/core/.pulumi/stacks/core/.json - -You can mix formats (e.g. source.webiny.json + target.pulumi.json). - -Optionally, drop CMS model exports into ${rel}/models/ - (export from Webiny Admin → CMS → Models → Export) -`); + return [ + "To populate your .env, you need output from both your source and target Webiny systems.", + "", + "Option A — Webiny CLI output (recommended):", + ` In your source system project: yarn webiny output core --json > ${rel}/source.webiny.json`, + ` In your target system project: yarn webiny output core --json > ${rel}/target.webiny.json`, + "", + "Option B — Pulumi state file (use when you don't have Webiny CLI access):", + ` Copy the Pulumi state file from your source system to: ${rel}/source.pulumi.json`, + ` Copy the Pulumi state file from your target system to: ${rel}/target.pulumi.json`, + " State files are at: .pulumi/apps/core/.pulumi/stacks/core/.json", + "", + "You can mix formats (e.g. source.webiny.json + target.pulumi.json).", + "", + `Optionally, drop CMS model exports into ${rel}/models/`, + " (export from Webiny Admin → CMS → Models → Export)" + ].join("\n"); } const CREATE_NEW = "__create__"; export class TransferWizard { - private readonly cwd: string; - - public constructor(cwd: string) { - this.cwd = cwd; - } + public constructor( + private readonly cwd: string, + private readonly prompts: Prompts.Interface, + private readonly ui: UI.Interface + ) {} public async run(): Promise { const projects = await discoverProjects(this.cwd); - const selected = await select({ + const selected = await this.prompts.select({ message: "Which project do you want to transfer?", - choices: [ - ...projects.map(p => ({ value: p, name: p })), - { value: CREATE_NEW, name: "+ Create new project" } + options: [ + ...projects.map(p => ({ value: p, label: p })), + { value: CREATE_NEW, label: "+ Create new project" } ] }); + if (selected === null) { + return null; + } let projectName: string; let justCreated: boolean; if (selected === CREATE_NEW) { - const rawName = await input({ + const rawName = await this.prompts.text({ message: "Project name:", validate: (v: string) => { const slug = slugify(v); @@ -132,9 +134,12 @@ export class TransferWizard { if (existsSync(join(this.cwd, "projects", slug))) { return `Project "projects/${slug}" already exists.`; } - return true; + return undefined; } }); + if (rawName === null) { + return null; + } const newName = slugify(rawName); try { await scaffoldProject({ name: newName, cwd: this.cwd }); @@ -143,7 +148,7 @@ export class TransferWizard { `Failed to create project "${newName}": ${err instanceof Error ? err.message : String(err)}` ); } - console.log(`\n✓ Created projects/${newName}/\n`); + this.ui.note(`Created projects/${newName}/`); projectName = newName; justCreated = true; } else { @@ -163,13 +168,16 @@ export class TransferWizard { } if (!justCreated && envExists && sourceValsInitial !== null && targetValsInitial !== null) { - const choice = await select({ + const choice = await this.prompts.select({ message: ".env already exists. What would you like to do?", - choices: [ - { value: "existing", name: "Use existing .env" }, - { value: "repopulate", name: "Repopulate .env from JSON files" } + options: [ + { value: "existing", label: "Use existing .env" }, + { value: "repopulate", label: "Repopulate .env from JSON files" } ] }); + if (choice === null) { + return null; + } if (choice === "existing") { return await this.runPresetSelection(projectName); } @@ -179,8 +187,14 @@ export class TransferWizard { let targetVals: RawOutputValues | null = targetValsInitial; while (sourceVals === null || targetVals === null) { - printInstructions(projectDir); - await input({ message: "Press Enter when you have placed the files:", default: "" }); + this.ui.note(buildInstructionsText(projectDir), "Setup instructions"); + const enter = await this.prompts.text({ + message: "Press Enter when you have placed the files:", + defaultValue: "" + }); + if (enter === null) { + return null; + } sourceVals = await resolveRawValues(projectDir, "source"); targetVals = await resolveRawValues(projectDir, "target"); } @@ -190,38 +204,41 @@ export class TransferWizard { targetVals.accountId && sourceVals.accountId !== targetVals.accountId ) { - const bold = "\x1b[1m"; - const yellow = "\x1b[33m"; - const dim = "\x1b[2m"; - const reset = "\x1b[0m"; - console.warn( - `\n${bold}${yellow}⚠ Source and target are in different AWS accounts:${reset}` + - `\n ${dim}source:${reset} ${bold}${sourceVals.accountId}${reset}` + - `\n ${dim}target:${reset} ${bold}${targetVals.accountId}${reset}` + - `\n ${dim}Set SOURCE_PROFILE and TARGET_PROFILE in .env to use the correct credentials.${reset}\n` + this.ui.warn( + `Source and target are in different AWS accounts:\n` + + ` source: ${sourceVals.accountId}\n` + + ` target: ${targetVals.accountId}\n` + + ` Set SOURCE_PROFILE and TARGET_PROFILE in .env to use the correct credentials.` ); } const osPresent = !!(sourceVals.osTableName || targetVals.osTableName); - const segmentsRaw = await input({ + const segmentsRaw = await this.prompts.text({ message: "Number of parallel DDB scan segments (SEGMENTS):", - default: "4", + defaultValue: "4", validate: v => { const n = Number(v); if (!Number.isInteger(n) || n < 1) { return "Must be a positive integer."; } - return true; + return undefined; } }); + if (segmentsRaw === null) { + return null; + } let targetOsIndexPrefix = ""; if (osPresent) { - targetOsIndexPrefix = await input({ + const prefix = await this.prompts.text({ message: "OpenSearch index prefix (TARGET_OS_INDEX_PREFIX, leave empty if none):", - default: "" + defaultValue: "" }); + if (prefix === null) { + return null; + } + targetOsIndexPrefix = prefix; } const envValues: EnvValues = { @@ -244,24 +261,21 @@ export class TransferWizard { await writeEnv(projectDir, envValues); - console.log( - `\n✓ .env written to projects/${projectName}/.env\n` + - ` Review it and re-run: yarn transfer\n` + this.ui.note( + `.env written to projects/${projectName}/.env\nReview it and re-run: yarn transfer` ); return null; } - private async runPresetSelection(projectName: string): Promise { + private async runPresetSelection(projectName: string): Promise { const projectDir = resolve(join(this.cwd, "projects", projectName)); const configPath = await discoverConfig(projectDir); if (!configPath) { - console.error( - `\nNo config.ts found in projects/${projectName}/.\n` + - `Run "yarn transfer" to set up the project first.\n` + throw new Error( + `No config.ts found in projects/${projectName}/. Run "yarn transfer" to set up the project first.` ); - process.exit(1); } let presetsDir: string | undefined; @@ -275,22 +289,27 @@ export class TransferWizard { const presets = await listAvailablePresetsWithDescriptions(presetsDir); if (presets.length === 0) { - console.error("\nNo presets available. Check your presetsDir configuration.\n"); - process.exit(1); + throw new Error("No presets available. Check your presetsDir configuration."); } - const preset = await select({ + const preset = await this.prompts.select({ message: "Which preset do you want to run?", - choices: presets.map(p => ({ + options: presets.map(p => ({ value: p.name, - name: p.description ? `${p.name} — ${p.description}` : p.name + label: p.description ? `${p.name} — ${p.description}` : p.name })) }); + if (preset === null) { + return null; + } - const dryRun = await confirm({ + const dryRun = await this.prompts.confirm({ message: "Dry run? (reads source, skips all writes to target)", - default: false + initialValue: false }); + if (dryRun === null) { + return null; + } return { configPath, preset, dryRun }; } diff --git a/src/commands/run/wizard/configDiscovery.ts b/src/commands/transfer/wizard/configDiscovery.ts similarity index 100% rename from src/commands/run/wizard/configDiscovery.ts rename to src/commands/transfer/wizard/configDiscovery.ts diff --git a/src/commands/run/wizard/envWriter.ts b/src/commands/transfer/wizard/envWriter.ts similarity index 100% rename from src/commands/run/wizard/envWriter.ts rename to src/commands/transfer/wizard/envWriter.ts diff --git a/src/commands/run/wizard/presetDiscovery.ts b/src/commands/transfer/wizard/presetDiscovery.ts similarity index 100% rename from src/commands/run/wizard/presetDiscovery.ts rename to src/commands/transfer/wizard/presetDiscovery.ts diff --git a/src/commands/run/wizard/projectDiscovery.ts b/src/commands/transfer/wizard/projectDiscovery.ts similarity index 100% rename from src/commands/run/wizard/projectDiscovery.ts rename to src/commands/transfer/wizard/projectDiscovery.ts diff --git a/src/commands/run/wizard/schemas/pulumiState.schema.ts b/src/commands/transfer/wizard/schemas/pulumiState.schema.ts similarity index 100% rename from src/commands/run/wizard/schemas/pulumiState.schema.ts rename to src/commands/transfer/wizard/schemas/pulumiState.schema.ts diff --git a/src/commands/run/wizard/schemas/webinyOutput.schema.ts b/src/commands/transfer/wizard/schemas/webinyOutput.schema.ts similarity index 100% rename from src/commands/run/wizard/schemas/webinyOutput.schema.ts rename to src/commands/transfer/wizard/schemas/webinyOutput.schema.ts diff --git a/src/commands/run/wizard/sources/PulumiStateSource.ts b/src/commands/transfer/wizard/sources/PulumiStateSource.ts similarity index 100% rename from src/commands/run/wizard/sources/PulumiStateSource.ts rename to src/commands/transfer/wizard/sources/PulumiStateSource.ts diff --git a/src/commands/run/wizard/sources/WebinyOutputSource.ts b/src/commands/transfer/wizard/sources/WebinyOutputSource.ts similarity index 100% rename from src/commands/run/wizard/sources/WebinyOutputSource.ts rename to src/commands/transfer/wizard/sources/WebinyOutputSource.ts diff --git a/src/commands/run/wizard/types.ts b/src/commands/transfer/wizard/types.ts similarity index 100% rename from src/commands/run/wizard/types.ts rename to src/commands/transfer/wizard/types.ts diff --git a/src/commands/updateSkills/UpdateSkillsCommand.ts b/src/commands/updateSkills/UpdateSkillsCommand.ts new file mode 100644 index 00000000..bac0a62a --- /dev/null +++ b/src/commands/updateSkills/UpdateSkillsCommand.ts @@ -0,0 +1,25 @@ +import type { Argv } from "yargs"; +import { Command as CommandAbstraction } from "~/commands/registry/abstractions/Command.js"; +import { EXIT_OK } from "~/commands/exitCodes.js"; +import { handler } from "./handler.ts"; + +class UpdateSkillsCommandImpl implements CommandAbstraction.Interface { + public readonly name = "update-skills"; + public readonly description = + "Update Claude Code skills from the installed @webiny/data-transfer package"; + public readonly hidden = true; + + public configure(yargs: Argv): Argv { + return yargs; + } + + public async run(): Promise { + handler(); + return EXIT_OK; + } +} + +export const UpdateSkillsCommand = CommandAbstraction.createImplementation({ + implementation: UpdateSkillsCommandImpl, + dependencies: [] +}); diff --git a/src/commands/updateSkills/register.ts b/src/commands/updateSkills/register.ts deleted file mode 100644 index 731f801d..00000000 --- a/src/commands/updateSkills/register.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Argv } from "yargs"; -import { handler } from "./handler.ts"; - -export function registerUpdateSkillsCommand(yargs: Argv): Argv { - return yargs.command( - "update-skills", - "Update Claude Code skills from the installed @webiny/data-transfer package", - () => {}, - () => { - handler(); - } - ); -} diff --git a/src/features/FixLive/BaseLiveFieldRunner.ts b/src/features/FixLive/BaseLiveFieldRunner.ts new file mode 100644 index 00000000..bfb915e7 --- /dev/null +++ b/src/features/FixLive/BaseLiveFieldRunner.ts @@ -0,0 +1,212 @@ +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import type { Logger } from "~/tools/Logger/abstractions/Logger.js"; +import type { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import type { LiveFieldRunner } from "./abstractions/LiveFieldRunner.ts"; +import type { ChangeReport } from "./abstractions/ChangeReport.ts"; +import { createEmptyStats } from "./createEmptyStats.ts"; +import { runConcurrently } from "./runConcurrently.ts"; +import { isCmsEntryRow } from "./cmsEntryGuards.ts"; + +const DEFAULT_SEGMENT_CONCURRENCY = 4; +const DEFAULT_WRITE_CONCURRENCY = 8; +const LATEST_SK = "L"; +const MD_ATTRIBUTE = "_md"; + +export interface ReadyGroup { + kind: "ready"; + records: Map; +} + +export interface IgnoredGroup { + kind: "ignored"; +} + +export interface SkippedGroup { + kind: "skipped"; + reason: LiveFieldReconciler.SkipReason; + detail?: string; +} + +export type GroupPreparation = ReadyGroup | IgnoredGroup | SkippedGroup; + +export interface AttributeWrite { + path: string[]; + value: unknown; +} + +interface SegmentRun { + segment: number; + totalSegments: number; +} + +export abstract class BaseLiveFieldRunner implements LiveFieldRunner.Interface { + protected abstract readonly table: LiveFieldReconciler.Table; + + protected constructor( + protected readonly reconciler: LiveFieldReconciler.Interface, + protected readonly logger: Logger.Interface + ) {} + + protected abstract acceptsRow(row: DatabaseRecord): boolean; + + protected abstract prepareGroup(pk: string, rows: DatabaseRecord[]): Promise; + + protected abstract buildWrite( + change: LiveFieldReconciler.Change, + record: LiveFieldReconciler.Record + ): Promise; + + public async run(options: LiveFieldRunner.Options): Promise { + const stats = createEmptyStats(); + const segments: SegmentRun[] = []; + for (let segment = 0; segment < options.target.segments; segment++) { + segments.push({ segment, totalSegments: options.target.segments }); + } + const concurrency = options.target.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY; + + await runConcurrently(segments, concurrency, run => this.runSegment(run, options, stats)); + + options.onProgress(stats); + return stats; + } + + private async runSegment( + run: SegmentRun, + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats + ): Promise { + const { client, tableName } = options.target; + const rows = client.scan(tableName, { + segment: run.segment, + totalSegments: run.totalSegments, + sortKeyEquals: LATEST_SK + }); + + for await (const row of rows) { + stats.scanned++; + if (!isCmsEntryRow(row) || !this.acceptsRow(row)) { + options.onProgress(stats); + continue; + } + + const groupRows = await client.queryAll(tableName, row.PK); + const prepared = await this.prepareGroup(row.PK, groupRows); + if (prepared.kind === "ignored") { + options.onProgress(stats); + continue; + } + + stats.entries++; + if (prepared.kind === "skipped") { + this.recordSkip(options, stats, { + pk: row.PK, + sk: LATEST_SK, + reason: prepared.reason, + detail: prepared.detail + }); + options.onProgress(stats); + continue; + } + + const decision = this.reconciler.decide({ + pk: row.PK, + table: this.table, + records: prepared.records + }); + for (const skip of decision.skips) { + this.recordSkip(options, stats, skip); + } + await this.applyChanges(decision.changes, prepared.records, options, stats); + options.onProgress(stats); + } + + this.logger.debug( + `fix-live[${this.table}]: segment ${run.segment + 1}/${run.totalSegments} done — ${stats.scanned} rows scanned so far` + ); + } + + private async applyChanges( + changes: LiveFieldReconciler.Change[], + records: Map, + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats + ): Promise { + for (const change of changes) { + stats.changes[change.reason]++; + } + if (options.mode === "dry-run") { + for (const change of changes) { + options.report.change(this.toReportChange(change, "dry-run")); + } + return; + } + const writeConcurrency = options.target.writeConcurrency ?? DEFAULT_WRITE_CONCURRENCY; + await runConcurrently(changes, writeConcurrency, change => + this.write(change, records, options, stats) + ); + } + + private async write( + change: LiveFieldReconciler.Change, + records: Map, + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats + ): Promise { + const record = records.get(change.sk); + if (!record) { + throw new Error( + `fix-live: decide() emitted a change for ${change.pk} ${change.sk}, which is not in the group` + ); + } + const { path, value } = await this.buildWrite(change, record); + const result = await options.target.client.updateAttribute(options.target.tableName, { + key: { PK: change.pk, SK: change.sk }, + path, + value, + condition: { attribute: MD_ATTRIBUTE, equals: change.expectedMd } + }); + + if (result === "written") { + stats.written++; + options.report.change(this.toReportChange(change, "written")); + return; + } + stats.conditionFailed++; + options.report.change(this.toReportChange(change, "condition-failed")); + this.recordSkip(options, stats, { + pk: change.pk, + sk: change.sk, + reason: "changed-during-run" + }); + } + + private recordSkip( + options: LiveFieldRunner.Options, + stats: LiveFieldRunner.Stats, + skip: LiveFieldReconciler.Skip + ): void { + stats.skips[skip.reason]++; + options.report.skip({ + table: this.table, + pk: skip.pk, + sk: skip.sk, + reason: skip.reason, + detail: skip.detail + }); + } + + private toReportChange( + change: LiveFieldReconciler.Change, + result: ChangeReport.Result + ): ChangeReport.Change { + return { + table: this.table, + pk: change.pk, + sk: change.sk, + reason: change.reason, + before: change.before, + after: change.after, + result + }; + } +} diff --git a/src/features/FixLive/ChangeReport.ts b/src/features/FixLive/ChangeReport.ts new file mode 100644 index 00000000..76e1e721 --- /dev/null +++ b/src/features/FixLive/ChangeReport.ts @@ -0,0 +1,62 @@ +import { join } from "node:path"; +import { ChangeReport as ChangeReportAbstraction } from "./abstractions/ChangeReport.ts"; +import { TransferContext } from "~/features/TransferLifecycle/abstractions/TransferContext.js"; +import { FileTool } from "~/tools/FileTool/abstractions/FileTool.js"; + +export type { IChangeReport } from "./abstractions/ChangeReport.js"; + +const REPORT_FILE_NAME = "fix-live-report.jsonl"; + +interface ChangeLine extends ChangeReportAbstraction.Change { + kind: "change"; +} + +interface SkipLine extends ChangeReportAbstraction.Skip { + kind: "skip"; +} + +type ReportLine = ChangeLine | SkipLine; + +class JsonlChangeReportImpl implements ChangeReportAbstraction.Interface { + public readonly path: string; + + public constructor( + transferContext: TransferContext.Interface, + private readonly fileTool: FileTool.Interface + ) { + this.path = join(process.cwd(), ".transfer", transferContext.runId, REPORT_FILE_NAME); + } + + public change(entry: ChangeReportAbstraction.Change): void { + this.append({ + kind: "change", + table: entry.table, + pk: entry.pk, + sk: entry.sk, + reason: entry.reason, + before: entry.before === undefined ? null : entry.before, + after: entry.after, + result: entry.result + }); + } + + public skip(entry: ChangeReportAbstraction.Skip): void { + this.append({ + kind: "skip", + table: entry.table, + pk: entry.pk, + sk: entry.sk, + reason: entry.reason, + detail: entry.detail + }); + } + + private append(line: ReportLine): void { + this.fileTool.appendLineOrThrow(this.path, JSON.stringify(line)); + } +} + +export const ChangeReport = ChangeReportAbstraction.createImplementation({ + implementation: JsonlChangeReportImpl, + dependencies: [TransferContext, FileTool] +}); diff --git a/src/features/FixLive/DdbLiveFieldRunner.ts b/src/features/FixLive/DdbLiveFieldRunner.ts new file mode 100644 index 00000000..7c6592d1 --- /dev/null +++ b/src/features/FixLive/DdbLiveFieldRunner.ts @@ -0,0 +1,50 @@ +import { DdbLiveFieldRunner as DdbLiveFieldRunnerAbstraction } from "./abstractions/LiveFieldRunner.ts"; +import { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import { Logger } from "~/tools/Logger/abstractions/Logger.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { + BaseLiveFieldRunner, + type AttributeWrite, + type GroupPreparation +} from "./BaseLiveFieldRunner.ts"; +import { isInternalModel, readModelId } from "./cmsEntryGuards.ts"; + +export type { ILiveFieldRunner } from "./abstractions/LiveFieldRunner.js"; + +class DdbLiveFieldRunnerImpl extends BaseLiveFieldRunner { + protected readonly table: LiveFieldReconciler.Table = "ddb"; + + public constructor(reconciler: LiveFieldReconciler.Interface, logger: Logger.Interface) { + super(reconciler, logger); + } + + protected acceptsRow(row: DatabaseRecord): boolean { + return !isInternalModel(readModelId(row)); + } + + protected async prepareGroup(_pk: string, rows: DatabaseRecord[]): Promise { + const records = new Map(); + for (const row of rows) { + records.set(row.SK, toReconcilable(row)); + } + return { kind: "ready", records }; + } + + protected async buildWrite(change: LiveFieldReconciler.Change): Promise { + return { path: ["data", "live"], value: change.after }; + } +} + +function toReconcilable(row: DatabaseRecord): LiveFieldReconciler.Record { + const data = row.data; + return { + ...row, + _md: typeof row._md === "string" ? row._md : "", + data: typeof data === "object" && data !== null ? (data as Record) : {} + }; +} + +export const DdbLiveFieldRunner = DdbLiveFieldRunnerAbstraction.createImplementation({ + implementation: DdbLiveFieldRunnerImpl, + dependencies: [LiveFieldReconciler, Logger] +}); diff --git a/src/features/FixLive/FixLiveState.ts b/src/features/FixLive/FixLiveState.ts new file mode 100644 index 00000000..99ac0ed7 --- /dev/null +++ b/src/features/FixLive/FixLiveState.ts @@ -0,0 +1,50 @@ +import { join } from "node:path"; +import { FixLiveState as FixLiveStateAbstraction } from "./abstractions/FixLiveState.ts"; +import { FileTool } from "~/tools/FileTool/abstractions/FileTool.js"; + +export type { IFixLiveState } from "./abstractions/FixLiveState.js"; + +class FixLiveStateImpl implements FixLiveStateAbstraction.Interface { + public constructor(private readonly fileTool: FileTool.Interface) {} + + public pathFor(key: FixLiveStateAbstraction.Key): string { + return join( + process.cwd(), + ".transfer", + "state", + "fix-live", + `${key.project}__${key.system}.json` + ); + } + + public read(key: FixLiveStateAbstraction.Key): FixLiveStateAbstraction.File | null { + const path = this.pathFor(key); + if (!this.fileTool.exists(path)) { + return null; + } + return JSON.parse(this.fileTool.readFileOrThrow(path)) as FixLiveStateAbstraction.File; + } + + public recordDryRun( + key: FixLiveStateAbstraction.Key, + summary: FixLiveStateAbstraction.RunSummary + ): void { + this.write(key, { ...(this.read(key) ?? {}), lastDryRun: summary }); + } + + public recordLiveRun( + key: FixLiveStateAbstraction.Key, + summary: FixLiveStateAbstraction.LiveRunSummary + ): void { + this.write(key, { ...(this.read(key) ?? {}), lastLiveRun: summary }); + } + + private write(key: FixLiveStateAbstraction.Key, file: FixLiveStateAbstraction.File): void { + this.fileTool.writeFileOrThrow(this.pathFor(key), `${JSON.stringify(file, null, 2)}\n`); + } +} + +export const FixLiveState = FixLiveStateAbstraction.createImplementation({ + implementation: FixLiveStateImpl, + dependencies: [FileTool] +}); diff --git a/src/features/FixLive/LiveFieldReconciler.ts b/src/features/FixLive/LiveFieldReconciler.ts new file mode 100644 index 00000000..1662869c --- /dev/null +++ b/src/features/FixLive/LiveFieldReconciler.ts @@ -0,0 +1,151 @@ +import { LiveFieldReconciler as LiveFieldReconcilerAbstraction } from "./abstractions/LiveFieldReconciler.ts"; + +export type { ILiveFieldReconciler } from "./abstractions/LiveFieldReconciler.js"; + +const LATEST_SK = "L"; +const PUBLISHED_SK = "P"; +const PUBLISHED_STATUS = "published"; + +type SkipWithoutPk = Omit; + +class LiveFieldReconcilerImpl implements LiveFieldReconcilerAbstraction.Interface { + public decide( + group: LiveFieldReconcilerAbstraction.Group + ): LiveFieldReconcilerAbstraction.Decision { + const latest = group.records.get(LATEST_SK); + if (!latest) { + return this.skip(group, { reason: "no-latest-record" }); + } + const published = group.records.get(PUBLISHED_SK); + if (!published) { + return this.decideUnpublished(group, latest); + } + return this.decidePublished(group, latest, published); + } + + private decideUnpublished( + group: LiveFieldReconcilerAbstraction.Group, + latest: LiveFieldReconcilerAbstraction.Record + ): LiveFieldReconcilerAbstraction.Decision { + if (latest.data.status === PUBLISHED_STATUS) { + return this.skip(group, { + sk: LATEST_SK, + reason: "latest-status-contradicts-unpublished", + detail: "P missing while L.status=published" + }); + } + return { changes: this.reconcile(group.pk, latest, null), skips: [] }; + } + + private decidePublished( + group: LiveFieldReconcilerAbstraction.Group, + latest: LiveFieldReconcilerAbstraction.Record, + published: LiveFieldReconcilerAbstraction.Record + ): LiveFieldReconcilerAbstraction.Decision { + const version = published.data.version; + if (!isPositiveInteger(version)) { + return this.skip(group, { + sk: PUBLISHED_SK, + reason: "invalid-version", + detail: `P.version=${String(version)}` + }); + } + + const latestVersion = latest.data.version; + const latestStatus = latest.data.status; + if (latestStatus === PUBLISHED_STATUS && latestVersion !== version) { + return this.skip(group, { + sk: LATEST_SK, + reason: "latest-status-contradicts-published", + detail: `L.status=published L.version=${String(latestVersion)} P.version=${version}` + }); + } + if (latestVersion === version && latestStatus !== PUBLISHED_STATUS) { + return this.skip(group, { + sk: LATEST_SK, + reason: "latest-status-contradicts-published", + detail: `L.version=P.version=${version} but L.status=${String(latestStatus)}` + }); + } + + const targets: LiveFieldReconcilerAbstraction.Record[] = [latest, published]; + if (group.table === "ddb") { + const revisionSk = `REV#${padVersion(version)}`; + const revision = group.records.get(revisionSk); + if (!revision) { + return this.skip(group, { + sk: revisionSk, + reason: "revision-record-missing", + detail: `P.version=${version}` + }); + } + if (revision.data.version !== version) { + return this.skip(group, { + sk: revisionSk, + reason: "revision-version-mismatch", + detail: `P.version=${version} ${revisionSk}.version=${String(revision.data.version)}` + }); + } + targets.push(revision); + } + + const expected: LiveFieldReconcilerAbstraction.LiveValue = { version }; + const changes = targets.flatMap(record => this.reconcile(group.pk, record, expected)); + return { changes, skips: [] }; + } + + private reconcile( + pk: string, + record: LiveFieldReconcilerAbstraction.Record, + expected: LiveFieldReconcilerAbstraction.LiveValue | null + ): LiveFieldReconcilerAbstraction.Change[] { + const live = record.data.live; + const base = { pk, sk: record.SK, before: live, expectedMd: record._md }; + + if (expected === null) { + if (live === undefined || live === null) { + return []; + } + return [{ ...base, after: null, reason: "stale-live" }]; + } + if (live === undefined || live === null) { + return [{ ...base, after: expected, reason: "missing-live" }]; + } + const current = readLiveVersion(live); + if (current === null) { + return [{ ...base, after: expected, reason: "empty-live" }]; + } + if (current !== expected.version) { + return [{ ...base, after: expected, reason: "wrong-version" }]; + } + return []; + } + + private skip( + group: LiveFieldReconcilerAbstraction.Group, + skip: SkipWithoutPk + ): LiveFieldReconcilerAbstraction.Decision { + return { changes: [], skips: [{ pk: group.pk, ...skip }] }; + } +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function padVersion(version: number): string { + return String(version).padStart(4, "0"); +} + +function readLiveVersion(live: unknown): number | null { + if (!live || typeof live !== "object") { + return null; + } + const { version } = live as Record; + return isPositiveInteger(version) ? version : null; +} + +export const LiveFieldReconciler = LiveFieldReconcilerAbstraction.createImplementation({ + implementation: LiveFieldReconcilerImpl, + dependencies: [] +}); diff --git a/src/features/FixLive/OsLiveFieldRunner.ts b/src/features/FixLive/OsLiveFieldRunner.ts new file mode 100644 index 00000000..a2c51428 --- /dev/null +++ b/src/features/FixLive/OsLiveFieldRunner.ts @@ -0,0 +1,82 @@ +import { CompressionHandler } from "@webiny/utils/exports/api.js"; +import { OsLiveFieldRunner as OsLiveFieldRunnerAbstraction } from "./abstractions/LiveFieldRunner.ts"; +import { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import { OsRecordDecompressor } from "~/features/OsRecordDecompressor/abstractions/OsRecordDecompressor.js"; +import { Logger } from "~/tools/Logger/abstractions/Logger.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import { + BaseLiveFieldRunner, + type AttributeWrite, + type GroupPreparation +} from "./BaseLiveFieldRunner.ts"; +import { isInternalModel } from "./cmsEntryGuards.ts"; + +export type { ILiveFieldRunner } from "./abstractions/LiveFieldRunner.js"; + +const LATEST_SK = "L"; + +class OsLiveFieldRunnerImpl extends BaseLiveFieldRunner { + protected readonly table: LiveFieldReconciler.Table = "os"; + + public constructor( + reconciler: LiveFieldReconciler.Interface, + logger: Logger.Interface, + private readonly decompressor: OsRecordDecompressor.Interface, + private readonly compression: CompressionHandler.Interface + ) { + super(reconciler, logger); + } + + protected acceptsRow(_row: DatabaseRecord): boolean { + return true; + } + + protected async prepareGroup(_pk: string, rows: DatabaseRecord[]): Promise { + const records = new Map(); + for (const row of rows) { + const data = await this.decompressRow(row); + if (data === null) { + return { + kind: "skipped", + reason: "decompress-failed", + detail: `SK=${row.SK}` + }; + } + records.set(row.SK, { + ...row, + _md: typeof row._md === "string" ? row._md : "", + data + }); + } + const latest = records.get(LATEST_SK); + if (latest && isInternalModel(latest.data.modelId)) { + return { kind: "ignored" }; + } + return { kind: "ready", records }; + } + + protected async buildWrite( + change: LiveFieldReconciler.Change, + record: LiveFieldReconciler.Record + ): Promise { + const data = { ...record.data, live: change.after }; + const compressed = await this.compression.compress(data); + return { path: ["data"], value: compressed }; + } + + private async decompressRow(row: DatabaseRecord): Promise | null> { + try { + return await this.decompressor.decompress(row as OsRecordDecompressor.Compressed); + } catch (error) { + this.logger.warn( + `fix-live[os]: failed to decompress ${row.PK} ${row.SK}: ${String(error)}` + ); + return null; + } + } +} + +export const OsLiveFieldRunner = OsLiveFieldRunnerAbstraction.createImplementation({ + implementation: OsLiveFieldRunnerImpl, + dependencies: [LiveFieldReconciler, Logger, OsRecordDecompressor, CompressionHandler] +}); diff --git a/src/features/FixLive/abstractions/ChangeReport.ts b/src/features/FixLive/abstractions/ChangeReport.ts new file mode 100644 index 00000000..fb243764 --- /dev/null +++ b/src/features/FixLive/abstractions/ChangeReport.ts @@ -0,0 +1,37 @@ +import { createAbstraction } from "~/base/index.js"; +import type { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; + +export type ChangeReportResult = "dry-run" | "written" | "condition-failed"; + +export interface ChangeReportChange { + table: LiveFieldReconciler.Table; + pk: string; + sk: string; + reason: LiveFieldReconciler.ChangeReason; + before: unknown; + after: LiveFieldReconciler.LiveValue | null; + result: ChangeReportResult; +} + +export interface ChangeReportSkip { + table: LiveFieldReconciler.Table; + pk: string; + sk?: string; + reason: LiveFieldReconciler.SkipReason; + detail?: string; +} + +export interface IChangeReport { + readonly path: string; + change(entry: ChangeReportChange): void; + skip(entry: ChangeReportSkip): void; +} + +export const ChangeReport = createAbstraction("FixLive/ChangeReport"); + +export namespace ChangeReport { + export type Interface = IChangeReport; + export type Result = ChangeReportResult; + export type Change = ChangeReportChange; + export type Skip = ChangeReportSkip; +} diff --git a/src/features/FixLive/abstractions/FixLiveState.ts b/src/features/FixLive/abstractions/FixLiveState.ts new file mode 100644 index 00000000..bd111aac --- /dev/null +++ b/src/features/FixLive/abstractions/FixLiveState.ts @@ -0,0 +1,40 @@ +import { createAbstraction } from "~/base/index.js"; + +export interface FixLiveRunSummary { + runId: string; + at: string; + changes: number; + skips: number; +} + +export interface FixLiveLiveRunSummary extends FixLiveRunSummary { + written: number; + conditionFailed: number; +} + +export interface FixLiveStateFile { + lastDryRun?: FixLiveRunSummary; + lastLiveRun?: FixLiveLiveRunSummary; +} + +export interface FixLiveStateKey { + project: string; + system: "source" | "target"; +} + +export interface IFixLiveState { + pathFor(key: FixLiveStateKey): string; + read(key: FixLiveStateKey): FixLiveStateFile | null; + recordDryRun(key: FixLiveStateKey, summary: FixLiveRunSummary): void; + recordLiveRun(key: FixLiveStateKey, summary: FixLiveLiveRunSummary): void; +} + +export const FixLiveState = createAbstraction("FixLive/State"); + +export namespace FixLiveState { + export type Interface = IFixLiveState; + export type Key = FixLiveStateKey; + export type RunSummary = FixLiveRunSummary; + export type LiveRunSummary = FixLiveLiveRunSummary; + export type File = FixLiveStateFile; +} diff --git a/src/features/FixLive/abstractions/LiveFieldReconciler.ts b/src/features/FixLive/abstractions/LiveFieldReconciler.ts new file mode 100644 index 00000000..19b21d3a --- /dev/null +++ b/src/features/FixLive/abstractions/LiveFieldReconciler.ts @@ -0,0 +1,71 @@ +import { createAbstraction } from "~/base/index.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; + +export type LiveFieldTable = "ddb" | "os"; + +export interface ReconcilableRecord extends DatabaseRecord { + _md: string; + data: Record; +} + +export interface LiveFieldGroup { + pk: string; + table: LiveFieldTable; + records: Map; +} + +export interface LiveFieldValue { + version: number; +} + +export type LiveFieldChangeReason = "missing-live" | "empty-live" | "wrong-version" | "stale-live"; + +export type LiveFieldSkipReason = + | "no-latest-record" + | "invalid-version" + | "revision-record-missing" + | "revision-version-mismatch" + | "latest-status-contradicts-published" + | "latest-status-contradicts-unpublished" + | "decompress-failed" + | "changed-during-run"; + +export interface LiveFieldChange { + pk: string; + sk: string; + before: unknown; + after: LiveFieldValue | null; + reason: LiveFieldChangeReason; + expectedMd: string; +} + +export interface LiveFieldSkip { + pk: string; + sk?: string; + reason: LiveFieldSkipReason; + detail?: string; +} + +export interface LiveFieldDecision { + changes: LiveFieldChange[]; + skips: LiveFieldSkip[]; +} + +export interface ILiveFieldReconciler { + decide(group: LiveFieldGroup): LiveFieldDecision; +} + +export const LiveFieldReconciler = createAbstraction("FixLive/Reconciler"); + +export namespace LiveFieldReconciler { + export type Interface = ILiveFieldReconciler; + export type Table = LiveFieldTable; + export type Record = ReconcilableRecord; + export type Group = LiveFieldGroup; + export type LiveValue = LiveFieldValue; + export type Change = LiveFieldChange; + export type Skip = LiveFieldSkip; + export type Decision = LiveFieldDecision; + export type ChangeReason = LiveFieldChangeReason; + export type SkipReason = LiveFieldSkipReason; +} diff --git a/src/features/FixLive/abstractions/LiveFieldRunner.ts b/src/features/FixLive/abstractions/LiveFieldRunner.ts new file mode 100644 index 00000000..f27d4096 --- /dev/null +++ b/src/features/FixLive/abstractions/LiveFieldRunner.ts @@ -0,0 +1,45 @@ +import { createAbstraction } from "~/base/index.js"; +import type { SourceDynamoDbClient } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; +import type { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; +import type { ChangeReport } from "./ChangeReport.ts"; + +export type LiveFieldRunMode = "dry-run" | "live"; + +export interface LiveFieldRunTarget { + client: SourceDynamoDbClient.Interface; + tableName: string; + segments: number; + concurrency?: number; + writeConcurrency?: number; +} + +export interface LiveFieldRunStats { + scanned: number; + entries: number; + changes: Record; + skips: Record; + written: number; + conditionFailed: number; +} + +export interface LiveFieldRunOptions { + mode: LiveFieldRunMode; + target: LiveFieldRunTarget; + report: ChangeReport.Interface; + onProgress(stats: LiveFieldRunStats): void; +} + +export interface ILiveFieldRunner { + run(options: LiveFieldRunOptions): Promise; +} + +export const DdbLiveFieldRunner = createAbstraction("FixLive/DdbRunner"); +export const OsLiveFieldRunner = createAbstraction("FixLive/OsRunner"); + +export namespace LiveFieldRunner { + export type Interface = ILiveFieldRunner; + export type Mode = LiveFieldRunMode; + export type Target = LiveFieldRunTarget; + export type Options = LiveFieldRunOptions; + export type Stats = LiveFieldRunStats; +} diff --git a/src/features/FixLive/abstractions/index.ts b/src/features/FixLive/abstractions/index.ts new file mode 100644 index 00000000..c142a660 --- /dev/null +++ b/src/features/FixLive/abstractions/index.ts @@ -0,0 +1,5 @@ +export { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; +export { DdbLiveFieldRunner, OsLiveFieldRunner } from "./LiveFieldRunner.ts"; +export type { LiveFieldRunner } from "./LiveFieldRunner.ts"; +export { ChangeReport } from "./ChangeReport.ts"; +export { FixLiveState } from "./FixLiveState.ts"; diff --git a/src/features/FixLive/cmsEntryGuards.ts b/src/features/FixLive/cmsEntryGuards.ts new file mode 100644 index 00000000..9a8ea0f1 --- /dev/null +++ b/src/features/FixLive/cmsEntryGuards.ts @@ -0,0 +1,21 @@ +import { isCmsEntry } from "~/domain/transform/filters.js"; +import type { BaseRecord } from "~/domain/transform/types/records.js"; +import type { DatabaseRecord } from "~/services/DynamoDbClient/abstractions/DynamoDbClient.js"; + +const INTERNAL_MODELS = new Set(["fmfile", "wbyfmfile"]); + +export function isCmsEntryRow(row: DatabaseRecord): boolean { + return isCmsEntry(row as BaseRecord); +} + +export function isInternalModel(modelId: unknown): boolean { + return typeof modelId === "string" && INTERNAL_MODELS.has(modelId.toLowerCase()); +} + +export function readModelId(record: DatabaseRecord): unknown { + if (record.modelId !== undefined) { + return record.modelId; + } + const data = record.data as Record | undefined; + return data?.modelId; +} diff --git a/src/features/FixLive/createEmptyStats.ts b/src/features/FixLive/createEmptyStats.ts new file mode 100644 index 00000000..abc7a7ac --- /dev/null +++ b/src/features/FixLive/createEmptyStats.ts @@ -0,0 +1,32 @@ +import type { LiveFieldReconciler } from "./abstractions/LiveFieldReconciler.ts"; +import type { LiveFieldRunner } from "./abstractions/LiveFieldRunner.ts"; + +export const CHANGE_REASONS: readonly LiveFieldReconciler.ChangeReason[] = [ + "missing-live", + "empty-live", + "wrong-version", + "stale-live" +]; + +export const SKIP_REASONS: readonly LiveFieldReconciler.SkipReason[] = [ + "no-latest-record", + "invalid-version", + "revision-record-missing", + "revision-version-mismatch", + "latest-status-contradicts-published", + "latest-status-contradicts-unpublished", + "decompress-failed", + "changed-during-run" +]; + +export function createEmptyStats(): LiveFieldRunner.Stats { + const changes = Object.fromEntries(CHANGE_REASONS.map(reason => [reason, 0])) as Record< + LiveFieldReconciler.ChangeReason, + number + >; + const skips = Object.fromEntries(SKIP_REASONS.map(reason => [reason, 0])) as Record< + LiveFieldReconciler.SkipReason, + number + >; + return { scanned: 0, entries: 0, changes, skips, written: 0, conditionFailed: 0 }; +} diff --git a/src/features/FixLive/feature.ts b/src/features/FixLive/feature.ts new file mode 100644 index 00000000..6f8fdb54 --- /dev/null +++ b/src/features/FixLive/feature.ts @@ -0,0 +1,17 @@ +import { createFeature } from "~/base/index.js"; +import { LiveFieldReconciler } from "./LiveFieldReconciler.ts"; +import { ChangeReport } from "./ChangeReport.ts"; +import { FixLiveState } from "./FixLiveState.ts"; +import { DdbLiveFieldRunner } from "./DdbLiveFieldRunner.ts"; +import { OsLiveFieldRunner } from "./OsLiveFieldRunner.ts"; + +export const FixLiveFeature = createFeature({ + name: "FixLive/FixLiveFeature", + register(container) { + container.register(LiveFieldReconciler).inSingletonScope(); + container.register(ChangeReport).inSingletonScope(); + container.register(FixLiveState).inSingletonScope(); + container.register(DdbLiveFieldRunner).inSingletonScope(); + container.register(OsLiveFieldRunner).inSingletonScope(); + } +}); diff --git a/src/features/FixLive/index.ts b/src/features/FixLive/index.ts new file mode 100644 index 00000000..acebd496 --- /dev/null +++ b/src/features/FixLive/index.ts @@ -0,0 +1,9 @@ +export { + LiveFieldReconciler, + DdbLiveFieldRunner, + OsLiveFieldRunner, + ChangeReport, + FixLiveState +} from "./abstractions/index.ts"; +export type { LiveFieldRunner } from "./abstractions/index.ts"; +export { FixLiveFeature } from "./feature.ts"; diff --git a/src/features/FixLive/runConcurrently.ts b/src/features/FixLive/runConcurrently.ts new file mode 100644 index 00000000..eb52bd43 --- /dev/null +++ b/src/features/FixLive/runConcurrently.ts @@ -0,0 +1,21 @@ +export async function runConcurrently( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const queue = [...items]; + const size = Math.max(1, Math.min(limit, queue.length)); + const workers: Promise[] = []; + + for (let i = 0; i < size; i++) { + workers.push( + (async () => { + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + await fn(next); + } + })() + ); + } + + await Promise.all(workers); +} diff --git a/src/features/OsProcessor/OsProcessor.ts b/src/features/OsProcessor/OsProcessor.ts index 0a419511..acb22a9a 100644 --- a/src/features/OsProcessor/OsProcessor.ts +++ b/src/features/OsProcessor/OsProcessor.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { Container } from "@webiny/di"; import { ContainerToken, isRetryableAwsError } from "~/base/index.js"; import { IndexConfigurationResolver } from "~/features/IndexConfigurationProvider/abstractions/IndexConfigurationResolver.js"; +import { OsRecordDecompressor } from "~/features/OsRecordDecompressor/abstractions/OsRecordDecompressor.js"; import { AccessCheck, Processor } from "~/domain/pipeline/abstractions/Processor.js"; import { DdbExecutor } from "~/features/DdbExecutor/abstractions/DdbExecutor.js"; import { @@ -61,7 +62,8 @@ class OsProcessorImpl implements Processor.Interface< private readonly fileTool: FileTool.Interface, private readonly sourceDb: SourceDynamoDbClient.Interface, private readonly targetDb: TargetDynamoDbClient.Interface, - private readonly indexConfigurationResolver: IndexConfigurationResolver.Interface + private readonly indexConfigurationResolver: IndexConfigurationResolver.Interface, + private readonly decompressor: OsRecordDecompressor.Interface ) {} private get osClient(): OpenSearchClient.Interface { @@ -82,6 +84,9 @@ class OsProcessorImpl implements Processor.Interface< const targetTable = this.config.target.opensearch.tableName; const sourceDb = this.sourceDb; const targetDb = this.targetDb; + const decompressRow = ( + row: OsRecordDecompressor.Compressed + ): Promise> => this.decompressRow(row); return { putRecord(record: Record) { base.addCommand(PutRecord.create({ table: targetTable, record })); @@ -90,15 +95,31 @@ class OsProcessorImpl implements Processor.Interface< pk: string, sk?: string ): Promise { - const results = await sourceDb.query(sourceTable, pk, sk); - return results.length > 0 ? (results[0] as unknown as T) : null; + const results = await sourceDb.query( + sourceTable, + pk, + sk + ); + const first = results[0]; + if (!first) { + return null; + } + return (await decompressRow(first)) as unknown as T; }, async queryTargetRecord = Record>( pk: string, sk?: string ): Promise { - const results = await targetDb.query(targetTable, pk, sk); - return results.length > 0 ? (results[0] as unknown as T) : null; + const results = await targetDb.query( + targetTable, + pk, + sk + ); + const first = results[0]; + if (!first) { + return null; + } + return (await decompressRow(first)) as unknown as T; } }; } @@ -186,6 +207,16 @@ class OsProcessorImpl implements Processor.Interface< return result; } + private async decompressRow( + row: OsRecordDecompressor.Compressed + ): Promise> { + const decompressed = await this.decompressor.decompress(row); + if (decompressed === null) { + return { ...row }; + } + return { ...row, data: decompressed }; + } + private async ensureIndex(indexName: string): Promise { if (this.touchedIndexes.has(indexName)) { return; @@ -318,6 +349,7 @@ export const OsProcessor = Processor.createImplementation({ FileTool, SourceDynamoDbClient, TargetDynamoDbClient, - IndexConfigurationResolver + IndexConfigurationResolver, + OsRecordDecompressor ] }); diff --git a/src/services/DynamoDbClient/DynamoDbClient.ts b/src/services/DynamoDbClient/DynamoDbClient.ts index 50195983..9a91c2f6 100644 --- a/src/services/DynamoDbClient/DynamoDbClient.ts +++ b/src/services/DynamoDbClient/DynamoDbClient.ts @@ -1,7 +1,8 @@ import { BatchWriteCommand, GetCommand, - ScanCommand + ScanCommand, + UpdateCommand } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"; @@ -72,13 +73,20 @@ export class DynamoDbClientImpl implements SourceDynamoDbClient.Interface { options?: SourceDynamoDbClient.Scan ): AsyncIterable { let lastEvaluatedKey: Record | undefined; + let yielded = 0; + const limit = options ? options.limit : undefined; + const sortKeyEquals = options ? options.sortKeyEquals : undefined; do { const command = new ScanCommand({ TableName: tableName, Segment: options ? options.segment : undefined, TotalSegments: options ? options.totalSegments : undefined, - ExclusiveStartKey: lastEvaluatedKey + ExclusiveStartKey: lastEvaluatedKey, + Limit: limit, + FilterExpression: sortKeyEquals !== undefined ? "SK = :sk" : undefined, + ExpressionAttributeValues: + sortKeyEquals !== undefined ? { ":sk": sortKeyEquals } : undefined }); const response = await this.executeWithRetry(async () => { @@ -88,6 +96,10 @@ export class DynamoDbClientImpl implements SourceDynamoDbClient.Interface { if (response.Items) { for (const item of response.Items) { yield item as T; + yielded++; + if (limit !== undefined && yielded >= limit) { + return; + } } } @@ -255,6 +267,42 @@ export class DynamoDbClientImpl implements SourceDynamoDbClient.Interface { } } + public async updateAttribute( + tableName: string, + request: SourceDynamoDbClient.UpdateRequest + ): Promise { + const names: Record = {}; + const pathExpression = request.path + .map((segment, index) => { + const placeholder = `#p${index}`; + names[placeholder] = segment; + return placeholder; + }) + .join("."); + names["#c"] = request.condition.attribute; + + const command = new UpdateCommand({ + TableName: tableName, + Key: request.key, + UpdateExpression: `SET ${pathExpression} = :v`, + ConditionExpression: "#c = :c", + ExpressionAttributeNames: names, + ExpressionAttributeValues: { ":v": request.value, ":c": request.condition.equals } + }); + + try { + await this.executeWithRetry(async () => { + return await this.client.send(command); + }); + return "written"; + } catch (error) { + if (isConditionalCheckFailed(error)) { + return "condition-failed"; + } + throw error; + } + } + private withTimeout(fn: () => Promise): Promise { const ms = this.requestTimeout; return Promise.race([ @@ -298,3 +346,11 @@ export class DynamoDbClientImpl implements SourceDynamoDbClient.Interface { throw lastError; } } + +function isConditionalCheckFailed(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const { name } = error as { name?: unknown }; + return name === "ConditionalCheckFailedException"; +} diff --git a/src/services/DynamoDbClient/abstractions/DynamoDbClient.ts b/src/services/DynamoDbClient/abstractions/DynamoDbClient.ts index eeaac83e..73530a29 100644 --- a/src/services/DynamoDbClient/abstractions/DynamoDbClient.ts +++ b/src/services/DynamoDbClient/abstractions/DynamoDbClient.ts @@ -18,8 +18,33 @@ export interface DatabaseRecord { export interface ScanOptions { segment?: number; totalSegments?: number; + /** Maximum number of items yielded by the generator (also sent as page `Limit`). */ + limit?: number; + /** Server-side `FilterExpression SK = :sk`. Does not reduce consumed capacity. */ + sortKeyEquals?: string; +} + +export interface UpdateAttributeKey { + PK: string; + SK: string; } +export interface UpdateAttributeCondition { + attribute: string; + equals: unknown; +} + +export interface UpdateAttributeRequest { + key: UpdateAttributeKey; + /** Attribute path, e.g. ["data", "live"]. */ + path: string[]; + /** Marshalled as-is; `null` allowed. */ + value: unknown; + condition: UpdateAttributeCondition; +} + +export type UpdateAttributeResult = "written" | "condition-failed"; + export interface QueryOptions { indexName?: string; pkAttribute?: string; @@ -56,6 +81,10 @@ export interface IDynamoDbClient { ): Promise; get(tableName: string, pk: string, sk: string): Promise; batchPut(tableName: string, records: T[]): Promise; + updateAttribute( + tableName: string, + request: UpdateAttributeRequest + ): Promise; } // ============================================================================ @@ -70,6 +99,8 @@ export namespace SourceDynamoDbClient { export type Record = DatabaseRecord; export type Scan = ScanOptions; export type Query = QueryOptions; + export type UpdateRequest = UpdateAttributeRequest; + export type UpdateResult = UpdateAttributeResult; } export namespace TargetDynamoDbClient { @@ -77,4 +108,6 @@ export namespace TargetDynamoDbClient { export type Record = DatabaseRecord; export type Scan = ScanOptions; export type Query = QueryOptions; + export type UpdateRequest = UpdateAttributeRequest; + export type UpdateResult = UpdateAttributeResult; } diff --git a/src/tools/FileTool/FileTool.ts b/src/tools/FileTool/FileTool.ts index fe31672a..e1c6b77b 100644 --- a/src/tools/FileTool/FileTool.ts +++ b/src/tools/FileTool/FileTool.ts @@ -1,4 +1,11 @@ -import { existsSync, readFileSync, writeFileSync, rmSync, copyFileSync } from "node:fs"; +import { + existsSync, + readFileSync, + writeFileSync, + appendFileSync, + rmSync, + copyFileSync +} from "node:fs"; import { dirname } from "node:path"; import { FileTool as FileToolAbstraction } from "./abstractions/FileTool.ts"; import { DirectoryTool } from "../DirectoryTool/abstractions/DirectoryTool.ts"; @@ -65,6 +72,11 @@ class FileToolImpl implements FileToolAbstraction.Interface { this.directoryTool.create(dirname(target)); copyFileSync(source, target); } + + public appendLineOrThrow(path: string, line: string): void { + this.directoryTool.create(dirname(path)); + appendFileSync(path, `${line}\n`, "utf-8"); + } } export const FileTool = FileToolAbstraction.createImplementation({ diff --git a/src/tools/FileTool/abstractions/FileTool.ts b/src/tools/FileTool/abstractions/FileTool.ts index a9e3b4b2..cb18e6d3 100644 --- a/src/tools/FileTool/abstractions/FileTool.ts +++ b/src/tools/FileTool/abstractions/FileTool.ts @@ -9,6 +9,7 @@ export interface IFileTool { remove(path: string): void; copy(source: string, target: string): void; copyOrThrow(source: string, target: string): void; + appendLineOrThrow(path: string, line: string): void; } export const FileTool = createAbstraction("Core/FileTool"); diff --git a/src/transformers/cms/addLiveField.ts b/src/transformers/cms/addLiveField.ts index 79495be0..01d9a063 100644 --- a/src/transformers/cms/addLiveField.ts +++ b/src/transformers/cms/addLiveField.ts @@ -20,45 +20,63 @@ export const addLiveField = createTransformer): number | null { + const nested = record.data as Record | undefined; + const raw = record.version !== undefined ? record.version : nested?.version; + if (typeof raw === "number" && Number.isInteger(raw) && raw > 0) { + return raw; + } + return null; +} + async function resolvePublishedVersion( ctx: DdbCoreTransformContext.Interface ): Promise { const cacheKey = `live:${ctx.original.PK}`; const cached = ctx.cache.get(cacheKey); - if (cached) { + if (cached !== undefined) { return cached === NO_PUBLISHED_REVISION ? null : cached; } - // This record IS the published revision — no query needed. - // P record: always the published revision by definition. - // L record with status "published": L and P point to the same revision. const data = ctx.record.data as Record; const originalSK = ctx.original.SK; const isPublishedRevision = originalSK === "P" || (originalSK === "L" && data.status === "published"); if (isPublishedRevision) { - const version = data.version as number; + const version = readPositiveIntegerVersion(data); + if (version === null) { + ctx.logger.warn( + `addLiveField: ${ctx.original.PK} ${originalSK} is the published revision but has no positive integer version — writing live: null` + ); + ctx.cache.set(cacheKey, NO_PUBLISHED_REVISION); + return null; + } ctx.cache.set(cacheKey, version); return version; } ctx.logger.debug(`Querying for published revision of ${ctx.original.PK}...`); const published = await ctx.querySourceRecord(ctx.original.PK, "P"); - const version = published ? (published.version as number) : NO_PUBLISHED_REVISION; + if (!published) { + ctx.cache.set(cacheKey, NO_PUBLISHED_REVISION); + return null; + } + + const version = readPositiveIntegerVersion(published); + if (version === null) { + ctx.logger.warn( + `addLiveField: P record for ${ctx.original.PK} has no positive integer version — writing live: null` + ); + ctx.cache.set(cacheKey, NO_PUBLISHED_REVISION); + return null; + } ctx.cache.set(cacheKey, version); - return version === NO_PUBLISHED_REVISION ? null : version; + return version; } diff --git a/vitest.config.ts b/vitest.config.ts index 61782989..ee1904a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,10 +21,10 @@ export default defineConfig({ reporter: ["text", "json", "html"], exclude: ["**/index.ts", "**/feature.ts", "src/presets/**/*.ts"], thresholds: { - lines: 79, - functions: 84, - branches: 71, - statements: 79 + lines: 81, + functions: 85, + branches: 74, + statements: 81 } } } diff --git a/yarn.lock b/yarn.lock index b995f429..5ab8a98e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -217,12 +217,12 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-dynamodb@npm:^3.1117.0": - version: 3.1117.0 - resolution: "@aws-sdk/client-dynamodb@npm:3.1117.0" +"@aws-sdk/client-dynamodb@npm:^3.1126.0": + version: 3.1126.0 + resolution: "@aws-sdk/client-dynamodb@npm:3.1126.0" dependencies: "@aws-sdk/core": "npm:^3.977.9" - "@aws-sdk/credential-provider-node": "npm:^3.972.81" + "@aws-sdk/credential-provider-node": "npm:^3.972.82" "@aws-sdk/dynamodb-codec": "npm:^3.973.44" "@aws-sdk/middleware-endpoint-discovery": "npm:^3.972.30" "@aws-sdk/types": "npm:^3.974.5" @@ -231,7 +231,7 @@ __metadata: "@smithy/node-http-handler": "npm:^4.11.3" "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10/dfd2c0c3374efcd931d7094f966f3af297ff5877b54eeb731c0adf2e0adfb894ac51472ccd73a20810261b722e8403de1b4f63f9d692eb3d0be317847f63a670 + checksum: 10/8f0f31e85f155b0746ea5430fb91e9df1657986bd7b0efddc05e04bbc1fc5f28734284777b162f7cea1a37362184fd57ba526d1ff5b5a044e75809f078ac03a3 languageName: node linkType: hard @@ -319,13 +319,13 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-s3@npm:^3.1117.0": - version: 3.1117.0 - resolution: "@aws-sdk/client-s3@npm:3.1117.0" +"@aws-sdk/client-s3@npm:^3.1126.0": + version: 3.1126.0 + resolution: "@aws-sdk/client-s3@npm:3.1126.0" dependencies: "@aws-sdk/checksums": "npm:^3.1000.29" "@aws-sdk/core": "npm:^3.977.9" - "@aws-sdk/credential-provider-node": "npm:^3.972.81" + "@aws-sdk/credential-provider-node": "npm:^3.972.82" "@aws-sdk/middleware-sdk-s3": "npm:^3.972.75" "@aws-sdk/signature-v4-multi-region": "npm:^3.996.46" "@aws-sdk/types": "npm:^3.974.5" @@ -334,7 +334,7 @@ __metadata: "@smithy/node-http-handler": "npm:^4.11.3" "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10/eefe7bf786665f3c9c4013086893f31b22910204cde4bd0d9d71fc6b1138892eda382d2d46fa3b12ffa4a87a51e890857752afe1d5bf9f8d39b182dd9396eacf + checksum: 10/1d5bab138fab99a1310377f926e31c560a0965e4cc6077eb84755c9591a4c282aab436eae4ba18676dfbcdc416f4edb33b192fca297ae02185c1542d8176c572 languageName: node linkType: hard @@ -496,9 +496,9 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:^3.972.73, @aws-sdk/credential-provider-node@npm:^3.972.81": - version: 3.972.81 - resolution: "@aws-sdk/credential-provider-node@npm:3.972.81" +"@aws-sdk/credential-provider-node@npm:^3.972.73, @aws-sdk/credential-provider-node@npm:^3.972.82": + version: 3.972.82 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.82" dependencies: "@aws-sdk/credential-provider-env": "npm:^3.972.70" "@aws-sdk/credential-provider-http": "npm:^3.972.72" @@ -511,7 +511,7 @@ __metadata: "@smithy/credential-provider-imds": "npm:^4.4.16" "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10/adc322099542d8a05954c7f9715f3db85172270500d2f2b12c115fc2d36a890ceea87d13ad55c2b43f4696079aca7633c7c17ed883a7e6f9d1f7c285119953f8 + checksum: 10/14075a6bed1a3b0a05b9764b4024e485ac918378e470e7ea9ce12303f3fd5b60142c923d2b0b6ca247c66f63e28e217216734ceb5fe120bf91ae144253030b78 languageName: node linkType: hard @@ -581,9 +581,9 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-providers@npm:^3.1117.0": - version: 3.1117.0 - resolution: "@aws-sdk/credential-providers@npm:3.1117.0" +"@aws-sdk/credential-providers@npm:^3.1126.0": + version: 3.1126.0 + resolution: "@aws-sdk/credential-providers@npm:3.1126.0" dependencies: "@aws-sdk/core": "npm:^3.977.9" "@aws-sdk/credential-provider-cognito-identity": "npm:^3.972.69" @@ -591,7 +591,7 @@ __metadata: "@aws-sdk/credential-provider-http": "npm:^3.972.72" "@aws-sdk/credential-provider-ini": "npm:^3.973.15" "@aws-sdk/credential-provider-login": "npm:^3.972.77" - "@aws-sdk/credential-provider-node": "npm:^3.972.81" + "@aws-sdk/credential-provider-node": "npm:^3.972.82" "@aws-sdk/credential-provider-process": "npm:^3.972.70" "@aws-sdk/credential-provider-sso": "npm:^3.973.14" "@aws-sdk/credential-provider-web-identity": "npm:^3.972.76" @@ -601,7 +601,7 @@ __metadata: "@smithy/credential-provider-imds": "npm:^4.4.16" "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" - checksum: 10/8bc484c5a200344d6b9ca51b9804f33dd3467e3b4014eee8f54fb80a4b587c551eaa3a15f514ce2683e466be13ff82897c91695426909ccdb696497fe1aee074 + checksum: 10/d5c6eb9e9d71bb917148940f5cc3f25b844da7e993f776b345af5477a0378846618396ec8701db5fba98b7b5d4c92b1a2a6603884949f4f8827751defa2c4988 languageName: node linkType: hard @@ -642,9 +642,9 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/lib-dynamodb@npm:^3.1117.0": - version: 3.1117.0 - resolution: "@aws-sdk/lib-dynamodb@npm:3.1117.0" +"@aws-sdk/lib-dynamodb@npm:^3.1126.0": + version: 3.1126.0 + resolution: "@aws-sdk/lib-dynamodb@npm:3.1126.0" dependencies: "@aws-sdk/core": "npm:^3.977.9" "@aws-sdk/util-dynamodb": "npm:^3.996.9" @@ -652,8 +652,8 @@ __metadata: "@smithy/types": "npm:^4.17.2" tslib: "npm:^2.6.2" peerDependencies: - "@aws-sdk/client-dynamodb": ^3.1117.0 - checksum: 10/3a7a668cb57049058ba83bcfc16180a2e7317e5c620710584d8ce3ee5b913af0b475f5c9cee2e10cfdb660ee16fa5854f34606df21fa3463968a9b5c9cdb9429 + "@aws-sdk/client-dynamodb": ^3.1126.0 + checksum: 10/5049c47495e35402f5b1fd2c48945e52002f58c8d251e9aa15fb36862db51f6be7ac3a8644b15ba25ac4f5aa2583788547e24a85b69e4ab64371965848929564 languageName: node linkType: hard @@ -1153,6 +1153,28 @@ __metadata: languageName: node linkType: hard +"@clack/core@npm:1.4.3": + version: 1.4.3 + resolution: "@clack/core@npm:1.4.3" + dependencies: + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10/9d875718cb161e0eca97c3ed3ba3063211436c4224a04556b840e045f18505c8fdb645754d9459181069b3a3b6699962d0f47e38e2502cb0acfef1649b3557b3 + languageName: node + linkType: hard + +"@clack/prompts@npm:^1.7.0": + version: 1.7.0 + resolution: "@clack/prompts@npm:1.7.0" + dependencies: + "@clack/core": "npm:1.4.3" + fast-string-width: "npm:^3.0.2" + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10/2bfc89d6755a1e10166a3b7a6cc883a09c1d4d82c08fbe98763284d522f256a58ed20c5ee4ab36f2a6e06bc1b3f0c16b02657159f7d593dab12c55260755f0c2 + languageName: node + linkType: hard + "@csstools/color-helpers@npm:^6.1.1": version: 6.1.1 resolution: "@csstools/color-helpers@npm:6.1.1" @@ -1171,15 +1193,15 @@ __metadata: linkType: hard "@csstools/css-color-parser@npm:^4.1.10": - version: 4.2.0 - resolution: "@csstools/css-color-parser@npm:4.2.0" + version: 4.2.2 + resolution: "@csstools/css-color-parser@npm:4.2.2" dependencies: "@csstools/color-helpers": "npm:^6.1.1" "@csstools/css-calc": "npm:^3.3.0" peerDependencies: "@csstools/css-parser-algorithms": ^4.0.0 "@csstools/css-tokenizer": ^4.0.0 - checksum: 10/f76aae69ed32c55989f3a2fe00953fffe9e511f761c6bad28c28dbec945733e2039b0967d74332b7e5cd4e85e90270faac96b23d0d57bf33d1a218498c745a7b + checksum: 10/84351aa22d87ae27eaa85b108f77256d2736aea99607551ad95f1fbebd0d914131e6fbb2f5a109f562b9c98c79db7a3e31095e6aa8e3ccad23e5819869ccc32e languageName: node linkType: hard @@ -1193,14 +1215,14 @@ __metadata: linkType: hard "@csstools/css-syntax-patches-for-csstree@npm:^1.1.7": - version: 1.1.8 - resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.8" + version: 1.1.12 + resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.12" peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: css-tree: optional: true - checksum: 10/6c837c7a7c514f483dddf2fe5f55082bb88cf19a273f50172cde0a59b9f7a1f974766fe357276a66d984cac0d9aaedfcb66c3c56cd888c7e66b663f3c04d1732 + checksum: 10/ae30bf06b20b81b54165c9dda6c5d0ed1ec91ceef6d497c0a2d33c896c1bde0998ec9d8587672892ebdf157dae5ca9117f603526b298559e69cc4bf1a01e0b59 languageName: node linkType: hard @@ -1255,7 +1277,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.11.1": +"@emnapi/runtime@npm:^1.11.3": version: 1.11.3 resolution: "@emnapi/runtime@npm:1.11.3" dependencies: @@ -1630,11 +1652,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-arm64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-darwin-arm64@npm:0.35.3" +"@img/sharp-darwin-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-darwin-arm64": "npm:1.3.2" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-darwin-arm64": optional: true @@ -1642,11 +1664,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-x64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-darwin-x64@npm:0.35.3" +"@img/sharp-darwin-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-darwin-x64": "npm:1.3.2" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-darwin-x64": optional: true @@ -1654,90 +1676,90 @@ __metadata: languageName: node linkType: hard -"@img/sharp-freebsd-wasm32@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-freebsd-wasm32@npm:0.35.3" +"@img/sharp-freebsd-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-freebsd-wasm32@npm:0.35.4" dependencies: - "@img/sharp-wasm32": "npm:0.35.3" + "@img/sharp-wasm32": "npm:0.35.4" conditions: os=freebsd languageName: node linkType: hard -"@img/sharp-libvips-darwin-arm64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.2" +"@img/sharp-libvips-darwin-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@img/sharp-libvips-darwin-x64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.2" +"@img/sharp-libvips-darwin-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@img/sharp-libvips-linux-arm64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.2" +"@img/sharp-libvips-linux-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.3" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-arm@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linux-arm@npm:1.3.2" +"@img/sharp-libvips-linux-arm@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm@npm:1.3.3" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-ppc64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.2" +"@img/sharp-libvips-linux-ppc64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.3" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-riscv64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.2" +"@img/sharp-libvips-linux-riscv64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.3" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-s390x@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.2" +"@img/sharp-libvips-linux-s390x@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.3" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-x64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linux-x64@npm:1.3.2" +"@img/sharp-libvips-linux-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-x64@npm:1.3.3" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.2" +"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-x64@npm:1.3.2": - version: 1.3.2 - resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.2" +"@img/sharp-libvips-linuxmusl-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.3" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@img/sharp-linux-arm64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linux-arm64@npm:0.35.3" +"@img/sharp-linux-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-arm64": "npm:1.3.2" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-arm64": optional: true @@ -1745,11 +1767,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-arm@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linux-arm@npm:0.35.3" +"@img/sharp-linux-arm@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-arm": "npm:1.3.2" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-arm": optional: true @@ -1757,11 +1779,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-ppc64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linux-ppc64@npm:0.35.3" +"@img/sharp-linux-ppc64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-ppc64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-ppc64": "npm:1.3.2" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-ppc64": optional: true @@ -1769,11 +1791,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-riscv64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linux-riscv64@npm:0.35.3" +"@img/sharp-linux-riscv64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-riscv64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-riscv64": "npm:1.3.2" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-riscv64": optional: true @@ -1781,11 +1803,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-s390x@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linux-s390x@npm:0.35.3" +"@img/sharp-linux-s390x@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-s390x@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-s390x": "npm:1.3.2" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-s390x": optional: true @@ -1793,11 +1815,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-x64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linux-x64@npm:0.35.3" +"@img/sharp-linux-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-x64": "npm:1.3.2" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-x64": optional: true @@ -1805,11 +1827,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-arm64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.3" +"@img/sharp-linuxmusl-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linuxmusl-arm64": optional: true @@ -1817,11 +1839,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-x64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-linuxmusl-x64@npm:0.35.3" +"@img/sharp-linuxmusl-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linuxmusl-x64": optional: true @@ -1829,135 +1851,45 @@ __metadata: languageName: node linkType: hard -"@img/sharp-wasm32@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-wasm32@npm:0.35.3" +"@img/sharp-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-wasm32@npm:0.35.4" dependencies: - "@emnapi/runtime": "npm:^1.11.1" - checksum: 10/9cad3671879be2448c6252a978af2dc39727e4464d335a3eea5aab6751596b8af68bba9487f5fd2600671807f7d4ec34abfbd78b01ededb48843d904c6a1387d + "@emnapi/runtime": "npm:^1.11.3" + checksum: 10/24250d2a5c1e681577c97a1774fd8785fc237066146badb9d25f8512dfd09771838b6d76fb0912baa0b29a0d7a564282dfc562062e747486aee832cc98941210 languageName: node linkType: hard -"@img/sharp-webcontainers-wasm32@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.3" +"@img/sharp-webcontainers-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.4" dependencies: - "@img/sharp-wasm32": "npm:0.35.3" + "@img/sharp-wasm32": "npm:0.35.4" conditions: cpu=wasm32 languageName: node linkType: hard -"@img/sharp-win32-arm64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-win32-arm64@npm:0.35.3" +"@img/sharp-win32-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-arm64@npm:0.35.4" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@img/sharp-win32-ia32@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-win32-ia32@npm:0.35.3" +"@img/sharp-win32-ia32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-ia32@npm:0.35.4" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@img/sharp-win32-x64@npm:0.35.3": - version: 0.35.3 - resolution: "@img/sharp-win32-x64@npm:0.35.3" +"@img/sharp-win32-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-x64@npm:0.35.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@inquirer/ansi@npm:^2.0.7": - version: 2.0.7 - resolution: "@inquirer/ansi@npm:2.0.7" - checksum: 10/ae4ff228412f1f67d78aa9a7410e07e692eeb7c9b75034825f1039898821b02505dfe934be53d6ee4ce5bde9e7ff6b4ce7547bacc1c9aa441396cb9b99717e0f - languageName: node - linkType: hard - -"@inquirer/checkbox@npm:^5.2.2": - version: 5.2.2 - resolution: "@inquirer/checkbox@npm:5.2.2" - dependencies: - "@inquirer/ansi": "npm:^2.0.7" - "@inquirer/core": "npm:^12.0.0" - "@inquirer/figures": "npm:^2.0.8" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/10ea9063aa5950dc3efdae3a75355d33f78b436fe071ef0290fe8668a020894f50185d54b3820f64f295c3a46633eb291f5502f38a46a871e90731d03f83bc6a - languageName: node - linkType: hard - -"@inquirer/confirm@npm:^6.2.0": - version: 6.2.0 - resolution: "@inquirer/confirm@npm:6.2.0" - dependencies: - "@inquirer/core": "npm:^12.0.0" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/b915b39cc231f9c3f79c7fc7b189ab4a88ee528a2e7276ecb4a8914c43509fe382f2b769fa2560bebcab92b5a6de39a8d149813bf37388fbd6c02fc49e4da94f - languageName: node - linkType: hard - -"@inquirer/core@npm:^12.0.0": - version: 12.0.0 - resolution: "@inquirer/core@npm:12.0.0" - dependencies: - "@inquirer/ansi": "npm:^2.0.7" - "@inquirer/figures": "npm:^2.0.8" - "@inquirer/type": "npm:^4.0.7" - cli-width: "npm:^4.1.0" - fast-wrap-ansi: "npm:^0.2.0" - mute-stream: "npm:^3.0.0" - signal-exit: "npm:^4.1.0" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/d5a2fd29de44c0c350eb6cefebd7d6b7318a45eb7c10131ff3775b5bea65eb8cf5bf1f7fcc33fc11377dfd0f8baab486ecac17aebfe87d3bcfcbde2d34e1870b - languageName: node - linkType: hard - -"@inquirer/editor@npm:^5.3.0": - version: 5.3.0 - resolution: "@inquirer/editor@npm:5.3.0" - dependencies: - "@inquirer/core": "npm:^12.0.0" - "@inquirer/external-editor": "npm:^3.0.4" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/a269825739b5b9cf04eb1728994c4f2d79c6e36dd1cc4691ca68becebe7a1416e4412b211c0f2855282ec08f89a07216f377d226c8097a6462b1466e62794924 - languageName: node - linkType: hard - -"@inquirer/expand@npm:^5.1.2": - version: 5.1.2 - resolution: "@inquirer/expand@npm:5.1.2" - dependencies: - "@inquirer/core": "npm:^12.0.0" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/e5d5f1d3f2fe2e3bc10ac5a7a0dccf8d0465c01eacbc1bc3acd402a2d99b307afc766b44ec9d7ffb9afd05f4d161f69fe9a93b7a6efbab24ac21f3349a362b08 - languageName: node - linkType: hard - "@inquirer/external-editor@npm:^1.0.2": version: 1.0.3 resolution: "@inquirer/external-editor@npm:1.0.3" @@ -1973,157 +1905,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/external-editor@npm:^3.0.4": - version: 3.0.4 - resolution: "@inquirer/external-editor@npm:3.0.4" - dependencies: - chardet: "npm:^2.1.1" - iconv-lite: "npm:^0.7.2" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/e3c5ad2a697f9b60d20de9743d62a39d2819b5c9007f973b210deb8a4872743fb73f14eb49549f98014279e10fefd481013a67b701626e3f8f1f4388fcda446d - languageName: node - linkType: hard - -"@inquirer/figures@npm:^2.0.8": - version: 2.0.8 - resolution: "@inquirer/figures@npm:2.0.8" - checksum: 10/d7f72980ff875f79f5b4ddd56f8527793ba1442b7e7d1ea5398f5fc35f71982cba13576b94ba718fd6b15e4d2f1a4a1f2a1dc86bce0e84c280da3833088e049f - languageName: node - linkType: hard - -"@inquirer/input@npm:^5.1.3": - version: 5.1.3 - resolution: "@inquirer/input@npm:5.1.3" - dependencies: - "@inquirer/core": "npm:^12.0.0" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/2ca0cf6714f83aa2fd5b43f61fbf7e7b82f725957a2e09a2060d908a3c61ba3abeba0179cb8f7316a69d20136480cf34da03c5150f8ab4f46f1ace62004308a1 - languageName: node - linkType: hard - -"@inquirer/number@npm:^4.2.0": - version: 4.2.0 - resolution: "@inquirer/number@npm:4.2.0" - dependencies: - "@inquirer/core": "npm:^12.0.0" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/5c610ed314b8d52aee8bfb89a634e0a9b465c774380f35c516de4a04b45baf645956c39ec1fc54dd2d12cefe8f0a3913edc0d6c51b5a6733807b759c870f315e - languageName: node - linkType: hard - -"@inquirer/password@npm:^5.1.2": - version: 5.1.2 - resolution: "@inquirer/password@npm:5.1.2" - dependencies: - "@inquirer/ansi": "npm:^2.0.7" - "@inquirer/core": "npm:^12.0.0" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/9284a67c6b23d87f9500e3ea570c0f8901544d17e175f35f92da3825ef126cd0f7cfd0f2210202dd9410d35b5ac991a3352f40f3e287d4d82003e1b1d42d209f - languageName: node - linkType: hard - -"@inquirer/prompts@npm:^8.6.0": - version: 8.6.0 - resolution: "@inquirer/prompts@npm:8.6.0" - dependencies: - "@inquirer/checkbox": "npm:^5.2.2" - "@inquirer/confirm": "npm:^6.2.0" - "@inquirer/editor": "npm:^5.3.0" - "@inquirer/expand": "npm:^5.1.2" - "@inquirer/input": "npm:^5.1.3" - "@inquirer/number": "npm:^4.2.0" - "@inquirer/password": "npm:^5.1.2" - "@inquirer/rawlist": "npm:^5.3.2" - "@inquirer/search": "npm:^4.3.0" - "@inquirer/select": "npm:^5.2.2" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/9a66d7dd190ea9c056c466bc84933c014e4239c4391623d29b2a3a3119a2a4e66cbd4861e4a3a7f0770abad3fe74eb2c7869411077b3f5c0d7fe2ede4ea7cf49 - languageName: node - linkType: hard - -"@inquirer/rawlist@npm:^5.3.2": - version: 5.3.2 - resolution: "@inquirer/rawlist@npm:5.3.2" - dependencies: - "@inquirer/core": "npm:^12.0.0" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/4fad0726cc2226acf797287fc083e2065585e1735bd99402b24c2d756d3fecc51729c3c5ed89a754dd070f89e7d94df603306877423a3880c0c1370968fb5cdd - languageName: node - linkType: hard - -"@inquirer/search@npm:^4.3.0": - version: 4.3.0 - resolution: "@inquirer/search@npm:4.3.0" - dependencies: - "@inquirer/core": "npm:^12.0.0" - "@inquirer/figures": "npm:^2.0.8" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/8f99dacf632869b0757fdd40d4805d3f335c2de2543dfaa827509a250750bcc2f6e3f455fca38af615342500ffde6c708852373e631b396f53ecece4a8da7f6d - languageName: node - linkType: hard - -"@inquirer/select@npm:^5.2.2": - version: 5.2.2 - resolution: "@inquirer/select@npm:5.2.2" - dependencies: - "@inquirer/ansi": "npm:^2.0.7" - "@inquirer/core": "npm:^12.0.0" - "@inquirer/figures": "npm:^2.0.8" - "@inquirer/type": "npm:^4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/50c0e38f0904ec0da9efd4b200446b26538f8eb91810a40acec538cac883070bc42ab4f39ae671716974158e3480eb3978d6fa84543ae87e071a0d9beee3fdd8 - languageName: node - linkType: hard - -"@inquirer/type@npm:^4.0.7": - version: 4.0.7 - resolution: "@inquirer/type@npm:4.0.7" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/97769b74264a2575c12c231e3d4acf98b4ae237fc987cec6b459f88b907b1729623403b8d9fe0cf2ca21743114777c64e3031fbeff72e9da37fbf4c8985f587f - languageName: node - linkType: hard - "@isaacs/fs-minipass@npm:^4.0.0": version: 4.0.1 resolution: "@isaacs/fs-minipass@npm:4.0.1" @@ -2141,13 +1922,13 @@ __metadata: linkType: hard "@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.5": - version: 1.5.5 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" - checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 + version: 1.6.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.6.0" + checksum: 10/eb53fbf7eb4051302a599d6b91425f381168b2856f321336fb00852cea1e0c1904af2610c157b1535e94a61caa98f0bd068d1d0394dd46adf9e9c45c4e11af91 languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.31": +"@jridgewell/trace-mapping@npm:0.3.31, @jridgewell/trace-mapping@npm:^0.3.31": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -2893,10 +2674,10 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.146.0": - version: 0.146.0 - resolution: "@oxc-project/types@npm:0.146.0" - checksum: 10/3a2289ad2efb1240b0a5b5c9108dfb21bb302fece8ab61e05128b98ec8f7b7394a0d5a1e647b23d344b751fabf8956dd0048e7b8555a545ddfaf27e662e72812 +"@oxc-project/types@npm:=0.148.0": + version: 0.148.0 + resolution: "@oxc-project/types@npm:0.148.0" + checksum: 10/835b0479161cd80c7dee653b77d31f6d5239e20530cda8a10466bcbb17de08363777939c29670e82fb9d9eca89dc49030e50742051418d987ec70f62faa3317b languageName: node linkType: hard @@ -2907,268 +2688,268 @@ __metadata: languageName: node linkType: hard -"@oxfmt/binding-android-arm-eabi@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-android-arm-eabi@npm:0.65.0" +"@oxfmt/binding-android-arm-eabi@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-android-arm-eabi@npm:0.66.0" conditions: os=android & cpu=arm languageName: node linkType: hard -"@oxfmt/binding-android-arm64@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-android-arm64@npm:0.65.0" +"@oxfmt/binding-android-arm64@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-android-arm64@npm:0.66.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-darwin-arm64@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-darwin-arm64@npm:0.65.0" +"@oxfmt/binding-darwin-arm64@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-darwin-arm64@npm:0.66.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-darwin-x64@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-darwin-x64@npm:0.65.0" +"@oxfmt/binding-darwin-x64@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-darwin-x64@npm:0.66.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxfmt/binding-freebsd-x64@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-freebsd-x64@npm:0.65.0" +"@oxfmt/binding-freebsd-x64@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-freebsd-x64@npm:0.66.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@oxfmt/binding-linux-arm-gnueabihf@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.65.0" +"@oxfmt/binding-linux-arm-gnueabihf@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.66.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxfmt/binding-linux-arm-musleabihf@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.65.0" +"@oxfmt/binding-linux-arm-musleabihf@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.66.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxfmt/binding-linux-arm64-gnu@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.65.0" +"@oxfmt/binding-linux-arm64-gnu@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.66.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-arm64-musl@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.65.0" +"@oxfmt/binding-linux-arm64-musl@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.66.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxfmt/binding-linux-ppc64-gnu@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.65.0" +"@oxfmt/binding-linux-ppc64-gnu@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.66.0" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-riscv64-gnu@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.65.0" +"@oxfmt/binding-linux-riscv64-gnu@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.66.0" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-riscv64-musl@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.65.0" +"@oxfmt/binding-linux-riscv64-musl@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.66.0" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@oxfmt/binding-linux-s390x-gnu@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.65.0" +"@oxfmt/binding-linux-s390x-gnu@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.66.0" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-x64-gnu@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.65.0" +"@oxfmt/binding-linux-x64-gnu@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.66.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxfmt/binding-linux-x64-musl@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-linux-x64-musl@npm:0.65.0" +"@oxfmt/binding-linux-x64-musl@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-linux-x64-musl@npm:0.66.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxfmt/binding-openharmony-arm64@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-openharmony-arm64@npm:0.65.0" +"@oxfmt/binding-openharmony-arm64@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-openharmony-arm64@npm:0.66.0" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-win32-arm64-msvc@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.65.0" +"@oxfmt/binding-win32-arm64-msvc@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.66.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxfmt/binding-win32-ia32-msvc@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.65.0" +"@oxfmt/binding-win32-ia32-msvc@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.66.0" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@oxfmt/binding-win32-x64-msvc@npm:0.65.0": - version: 0.65.0 - resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.65.0" +"@oxfmt/binding-win32-x64-msvc@npm:0.66.0": + version: 0.66.0 + resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.66.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-android-arm-eabi@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-android-arm-eabi@npm:1.80.0" +"@oxlint/binding-android-arm-eabi@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-android-arm-eabi@npm:1.81.0" conditions: os=android & cpu=arm languageName: node linkType: hard -"@oxlint/binding-android-arm64@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-android-arm64@npm:1.80.0" +"@oxlint/binding-android-arm64@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-android-arm64@npm:1.81.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-darwin-arm64@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-darwin-arm64@npm:1.80.0" +"@oxlint/binding-darwin-arm64@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-darwin-arm64@npm:1.81.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-darwin-x64@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-darwin-x64@npm:1.80.0" +"@oxlint/binding-darwin-x64@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-darwin-x64@npm:1.81.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-freebsd-x64@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-freebsd-x64@npm:1.80.0" +"@oxlint/binding-freebsd-x64@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-freebsd-x64@npm:1.81.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@oxlint/binding-linux-arm-gnueabihf@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.80.0" +"@oxlint/binding-linux-arm-gnueabihf@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.81.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxlint/binding-linux-arm-musleabihf@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.80.0" +"@oxlint/binding-linux-arm-musleabihf@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.81.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxlint/binding-linux-arm64-gnu@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.80.0" +"@oxlint/binding-linux-arm64-gnu@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.81.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-arm64-musl@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-arm64-musl@npm:1.80.0" +"@oxlint/binding-linux-arm64-musl@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-arm64-musl@npm:1.81.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-linux-ppc64-gnu@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.80.0" +"@oxlint/binding-linux-ppc64-gnu@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.81.0" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-riscv64-gnu@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.80.0" +"@oxlint/binding-linux-riscv64-gnu@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.81.0" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-riscv64-musl@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.80.0" +"@oxlint/binding-linux-riscv64-musl@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.81.0" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-linux-s390x-gnu@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.80.0" +"@oxlint/binding-linux-s390x-gnu@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.81.0" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-x64-gnu@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-x64-gnu@npm:1.80.0" +"@oxlint/binding-linux-x64-gnu@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-x64-gnu@npm:1.81.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxlint/binding-linux-x64-musl@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-linux-x64-musl@npm:1.80.0" +"@oxlint/binding-linux-x64-musl@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-linux-x64-musl@npm:1.81.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxlint/binding-openharmony-arm64@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-openharmony-arm64@npm:1.80.0" +"@oxlint/binding-openharmony-arm64@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-openharmony-arm64@npm:1.81.0" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-win32-arm64-msvc@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.80.0" +"@oxlint/binding-win32-arm64-msvc@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.81.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/binding-win32-ia32-msvc@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.80.0" +"@oxlint/binding-win32-ia32-msvc@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.81.0" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@oxlint/binding-win32-x64-msvc@npm:1.80.0": - version: 1.80.0 - resolution: "@oxlint/binding-win32-x64-msvc@npm:1.80.0" +"@oxlint/binding-win32-x64-msvc@npm:1.81.0": + version: 1.81.0 + resolution: "@oxlint/binding-win32-x64-msvc@npm:1.81.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -3187,107 +2968,107 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm-eabi@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.5" +"@rolldown/binding-android-arm-eabi@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.7" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-android-arm64@npm:1.2.5" +"@rolldown/binding-android-arm64@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-android-arm64@npm:1.2.7" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-darwin-arm64@npm:1.2.5" +"@rolldown/binding-darwin-arm64@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.7" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-darwin-x64@npm:1.2.5" +"@rolldown/binding-darwin-x64@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.7" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-freebsd-x64@npm:1.2.5" +"@rolldown/binding-freebsd-x64@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.7" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.5" +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.7" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.5" +"@rolldown/binding-linux-arm64-gnu@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.7" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.5" +"@rolldown/binding-linux-arm64-musl@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.7" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.5" +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.7" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.5" +"@rolldown/binding-linux-s390x-gnu@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.7" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.5" +"@rolldown/binding-linux-x64-gnu@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.7" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.5" +"@rolldown/binding-linux-x64-musl@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.7" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.5" +"@rolldown/binding-openharmony-arm64@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.7" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.5" +"@rolldown/binding-win32-arm64-msvc@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.7" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.2.5": - version: 1.2.5 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.5" +"@rolldown/binding-win32-x64-msvc@npm:1.2.7": + version: 1.2.7 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.7" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -3379,24 +3160,24 @@ __metadata: linkType: hard "@smithy/fetch-http-handler@npm:^5.6.10, @smithy/fetch-http-handler@npm:^5.7.2": - version: 5.7.2 - resolution: "@smithy/fetch-http-handler@npm:5.7.2" + version: 5.8.0 + resolution: "@smithy/fetch-http-handler@npm:5.8.0" dependencies: - "@smithy/core": "npm:^3.33.2" - "@smithy/types": "npm:^4.17.2" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.18.0" tslib: "npm:^2.6.2" - checksum: 10/21d66b9fe99771d608bb5950d7e123f6b4cd4ecf1d7d51d55bfcac715cfb29e5613523ca02239f0167a1f0dc3a66656b09c2b2673a53b6e31c827c4610fa5519 + checksum: 10/9f5374a6d2db6a1314393a05f9500efc0b73a0ef9d491a9e64aab6fdc8e0ce39214a5ec0b6af3bf5c948e452c7f91f91b0cda13d3325399276811392b3ac36b7 languageName: node linkType: hard "@smithy/node-http-handler@npm:^4.11.3, @smithy/node-http-handler@npm:^4.9.10": - version: 4.11.3 - resolution: "@smithy/node-http-handler@npm:4.11.3" + version: 4.12.1 + resolution: "@smithy/node-http-handler@npm:4.12.1" dependencies: "@smithy/core": "npm:^3.33.3" - "@smithy/types": "npm:^4.17.2" + "@smithy/types": "npm:^4.18.0" tslib: "npm:^2.6.2" - checksum: 10/4a414ca3f0f71ff05fd9b9bc8f131de6d54d722bd0a7ae12ac3dc36d6d96d8fdbe5531998861967a6c31bd7da083548fe19870b6f68a5aa6600072d600b7692c + checksum: 10/291ab9053afeefc7f6524c18dd5ade4aab42af7fddba7d5c087c23c23d4d686fc9f6674c5c28aa1af708d93db9fc2be18999cd64bbbbed8b88f2004a0aa37bbf languageName: node linkType: hard @@ -3411,12 +3192,12 @@ __metadata: languageName: node linkType: hard -"@smithy/types@npm:^4.16.1, @smithy/types@npm:^4.17.2": - version: 4.17.2 - resolution: "@smithy/types@npm:4.17.2" +"@smithy/types@npm:^4.16.1, @smithy/types@npm:^4.17.2, @smithy/types@npm:^4.18.0": + version: 4.18.0 + resolution: "@smithy/types@npm:4.18.0" dependencies: tslib: "npm:^2.6.2" - checksum: 10/e54500bb56a12a6f92374d6095834af04f19868e5178836b21fdcbce0c0fef0570dede2a39777b9d3330075d55096db206867f73ddf4dfe530d1b392f8d0aea9 + checksum: 10/18719c5ae8eef71ef10d79e8628b528a9f0aede5fb036c9a798f676b7b4a35efd10fc0d3b7e1f8921746c66c4ae2a8277570f99a0545fbc4eae6c994997f83cd languageName: node linkType: hard @@ -3497,11 +3278,11 @@ __metadata: linkType: hard "@types/node@npm:*, @types/node@npm:>=20.0.0": - version: 26.3.0 - resolution: "@types/node@npm:26.3.0" + version: 26.4.1 + resolution: "@types/node@npm:26.4.1" dependencies: undici-types: "npm:~8.3.0" - checksum: 10/77a9587a3c8ce2e4bf1547d70706f262ba6dca2a39012091df36f1729132c06485833fe6f74ffbd5271d12966d6b729f6343d5af95681b7a08d437115b125015 + checksum: 10/63fbe80250fc8824d52a1c8787b591f17be566db11560fad0a29e9e3d230303ca7a3e77d5c474d60438d14120dcb9323950641db754197b4822e4fae3c506927 languageName: node linkType: hard @@ -3737,44 +3518,45 @@ __metadata: languageName: node linkType: hard -"@verdaccio/auth@npm:8.1.2": - version: 8.1.2 - resolution: "@verdaccio/auth@npm:8.1.2" +"@verdaccio/auth@npm:8.1.3": + version: 8.1.3 + resolution: "@verdaccio/auth@npm:8.1.3" dependencies: - "@verdaccio/config": "npm:8.2.2" - "@verdaccio/core": "npm:8.2.2" - "@verdaccio/loaders": "npm:8.1.2" - "@verdaccio/signature": "npm:8.1.2" + "@verdaccio/config": "npm:8.3.0" + "@verdaccio/core": "npm:8.3.0" + "@verdaccio/loaders": "npm:8.1.3" + "@verdaccio/signature": "npm:8.1.3" debug: "npm:4.4.3" lodash: "npm:4.18.1" - verdaccio-htpasswd: "npm:13.1.2" - checksum: 10/a3999802482f7a9694dc0919b224033feac18ad3182f76f623fa3f6086e1b5f73e0837a02f1bfbaebebf21d8bd98b230065a138390afc768c9e735a72dfa7568 + verdaccio-htpasswd: "npm:13.1.3" + checksum: 10/2b7185f3dbce474a4d5d4a1c6e58e5b709ffd4b7a85a06306b6149905203917e80597919ea541805888baa3ac8354a187edc07d9b071d7385c29e3c345956439 languageName: node linkType: hard -"@verdaccio/config@npm:8.2.2": - version: 8.2.2 - resolution: "@verdaccio/config@npm:8.2.2" +"@verdaccio/config@npm:8.3.0": + version: 8.3.0 + resolution: "@verdaccio/config@npm:8.3.0" dependencies: - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/core": "npm:8.3.0" debug: "npm:4.4.3" js-yaml: "npm:5.2.2" lodash: "npm:4.18.1" - checksum: 10/2eb71d4f78dec528b82f7fdcad71528dbd4efb5203e624d35a7eba61f22f194ba23a68a721126b469d37bbaeaf46fb3c16c7309f4541b382f3de0d9af9b4375d + checksum: 10/62a98d515478afbeb7cb25825b9f14a4833da2854a5ab37565a70b5e0ec6f90bf8d979d364e3046e2c375a74d656abdc48aca4bed51976728f7a70e19112e0a8 languageName: node linkType: hard -"@verdaccio/core@npm:8.2.2": - version: 8.2.2 - resolution: "@verdaccio/core@npm:8.2.2" +"@verdaccio/core@npm:8.3.0": + version: 8.3.0 + resolution: "@verdaccio/core@npm:8.3.0" dependencies: ajv: "npm:8.20.0" http-errors: "npm:2.0.1" http-status-codes: "npm:2.3.0" + lodash: "npm:4.18.1" minimatch: "npm:10.2.6" process-warning: "npm:1.0.0" semver: "npm:7.8.5" - checksum: 10/a7daa4a1c92f75376eff5e8c48c013bfa622d49df3ef0f2b27db1dbe28cdfb6950f04621fd4da85ca12520c36d05d1e00c036eb9d56268e269d6f0302f660233 + checksum: 10/0e321a8331f60605e1887c05e2a017be4df94272e868901abbe1a6cede3b2400a5b12beca6d5ec874eaf3de17084ae567c2af399f5f8cf3ec457d99ba1a86355 languageName: node linkType: hard @@ -3787,35 +3569,35 @@ __metadata: languageName: node linkType: hard -"@verdaccio/hooks@npm:8.1.3": - version: 8.1.3 - resolution: "@verdaccio/hooks@npm:8.1.3" +"@verdaccio/hooks@npm:8.1.4": + version: 8.1.4 + resolution: "@verdaccio/hooks@npm:8.1.4" dependencies: - "@verdaccio/core": "npm:8.2.2" - "@verdaccio/logger": "npm:8.1.2" + "@verdaccio/core": "npm:8.3.0" + "@verdaccio/logger": "npm:8.1.3" debug: "npm:4.4.3" got: "npm:15.1.0" handlebars: "npm:4.7.9" - checksum: 10/3618c4b64c41612241f2d8b228b60ab2463fa1a8d2f06e8c6994908da639878b3ad6e46597e6bc1f38200eeb97545c3b9ef79b698449b7103b2cab18c3e1b9b7 + checksum: 10/c4959e4bcae421b40c8819f8c1bc9df74892e6e935788b27b2a1b1c28b2f86784ab670d76ccd8a28cff7aebb0ea850f58da26cdf06bde29b94ee8263626d6e52 languageName: node linkType: hard -"@verdaccio/loaders@npm:8.1.2": - version: 8.1.2 - resolution: "@verdaccio/loaders@npm:8.1.2" +"@verdaccio/loaders@npm:8.1.3": + version: 8.1.3 + resolution: "@verdaccio/loaders@npm:8.1.3" dependencies: - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/core": "npm:8.3.0" debug: "npm:4.4.3" lodash: "npm:4.18.1" - checksum: 10/523abea34e35e77a652d42c1ebc1e9cf4c5f1cfafbaa499e20e713e29efc807fa46e4dee48866a4bb127fba5988503df4e818bf5f83c9db7e185a34c2b31d3f9 + checksum: 10/8b36dc4ef0a63b71a66daeaa3dd2dd92987795925e832c188a16910c7fe6e6e69321f78ad33af9104bcd8b61803afdb6b8fde9190ea8ea3607d6747acd9a644d languageName: node linkType: hard -"@verdaccio/local-storage-legacy@npm:11.4.2": - version: 11.4.2 - resolution: "@verdaccio/local-storage-legacy@npm:11.4.2" +"@verdaccio/local-storage-legacy@npm:11.4.3": + version: 11.4.3 + resolution: "@verdaccio/local-storage-legacy@npm:11.4.3" dependencies: - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/core": "npm:8.3.0" "@verdaccio/file-locking": "npm:13.1.0" "@verdaccio/streams": "npm:10.3.0" debug: "npm:4.4.3" @@ -3824,19 +3606,19 @@ __metadata: lowdb: "npm:1.0.0" mkdirp: "npm:1.0.4" sanitize-filename: "npm:1.6.4" - checksum: 10/c3df168d8cc00ff77dc2544aa6fd8698325f0e7177acd679f29e89c66bb1d69eab1a86bdbcf71b6c9b4681d9df799afb07abdd44cc43145d8b41d3296d12e593 + checksum: 10/c560c71b92e0b0411f6699f85d126bda3348ae915121cbdd4eded2b6b202c8929ee9dc3a8e5cfced818f3e746c6d92fbba15ef36318c0004ccab1c8e2adac4f0 languageName: node linkType: hard -"@verdaccio/logger-commons@npm:8.1.2": - version: 8.1.2 - resolution: "@verdaccio/logger-commons@npm:8.1.2" +"@verdaccio/logger-commons@npm:8.1.3": + version: 8.1.3 + resolution: "@verdaccio/logger-commons@npm:8.1.3" dependencies: - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/core": "npm:8.3.0" "@verdaccio/logger-prettify": "npm:8.1.0" colorette: "npm:2.0.20" debug: "npm:4.4.3" - checksum: 10/d6c839cc1e212308a0efd0eff2a65ac15bb2f4edceaad28d5249371c5b289583e00d638d8033cbaee319489a12d3e2bc0bfd8ba8cdc87ae426c1249838af5383 + checksum: 10/bbb135793571a7196630fab2e7ea5fe6079adafeceb9a5dd509d6f43b05e6e3645a67ee98b895cc065eda877400fe411720f8b24b18cfdfbe648ab5ac8ee9bfe languageName: node linkType: hard @@ -3854,40 +3636,40 @@ __metadata: languageName: node linkType: hard -"@verdaccio/logger@npm:8.1.2": - version: 8.1.2 - resolution: "@verdaccio/logger@npm:8.1.2" +"@verdaccio/logger@npm:8.1.3": + version: 8.1.3 + resolution: "@verdaccio/logger@npm:8.1.3" dependencies: - "@verdaccio/logger-commons": "npm:8.1.2" + "@verdaccio/logger-commons": "npm:8.1.3" pino: "npm:9.14.0" - checksum: 10/8b0387ea0b84c993f5f7a918ac7bbd9b01f96023c3a8ba11bf8a539b547aa8fc62bdc95d58f35d214fa01996a474d82612a56f90fb1ff85bb320aa4e832f98e3 + checksum: 10/6704db71117b8919be63dbd37ee9b46c4b9e1716a3fb6b4858ce988f3281a03d979e08b6711944128e4f3329f1f8b312f1c9fb14ce262a9a98c4319435cd7426 languageName: node linkType: hard -"@verdaccio/middleware@npm:8.1.2": - version: 8.1.2 - resolution: "@verdaccio/middleware@npm:8.1.2" +"@verdaccio/middleware@npm:8.1.3": + version: 8.1.3 + resolution: "@verdaccio/middleware@npm:8.1.3" dependencies: - "@verdaccio/config": "npm:8.2.2" - "@verdaccio/core": "npm:8.2.2" - "@verdaccio/url": "npm:13.1.2" + "@verdaccio/config": "npm:8.3.0" + "@verdaccio/core": "npm:8.3.0" + "@verdaccio/url": "npm:13.1.3" debug: "npm:4.4.3" express: "npm:4.22.2" express-rate-limit: "npm:5.5.1" lodash: "npm:4.18.1" lru-cache: "npm:7.18.3" - checksum: 10/e7789616657704321be5b5fa5e6efbfefd4bc1f460b03c67a01b02563a46c523410e27b03161ee922fb87e732686d0b2eb6ce9c2c40b8715dacac65b618f349f + checksum: 10/71ae57e1313a93f97a9cf03174399c839f7784a8239a8e10bf7a98fd2995643e7bfd81aebc41a66e88dd42f1c9f1b7b68d11d151425186d4b80120380ac0fc45 languageName: node linkType: hard -"@verdaccio/package-filter@npm:13.2.0": - version: 13.2.0 - resolution: "@verdaccio/package-filter@npm:13.2.0" +"@verdaccio/package-filter@npm:13.2.1": + version: 13.2.1 + resolution: "@verdaccio/package-filter@npm:13.2.1" dependencies: - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/core": "npm:8.3.0" debug: "npm:4.4.3" semver: "npm:7.8.5" - checksum: 10/fb606a9e03f4fa9e45759da8c82c20c53a18ed6b4a680a62d2b417ffbaab79ffc0fcf9ccbf3491700feab2784ec47204f9860f515d75cf03bb9a4f39d03570a4 + checksum: 10/b8f6c6085c26995dae36dcf5f7b4eba1bff4996c926260013e4fcdeee6d27d8b318d126a3fecd12f41f511e339a26a707f0d316545ce6726104782f95eba62ab languageName: node linkType: hard @@ -3901,15 +3683,15 @@ __metadata: languageName: node linkType: hard -"@verdaccio/signature@npm:8.1.2": - version: 8.1.2 - resolution: "@verdaccio/signature@npm:8.1.2" +"@verdaccio/signature@npm:8.1.3": + version: 8.1.3 + resolution: "@verdaccio/signature@npm:8.1.3" dependencies: - "@verdaccio/config": "npm:8.2.2" - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/config": "npm:8.3.0" + "@verdaccio/core": "npm:8.3.0" debug: "npm:4.4.3" jsonwebtoken: "npm:9.0.3" - checksum: 10/6d1bc07c51e421955d46b7f0832f72f60866d38d1fd7a9c6de6eb12d058a25365c667f89e7f2c0844701ee824edc2ed781b9e8da1b3b4ad388de0905f177ed9b + checksum: 10/bdb96944151a6de9123abd7d7ef87d1a104c6de4e395f2bd107b59030c2ff64ffca5d8de1b3da9aedd35325e52b08fdc088a70b18dba74e012fafc387ea92601 languageName: node linkType: hard @@ -3920,95 +3702,85 @@ __metadata: languageName: node linkType: hard -"@verdaccio/tarball@npm:13.1.2": - version: 13.1.2 - resolution: "@verdaccio/tarball@npm:13.1.2" +"@verdaccio/tarball@npm:13.1.3": + version: 13.1.3 + resolution: "@verdaccio/tarball@npm:13.1.3" dependencies: - "@verdaccio/core": "npm:8.2.2" - "@verdaccio/url": "npm:13.1.2" + "@verdaccio/core": "npm:8.3.0" + "@verdaccio/url": "npm:13.1.3" debug: "npm:4.4.3" gunzip-maybe: "npm:1.4.2" tar-stream: "npm:3.2.0" - checksum: 10/adfbfc53459a6b87c35c3a2ad807cc709f9019c2432ff49f02baca6f1ee989668f1f41fa0157c2ab7439eedb356308d12d1f3aac0d70dae404571868d2a6a129 + checksum: 10/af41d3b503b361c3f3a24ee6e267839a157de30226aab8e603e3a93d8e558c3accae1f26b47fba0a442784cd539dc66a4d91748568a55aa9d3c8ee8a1d0c3e95 languageName: node linkType: hard -"@verdaccio/ui-theme@npm:9.0.0-next-9.26": - version: 9.0.0-next-9.26 - resolution: "@verdaccio/ui-theme@npm:9.0.0-next-9.26" +"@verdaccio/ui-theme@npm:9.0.0-next-9.30": + version: 9.0.0-next-9.30 + resolution: "@verdaccio/ui-theme@npm:9.0.0-next-9.30" dependencies: debug: "npm:4.4.3" - checksum: 10/3cd3a458263208c9081504a5ac4d48724d8562d3d40e97069d3883b44c8e7beee033c332c8b033a063885d8bb3f2fd75ff1aa0631a1fac93177d88a383914100 + checksum: 10/6877cdf5371903d7eb9a5908cc31310272ca5a0f901e9886582b17dff8e6b10da5eb6f5a5fbb08843dd4629898feb74efec89b4d014398bb88b4bb69f7c14bee languageName: node linkType: hard -"@verdaccio/url@npm:13.1.2": - version: 13.1.2 - resolution: "@verdaccio/url@npm:13.1.2" +"@verdaccio/url@npm:13.1.3": + version: 13.1.3 + resolution: "@verdaccio/url@npm:13.1.3" dependencies: - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/core": "npm:8.3.0" debug: "npm:4.4.3" validator: "npm:13.15.26" - checksum: 10/83988876a1d93554dfc02f73ad2c81fbce0a6adf200b5a83ec55c12da2aef2ad68621b200e1d3f31aaac4f54f62dc6fe243031d2a62c9b4261799bd8dd79021e - languageName: node - linkType: hard - -"@verdaccio/utils@npm:8.2.2": - version: 8.2.2 - resolution: "@verdaccio/utils@npm:8.2.2" - dependencies: - "@verdaccio/core": "npm:8.2.2" - lodash: "npm:4.18.1" - minimatch: "npm:10.2.6" - checksum: 10/db26a6b7b2700fa87c4defec3a95f08292d2a16ab242a523bf7edbee58db35a96f700abced645865b972aa1674df65c904525a1cc69cbd6df669890df79a967e + checksum: 10/149636ea3298523e23699bc1e2c6a4004d966fcb7c9d94ff7bc5cd1212db776c12ac63b0e0390c1d670844361ea9a3b91340034eab2d8276717cbc5dcb62d44f languageName: node linkType: hard -"@vitest/coverage-v8@npm:^4.1.11": - version: 4.1.11 - resolution: "@vitest/coverage-v8@npm:4.1.11" +"@vitest/coverage-v8@npm:^5.0.0": + version: 5.0.0 + resolution: "@vitest/coverage-v8@npm:5.0.0" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.1.11" - ast-v8-to-istanbul: "npm:^1.0.0" - istanbul-lib-coverage: "npm:^3.2.2" - istanbul-lib-report: "npm:^3.0.1" - istanbul-reports: "npm:^3.2.0" - magicast: "npm:^0.5.2" - obug: "npm:^2.1.1" - std-env: "npm:^4.0.0-rc.1" - tinyrainbow: "npm:^3.1.0" + "@vitest/istanbul-lib-coverage": "npm:^1.0.0" + "@vitest/istanbul-lib-report": "npm:^1.0.0" + ast-v8-to-istanbul: "npm:^1.0.5" + magicast: "npm:^0.5.4" + obug: "npm:^2.1.4" + std-env: "npm:^4.2.0" + tinyrainbow: "npm:^3.1.1" peerDependencies: - "@vitest/browser": 4.1.11 - vitest: 4.1.11 + "@vitest/browser": 5.0.0 + vitest: 5.0.0 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10/b6171ec592e0017c3b10954a9400b10af0becf944a0533af01b002a4cc35b6e562a353b4837451a44b73c5561de358c23a2c135d4e1e79bc9041ebb241a68440 + checksum: 10/4c2354618f74c4375ac5e73a548155b9d9e19b1a01b7de3b3ab403986c10f77cad9ec677f1b9e283bdc4844fd34ed17ee8661afd8e52d1d8999ed72e2fb55e00 + languageName: node + linkType: hard + +"@vitest/istanbul-lib-coverage@npm:1.0.1, @vitest/istanbul-lib-coverage@npm:^1.0.0": + version: 1.0.1 + resolution: "@vitest/istanbul-lib-coverage@npm:1.0.1" + checksum: 10/7f939ba10c0ea1b2bb3e901d58f6be7548be6b4bb6757e1bdf33d9dd61522a9fca9aa39f00e4869d333101dc8824a123e8bbb7a2664f96bb9d4de6978f852417 languageName: node linkType: hard -"@vitest/expect@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/expect@npm:4.1.11" +"@vitest/istanbul-lib-report@npm:^1.0.0": + version: 1.0.1 + resolution: "@vitest/istanbul-lib-report@npm:1.0.1" dependencies: - "@standard-schema/spec": "npm:^1.1.0" - "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - chai: "npm:^6.2.2" - tinyrainbow: "npm:^3.1.0" - checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa + "@vitest/istanbul-lib-coverage": "npm:1.0.1" + checksum: 10/3a1417c3960277b094c51795c6bc8dcb74689562f6489c0eea96ee6befbc2b0baa98b347f44469081dc750bde98c7981f0daa4b5c12d1e6abe87860052e9464f languageName: node linkType: hard -"@vitest/mocker@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/mocker@npm:4.1.11" +"@vitest/mocker@npm:5.0.0": + version: 5.0.0 + resolution: "@vitest/mocker@npm:5.0.0" dependencies: - "@vitest/spy": "npm:4.1.11" + "@jridgewell/trace-mapping": "npm:0.3.31" + "@vitest/spy": "npm:5.0.0" estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.21" + magic-string: "npm:^1.2.3" peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4017,56 +3789,14 @@ __metadata: optional: true vite: optional: true - checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509 - languageName: node - linkType: hard - -"@vitest/pretty-format@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/pretty-format@npm:4.1.11" - dependencies: - tinyrainbow: "npm:^3.1.0" - checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc - languageName: node - linkType: hard - -"@vitest/runner@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/runner@npm:4.1.11" - dependencies: - "@vitest/utils": "npm:4.1.11" - pathe: "npm:^2.0.3" - checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239 + checksum: 10/57a1e55c18946c8977d304dfc8a1b5551331c7fc240a97034e497708d1d9520305193346ff96ff629e951c7c538ee649bba71bf074b29f53e0b95419126896af languageName: node linkType: hard -"@vitest/snapshot@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/snapshot@npm:4.1.11" - dependencies: - "@vitest/pretty-format": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - magic-string: "npm:^0.30.21" - pathe: "npm:^2.0.3" - checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982 - languageName: node - linkType: hard - -"@vitest/spy@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/spy@npm:4.1.11" - checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6 - languageName: node - linkType: hard - -"@vitest/utils@npm:4.1.11": - version: 4.1.11 - resolution: "@vitest/utils@npm:4.1.11" - dependencies: - "@vitest/pretty-format": "npm:4.1.11" - convert-source-map: "npm:^2.0.0" - tinyrainbow: "npm:^3.1.0" - checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4 +"@vitest/spy@npm:5.0.0": + version: 5.0.0 + resolution: "@vitest/spy@npm:5.0.0" + checksum: 10/71ab66f41517078d25af58b60d3792c0ba3d4bece080f496994662e803cd0a2d8c56b073165bf8028f3970d388704e5f1d869378bcfd99a5a17664cfb2f24355 languageName: node linkType: hard @@ -4241,21 +3971,20 @@ __metadata: version: 0.0.0-use.local resolution: "@webiny/data-transfer@workspace:." dependencies: - "@aws-sdk/client-dynamodb": "npm:^3.1117.0" - "@aws-sdk/client-s3": "npm:^3.1117.0" - "@aws-sdk/credential-providers": "npm:^3.1117.0" - "@aws-sdk/lib-dynamodb": "npm:^3.1117.0" + "@aws-sdk/client-dynamodb": "npm:^3.1126.0" + "@aws-sdk/client-s3": "npm:^3.1126.0" + "@aws-sdk/credential-providers": "npm:^3.1126.0" + "@aws-sdk/lib-dynamodb": "npm:^3.1126.0" "@changesets/cli": "npm:^2.31.1" + "@clack/prompts": "npm:^1.7.0" "@faker-js/faker": "npm:^10.6.0" - "@inquirer/core": "npm:^12.0.0" - "@inquirer/prompts": "npm:^8.6.0" "@modelcontextprotocol/sdk": "npm:^1.30.0" "@opensearch-project/opensearch": "npm:3.6.0" "@smithy/util-stream": "npm:^4.8.2" "@types/jsdom": "npm:^30.0.0" "@types/node": "npm:^24.13.3" "@types/yargs": "npm:^17.0.35" - "@vitest/coverage-v8": "npm:^4.1.11" + "@vitest/coverage-v8": "npm:^5.0.0" "@webiny/api-headless-cms-utils-os": "npm:^6.6.0-alpha.0" "@webiny/api-opensearch": "npm:^6.6.0-alpha.0" "@webiny/aws-sdk": "npm:^6.6.0-alpha.0" @@ -4271,17 +4000,18 @@ __metadata: exifreader: "npm:^4.44.0" front-matter: "npm:^4.0.2" jsdom: "npm:^30.0.1" - oxfmt: "npm:^0.65.0" - oxlint: "npm:^1.80.0" + oxfmt: "npm:^0.66.0" + oxlint: "npm:^1.81.0" pino: "npm:^10.3.1" pino-pretty: "npm:^13.1.3" - sharp: "npm:^0.35.3" - tsx: "npm:^4.23.12" + sharp: "npm:^0.35.4" + tsx: "npm:^4.23.13" typescript: "npm:^7.0.2" - verdaccio: "npm:^6.10.0" - vitest: "npm:^4.1.11" + verdaccio: "npm:^6.10.2" + vite: "npm:^8.2.2" + vitest: "npm:^5.0.0" yargs: "npm:^18.1.0" - zod: "npm:^4.4.3" + zod: "npm:^4.5.4" bin: data-transfer: ./dist/cli.js webiny-data-transfer: ./dist/cli.js @@ -4707,7 +4437,7 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^1.0.0": +"ast-v8-to-istanbul@npm:^1.0.5": version: 1.0.5 resolution: "ast-v8-to-istanbul@npm:1.0.5" dependencies: @@ -4798,14 +4528,14 @@ __metadata: linkType: hard "bare-events@npm:^2.5.4, bare-events@npm:^2.7.0": - version: 2.9.1 - resolution: "bare-events@npm:2.9.1" + version: 2.9.2 + resolution: "bare-events@npm:2.9.2" peerDependencies: bare-abort-controller: "*" peerDependenciesMeta: bare-abort-controller: optional: true - checksum: 10/0692be1767f4f326e39c8b1ec76f88e9f566d96fce4fe23ff10c1a6e69843237d1eee0d14a52d9aa4b89c8efa6511fe6c0e640d96fa8586241bfa877c4f7bb46 + checksum: 10/28b1571b49bce4fb6ea7701f201aa364134607a5840d79285c2fa4a17d9248781bc14a6da16b4004b3bc6e87fd0de6eadf4f5f30346c1617845ef8d4b91fd3b9 languageName: node linkType: hard @@ -4828,15 +4558,15 @@ __metadata: linkType: hard "bare-path@npm:^3.0.0": - version: 3.1.1 - resolution: "bare-path@npm:3.1.1" - checksum: 10/a8700e81203a48bf4de260240ca383d37d02aa287af7ec0da3483e875a534f3c54446243b0f88a77cfcea5adb142584f64c17268d014ce5e978d8a7236ba76e6 + version: 3.1.2 + resolution: "bare-path@npm:3.1.2" + checksum: 10/f596ee6528eb2f011df8f67c5af0d159dfc028b9ac63c7a24e95947ff0d2b19864019c893bc4c4d10463b7f7161fb55c2db04181a95e5a37b0165b42af096fad languageName: node linkType: hard "bare-stream@npm:^2.6.4": - version: 2.13.3 - resolution: "bare-stream@npm:2.13.3" + version: 2.13.4 + resolution: "bare-stream@npm:2.13.4" dependencies: b4a: "npm:^1.8.1" streamx: "npm:^2.25.0" @@ -4852,16 +4582,16 @@ __metadata: optional: true bare-events: optional: true - checksum: 10/771f0f5a05af4a1bc33e86ba18c5afd90c73496bfa2b457d71146ce78837c7c4cdef4b980f7889108434ceab57ac29e219a3a3cc39892db81a5cae2c54c91c38 + checksum: 10/9b7b03d134072697f13b3e28ce7feb0a743b9cd8640659ff502d0e7d222f033459c723d57682288eb09b61ff6e0652e0c7efb87d539696f64a8203622949c29b languageName: node linkType: hard "bare-url@npm:^2.2.2": - version: 2.5.2 - resolution: "bare-url@npm:2.5.2" + version: 2.5.4 + resolution: "bare-url@npm:2.5.4" dependencies: bare-path: "npm:^3.0.0" - checksum: 10/4a3eb8ad515e86d13c25d25796db36eb48968d9af69aff9c5e311355592d315dbec3f3f1ff2986e076ef1a46246df0740d711cebab45e5ce51e83f9378e1929e + checksum: 10/f7ce85745089999350a94c7b5bcc5f0f68b46f12fbd7e080913ae95498928ca06f0fbd6621d232cdd8901e202bde730824d41fdf767c048c79896bcee032d724 languageName: node linkType: hard @@ -5208,13 +4938,6 @@ __metadata: languageName: node linkType: hard -"cli-width@npm:^4.1.0": - version: 4.1.0 - resolution: "cli-width@npm:4.1.0" - checksum: 10/b58876fbf0310a8a35c79b72ecfcf579b354e18ad04e6b20588724ea2b522799a758507a37dfe132fafaf93a9922cafd9514d9e1598e6b2cd46694853aed099f - languageName: node - linkType: hard - "clipanion@npm:4.0.0-rc.4": version: 4.0.0-rc.4 resolution: "clipanion@npm:4.0.0-rc.4" @@ -5307,13 +5030,6 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^2.0.0": - version: 2.0.0 - resolution: "convert-source-map@npm:2.0.0" - checksum: 10/c987be3ec061348cdb3c2bfb924bec86dea1eacad10550a85ca23edb0fe3556c3a61c7399114f3331ccb3499d7fd0285ab24566e5745929412983494c3926e15 - languageName: node - linkType: hard - "cookie-signature@npm:^1.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" @@ -5820,7 +5536,7 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^2.0.0": +"es-module-lexer@npm:^2.3.2": version: 2.3.2 resolution: "es-module-lexer@npm:2.3.2" checksum: 10/065246d6e2b2dfea3287650b5800a30326771e0d9d238570e81371d7ae8d53db239bccb2166e272926bba44917da7c66dd7407a5214d17b0af5269005b1fec52 @@ -6050,7 +5766,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.3.0": +"expect-type@npm:^1.4.0": version: 1.4.0 resolution: "expect-type@npm:1.4.0" checksum: 10/bad91f4b7eb807248695ee840a935d12818fe531ad523bc7ac7dcc540a4d86dc566a3ef829f4da6e8b5a458ac84092771739865114c91e7e8a9317302f401f09 @@ -6072,14 +5788,14 @@ __metadata: linkType: hard "express-rate-limit@npm:^8.2.1": - version: 8.6.2 - resolution: "express-rate-limit@npm:8.6.2" + version: 8.7.0 + resolution: "express-rate-limit@npm:8.7.0" dependencies: debug: "npm:^4.4.3" ip-address: "npm:^10.2.0" peerDependencies: express: ">= 4.11" - checksum: 10/cf9d2a286821a650890366ed14f3d582293175b6f540e3f64b5fd276201cacef6c1e218448dd54f4a1e860de48c395e3d8ddda4d4a032812266a19d8d947208a + checksum: 10/aff2995369d8a14380a46eefa9f7f720ef6c5d6c4478d09b20544f1e91d8fa21dfe442bf8ef5e652b226ee2dea110d3f8e57dc5cf44331d51106ecdef3d85470 languageName: node linkType: hard @@ -6187,9 +5903,9 @@ __metadata: linkType: hard "fast-copy@npm:^4.0.0": - version: 4.0.4 - resolution: "fast-copy@npm:4.0.4" - checksum: 10/ec359234d602c098e03df042da632203b4be8609c0db5c36c61873021aad1fafcbcc6250c3991b93ef4d5ef3729c8c4d589041e4212598738ba52f37384c514e + version: 4.1.1 + resolution: "fast-copy@npm:4.1.1" + checksum: 10/0f0552d51cbf587938f1486b9d7ee0ef5d29140ac7833f30f13844236caccbc4e5d0e7069ebe9ca0ae2932772d232dc4fc296aef8938f0f4477f18935a189920 languageName: node linkType: hard @@ -6244,9 +5960,9 @@ __metadata: linkType: hard "fast-uri@npm:^3.0.1": - version: 3.1.6 - resolution: "fast-uri@npm:3.1.6" - checksum: 10/de34fceafd3a1d917989a1116b092d9b6ebe6fa3c9e908a308b988a6455449fa0a0256b6ef628aa7da80f1f84031d704c290d6ba0ea4c6f4c42b7974a3bd43ae + version: 3.1.7 + resolution: "fast-uri@npm:3.1.7" + checksum: 10/6d62ea818841e1b460f60482fd31582faa23ea9d9b33f856412b24c42da11fd72bd003be6696390ec896dbe4e9e78d3bb8d93bf70a511c7f69e5e807ac9b284f languageName: node linkType: hard @@ -6260,11 +5976,11 @@ __metadata: linkType: hard "fastq@npm:^1.6.0": - version: 1.20.1 - resolution: "fastq@npm:1.20.1" + version: 1.20.3 + resolution: "fastq@npm:1.20.3" dependencies: reusify: "npm:^1.0.4" - checksum: 10/ab2fe3a7a108112e7752cfe7fc11683c21e595913a6a593ad0b4415f31dddbfc283775ab66f2c8ccea6ab7cfc116157cbddcfae9798d9de98d08fe0a2c3e97b2 + checksum: 10/952dfa14c62d6a6825baad9c836de6550ece1de4d5d6836203b0851f2c6432ce0aa8cf5fc53dda4560b3b62ce12ef53eeef3d7b9d66efc9a8ea78e5dfc78baf4 languageName: node linkType: hard @@ -6659,8 +6375,8 @@ __metadata: linkType: hard "happy-dom@npm:^20.9.0": - version: 20.11.6 - resolution: "happy-dom@npm:20.11.6" + version: 20.14.0 + resolution: "happy-dom@npm:20.14.0" dependencies: "@types/node": "npm:>=20.0.0" "@types/whatwg-mimetype": "npm:^3.0.2" @@ -6669,7 +6385,7 @@ __metadata: entities: "npm:^7.0.1" whatwg-mimetype: "npm:^3.0.0" ws: "npm:^8.21.0" - checksum: 10/7aeb3cd36d63ec13080863aee2446106d6ea1cfbf74f27c62d6f1c7f8d019eb0f288d86935c97e9051a2ba90375ee10987aa4fb004ffe6751c00b5e5040e49cf + checksum: 10/d6cc3d22c4cec42e163225eddf2afe7fe9c98d38a69d5870d0b6195c83478f0d833c9747ba44f4a3cfb99f94d0c6ce4ab9a5b8174e8500a8d6b7f53f21bc0426 languageName: node linkType: hard @@ -6713,9 +6429,9 @@ __metadata: linkType: hard "hono@npm:^4.11.4": - version: 4.13.4 - resolution: "hono@npm:4.13.4" - checksum: 10/e8d7ed2b21c719162e6af72f82ddeefafb407cadb72d8319ffc7ed3146077724703540799482ac4fc681adcbe2648845b9ba9921aa6cc1a6a361507752e2d423 + version: 4.13.5 + resolution: "hono@npm:4.13.5" + checksum: 10/a302e8d3a5329276ca436333e54679ef864e92e1b3fbd4db0d98cc56e71748622a1654103fcdf9ee866027b797255557e1ade05c8ceb866411603d74bf71bf43 languageName: node linkType: hard @@ -6735,13 +6451,6 @@ __metadata: languageName: node linkType: hard -"html-escaper@npm:^2.0.0": - version: 2.0.2 - resolution: "html-escaper@npm:2.0.2" - checksum: 10/034d74029dcca544a34fb6135e98d427acd73019796ffc17383eaa3ec2fe1c0471dcbbc8f8ed39e46e86d43ccd753a160631615e4048285e313569609b66d5b7 - languageName: node - linkType: hard - "htmlparser2@npm:^10.1.0": version: 10.1.0 resolution: "htmlparser2@npm:10.1.0" @@ -6887,9 +6596,9 @@ __metadata: linkType: hard "ip-address@npm:^10.2.0": - version: 10.5.0 - resolution: "ip-address@npm:10.5.0" - checksum: 10/239645a11ae56f1f8721df22fa852dec51039a0add5371a474ad858810a6b6a4924c9346e467821c4e614bc9b8c8947a58ba1fdfb155a7a30437a440072ffbf5 + version: 10.7.0 + resolution: "ip-address@npm:10.7.0" + checksum: 10/81d954eb5806e82460cb749b9651a2040630cadf4db9d53abc13122e60e4ae6f9ae8555282386c63a11093dd6380a62f64f7a22aba18abc6109ff316a6660b0a languageName: node linkType: hard @@ -7051,34 +6760,6 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": - version: 3.2.2 - resolution: "istanbul-lib-coverage@npm:3.2.2" - checksum: 10/40bbdd1e937dfd8c830fa286d0f665e81b7a78bdabcd4565f6d5667c99828bda3db7fb7ac6b96a3e2e8a2461ddbc5452d9f8bc7d00cb00075fa6a3e99f5b6a81 - languageName: node - linkType: hard - -"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": - version: 3.0.1 - resolution: "istanbul-lib-report@npm:3.0.1" - dependencies: - istanbul-lib-coverage: "npm:^3.0.0" - make-dir: "npm:^4.0.0" - supports-color: "npm:^7.1.0" - checksum: 10/86a83421ca1cf2109a9f6d193c06c31ef04a45e72a74579b11060b1e7bb9b6337a4e6f04abfb8857e2d569c271273c65e855ee429376a0d7c91ad91db42accd1 - languageName: node - linkType: hard - -"istanbul-reports@npm:^3.2.0": - version: 3.2.0 - resolution: "istanbul-reports@npm:3.2.0" - dependencies: - html-escaper: "npm:^2.0.0" - istanbul-lib-report: "npm:^3.0.0" - checksum: 10/6773a1d5c7d47eeec75b317144fe2a3b1da84a44b6282bebdc856e09667865e58c9b025b75b3d87f5bc62939126cbba4c871ee84254537d934ba5da5d4c4ec4e - languageName: node - linkType: hard - "jose@npm:6.2.4": version: 6.2.4 resolution: "jose@npm:6.2.4" @@ -7087,9 +6768,9 @@ __metadata: linkType: hard "jose@npm:^6.1.3": - version: 6.2.10 - resolution: "jose@npm:6.2.10" - checksum: 10/232fccd0d98105242fa5455b729a40048932e2dd697442d934c39d83f4d9b4e331b5f1905971fccdacb51c0d3594fc7864a38af8d02b23cf7182317d6f38e220 + version: 6.2.11 + resolution: "jose@npm:6.2.11" + checksum: 10/89dbbe3eee3d7b21537d5c819a03676ba87a6c34f69be08cdc9a87186555c21c05faba2a18f08f1d2e2502000b7d12b6db7e7766d75eee85d587db0b17e213dc languageName: node linkType: hard @@ -7126,25 +6807,25 @@ __metadata: linkType: hard "js-yaml@npm:^3.13.1, js-yaml@npm:^3.6.1": - version: 3.15.1 - resolution: "js-yaml@npm:3.15.1" + version: 3.15.2 + resolution: "js-yaml@npm:3.15.2" dependencies: argparse: "npm:^1.0.7" esprima: "npm:^4.0.0" bin: js-yaml: bin/js-yaml.js - checksum: 10/905842ce08c18b154ff2c187ff81bb41294cc5dda214331b5d9b8bcf395894e48b00fa37b9d4dd4c3ffec672b7496a94558e3876d3a8ad4d12b06248112e6d92 + checksum: 10/320b5471c1b7309a15d9c0068ba273e2da7382a369038a9746c92ef649087c3d6d2a5c676810154efb4eb2ca9584179c8ae14e2fc2e4bd558d964e6bf4fafd3d languageName: node linkType: hard "js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1": - version: 4.3.1 - resolution: "js-yaml@npm:4.3.1" + version: 4.3.2 + resolution: "js-yaml@npm:4.3.2" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10/2ce71b5d632abbd77da80447bf860e8a0264e54bffe94840984887d58b023761495b523727547904517a6107a1ef189854b361e0fc44995ee13a84f222d7bd42 + checksum: 10/05c44b9c73e4901d92703b155e76518df64bf01ac62e4c036b47de4b391e19b72e32656e8954d51b436307f08cc9d0c0d4ec617d061cf2f65fffee9f3114bee7 languageName: node linkType: hard @@ -7642,16 +7323,16 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.21": - version: 0.30.21 - resolution: "magic-string@npm:0.30.21" +"magic-string@npm:^1.2.3": + version: 1.2.3 + resolution: "magic-string@npm:1.2.3" dependencies: "@jridgewell/sourcemap-codec": "npm:^1.5.5" - checksum: 10/57d5691f41ed40d962d8bd300148114f53db67fadbff336207db10a99f2bdf4a1be9cac3a68ee85dba575912ee1d4402e4396408196ec2d3afd043b076156221 + checksum: 10/d1a943913fad82733704cc6b4ff327a1ee9dcdbaa1475931f03368aa0ac54305eb37f591ff1478c36e7f9c62dabc335d6dc5d28744e632f62d42f350031d758f languageName: node linkType: hard -"magicast@npm:^0.5.2": +"magicast@npm:^0.5.4": version: 0.5.4 resolution: "magicast@npm:0.5.4" dependencies: @@ -7662,15 +7343,6 @@ __metadata: languageName: node linkType: hard -"make-dir@npm:^4.0.0": - version: 4.0.0 - resolution: "make-dir@npm:4.0.0" - dependencies: - semver: "npm:^7.5.3" - checksum: 10/bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a - languageName: node - linkType: hard - "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -7890,13 +7562,6 @@ __metadata: languageName: node linkType: hard -"mute-stream@npm:^3.0.0": - version: 3.0.0 - resolution: "mute-stream@npm:3.0.0" - checksum: 10/bee5db5c996a4585dbffc49e51fea10f3582d7f65441db9bc63126f16269541713c6ccb5a6fe37e08f627967b6eb28dd6b35e54a8dce53cf3837d7e010917b43 - languageName: node - linkType: hard - "nanoid-dictionary@npm:5.0.0": version: 5.0.0 resolution: "nanoid-dictionary@npm:5.0.0" @@ -7913,7 +7578,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.17": +"nanoid@npm:^3.3.18": version: 3.3.18 resolution: "nanoid@npm:3.3.18" bin: @@ -7997,8 +7662,8 @@ __metadata: linkType: hard "node-gyp@npm:latest": - version: 13.0.1 - resolution: "node-gyp@npm:13.0.1" + version: 13.0.2 + resolution: "node-gyp@npm:13.0.2" dependencies: env-paths: "npm:^2.2.0" exponential-backoff: "npm:^3.1.1" @@ -8012,7 +7677,7 @@ __metadata: which: "npm:^7.0.0" bin: node-gyp: bin/node-gyp.js - checksum: 10/227ad4aaa7cda1b5d3bc20a58e36d354e400db9b0682a6540ce8e9aa530a56feec963100d7c449ccfcd85efd850383c9988b62a1d95dadef246117be42c78316 + checksum: 10/018ca69386d40576c0ab4bb556dc3ecf40a06a260174a8f5d50da9a592d0a90bd5cf936ca481f19de7ccb7f79dc801d17032d8e2b70963194965a1cc79f8063e languageName: node linkType: hard @@ -8074,7 +7739,7 @@ __metadata: languageName: node linkType: hard -"obug@npm:^2.1.1": +"obug@npm:^2.1.4": version: 2.1.4 resolution: "obug@npm:2.1.4" checksum: 10/05e3ac83f60ef18edb935d67703bada2cbc0feb1a1cef575240b1e48e6e877df95b74048e69bbe549759df18c57ad1808e1e60a9fc4f785525c8549bf7fe81db @@ -8190,29 +7855,29 @@ __metadata: languageName: node linkType: hard -"oxfmt@npm:^0.65.0": - version: 0.65.0 - resolution: "oxfmt@npm:0.65.0" - dependencies: - "@oxfmt/binding-android-arm-eabi": "npm:0.65.0" - "@oxfmt/binding-android-arm64": "npm:0.65.0" - "@oxfmt/binding-darwin-arm64": "npm:0.65.0" - "@oxfmt/binding-darwin-x64": "npm:0.65.0" - "@oxfmt/binding-freebsd-x64": "npm:0.65.0" - "@oxfmt/binding-linux-arm-gnueabihf": "npm:0.65.0" - "@oxfmt/binding-linux-arm-musleabihf": "npm:0.65.0" - "@oxfmt/binding-linux-arm64-gnu": "npm:0.65.0" - "@oxfmt/binding-linux-arm64-musl": "npm:0.65.0" - "@oxfmt/binding-linux-ppc64-gnu": "npm:0.65.0" - "@oxfmt/binding-linux-riscv64-gnu": "npm:0.65.0" - "@oxfmt/binding-linux-riscv64-musl": "npm:0.65.0" - "@oxfmt/binding-linux-s390x-gnu": "npm:0.65.0" - "@oxfmt/binding-linux-x64-gnu": "npm:0.65.0" - "@oxfmt/binding-linux-x64-musl": "npm:0.65.0" - "@oxfmt/binding-openharmony-arm64": "npm:0.65.0" - "@oxfmt/binding-win32-arm64-msvc": "npm:0.65.0" - "@oxfmt/binding-win32-ia32-msvc": "npm:0.65.0" - "@oxfmt/binding-win32-x64-msvc": "npm:0.65.0" +"oxfmt@npm:^0.66.0": + version: 0.66.0 + resolution: "oxfmt@npm:0.66.0" + dependencies: + "@oxfmt/binding-android-arm-eabi": "npm:0.66.0" + "@oxfmt/binding-android-arm64": "npm:0.66.0" + "@oxfmt/binding-darwin-arm64": "npm:0.66.0" + "@oxfmt/binding-darwin-x64": "npm:0.66.0" + "@oxfmt/binding-freebsd-x64": "npm:0.66.0" + "@oxfmt/binding-linux-arm-gnueabihf": "npm:0.66.0" + "@oxfmt/binding-linux-arm-musleabihf": "npm:0.66.0" + "@oxfmt/binding-linux-arm64-gnu": "npm:0.66.0" + "@oxfmt/binding-linux-arm64-musl": "npm:0.66.0" + "@oxfmt/binding-linux-ppc64-gnu": "npm:0.66.0" + "@oxfmt/binding-linux-riscv64-gnu": "npm:0.66.0" + "@oxfmt/binding-linux-riscv64-musl": "npm:0.66.0" + "@oxfmt/binding-linux-s390x-gnu": "npm:0.66.0" + "@oxfmt/binding-linux-x64-gnu": "npm:0.66.0" + "@oxfmt/binding-linux-x64-musl": "npm:0.66.0" + "@oxfmt/binding-openharmony-arm64": "npm:0.66.0" + "@oxfmt/binding-win32-arm64-msvc": "npm:0.66.0" + "@oxfmt/binding-win32-ia32-msvc": "npm:0.66.0" + "@oxfmt/binding-win32-x64-msvc": "npm:0.66.0" tinypool: "npm:2.1.0" peerDependencies: svelte: ^5.0.0 @@ -8263,33 +7928,33 @@ __metadata: optional: true bin: oxfmt: bin/oxfmt - checksum: 10/17152ac226710082ab1060d536d15ac86ad29e122b2724052588484fa5a80493f414d1b02b6f0a96a7b8afc2d027c4fe7fcf1a59067e3283145907fdb73c704b - languageName: node - linkType: hard - -"oxlint@npm:^1.80.0": - version: 1.80.0 - resolution: "oxlint@npm:1.80.0" - dependencies: - "@oxlint/binding-android-arm-eabi": "npm:1.80.0" - "@oxlint/binding-android-arm64": "npm:1.80.0" - "@oxlint/binding-darwin-arm64": "npm:1.80.0" - "@oxlint/binding-darwin-x64": "npm:1.80.0" - "@oxlint/binding-freebsd-x64": "npm:1.80.0" - "@oxlint/binding-linux-arm-gnueabihf": "npm:1.80.0" - "@oxlint/binding-linux-arm-musleabihf": "npm:1.80.0" - "@oxlint/binding-linux-arm64-gnu": "npm:1.80.0" - "@oxlint/binding-linux-arm64-musl": "npm:1.80.0" - "@oxlint/binding-linux-ppc64-gnu": "npm:1.80.0" - "@oxlint/binding-linux-riscv64-gnu": "npm:1.80.0" - "@oxlint/binding-linux-riscv64-musl": "npm:1.80.0" - "@oxlint/binding-linux-s390x-gnu": "npm:1.80.0" - "@oxlint/binding-linux-x64-gnu": "npm:1.80.0" - "@oxlint/binding-linux-x64-musl": "npm:1.80.0" - "@oxlint/binding-openharmony-arm64": "npm:1.80.0" - "@oxlint/binding-win32-arm64-msvc": "npm:1.80.0" - "@oxlint/binding-win32-ia32-msvc": "npm:1.80.0" - "@oxlint/binding-win32-x64-msvc": "npm:1.80.0" + checksum: 10/8b467a440a5a62805bb468a33a630fda5a42930e5226c9fde2e77d04c4b6868ce042c54af952cdad50b96e9b12bd5b4e5b5d3ab406853725a0933e115da093e5 + languageName: node + linkType: hard + +"oxlint@npm:^1.81.0": + version: 1.81.0 + resolution: "oxlint@npm:1.81.0" + dependencies: + "@oxlint/binding-android-arm-eabi": "npm:1.81.0" + "@oxlint/binding-android-arm64": "npm:1.81.0" + "@oxlint/binding-darwin-arm64": "npm:1.81.0" + "@oxlint/binding-darwin-x64": "npm:1.81.0" + "@oxlint/binding-freebsd-x64": "npm:1.81.0" + "@oxlint/binding-linux-arm-gnueabihf": "npm:1.81.0" + "@oxlint/binding-linux-arm-musleabihf": "npm:1.81.0" + "@oxlint/binding-linux-arm64-gnu": "npm:1.81.0" + "@oxlint/binding-linux-arm64-musl": "npm:1.81.0" + "@oxlint/binding-linux-ppc64-gnu": "npm:1.81.0" + "@oxlint/binding-linux-riscv64-gnu": "npm:1.81.0" + "@oxlint/binding-linux-riscv64-musl": "npm:1.81.0" + "@oxlint/binding-linux-s390x-gnu": "npm:1.81.0" + "@oxlint/binding-linux-x64-gnu": "npm:1.81.0" + "@oxlint/binding-linux-x64-musl": "npm:1.81.0" + "@oxlint/binding-openharmony-arm64": "npm:1.81.0" + "@oxlint/binding-win32-arm64-msvc": "npm:1.81.0" + "@oxlint/binding-win32-ia32-msvc": "npm:1.81.0" + "@oxlint/binding-win32-x64-msvc": "npm:1.81.0" peerDependencies: oxlint-tsgolint: ">=7.0.2001" vite-plus: "*" @@ -8339,7 +8004,7 @@ __metadata: optional: true bin: oxlint: bin/oxlint - checksum: 10/471f69d371328b0daccb3d1b7b483717ba485d7875505d793452d1b4b2c921cd18c50b91be5c23957dd6fbd34a34e85f5e79494125e92d041d7e284d1f27a991 + checksum: 10/a783a09c640e599ed1909511247f9ec93057c9111a3cb421c65903739b6427b9fa2f98a0a6f5d9a3bb9e22075a55ad2fb44bd279d0597aab8c2bef76f326fc51 languageName: node linkType: hard @@ -8547,13 +8212,6 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^2.0.3": - version: 2.0.3 - resolution: "pathe@npm:2.0.3" - checksum: 10/01e9a69928f39087d96e1751ce7d6d50da8c39abf9a12e0ac2389c42c83bc76f78c45a475bd9026a02e6a6f79be63acc75667df855862fe567d99a00a540d23d - languageName: node - linkType: hard - "peek-stream@npm:^1.1.0": version: 1.1.3 resolution: "peek-stream@npm:1.1.3" @@ -8586,7 +8244,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4, picomatch@npm:^4.0.5": +"picomatch@npm:^4.0.4, picomatch@npm:^4.0.5, picomatch@npm:^4.0.7": version: 4.0.7 resolution: "picomatch@npm:4.0.7" checksum: 10/66e1df34bc39c72fa3756be4069f7887ea3e35e94bba1c3f6167763007698cb04b8a0a365161d186fda6e9571a2d3efb606b2966eb6a7dea6252911224dc2f48 @@ -8731,13 +8389,13 @@ __metadata: linkType: hard "postcss@npm:^8.5.26": - version: 8.5.26 - resolution: "postcss@npm:8.5.26" + version: 8.5.28 + resolution: "postcss@npm:8.5.28" dependencies: - nanoid: "npm:^3.3.17" + nanoid: "npm:^3.3.18" picocolors: "npm:^1.1.1" source-map-js: "npm:^1.2.1" - checksum: 10/842a624f822f77cb37264cc24f0cf97c0a69dd8d6f7b4a6d76b5a8dc95355899dd044f33a2d4e890da858293de570fce18dff453f3b629ff14cac67a3591895a + checksum: 10/c34814c1da499f370cd3b02a085a2d866989a966629b7bd4025f4a2a10d538a2ec83dc3181e1008550edb4cf44e49a0c40ec82332b08d9600877e6841240ad05 languageName: node linkType: hard @@ -8751,11 +8409,11 @@ __metadata: linkType: hard "pretty-ms@npm:^9.3.0": - version: 9.3.0 - resolution: "pretty-ms@npm:9.3.0" + version: 9.3.1 + resolution: "pretty-ms@npm:9.3.1" dependencies: parse-ms: "npm:^4.0.0" - checksum: 10/beb4e04dc17071885b827e3f33d36be279791f2f36a8c29a45c77e59979dad79a5d7e5211922c72a3f6f109bb64a707d70fcdba6746e077122afcd88ce202e98 + checksum: 10/03a8a8bea0ac3c9952378b4b591bbdb8d0fc27843e0a7809945036e14e4a6a9b07781ac4172d5e61e25c7eabe242f4a2a1252cd2876d6d9e24c78471fcb7f303 languageName: node linkType: hard @@ -8849,7 +8507,17 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.14.0, qs@npm:^6.15.2, qs@npm:~6.15.1": +"qs@npm:^6.14.0, qs@npm:^6.15.2": + version: 6.16.0 + resolution: "qs@npm:6.16.0" + dependencies: + es-define-property: "npm:^1.0.1" + side-channel: "npm:^1.1.1" + checksum: 10/7fbf9c2eb9c9bbd8997700caecc4b057ac259635873deefd5027fb26d06d79426cf468da55da87ffd147a24e93d3641352d8ac6664bd84688ad5b2f0b8d04828 + languageName: node + linkType: hard + +"qs@npm:~6.15.1": version: 6.15.3 resolution: "qs@npm:6.15.3" dependencies: @@ -9051,25 +8719,25 @@ __metadata: linkType: hard "rolldown@npm:~1.2.4": - version: 1.2.5 - resolution: "rolldown@npm:1.2.5" - dependencies: - "@oxc-project/types": "npm:=0.146.0" - "@rolldown/binding-android-arm-eabi": "npm:1.2.5" - "@rolldown/binding-android-arm64": "npm:1.2.5" - "@rolldown/binding-darwin-arm64": "npm:1.2.5" - "@rolldown/binding-darwin-x64": "npm:1.2.5" - "@rolldown/binding-freebsd-x64": "npm:1.2.5" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.5" - "@rolldown/binding-linux-arm64-gnu": "npm:1.2.5" - "@rolldown/binding-linux-arm64-musl": "npm:1.2.5" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.5" - "@rolldown/binding-linux-s390x-gnu": "npm:1.2.5" - "@rolldown/binding-linux-x64-gnu": "npm:1.2.5" - "@rolldown/binding-linux-x64-musl": "npm:1.2.5" - "@rolldown/binding-openharmony-arm64": "npm:1.2.5" - "@rolldown/binding-win32-arm64-msvc": "npm:1.2.5" - "@rolldown/binding-win32-x64-msvc": "npm:1.2.5" + version: 1.2.7 + resolution: "rolldown@npm:1.2.7" + dependencies: + "@oxc-project/types": "npm:=0.148.0" + "@rolldown/binding-android-arm-eabi": "npm:1.2.7" + "@rolldown/binding-android-arm64": "npm:1.2.7" + "@rolldown/binding-darwin-arm64": "npm:1.2.7" + "@rolldown/binding-darwin-x64": "npm:1.2.7" + "@rolldown/binding-freebsd-x64": "npm:1.2.7" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.7" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.7" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.7" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.7" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.7" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.7" + "@rolldown/binding-linux-x64-musl": "npm:1.2.7" + "@rolldown/binding-openharmony-arm64": "npm:1.2.7" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.7" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.7" "@rolldown/pluginutils": "npm:^1.0.0" dependenciesMeta: "@rolldown/binding-android-arm-eabi": @@ -9104,7 +8772,7 @@ __metadata: optional: true bin: rolldown: ./bin/cli.mjs - checksum: 10/5c3fc88aa385adf6f4420ee57071149670618514bc85702bcf44ae1ddc9277fdbbd659d455bb28615ba0a4fba647051689006f2c7fedbaafa193c2c4ee4339a7 + checksum: 10/e3c23a65a2dbd4733d508a5464727064c7c838e3ff2a76b59c5b6f512a250e21fdc3e622af8f985d1970d0d72518afbd7117fb8f111b65a6d2c228e4b666633c languageName: node linkType: hard @@ -9270,36 +8938,36 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.35.3": - version: 0.35.3 - resolution: "sharp@npm:0.35.3" +"sharp@npm:^0.35.4": + version: 0.35.4 + resolution: "sharp@npm:0.35.4" dependencies: "@img/colour": "npm:^1.1.0" - "@img/sharp-darwin-arm64": "npm:0.35.3" - "@img/sharp-darwin-x64": "npm:0.35.3" - "@img/sharp-freebsd-wasm32": "npm:0.35.3" - "@img/sharp-libvips-darwin-arm64": "npm:1.3.2" - "@img/sharp-libvips-darwin-x64": "npm:1.3.2" - "@img/sharp-libvips-linux-arm": "npm:1.3.2" - "@img/sharp-libvips-linux-arm64": "npm:1.3.2" - "@img/sharp-libvips-linux-ppc64": "npm:1.3.2" - "@img/sharp-libvips-linux-riscv64": "npm:1.3.2" - "@img/sharp-libvips-linux-s390x": "npm:1.3.2" - "@img/sharp-libvips-linux-x64": "npm:1.3.2" - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.2" - "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.2" - "@img/sharp-linux-arm": "npm:0.35.3" - "@img/sharp-linux-arm64": "npm:0.35.3" - "@img/sharp-linux-ppc64": "npm:0.35.3" - "@img/sharp-linux-riscv64": "npm:0.35.3" - "@img/sharp-linux-s390x": "npm:0.35.3" - "@img/sharp-linux-x64": "npm:0.35.3" - "@img/sharp-linuxmusl-arm64": "npm:0.35.3" - "@img/sharp-linuxmusl-x64": "npm:0.35.3" - "@img/sharp-webcontainers-wasm32": "npm:0.35.3" - "@img/sharp-win32-arm64": "npm:0.35.3" - "@img/sharp-win32-ia32": "npm:0.35.3" - "@img/sharp-win32-x64": "npm:0.35.3" + "@img/sharp-darwin-arm64": "npm:0.35.4" + "@img/sharp-darwin-x64": "npm:0.35.4" + "@img/sharp-freebsd-wasm32": "npm:0.35.4" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" + "@img/sharp-linux-arm": "npm:0.35.4" + "@img/sharp-linux-arm64": "npm:0.35.4" + "@img/sharp-linux-ppc64": "npm:0.35.4" + "@img/sharp-linux-riscv64": "npm:0.35.4" + "@img/sharp-linux-s390x": "npm:0.35.4" + "@img/sharp-linux-x64": "npm:0.35.4" + "@img/sharp-linuxmusl-arm64": "npm:0.35.4" + "@img/sharp-linuxmusl-x64": "npm:0.35.4" + "@img/sharp-webcontainers-wasm32": "npm:0.35.4" + "@img/sharp-win32-arm64": "npm:0.35.4" + "@img/sharp-win32-ia32": "npm:0.35.4" + "@img/sharp-win32-x64": "npm:0.35.4" detect-libc: "npm:^2.1.2" semver: "npm:^7.8.5" dependenciesMeta: @@ -9356,7 +9024,7 @@ __metadata: peerDependenciesMeta: "@types/node": optional: true - checksum: 10/5f5c7739421f470d18e1b0d8160342103530724f59e3ef11d9cf15d5ec22e36fd2226d3c8f15ac72b3da947605c4076faf9ab8902e08575ed950c6f48a36ffa8 + checksum: 10/f3130f6f126e532d67560b808b95d7c235e669b7f4dd68e0d464feed9a70d52c356ac53867eb44ec877788176a15a3bdffc8ac777a9ed777fc4b63cc99582cb2 languageName: node linkType: hard @@ -9459,6 +9127,13 @@ __metadata: languageName: node linkType: hard +"sisteransi@npm:^1.0.5": + version: 1.0.5 + resolution: "sisteransi@npm:1.0.5" + checksum: 10/aba6438f46d2bfcef94cf112c835ab395172c75f67453fe05c340c770d3c402363018ae1ab4172a1026a90c47eaccf3af7b6ff6fa749a680c2929bd7fa2b37a4 + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -9564,7 +9239,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^4.0.0-rc.1": +"std-env@npm:^4.2.0": version: 4.2.0 resolution: "std-env@npm:4.2.0" checksum: 10/d30c3ae49c5568b4e61dca628eaafe12944dbcfe76980e5b1b1499b48e23f85f1ee01fe2306eeef5964a80b763948bbf2ba40490bc53709f45cf989071c9e4e7 @@ -9598,13 +9273,13 @@ __metadata: linkType: hard "streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": - version: 2.28.0 - resolution: "streamx@npm:2.28.0" + version: 2.28.1 + resolution: "streamx@npm:2.28.1" dependencies: events-universal: "npm:^1.0.0" fast-fifo: "npm:^1.3.2" text-decoder: "npm:^1.1.0" - checksum: 10/ed9a289f09dca9a7bb03790f8b60f9e6bab1032e3fc426e939e7c8207b0511777d96905589a7c9c6d9c9b32e2f94dc884c2e5477ac38524e37f9ea0dfaf8227d + checksum: 10/1bbfad45ee015af1e8ccf73315e1a03bb3c650865c06b64c28b328c20d9a8ff1aa7993d79a124b26ac17720b4f4825fe225bc4ab78a1b2c36a7d04343d312e51 languageName: node linkType: hard @@ -9686,7 +9361,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^7, supports-color@npm:^7.1.0": +"supports-color@npm:^7": version: 7.2.0 resolution: "supports-color@npm:7.2.0" dependencies: @@ -9801,21 +9476,21 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.9.0": - version: 2.9.0 - resolution: "tinybench@npm:2.9.0" - checksum: 10/cfa1e1418e91289219501703c4693c70708c91ffb7f040fd318d24aef419fb5a43e0c0160df9471499191968b2451d8da7f8087b08c3133c251c40d24aced06c +"tinybench@npm:6.1.4": + version: 6.1.4 + resolution: "tinybench@npm:6.1.4" + checksum: 10/dee4bb749b2cb3fa7ca093f8ee9576a58d07409268e4219a87565e28911f6310c72561650bc706676ee6c109dc28828ab27b813e78836467e481643e09f33535 languageName: node linkType: hard -"tinyexec@npm:^1.0.2": +"tinyexec@npm:1.3.0": version: 1.3.0 resolution: "tinyexec@npm:1.3.0" checksum: 10/749a8c5aac2ffe3f04220b8966f414636c6c324d4ae88895dfffb2319db61cf1c01c5d7e8f0fb8ab747389ea0be1f0f80d850a9cce88a9d96cc5ebcb345db018 languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.17": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" dependencies: @@ -9832,7 +9507,7 @@ __metadata: languageName: node linkType: hard -"tinyrainbow@npm:^3.1.0": +"tinyrainbow@npm:^3.1.1": version: 3.1.1 resolution: "tinyrainbow@npm:3.1.1" checksum: 10/6aa4aadf89cc8ecf8c227ef189616911292c4f0d2d5708b669b00375c5a8b43058115685f043034ffb11a8744c24650a30493525ff62134b436d94c84fa33ed5 @@ -9948,9 +9623,9 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.23.12": - version: 4.23.12 - resolution: "tsx@npm:4.23.12" +"tsx@npm:^4.23.13": + version: 4.23.13 + resolution: "tsx@npm:4.23.13" dependencies: esbuild: "npm:~0.28.0" fsevents: "npm:~2.3.3" @@ -9959,7 +9634,7 @@ __metadata: optional: true bin: tsx: dist/cli.mjs - checksum: 10/167a5571cb211aff257d200cd75a1003b1e0e59d641c1e6ff99f6cdfada46ff6ce311e105722699b4763feb1371b2d8084bcc413c7e54aae3761c852817dc0b0 + checksum: 10/064c7de2b3ad923fee8feeffc5698738a75f10d98d8a024c6c8efac5351a7825e84aa7a933778145c4f769405f8a95f8f49289a7bff698ba79fd92ff67e26289 languageName: node linkType: hard @@ -10000,7 +9675,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:5.8.0, type-fest@npm:^5.0.0, type-fest@npm:^5.6.0": +"type-fest@npm:5.8.0": version: 5.8.0 resolution: "type-fest@npm:5.8.0" dependencies: @@ -10009,6 +9684,15 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^5.0.0, type-fest@npm:^5.6.0": + version: 5.9.0 + resolution: "type-fest@npm:5.9.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10/915bee435848a8c36a01135afd239ca337df31f605da60684f053eb395846bfedd3d0c8cb6b00b14788814fb9c34ddb6e00ed1f6d06c4c2547f70475b76c1358 + languageName: node + linkType: hard + "type-is@npm:^2.0.1, type-is@npm:^2.1.0": version: 2.1.0 resolution: "type-is@npm:2.1.0" @@ -10189,9 +9873,9 @@ __metadata: linkType: hard "undici-types@npm:^8.9.0": - version: 8.10.0 - resolution: "undici-types@npm:8.10.0" - checksum: 10/eae2070aa650bb2ce972c621ffc8a3d627670620233ba978d211ececb64c7d07597d11b6028a3058bc205421de98e858d9809d22eff30df1bc915755c43c118a + version: 8.10.1 + resolution: "undici-types@npm:8.10.1" + checksum: 10/777e36b3ecadbec0cd21683de2b061af3fcda2bfb5de71b0b66c9d2cc863bfe62fd6e4cf3e176cdf78617456cb2dfacdc5b7910624f1dd420760749fb4f8991e languageName: node linkType: hard @@ -10217,9 +9901,9 @@ __metadata: linkType: hard "undici@npm:^8.4.1, undici@npm:^8.9.0": - version: 8.10.0 - resolution: "undici@npm:8.10.0" - checksum: 10/254219966d4a2fb110f565ffe73131caa82e949f207b9dd88930ebc23dfb6561db72ca187391cbd408ceeba7c647cb47d4110e8ae00df44c0c66e8a21e091dc0 + version: 8.10.1 + resolution: "undici@npm:8.10.1" + checksum: 10/7983c1a9ecf078cf30ca5e70aade598f73ae24451597501e07ec65c411d75f78fe601870e0003e910501eca2e9a8be740cf1fd30044f3ca05297bc6012836c64 languageName: node linkType: hard @@ -10293,55 +9977,54 @@ __metadata: languageName: node linkType: hard -"verdaccio-audit@npm:13.1.2": - version: 13.1.2 - resolution: "verdaccio-audit@npm:13.1.2" +"verdaccio-audit@npm:13.1.3": + version: 13.1.3 + resolution: "verdaccio-audit@npm:13.1.3" dependencies: - "@verdaccio/config": "npm:8.2.2" - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/config": "npm:8.3.0" + "@verdaccio/core": "npm:8.3.0" express: "npm:4.22.2" https-proxy-agent: "npm:5.0.1" node-fetch: "npm:cjs" - checksum: 10/9cacecbd1c0a7204b8752cb35fea663cc674ba2d3f15b23810a85fb78a992aae5825b742106be68c066fbe8db1930fa7513748dfb684375297f03733584632d2 + checksum: 10/e88eef5ec94c1e31f8568aae186d5ee56f72f68dfc6889680bc8f01dd52e6e113959ff5d42cc95ca80dd0b01f5526c6e3d73be459ac9461b98b082f83b31af79 languageName: node linkType: hard -"verdaccio-htpasswd@npm:13.1.2": - version: 13.1.2 - resolution: "verdaccio-htpasswd@npm:13.1.2" +"verdaccio-htpasswd@npm:13.1.3": + version: 13.1.3 + resolution: "verdaccio-htpasswd@npm:13.1.3" dependencies: - "@verdaccio/core": "npm:8.2.2" + "@verdaccio/core": "npm:8.3.0" "@verdaccio/file-locking": "npm:13.1.0" apache-md5: "npm:1.1.8" bcryptjs: "npm:2.4.3" debug: "npm:4.4.3" http-errors: "npm:2.0.1" unix-crypt-td-js: "npm:1.1.4" - checksum: 10/fadd33aa90774051d9db551b6745348d4d0f948e99ecf5a0f7e83476ad5d743d0c6317a6fe115550beeb68f0d8010b0753ec618185e2b9412d8e0958446de744 + checksum: 10/585ff137a9cc3a4ccf7f54f586748fc39fa6ef2aea35aa122df87e07716021679690ceadf5eb9aecdcff72695a667bb7562da2c35d8692cefa1fba199f9c39f4 languageName: node linkType: hard -"verdaccio@npm:^6.10.0": - version: 6.10.0 - resolution: "verdaccio@npm:6.10.0" +"verdaccio@npm:^6.10.2": + version: 6.10.2 + resolution: "verdaccio@npm:6.10.2" dependencies: "@cypress/request": "npm:4.0.1" - "@verdaccio/auth": "npm:8.1.2" - "@verdaccio/config": "npm:8.2.2" - "@verdaccio/core": "npm:8.2.2" - "@verdaccio/hooks": "npm:8.1.3" - "@verdaccio/loaders": "npm:8.1.2" - "@verdaccio/local-storage-legacy": "npm:11.4.2" - "@verdaccio/logger": "npm:8.1.2" - "@verdaccio/middleware": "npm:8.1.2" - "@verdaccio/package-filter": "npm:13.2.0" + "@verdaccio/auth": "npm:8.1.3" + "@verdaccio/config": "npm:8.3.0" + "@verdaccio/core": "npm:8.3.0" + "@verdaccio/hooks": "npm:8.1.4" + "@verdaccio/loaders": "npm:8.1.3" + "@verdaccio/local-storage-legacy": "npm:11.4.3" + "@verdaccio/logger": "npm:8.1.3" + "@verdaccio/middleware": "npm:8.1.3" + "@verdaccio/package-filter": "npm:13.2.1" "@verdaccio/search-indexer": "npm:8.1.0" - "@verdaccio/signature": "npm:8.1.2" + "@verdaccio/signature": "npm:8.1.3" "@verdaccio/streams": "npm:10.3.0" - "@verdaccio/tarball": "npm:13.1.2" - "@verdaccio/ui-theme": "npm:9.0.0-next-9.26" - "@verdaccio/url": "npm:13.1.2" - "@verdaccio/utils": "npm:8.2.2" + "@verdaccio/tarball": "npm:13.1.3" + "@verdaccio/ui-theme": "npm:9.0.0-next-9.30" + "@verdaccio/url": "npm:13.1.3" JSONStream: "npm:1.3.5" async: "npm:3.2.6" clipanion: "npm:4.0.0-rc.4" @@ -10354,11 +10037,11 @@ __metadata: lru-cache: "npm:7.18.3" mime: "npm:3.0.0" semver: "npm:7.8.5" - verdaccio-audit: "npm:13.1.2" - verdaccio-htpasswd: "npm:13.1.2" + verdaccio-audit: "npm:13.1.3" + verdaccio-htpasswd: "npm:13.1.3" bin: verdaccio: bin/verdaccio - checksum: 10/f562736a2a9676057771ef18ae01534a82196d6cecb8fd13d42e1ce0b4f2853fff361ba4e45a9c3f5041e99434d4e3ee86549a9468c495bfb8697f87762d9332 + checksum: 10/c121ff027ccff3346ea782a8a33a208b4425d5e2613d2e970f6d61cf025d26fe16f8681e00741770ec32357e2cd381130b189cf5ec3c40e316310b7b694182ba languageName: node linkType: hard @@ -10373,7 +10056,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": +"vite@npm:^8.2.2": version: 8.2.2 resolution: "vite@npm:8.2.2" dependencies: @@ -10430,43 +10113,36 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^4.1.11": - version: 4.1.11 - resolution: "vitest@npm:4.1.11" - dependencies: - "@vitest/expect": "npm:4.1.11" - "@vitest/mocker": "npm:4.1.11" - "@vitest/pretty-format": "npm:4.1.11" - "@vitest/runner": "npm:4.1.11" - "@vitest/snapshot": "npm:4.1.11" - "@vitest/spy": "npm:4.1.11" - "@vitest/utils": "npm:4.1.11" - es-module-lexer: "npm:^2.0.0" - expect-type: "npm:^1.3.0" - magic-string: "npm:^0.30.21" - obug: "npm:^2.1.1" - pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.3" - std-env: "npm:^4.0.0-rc.1" - tinybench: "npm:^2.9.0" - tinyexec: "npm:^1.0.2" - tinyglobby: "npm:^0.2.15" - tinyrainbow: "npm:^3.1.0" - vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" +"vitest@npm:^5.0.0": + version: 5.0.0 + resolution: "vitest@npm:5.0.0" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/mocker": "npm:5.0.0" + chai: "npm:^6.2.2" + es-module-lexer: "npm:^2.3.2" + expect-type: "npm:^1.4.0" + magic-string: "npm:^1.2.3" + obug: "npm:^2.1.4" + picomatch: "npm:^4.0.7" + std-env: "npm:^4.2.0" + tinybench: "npm:6.1.4" + tinyexec: "npm:1.3.0" + tinyglobby: "npm:^0.2.17" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 - "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.11 - "@vitest/browser-preview": 4.1.11 - "@vitest/browser-webdriverio": 4.1.11 - "@vitest/coverage-istanbul": 4.1.11 - "@vitest/coverage-v8": 4.1.11 - "@vitest/ui": 4.1.11 + "@types/node": ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 5.0.0 + "@vitest/browser-preview": 5.0.0 + "@vitest/browser-webdriverio": ^5.0.0-beta.5 || >=5.0.0 + "@vitest/coverage-istanbul": 5.0.0 + "@vitest/coverage-v8": 5.0.0 + "@vitest/ui": 5.0.0 happy-dom: "*" jsdom: "*" - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: "@edge-runtime/vm": optional: true @@ -10494,7 +10170,7 @@ __metadata: optional: false bin: vitest: ./vitest.mjs - checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b + checksum: 10/66a8272ba5d102c97954e54c048a105e27d061729511a8a21e0189b38858058264c02835d6fa4aaa674455b58cfd74c798c04bc70e4072f3126559054696a9ee languageName: node linkType: hard @@ -10738,9 +10414,16 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.4.3, zod@npm:^3.25 || ^4.0, zod@npm:^4.4.3": +"zod@npm:4.4.3": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 languageName: node linkType: hard + +"zod@npm:^3.25 || ^4.0, zod@npm:^4.5.4": + version: 4.5.4 + resolution: "zod@npm:4.5.4" + checksum: 10/b2fd4aaf358359650a1f27f303a462f0f49b4e5582f517ad8894ed566fdceb9da391b59babffe3c70574c8064174883735657eb28e12db5c7021455a631866c4 + languageName: node + linkType: hard