From 7f61a442ba7152db5e7df438b97c32e5b7223c60 Mon Sep 17 00:00:00 2001 From: Shah Hussain Date: Mon, 8 Jun 2026 12:24:29 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(domain):=20getmnemo.xyz=20=E2=86=92=20m?= =?UTF-8?q?nemohq.com=20(post-cutover=20dead-domain=20repoint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 8101fc8..df461ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,7 +31,7 @@ function buildAdapterConfig(opts: CommonAdapterOpts): AdapterConfig { function getMnemoClient(): Mnemo { const apiKey = process.env.GETMNEMO_API_KEY; const workspaceId = process.env.GETMNEMO_WORKSPACE_ID; - const apiUrl = process.env.GETMNEMO_API_URL ?? "https://api.getmnemo.xyz"; + const apiUrl = process.env.GETMNEMO_API_URL ?? "https://api.mnemohq.com"; if (!apiKey || !workspaceId) { throw new Error( "GETMNEMO_API_KEY and GETMNEMO_WORKSPACE_ID must be set (run `getmnemo login` from @mnemo/cli or export them).", From 0f4fba3a613c5120be2b7be5c4c567f1cc53891e Mon Sep 17 00:00:00 2001 From: Shah Hussain <95882307+shhahhussain@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:42:03 +0500 Subject: [PATCH 2/2] feat: add resumable historical imports --- README.md | 158 +- docs/pilot-integration.md | 81 ++ package-lock.json | 2155 ++++++++++++++++++++++++++++ package.json | 12 +- src/adapters/base.ts | 5 + src/adapters/directory.ts | 71 + src/adapters/file-adapters.test.ts | 99 ++ src/adapters/index.ts | 37 +- src/adapters/letta.ts | 1 + src/adapters/mem0.ts | 1 + src/adapters/raw-csv.ts | 35 + src/adapters/raw-json.ts | 39 + src/adapters/raw-jsonl.ts | 46 +- src/adapters/record-mapping.ts | 60 + src/adapters/supermemory.ts | 1 + src/adapters/text-file.ts | 29 + src/adapters/zep.ts | 9 +- src/cli.ts | 343 +++-- src/lib/jobs.ts | 173 ++- src/lib/migrator.ts | 265 +++- src/lib/mnemo-writer.test.ts | 100 ++ src/lib/mnemo-writer.ts | 192 +++ src/migrator.test.ts | 176 ++- 23 files changed, 3634 insertions(+), 454 deletions(-) create mode 100644 docs/pilot-integration.md create mode 100644 package-lock.json create mode 100644 src/adapters/directory.ts create mode 100644 src/adapters/file-adapters.test.ts create mode 100644 src/adapters/raw-csv.ts create mode 100644 src/adapters/raw-json.ts create mode 100644 src/adapters/record-mapping.ts create mode 100644 src/adapters/text-file.ts create mode 100644 src/lib/mnemo-writer.test.ts create mode 100644 src/lib/mnemo-writer.ts diff --git a/README.md b/README.md index c946f07..2af9236 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,112 @@ -# @ledgermem/migrate +# Mnemo Migrate -Importer CLI for moving memories from other providers (or raw JSONL) into [LedgerMem](https://proofly.dev). Resumable, concurrent, and dry-run friendly. +Resumable CLI for importing historical documents and memories into [Mnemo](https://mnemohq.com). It accepts common customer exports without giving Mnemo credentials to the source system. ## Install ```bash -npm install -g @ledgermem/migrate +npm install -g getmnemo-migrate ``` +Until the package is published, run it from this repository with `npm run dev --`. + ## Quickstart ```bash -export LEDGERMEM_API_KEY=... -export LEDGERMEM_WORKSPACE_ID=ws_... -export MEM0_API_KEY=... - -ledgermem-migrate plan from mem0 -ledgermem-migrate from mem0 --concurrency 5 -ledgermem-migrate status +export GETMNEMO_API_KEY=mn_... +export GETMNEMO_WORKSPACE_ID=... + +# Inspect a HubSpot-style CSV export. This makes no API calls. +getmnemo-migrate plan from raw-csv \ + --file ./contacts.csv \ + --map 'content=*,id=Record ID' + +# Submit it to the isolated pilot container and wait for ingestion. +getmnemo-migrate from raw-csv \ + --file ./contacts.csv \ + --map 'content=*,id=Record ID' \ + --container pilot:customer-name ``` -## Commands - -| Command | Description | -| --- | --- | -| `from ` | Run a migration from `` into the configured LedgerMem workspace. | -| `plan from ` | Count records and print a sample — no writes. | -| `resume ` | Resume an interrupted job (skips records that were already written). | -| `status [jobId]` | Show one job's full state, or list the most recent jobs. | -| `cancel ` | Mark a running job as `paused` locally. | +The `content=*` mapping serializes each complete CSV or JSON record. For cleaner source data, map a specific field instead, such as `content=notes,id=record_id,company=company`. -Common flags: `--concurrency ` (default `5`), `--page-size ` (default `100`), `--user `, `--base-url `. +## Import Lanes -Job state is persisted to `~/.ledgermem/migrations/.json`. +| Lane | Default sources | Behavior | +| --- | --- | --- | +| `document` | CSV, JSON, JSONL, Markdown, text, directories | Sends source material through Mnemo document ingestion so facts, provenance, entities, and time can be extracted. | +| `memory` | Mem0, Zep, Supermemory, Letta | Preserves already-extracted memory text as atomic memories. | -## Adapters +Override the default only when the source semantics require it with `--lane memory` or `--lane document`. -### `mem0` +## File Sources -Pulls memories from the [Mem0](https://mem0.ai) API. +| Source | Required option | Notes | +| --- | --- | --- | +| `raw-csv` | `--file export.csv` | RFC-compatible CSV parsing, including quoted multiline fields. | +| `raw-json` | `--file export.json` | Top-level arrays up to 50 MB. Convert larger files to JSONL. | +| `raw-jsonl` | `--file export.jsonl` | Streams one JSON object per line. | +| `text-file` | `--file notes.md` | Imports one Markdown or text document. | +| `directory` | `--file ./export` | Recursively imports supported files in deterministic order; ignores symlinks and unsupported files. | -- Required env: `MEM0_API_KEY` -- Optional: `--user ` to scope to a single Mem0 user, `--base-url` to override (default `https://api.mem0.ai`). -- Pagination: follows the `next` cursor returned by `/v1/memories/`. +Mappings use `target=source` pairs and support dotted source paths: ```bash -MEM0_API_KEY=... ledgermem-migrate from mem0 --user u_42 +getmnemo-migrate from raw-json \ + --file ./history.json \ + --map 'content=payload.body,id=record.id,customer=account.id' \ + --container pilot:customer-name ``` -### `zep` - -Pulls messages from [Zep Cloud](https://www.getzep.com). - -- Required env: `ZEP_API_KEY` -- **Required flag:** `--user ` (Zep memories belong to a user). -- Optional: `--base-url` (default `https://api.getzep.com`). -- Walks every session belonging to the user and exports each message as a memory. +## Provider Sources -```bash -ZEP_API_KEY=... ledgermem-migrate from zep --user u_42 -``` - -### `supermemory` +| Source | Source credentials | Useful options | +| --- | --- | --- | +| `mem0` | `MEM0_API_KEY` | `--user`, `--base-url` | +| `zep` | `ZEP_API_KEY` | `--user` is required, `--base-url` | +| `supermemory` | `SUPERMEMORY_API_KEY` | `--base-url` | +| `letta` | `LETTA_API_KEY`, `LETTA_BASE_URL` | Imports core and archival memory | -Pulls items from the Supermemory API. +## Safe Operating Flow -- Required env: `SUPERMEMORY_API_KEY` -- Optional: `--base-url` (default `https://api.supermemory.ai`). -- Uses the `/v3/memories` cursor pagination. +1. Export data from the source system. +2. Run `plan from ...` and inspect the count plus sample. No Mnemo credentials are needed for planning. +3. Import into a customer-specific container with conservative concurrency. +4. Keep the printed migration job ID. +5. Run `status ` or `reconcile ` to obtain the final completion report. +6. Validate retrieval against a small set of customer-approved questions before connecting an agent. ```bash -SUPERMEMORY_API_KEY=... ledgermem-migrate from supermemory +getmnemo-migrate status +getmnemo-migrate cancel mig_... +getmnemo-migrate resume mig_... +getmnemo-migrate reconcile mig_... ``` -### `letta` +Cancellation pauses after the current bounded batch. Resume skips successful source records and retries unresolved ones. Mnemo custom IDs and idempotency keys also protect against duplicate writes if local state is lost. -Pulls both core memory blocks and archival memories from a Letta server. +Job state, processed IDs, remote job IDs, and failure journals are stored under `~/.getmnemo/migrations` with owner-only permissions. Set `GETMNEMO_MIGRATION_DIR` to use another state directory. -- Required env: `LETTA_API_KEY`, `LETTA_BASE_URL` -- Iterates every agent and exports core blocks (`core_block` kind) plus paginated archival memories (`archival` kind). - -```bash -LETTA_API_KEY=... LETTA_BASE_URL=https://my-letta-host \ - ledgermem-migrate from letta -``` +## Reliability Limits -### `raw-jsonl` - -Generic ingest from a local JSONL file. Each line is one record. - -- Required flag: `--file ` -- Optional: `--map content=text,id=mem_id,topic=category` (default `content=content,id=id`). -- Dotted paths are supported in `--map` source keys (`content=payload.body`). - -```bash -ledgermem-migrate from raw-jsonl \ - --file ./memories.jsonl \ - --map content=text,id=mem_id,user=user_id -``` - -## Resuming - -Every successful write records the source's native id in the job state. Re-running with `resume ` skips those ids, so transient network failures are recoverable without dedup work. - -```bash -ledgermem-migrate from mem0 --concurrency 10 -# ... interrupted ... -ledgermem-migrate status -ledgermem-migrate resume mig_abc123... -``` +- Concurrency is capped at 25 even if a larger value is requested. +- HTTP 429 and server/network failures retry up to five times with backoff. +- Documents larger than the API limit are split deterministically near line boundaries. +- Atomic memories longer than the memory endpoint limit are split with part metadata. +- JSON arrays are capped at 50 MB to avoid loading unbounded files into memory; JSONL and CSV stream. +- The completion report distinguishes accepted writes from completed and failed ingestion jobs. ## Environment | Variable | Purpose | | --- | --- | -| `LEDGERMEM_API_KEY` | Destination API key. **Required.** | -| `LEDGERMEM_WORKSPACE_ID` | Destination workspace. **Required.** | -| `LEDGERMEM_API_URL` | Destination API base URL (default `https://api.proofly.dev`). | -| `MEM0_API_KEY` | mem0 adapter | -| `ZEP_API_KEY` | zep adapter | -| `SUPERMEMORY_API_KEY` | supermemory adapter | -| `LETTA_API_KEY` + `LETTA_BASE_URL` | letta adapter | +| `GETMNEMO_API_KEY` | Destination API key. Required for writes and reconciliation. | +| `GETMNEMO_WORKSPACE_ID` | Destination workspace. Required for writes and reconciliation. | +| `GETMNEMO_API_URL` | API base URL. Defaults to `https://api.mnemohq.com`. | +| `GETMNEMO_CONTAINER_TAG` | Optional default destination container. | +| `GETMNEMO_MIGRATION_DIR` | Optional local checkpoint directory. | + +See [Pilot integration contract](./docs/pilot-integration.md) for the boundary between source exports, Mnemo, governance, and agents. ## License diff --git a/docs/pilot-integration.md b/docs/pilot-integration.md new file mode 100644 index 0000000..542c6aa --- /dev/null +++ b/docs/pilot-integration.md @@ -0,0 +1,81 @@ +# Pilot Integration Contract + +This is the minimum integration surface for the Kira, governance, and Mnemo pilot discussed on 22 July 2026. + +## Responsibility Boundary + +```text +customer-approved exports + | + v +Mnemo Migrate -> Mnemo ingestion and isolated memory + | + v + governance and guardrails + | + v + Kira and swarm agents +``` + +Mnemo owns historical import, source provenance, customer isolation, durable memory, and retrieval. The governance layer decides which memories an agent may use and applies business protocols. Kira and swarm agents own user interaction and task execution. + +The pilot does not require Mnemo to hold HubSpot, Notion, database, or email credentials. The customer exports approved data, the import is reviewed locally, and only the approved export is sent to Mnemo. Native source connectors can be evaluated later from observed pilot demand. + +## Pilot Inputs + +Start with a bounded export rather than every available system: + +- one customer; +- one source, such as a HubSpot CSV export; +- one Mnemo workspace and one customer-specific container; +- a small customer-approved retrieval question set; +- no secrets, credentials, or fields outside the agreed pilot scope. + +## Import Procedure + +```bash +getmnemo-migrate plan from raw-csv \ + --file ./hubspot-export.csv \ + --map 'content=*,id=Record ID' + +getmnemo-migrate from raw-csv \ + --file ./hubspot-export.csv \ + --map 'content=*,id=Record ID' \ + --container pilot:customer-name \ + --concurrency 5 +``` + +Record the migration job ID and keep the local migration directory until acceptance is complete. A paused or interrupted import is resumed with `getmnemo-migrate resume `; it does not restart successful records. + +## Runtime API Contract + +The integrated system needs three Mnemo operations: + +| Operation | Endpoint | Purpose | +| --- | --- | --- | +| Add source material | `POST /v1/documents` | Asynchronous ingestion with provenance and deterministic `customId`. | +| Check ingestion | `GET /v1/jobs?ids=...` | Completion and failure reconciliation in groups of at most 50 job IDs. | +| Retrieve context | `POST /v1/search` | Scoped retrieval for the governance layer before an agent acts. | + +Authenticate with a Mnemo API key and `x-workspace-id`. Every document and search request must use the pilot customer container. API credentials stay in the server-side governance service, never in Kira’s browser or prompt. + +The governance layer should call search with `includeSources: true` so it can retain evidence, then apply its own access rules before returning context to an agent. + +## Acceptance Evidence + +The pilot is ready for agent integration when all of the following are true: + +- the import plan count matches the approved export; +- the final report has no pending jobs and any failed records are explained or retried; +- rerunning or resuming does not create duplicate source records; +- customer-approved test questions retrieve the expected current context and provenance; +- searches cannot cross the pilot customer’s workspace and container boundary; +- removing Mnemo access from the governance service prevents further reads and writes. + +## Deliberately Deferred + +- direct HubSpot or Notion OAuth connectors; +- continuous bidirectional synchronization; +- governance policy authoring inside Mnemo; +- Kira prompt conversion and swarm construction; +- broad ingestion of every customer system before pilot retrieval is validated. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..104d915 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2155 @@ +{ + "name": "getmnemo-migrate", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "getmnemo-migrate", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "cli-progress": "^3.12.0", + "commander": "^12.1.0", + "csv-parse": "^6.1.0", + "kleur": "^4.1.5", + "ora": "^8.1.1" + }, + "bin": { + "getmnemo-migrate": "dist/cli.js" + }, + "devDependencies": { + "@types/cli-progress": "^3.11.6", + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csv-parse": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz", + "integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json index dae4221..572dbe5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@mnemo/migrate", + "name": "getmnemo-migrate", "version": "0.1.0", - "description": "Importer CLI for migrating memories from Mem0, Zep, Supermemory, Letta, or raw JSONL into Mnemo.", + "description": "Resumable CLI for importing historical documents and memories into Mnemo.", "type": "module", "bin": { "getmnemo-migrate": "./dist/cli.js" @@ -9,6 +9,7 @@ "main": "./dist/cli.js", "files": [ "dist", + "docs", "README.md", "LICENSE" ], @@ -21,19 +22,18 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@mnemo/memory": "^0.1.0", "cli-progress": "^3.12.0", "commander": "^12.1.0", + "csv-parse": "^6.1.0", "kleur": "^4.1.5", - "ora": "^8.1.1", - "p-limit": "^6.2.0" + "ora": "^8.1.1" }, "devDependencies": { "@types/cli-progress": "^3.11.6", "@types/node": "^22.10.0", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^2.1.8" + "vitest": "^4.1.10" }, "engines": { "node": ">=20" diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 40bc006..a443923 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -1,3 +1,5 @@ +export type ImportLane = "memory" | "document"; + export interface SourceRecord { /** Provider-native id; carried into Mnemo metadata for traceability. */ sourceId?: string; @@ -5,6 +7,8 @@ export interface SourceRecord { content: string; /** Optional metadata to merge into the Mnemo memory. */ metadata?: Record; + /** Optional source-provided content classification. */ + contentType?: string; } export interface AdapterConfig { @@ -22,6 +26,7 @@ export interface AdapterConfig { export abstract class Adapter { abstract readonly name: string; + abstract readonly defaultLane: ImportLane; constructor(protected readonly cfg: AdapterConfig = {}) {} diff --git a/src/adapters/directory.ts b/src/adapters/directory.ts new file mode 100644 index 0000000..5df7937 --- /dev/null +++ b/src/adapters/directory.ts @@ -0,0 +1,71 @@ +import { promises as fs } from "node:fs"; +import { extname, relative, resolve } from "node:path"; +import { Adapter, AdapterConfig, SourceRecord } from "./base.js"; +import { RawCsvAdapter } from "./raw-csv.js"; +import { RawJsonAdapter } from "./raw-json.js"; +import { RawJsonlAdapter } from "./raw-jsonl.js"; +import { TextFileAdapter } from "./text-file.js"; + +const SUPPORTED_EXTENSIONS = new Set([".csv", ".json", ".jsonl", ".md", ".txt"]); + +async function listFiles(root: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const path = resolve(root, entry.name); + if (entry.isDirectory()) files.push(...(await listFiles(path))); + if (entry.isFile() && SUPPORTED_EXTENSIONS.has(extname(entry.name).toLowerCase())) files.push(path); + } + return files; +} + +function adapterFor(file: string, cfg: AdapterConfig): Adapter { + const childConfig = { ...cfg, file }; + switch (extname(file).toLowerCase()) { + case ".csv": + return new RawCsvAdapter(childConfig); + case ".json": + return new RawJsonAdapter(childConfig); + case ".jsonl": + return new RawJsonlAdapter(childConfig); + case ".md": + case ".txt": + return new TextFileAdapter(childConfig); + default: + throw new Error(`Unsupported import file: ${file}`); + } +} + +export class DirectoryAdapter extends Adapter { + readonly name = "directory"; + readonly defaultLane = "document" as const; + + constructor(cfg: AdapterConfig = {}) { + super(cfg); + if (!cfg.file) throw new Error("--file is required for the directory adapter"); + } + + async count(): Promise { + let total = 0; + for (const file of await listFiles(this.cfg.file!)) { + const count = await adapterFor(file, this.cfg).count(); + if (count === null) return null; + total += count; + } + return total; + } + + async *iterate(): AsyncIterable { + const root = resolve(this.cfg.file!); + for (const file of await listFiles(root)) { + const relativePath = relative(root, file); + for await (const record of adapterFor(file, this.cfg).iterate()) { + yield { + ...record, + sourceId: `${relativePath}:${record.sourceId ?? "document"}`, + metadata: { ...record.metadata, importPath: relativePath }, + }; + } + } + } +} diff --git a/src/adapters/file-adapters.test.ts b/src/adapters/file-adapters.test.ts new file mode 100644 index 0000000..5bf2eb1 --- /dev/null +++ b/src/adapters/file-adapters.test.ts @@ -0,0 +1,99 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DirectoryAdapter } from "./directory.js"; +import { RawCsvAdapter } from "./raw-csv.js"; +import { RawJsonAdapter } from "./raw-json.js"; + +async function collect(adapter: { iterate(): AsyncIterable }): Promise { + const records: unknown[] = []; + for await (const record of adapter.iterate()) records.push(record); + return records; +} + +let directory: string; + +beforeEach(async () => { + directory = await fs.mkdtemp(join(tmpdir(), "getmnemo-adapters-")); +}); + +afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); +}); + +describe("file adapters", () => { + it("parses quoted multiline CSV and applies field mapping", async () => { + const file = join(directory, "contacts.csv"); + await fs.writeFile(file, 'record_id,notes,company\n1,"First line\nsecond line",Acme\n2,Hello,Globex\n'); + const adapter = new RawCsvAdapter({ file, map: "content=notes,id=record_id,company=company" }); + + expect(await adapter.count()).toBe(2); + expect(await collect(adapter)).toEqual([ + { + sourceId: "1", + content: "First line\nsecond line", + contentType: undefined, + metadata: { source: "raw-csv", file, company: "Acme" }, + }, + { + sourceId: "2", + content: "Hello", + contentType: undefined, + metadata: { source: "raw-csv", file, company: "Globex" }, + }, + ]); + }); + + it("parses a JSON array with dotted mappings", async () => { + const file = join(directory, "export.json"); + await fs.writeFile( + file, + JSON.stringify([{ key: 7, payload: { body: "Customer history" }, kind: "crm" }]), + ); + const adapter = new RawJsonAdapter({ + file, + map: "content=payload.body,id=key,contentType=kind", + }); + + expect(await collect(adapter)).toEqual([ + { + sourceId: "7", + content: "Customer history", + contentType: "crm", + metadata: { source: "raw-json", file }, + }, + ]); + }); + + it("walks supported directory files in deterministic order", async () => { + await fs.mkdir(join(directory, "nested")); + await fs.writeFile(join(directory, "b.txt"), "Second document"); + await fs.writeFile(join(directory, "a.md"), "# First document"); + await fs.writeFile(join(directory, "ignored.pdf"), "not imported"); + await fs.writeFile(join(directory, "nested", "c.jsonl"), '{"id":"3","content":"Third"}\n'); + const adapter = new DirectoryAdapter({ file: directory }); + + const records = await collect(adapter) as Array<{ sourceId?: string }>; + expect(records.map((record) => record.sourceId)).toEqual([ + "a.md:a.md", + "b.txt:b.txt", + "nested/c.jsonl:3", + ]); + expect(await adapter.count()).toBe(3); + }); + + it("can serialize a complete exported record without custom preprocessing", async () => { + const file = join(directory, "hubspot.csv"); + await fs.writeFile(file, "Record ID,Company,Status\n101,Acme,Customer\n"); + const adapter = new RawCsvAdapter({ file, map: "content=*,id=Record ID" }); + + const records = await collect(adapter) as Array<{ sourceId?: string; content: string }>; + expect(records[0]?.sourceId).toBe("101"); + expect(JSON.parse(records[0]?.content ?? "{}")).toEqual({ + "Record ID": "101", + Company: "Acme", + Status: "Customer", + }); + }); +}); diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 5548e36..a85243a 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -4,10 +4,33 @@ import { ZepAdapter } from "./zep.js"; import { SupermemoryAdapter } from "./supermemory.js"; import { LettaAdapter } from "./letta.js"; import { RawJsonlAdapter } from "./raw-jsonl.js"; +import { RawJsonAdapter } from "./raw-json.js"; +import { RawCsvAdapter } from "./raw-csv.js"; +import { TextFileAdapter } from "./text-file.js"; +import { DirectoryAdapter } from "./directory.js"; -export type AdapterName = "mem0" | "zep" | "supermemory" | "letta" | "raw-jsonl"; +export type AdapterName = + | "mem0" + | "zep" + | "supermemory" + | "letta" + | "raw-jsonl" + | "raw-json" + | "raw-csv" + | "text-file" + | "directory"; -export const ADAPTER_NAMES: AdapterName[] = ["mem0", "zep", "supermemory", "letta", "raw-jsonl"]; +export const ADAPTER_NAMES: AdapterName[] = [ + "mem0", + "zep", + "supermemory", + "letta", + "raw-jsonl", + "raw-json", + "raw-csv", + "text-file", + "directory", +]; export function createAdapter(name: string, cfg: AdapterConfig = {}): Adapter { switch (name) { @@ -21,10 +44,18 @@ export function createAdapter(name: string, cfg: AdapterConfig = {}): Adapter { return new LettaAdapter(cfg); case "raw-jsonl": return new RawJsonlAdapter(cfg); + case "raw-json": + return new RawJsonAdapter(cfg); + case "raw-csv": + return new RawCsvAdapter(cfg); + case "text-file": + return new TextFileAdapter(cfg); + case "directory": + return new DirectoryAdapter(cfg); default: throw new Error(`Unknown adapter "${name}". Valid: ${ADAPTER_NAMES.join(", ")}`); } } export { Adapter }; -export type { AdapterConfig, SourceRecord } from "./base.js"; +export type { AdapterConfig, ImportLane, SourceRecord } from "./base.js"; diff --git a/src/adapters/letta.ts b/src/adapters/letta.ts index 7a50fa5..8b89ef2 100644 --- a/src/adapters/letta.ts +++ b/src/adapters/letta.ts @@ -23,6 +23,7 @@ interface LettaAgent { export class LettaAdapter extends Adapter { readonly name = "letta"; + readonly defaultLane = "memory" as const; private apiKey: string; private baseUrl: string; diff --git a/src/adapters/mem0.ts b/src/adapters/mem0.ts index 4d68e23..8ce7dfa 100644 --- a/src/adapters/mem0.ts +++ b/src/adapters/mem0.ts @@ -20,6 +20,7 @@ const DEFAULT_BASE_URL = "https://api.mem0.ai"; export class Mem0Adapter extends Adapter { readonly name = "mem0"; + readonly defaultLane = "memory" as const; private apiKey: string; private baseUrl: string; diff --git a/src/adapters/raw-csv.ts b/src/adapters/raw-csv.ts new file mode 100644 index 0000000..90203ec --- /dev/null +++ b/src/adapters/raw-csv.ts @@ -0,0 +1,35 @@ +import { createReadStream } from "node:fs"; +import { parse } from "csv-parse"; +import { Adapter, AdapterConfig, SourceRecord } from "./base.js"; +import { parseMap, recordFromObject } from "./record-mapping.js"; + +export class RawCsvAdapter extends Adapter { + readonly name = "raw-csv"; + readonly defaultLane = "document" as const; + + constructor(cfg: AdapterConfig = {}) { + super(cfg); + if (!cfg.file) throw new Error("--file is required for the raw-csv adapter"); + } + + async count(): Promise { + let total = 0; + for await (const _record of this.iterate()) total += 1; + return total; + } + + async *iterate(): AsyncIterable { + const parser = createReadStream(this.cfg.file!).pipe( + parse({ bom: true, columns: true, skip_empty_lines: true, trim: true }), + ); + const map = parseMap(this.cfg.map); + for await (const value of parser) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const record = recordFromObject(value as Record, map, { + source: "raw-csv", + file: this.cfg.file!, + }); + if (record) yield record; + } + } +} diff --git a/src/adapters/raw-json.ts b/src/adapters/raw-json.ts new file mode 100644 index 0000000..c63e149 --- /dev/null +++ b/src/adapters/raw-json.ts @@ -0,0 +1,39 @@ +import { promises as fs } from "node:fs"; +import { Adapter, AdapterConfig, SourceRecord } from "./base.js"; +import { parseMap, recordFromObject } from "./record-mapping.js"; + +const MAX_JSON_BYTES = 50 * 1024 * 1024; + +export class RawJsonAdapter extends Adapter { + readonly name = "raw-json"; + readonly defaultLane = "document" as const; + + constructor(cfg: AdapterConfig = {}) { + super(cfg); + if (!cfg.file) throw new Error("--file is required for the raw-json adapter"); + } + + private async load(): Promise[]> { + const stat = await fs.stat(this.cfg.file!); + if (stat.size > MAX_JSON_BYTES) { + throw new Error("JSON arrays are limited to 50 MB; convert larger exports to JSONL"); + } + const parsed: unknown = JSON.parse(await fs.readFile(this.cfg.file!, "utf8")); + if (!Array.isArray(parsed)) throw new Error("raw-json expects a top-level JSON array"); + return parsed.filter( + (item): item is Record => Boolean(item) && typeof item === "object" && !Array.isArray(item), + ); + } + + async count(): Promise { + return (await this.load()).length; + } + + async *iterate(): AsyncIterable { + const map = parseMap(this.cfg.map); + for (const item of await this.load()) { + const record = recordFromObject(item, map, { source: "raw-json", file: this.cfg.file! }); + if (record) yield record; + } + } +} diff --git a/src/adapters/raw-jsonl.ts b/src/adapters/raw-jsonl.ts index dddf9a8..3589705 100644 --- a/src/adapters/raw-jsonl.ts +++ b/src/adapters/raw-jsonl.ts @@ -1,34 +1,11 @@ import { createReadStream, promises as fs } from "node:fs"; import { createInterface } from "node:readline"; import { Adapter, AdapterConfig, SourceRecord } from "./base.js"; - -type FieldMap = Record; - -function parseMap(spec: string | undefined): FieldMap { - if (!spec) return { content: "content", id: "id" }; - const out: FieldMap = {}; - for (const part of spec.split(",")) { - const [target, source] = part.split("="); - if (!target || !source) continue; - out[target.trim()] = source.trim(); - } - if (!out.content) out.content = "content"; - if (!out.id) out.id = "id"; - return out; -} - -function pluck(obj: Record, key: string): unknown { - if (!key.includes(".")) return obj[key]; - let cur: unknown = obj; - for (const seg of key.split(".")) { - if (!cur || typeof cur !== "object") return undefined; - cur = (cur as Record)[seg]; - } - return cur; -} +import { parseMap, recordFromObject, type FieldMap } from "./record-mapping.js"; export class RawJsonlAdapter extends Adapter { readonly name = "raw-jsonl"; + readonly defaultLane = "document" as const; private filePath: string; private map: FieldMap; @@ -65,20 +42,11 @@ export class RawJsonlAdapter extends Adapter { } catch (err) { throw new Error(`Invalid JSON on line ${lineNo} of ${this.filePath}: ${(err as Error).message}`); } - const content = pluck(parsed, this.map.content!); - if (typeof content !== "string" || content.length === 0) continue; - const id = pluck(parsed, this.map.id!); - const metadata: Record = { source: "raw-jsonl", file: this.filePath }; - for (const [target, source] of Object.entries(this.map)) { - if (target === "content" || target === "id") continue; - const v = pluck(parsed, source); - if (v !== undefined) metadata[target] = v; - } - yield { - sourceId: typeof id === "string" || typeof id === "number" ? String(id) : undefined, - content, - metadata, - }; + const record = recordFromObject(parsed, this.map, { + source: "raw-jsonl", + file: this.filePath, + }); + if (record) yield record; } } } diff --git a/src/adapters/record-mapping.ts b/src/adapters/record-mapping.ts new file mode 100644 index 0000000..2680c81 --- /dev/null +++ b/src/adapters/record-mapping.ts @@ -0,0 +1,60 @@ +import type { SourceRecord } from "./base.js"; + +export type FieldMap = Record; + +export function parseMap(spec: string | undefined): FieldMap { + if (!spec) return { content: "content", id: "id" }; + const out: FieldMap = {}; + for (const part of spec.split(",")) { + const separator = part.indexOf("="); + if (separator < 1) continue; + const target = part.slice(0, separator).trim(); + const source = part.slice(separator + 1).trim(); + if (target && source) out[target] = source; + } + if (!out.content) out.content = "content"; + if (!out.id) out.id = "id"; + return out; +} + +export function pluck(obj: Record, key: string): unknown { + if (key === "*") return obj; + if (!key.includes(".")) return obj[key]; + let current: unknown = obj; + for (const segment of key.split(".")) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +export function recordFromObject( + input: Record, + map: FieldMap, + baseMetadata: Record, +): SourceRecord | null { + const rawContent = pluck(input, map.content!); + const content = + typeof rawContent === "string" + ? rawContent + : rawContent && typeof rawContent === "object" + ? JSON.stringify(rawContent) + : undefined; + if (!content?.trim()) return null; + + const id = pluck(input, map.id!); + const contentType = map.contentType ? pluck(input, map.contentType) : undefined; + const metadata: Record = { ...baseMetadata }; + for (const [target, source] of Object.entries(map)) { + if (target === "content" || target === "id" || target === "contentType") continue; + const value = pluck(input, source); + if (value !== undefined) metadata[target] = value; + } + + return { + sourceId: typeof id === "string" || typeof id === "number" ? String(id) : undefined, + content, + metadata, + contentType: typeof contentType === "string" ? contentType : undefined, + }; +} diff --git a/src/adapters/supermemory.ts b/src/adapters/supermemory.ts index 61144b5..394e1b3 100644 --- a/src/adapters/supermemory.ts +++ b/src/adapters/supermemory.ts @@ -19,6 +19,7 @@ const DEFAULT_BASE_URL = "https://api.supermemory.ai"; export class SupermemoryAdapter extends Adapter { readonly name = "supermemory"; + readonly defaultLane = "memory" as const; private apiKey: string; private baseUrl: string; diff --git a/src/adapters/text-file.ts b/src/adapters/text-file.ts new file mode 100644 index 0000000..9c0b65b --- /dev/null +++ b/src/adapters/text-file.ts @@ -0,0 +1,29 @@ +import { promises as fs } from "node:fs"; +import { basename, extname } from "node:path"; +import { Adapter, AdapterConfig, SourceRecord } from "./base.js"; + +export class TextFileAdapter extends Adapter { + readonly name = "text-file"; + readonly defaultLane = "document" as const; + + constructor(cfg: AdapterConfig = {}) { + super(cfg); + if (!cfg.file) throw new Error("--file is required for the text-file adapter"); + } + + async count(): Promise { + return 1; + } + + async *iterate(): AsyncIterable { + const content = await fs.readFile(this.cfg.file!, "utf8"); + if (!content.trim()) return; + const extension = extname(this.cfg.file!).toLowerCase(); + yield { + sourceId: basename(this.cfg.file!), + content, + contentType: extension === ".md" ? "markdown" : "text", + metadata: { source: "text-file", file: this.cfg.file! }, + }; + } +} diff --git a/src/adapters/zep.ts b/src/adapters/zep.ts index 5093d5f..0e17879 100644 --- a/src/adapters/zep.ts +++ b/src/adapters/zep.ts @@ -29,6 +29,7 @@ const DEFAULT_BASE_URL = "https://api.getzep.com"; export class ZepAdapter extends Adapter { readonly name = "zep"; + readonly defaultLane = "memory" as const; private apiKey: string; private baseUrl: string; @@ -75,20 +76,20 @@ export class ZepAdapter extends Adapter { let sessionCursor: string | null = null; do { - const sessionsPath = `/api/v2/users/${encodeURIComponent(userId)}/sessions?limit=${pageSize}${ + const sessionsPath: string = `/api/v2/users/${encodeURIComponent(userId)}/sessions?limit=${pageSize}${ sessionCursor ? `&cursor=${encodeURIComponent(sessionCursor)}` : "" }`; - const sessionsPage = await this.request(sessionsPath); + const sessionsPage: ZepSessionsPage = await this.request(sessionsPath); const sessions = sessionsPage.sessions ?? []; for (const session of sessions) { const sid = session.session_id; if (!sid) continue; let msgCursor: string | null = null; do { - const path = `/api/v2/sessions/${encodeURIComponent(sid)}/messages?limit=${pageSize}${ + const path: string = `/api/v2/sessions/${encodeURIComponent(sid)}/messages?limit=${pageSize}${ msgCursor ? `&cursor=${encodeURIComponent(msgCursor)}` : "" }`; - const messagesPage = await this.request(path); + const messagesPage: ZepMessagesPage = await this.request(path); for (const msg of messagesPage.messages ?? []) { if (!msg.content) continue; yield { diff --git a/src/cli.ts b/src/cli.ts index df461ed..c2c9713 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,23 @@ #!/usr/bin/env node -import { Command } from "commander"; +import { Command, Option } from "commander"; import cliProgress from "cli-progress"; import kleur from "kleur"; import ora from "ora"; -import { Mnemo } from "@mnemo/memory"; -import { ADAPTER_NAMES, AdapterConfig, createAdapter } from "./adapters/index.js"; -import { migrate, planMigration } from "./lib/migrator.js"; -import { listJobs, readJob, writeJob } from "./lib/jobs.js"; +import { + ADAPTER_NAMES, + createAdapter, + type AdapterConfig, + type ImportLane, +} from "./adapters/index.js"; +import { + listJobs, + readJob, + requestCancellation, + writeJob, + type JobState, +} from "./lib/jobs.js"; +import { migrate, planMigration, reconcileMigration } from "./lib/migrator.js"; +import { MnemoWriter } from "./lib/mnemo-writer.js"; const VERSION = "0.1.0"; @@ -18,192 +29,252 @@ interface CommonAdapterOpts { pageSize?: string; } +interface RunOpts extends CommonAdapterOpts { + concurrency?: string; + lane?: string; + container?: string; + wait: boolean; + waitTimeout?: string; +} + +function parsePositiveInt(value: string | undefined, fallback: number, name: string): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`); + return parsed; +} + function buildAdapterConfig(opts: CommonAdapterOpts): AdapterConfig { return { - file: opts.file, - map: opts.map, - user: opts.user, - baseUrl: opts.baseUrl, - pageSize: opts.pageSize ? Number.parseInt(opts.pageSize, 10) : undefined, + ...(opts.file !== undefined ? { file: opts.file } : {}), + ...(opts.map !== undefined ? { map: opts.map } : {}), + ...(opts.user !== undefined ? { user: opts.user } : {}), + ...(opts.baseUrl !== undefined ? { baseUrl: opts.baseUrl } : {}), + ...(opts.pageSize !== undefined + ? { pageSize: parsePositiveInt(opts.pageSize, 100, "--page-size") } + : {}), }; } -function getMnemoClient(): Mnemo { +function mergeAdapterConfig(saved: AdapterConfig, opts: CommonAdapterOpts): AdapterConfig { + return { ...saved, ...buildAdapterConfig(opts) }; +} + +function parseLane(value: string | undefined, fallback: ImportLane): ImportLane { + const lane = value ?? fallback; + if (lane !== "memory" && lane !== "document") { + throw new Error('--lane must be either "memory" or "document"'); + } + return lane; +} + +function requireContainer(value: string | undefined): string { + const container = value ?? process.env.GETMNEMO_CONTAINER_TAG; + if (!container) throw new Error("--container or GETMNEMO_CONTAINER_TAG is required"); + return container; +} + +function getWriter(): MnemoWriter { const apiKey = process.env.GETMNEMO_API_KEY; const workspaceId = process.env.GETMNEMO_WORKSPACE_ID; - const apiUrl = process.env.GETMNEMO_API_URL ?? "https://api.mnemohq.com"; if (!apiKey || !workspaceId) { - throw new Error( - "GETMNEMO_API_KEY and GETMNEMO_WORKSPACE_ID must be set (run `getmnemo login` from @mnemo/cli or export them).", - ); + throw new Error("GETMNEMO_API_KEY and GETMNEMO_WORKSPACE_ID must be set"); } - return new Mnemo({ apiKey, workspaceId, apiUrl }); + return new MnemoWriter({ + apiKey, + workspaceId, + apiUrl: process.env.GETMNEMO_API_URL, + }); +} + +function addAdapterOptions(command: Command, includeDefaults: boolean): Command { + command + .option("--file ", "source file or directory") + .option("--map ", "field mapping such as content=body,id=record_id") + .option("--user ", "source user id (required by some providers)") + .option("--base-url ", "override provider base URL") + .option( + "--page-size ", + "source page-size hint", + includeDefaults ? "100" : undefined, + ); + return command; +} + +function addRunOptions(command: Command, includeDefaults: boolean): Command { + addAdapterOptions(command, includeDefaults) + .option("--concurrency ", "maximum concurrent writes", includeDefaults ? "5" : undefined) + .option("--container ", "destination container tag") + .addOption(new Option("--lane ", "memory or document").choices(["memory", "document"])) + .option("--no-wait", "return after document jobs are accepted") + .option("--wait-timeout ", "maximum ingestion wait", includeDefaults ? "86400" : undefined); + return command; +} + +function startProgress(source: string, total: number | null): cliProgress.SingleBar { + const bar = new cliProgress.SingleBar( + { + format: `${kleur.cyan("import")} ${source} |{bar}| {value}/{total} handled ({failed} failed) | ETA: {eta_formatted}`, + hideCursor: true, + }, + cliProgress.Presets.shades_classic, + ); + bar.start(total ?? 0, 0, { failed: 0 }); + return bar; +} + +function printSummary(state: JobState): void { + process.stdout.write( + `\n${kleur.green("✓")} job ${kleur.bold(state.id)}: ${state.status}\n` + + ` records written: ${state.written}\n` + + ` records reused: ${state.reused}\n` + + ` write failures: ${state.failed}\n` + + ` jobs completed: ${state.remoteCompleted}\n` + + ` jobs failed: ${state.remoteFailed}\n` + + ` jobs pending: ${state.remotePending}\n`, + ); } export function buildCli(): Command { const program = new Command(); - program .name("getmnemo-migrate") - .description(`${kleur.cyan("Mnemo migrate")} — import memories from external providers.`) + .description(`${kleur.cyan("Mnemo migrate")} — import external memories and customer exports.`) .version(VERSION, "-v, --version") .showHelpAfterError("(add --help for additional information)"); - const fromCmd = program - .command("from ") - .description(`migrate memories from a source (${ADAPTER_NAMES.join(", ")})`) - .option("--concurrency ", "concurrent writes", "5") - .option("--file ", "JSONL file (raw-jsonl adapter)") - .option("--map ", "field mapping like content=text,id=mem_id") - .option("--user ", "user id (zep, optional for mem0)") - .option("--base-url ", "override provider base URL") - .option("--page-size ", "page size hint", "100") - .action(async (source: string, opts: CommonAdapterOpts & { concurrency?: string }) => { - const adapter = createAdapter(source, buildAdapterConfig(opts)); - const writer = getMnemoClient(); - const concurrency = Number.parseInt(opts.concurrency ?? "5", 10); - - const total = await adapter.count(); - const bar = new cliProgress.SingleBar( - { - format: `${kleur.cyan("migrate")} ${source} |{bar}| {value}/{total} written ({failed} failed) | ETA: {eta_formatted}`, - hideCursor: true, - }, - cliProgress.Presets.shades_classic, - ); - bar.start(total ?? 0, 0, { failed: 0 }); - - const finalState = await migrate(adapter, writer, { - concurrency, - onProgress: (p) => { - if (p.total != null && bar.getTotal() !== p.total) bar.setTotal(p.total); - bar.update(p.written, { failed: p.failed }); - }, - }); - - bar.stop(); - process.stdout.write( - `\n${kleur.green("✓")} job ${kleur.bold(finalState.id)}: ${finalState.written} written, ${finalState.failed} failed\n`, - ); + const from = program.command("from ").description(`import from ${ADAPTER_NAMES.join(", ")}`); + addRunOptions(from, true).action(async (source: string, opts: RunOpts) => { + const adapterConfig = buildAdapterConfig(opts); + const adapter = createAdapter(source, adapterConfig); + const lane = parseLane(opts.lane, adapter.defaultLane); + const containerTag = requireContainer(opts.container); + const writer = getWriter(); + const total = await adapter.count(); + const bar = startProgress(source, total); + let state = await migrate(adapter, writer, { + concurrency: parsePositiveInt(opts.concurrency, 5, "--concurrency"), + lane, + containerTag, + adapterConfig, + onProgress: (progress) => { + if (progress.total !== null && bar.getTotal() !== progress.total) bar.setTotal(progress.total); + bar.update(progress.written + progress.failed, { failed: progress.failed }); + }, }); + bar.stop(); + if (lane === "document" && opts.wait && state.status !== "paused") { + state = await reconcileMigration( + writer, + state, + parsePositiveInt(opts.waitTimeout, 86_400, "--wait-timeout") * 1000, + ); + } + printSummary(state); + }); - const planCmd = program - .command("plan") - .description("preview a migration without writing"); - - planCmd - .command("from ") - .description("preview a migration plan from a source") - .option("--file ", "JSONL file (raw-jsonl adapter)") - .option("--map ", "field mapping") - .option("--user ", "user id (zep, optional for mem0)") - .option("--base-url ", "override provider base URL") - .option("--page-size ", "page size hint", "100") - .option("--sample ", "sample size to print", "5") + const plan = program.command("plan").description("preview an import without writing"); + const planFrom = plan.command("from ").description("count and sample source records"); + addAdapterOptions(planFrom, true) + .option("--sample ", "sample size", "5") .action(async (source: string, opts: CommonAdapterOpts & { sample?: string }) => { const adapter = createAdapter(source, buildAdapterConfig(opts)); - const spinner = ora(`Counting records in ${source}...`).start(); - const sampleSize = Number.parseInt(opts.sample ?? "5", 10); - const plan = await planMigration(adapter, sampleSize); + const spinner = ora(`Inspecting ${source}...`).start(); + const result = await planMigration(adapter, parsePositiveInt(opts.sample, 5, "--sample")); spinner.stop(); process.stdout.write(`${kleur.cyan("source:")} ${source}\n`); - process.stdout.write( - `${kleur.cyan("total:")} ${plan.total != null ? plan.total : kleur.dim("unknown")}\n`, - ); + process.stdout.write(`${kleur.cyan("lane:")} ${adapter.defaultLane}\n`); + process.stdout.write(`${kleur.cyan("total:")} ${result.total ?? "unknown"}\n`); process.stdout.write(`${kleur.cyan("sample:")}\n`); - for (const rec of plan.sample) { - const preview = rec.content.length > 120 ? rec.content.slice(0, 119) + "…" : rec.content; - process.stdout.write( - ` - ${kleur.dim(rec.sourceId ?? "(no id)")} ${preview}\n`, - ); + for (const record of result.sample) { + const preview = record.content.length > 120 ? `${record.content.slice(0, 119)}…` : record.content; + process.stdout.write(` - ${kleur.dim(record.sourceId ?? "content hash")} ${preview}\n`); } }); - program - .command("resume ") - .description("resume a previously-failed or paused migration") - .option("--concurrency ", "concurrent writes", "5") - .option("--file ", "JSONL file (raw-jsonl adapter)") - .option("--map ", "field mapping") - .option("--user ", "user id") - .option("--base-url ", "override provider base URL") - .option("--page-size ", "page size hint", "100") - .action(async (jobId: string, opts: CommonAdapterOpts & { concurrency?: string }) => { - const existing = await readJob(jobId); - const adapter = createAdapter(existing.source, buildAdapterConfig(opts)); - const writer = getMnemoClient(); - const concurrency = Number.parseInt(opts.concurrency ?? "5", 10); - - const bar = new cliProgress.SingleBar( - { - format: `${kleur.cyan("resume")} ${existing.source} |{bar}| {value}/{total} written ({failed} failed) | ETA: {eta_formatted}`, - hideCursor: true, - }, - cliProgress.Presets.shades_classic, + const resume = program.command("resume ").description("resume an interrupted or paused import"); + addRunOptions(resume, false).action(async (jobId: string, opts: RunOpts) => { + const existing = await readJob(jobId); + const adapterConfig = mergeAdapterConfig(existing.adapterConfig, opts); + const adapter = createAdapter(existing.source, adapterConfig); + const lane = parseLane(opts.lane, existing.lane); + const containerTag = opts.container ?? existing.containerTag; + const writer = getWriter(); + const bar = startProgress(existing.source, existing.total ?? null); + bar.update(existing.written + existing.failed, { failed: existing.failed }); + let state = await migrate(adapter, writer, { + concurrency: parsePositiveInt(opts.concurrency, 5, "--concurrency"), + resumeJobId: jobId, + lane, + containerTag, + adapterConfig, + onProgress: (progress) => + bar.update(progress.written + progress.failed, { failed: progress.failed }), + }); + bar.stop(); + if (lane === "document" && opts.wait && state.status !== "paused") { + state = await reconcileMigration( + writer, + state, + parsePositiveInt(opts.waitTimeout, 86_400, "--wait-timeout") * 1000, ); - bar.start(existing.total ?? 0, existing.written, { failed: existing.failed }); - - const finalState = await migrate(adapter, writer, { - concurrency, - resumeJobId: jobId, - onProgress: (p) => { - if (p.total != null && bar.getTotal() !== p.total) bar.setTotal(p.total); - bar.update(p.written, { failed: p.failed }); - }, - }); - bar.stop(); - process.stdout.write( - `\n${kleur.green("✓")} resumed job ${finalState.id}: ${finalState.written} written, ${finalState.failed} failed\n`, + } + printSummary(state); + }); + + program + .command("reconcile ") + .description("refresh all remote ingestion jobs and print the completion report") + .option("--wait-timeout ", "maximum wait", "86400") + .action(async (jobId: string, opts: { waitTimeout: string }) => { + const state = await reconcileMigration( + getWriter(), + await readJob(jobId), + parsePositiveInt(opts.waitTimeout, 86_400, "--wait-timeout") * 1000, ); + printSummary(state); }); program .command("status [jobId]") - .description("show status for a single job, or list recent jobs") + .description("show one import report or list recent imports") .action(async (jobId?: string) => { if (jobId) { - const state = await readJob(jobId); - process.stdout.write(JSON.stringify(state, null, 2) + "\n"); + process.stdout.write(`${JSON.stringify(await readJob(jobId), null, 2)}\n`); return; } const jobs = await listJobs(); - if (jobs.length === 0) { - process.stdout.write("No migration jobs yet.\n"); - return; - } - for (const j of jobs.slice(0, 20)) { + if (jobs.length === 0) process.stdout.write("No migration jobs yet.\n"); + for (const job of jobs.slice(0, 20)) { process.stdout.write( - `${j.id} ${j.status.padEnd(9)} ${j.source.padEnd(12)} ${j.written}/${j.total ?? "?"} written\n`, + `${job.id} ${job.status.padEnd(22)} ${job.source.padEnd(12)} ${job.written}/${job.total ?? "?"} written\n`, ); } }); program .command("cancel ") - .description("mark a job as paused locally") + .description("request a running import to pause after its current bounded batch") .action(async (jobId: string) => { const state = await readJob(jobId); + if (["completed", "completed_with_errors", "failed"].includes(state.status)) { + throw new Error(`job ${jobId} is already ${state.status}`); + } + await requestCancellation(jobId); state.status = "paused"; await writeJob(state); - process.stdout.write(`${kleur.yellow("⚠")} job ${jobId} marked as paused\n`); + process.stdout.write(`${kleur.yellow("!")} pause requested for ${jobId}\n`); }); - program.exitOverride((err) => { - if (err.code === "commander.helpDisplayed" || err.code === "commander.version") process.exit(0); - if (err.code === "commander.unknownCommand" || err.code === "commander.unknownOption") process.exit(2); - process.exit(err.exitCode ?? 1); - }); - return program; } async function main(): Promise { - const program = buildCli(); try { - await program.parseAsync(process.argv); - } catch (err) { - process.stderr.write(kleur.red(`error: ${err instanceof Error ? err.message : String(err)}\n`)); - process.exit(1); + await buildCli().parseAsync(process.argv); + } catch (error) { + process.stderr.write(kleur.red(`error: ${error instanceof Error ? error.message : String(error)}\n`)); + process.exitCode = 1; } } @@ -212,6 +283,4 @@ const isMain = process.argv[1]?.endsWith("cli.js") || process.argv[1]?.endsWith("cli.ts"); -if (isMain) { - void main(); -} +if (isMain) void main(); diff --git a/src/lib/jobs.ts b/src/lib/jobs.ts index 3dca242..37ad8d1 100644 --- a/src/lib/jobs.ts +++ b/src/lib/jobs.ts @@ -1,34 +1,59 @@ +import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { randomUUID } from "node:crypto"; -import { createHash } from "node:crypto"; +import type { AdapterConfig, ImportLane } from "../adapters/index.js"; -export type JobStatus = "running" | "completed" | "failed" | "paused"; +export type JobStatus = + | "running" + | "submitted" + | "completed" + | "completed_with_errors" + | "failed" + | "paused"; export interface JobState { id: string; source: string; + lane: ImportLane; + containerTag: string; + adapterConfig: AdapterConfig; status: JobStatus; startedAt: string; updatedAt: string; finishedAt?: string; total?: number | null; written: number; + reused: number; failed: number; - /** Provider-native ids that have been successfully written. Used to skip on resume. */ - processedIds: string[]; + remoteCompleted: number; + remoteFailed: number; + remotePending: number; + /** Backward compatibility with jobs written by 0.1.0. */ + processedIds?: string[]; lastError?: string; } -const JOB_DIR = join(homedir(), ".getmnemo", "migrations"); +export interface FailedRecord { + sourceId: string; + error: string; + occurredAt: string; +} + +function resolveJobsDir(): string { + return process.env.GETMNEMO_MIGRATION_DIR ?? join(homedir(), ".getmnemo", "migrations"); +} export function jobsDir(): string { - return JOB_DIR; + return resolveJobsDir(); } export function jobPath(id: string): string { - return join(JOB_DIR, `${id}.json`); + return join(resolveJobsDir(), `${id}.json`); +} + +function artifactPath(id: string, suffix: string): string { + return join(resolveJobsDir(), `${id}.${suffix}`); } export function newJobId(): string { @@ -44,63 +69,121 @@ export async function writeJob(state: JobState): Promise { const path = jobPath(state.id); await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); const next: JobState = { ...state, updatedAt: new Date().toISOString() }; - // Atomic write: write to a temp file in the same directory, then rename. - // Without this, a crash mid-write leaves a half-written JSON file that - // makes the job impossible to resume. const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`; await fs.writeFile(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 }); await fs.rename(tmp, path); } -/** - * Acquire an exclusive lock for `source` so two concurrent invocations of - * the migrator on the same provider can't double-import the same records. - * The lock is implemented via `O_EXCL | O_CREAT` on a per-source file — - * `fs.open` with `wx` flag rejects when the file exists. The PID is written - * for diagnostics. Returns a release function. - * - * Without this guard, two operators each running `getmnemo-migrate from - * mem0 --user u123` produce overlapping writes: each task list filters by - * its own `seen` set, but the OTHER process has no view of those ids and - * happily re-imports the same record, creating duplicate memories. - */ -export async function acquireSourceLock(source: string): Promise<() => Promise> { - const safe = createHash("sha256").update(source).digest("hex").slice(0, 16); - const lockPath = join(JOB_DIR, `${safe}.lock`); - await fs.mkdir(JOB_DIR, { recursive: true, mode: 0o700 }); +async function appendLine(path: string, line: string): Promise { + await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await fs.appendFile(path, line + "\n", { mode: 0o600 }); +} + +export async function appendProcessedId(jobId: string, sourceId: string): Promise { + await appendLine(artifactPath(jobId, "processed"), sourceId); +} + +export async function loadProcessedIds(jobId: string, legacy: string[] = []): Promise> { try { - const handle = await fs.open(lockPath, "wx", 0o600); - await handle.writeFile(`${process.pid}\n`); - await handle.close(); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EEXIST") { - throw new Error( - `another migration is already running for source "${source}" (lock at ${lockPath}). ` + - `If you are sure no other process is running, remove the lock file.`, - ); + const raw = await fs.readFile(artifactPath(jobId, "processed"), "utf8"); + return new Set([...legacy, ...raw.split("\n").filter(Boolean)]); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set(legacy); + throw error; + } +} + +export async function appendRemoteJob(jobId: string, remoteJobId: string): Promise { + await appendLine(artifactPath(jobId, "remote-jobs"), remoteJobId); +} + +export async function loadRemoteJobs(jobId: string): Promise { + try { + const raw = await fs.readFile(artifactPath(jobId, "remote-jobs"), "utf8"); + return [...new Set(raw.split("\n").filter(Boolean))]; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +export async function appendFailure(jobId: string, failure: FailedRecord): Promise { + await appendLine(artifactPath(jobId, "failures.jsonl"), JSON.stringify(failure)); +} + +export async function requestCancellation(jobId: string): Promise { + await fs.writeFile(artifactPath(jobId, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 }); +} + +export async function clearCancellation(jobId: string): Promise { + try { + await fs.unlink(artifactPath(jobId, "cancel")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +export async function cancellationRequested(jobId: string): Promise { + try { + await fs.access(artifactPath(jobId, "cancel")); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export async function acquireSourceLock(lockKey: string): Promise<() => Promise> { + const safe = createHash("sha256").update(lockKey).digest("hex").slice(0, 16); + const directory = resolveJobsDir(); + const lockPath = join(directory, `${safe}.lock`); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const handle = await fs.open(lockPath, "wx", 0o600); + await handle.writeFile(`${process.pid}\n`); + await handle.close(); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const owner = Number.parseInt(await fs.readFile(lockPath, "utf8"), 10); + let ownerIsAlive = Number.isInteger(owner) && owner > 0; + if (ownerIsAlive) { + try { + process.kill(owner, 0); + } catch (probeError) { + ownerIsAlive = (probeError as NodeJS.ErrnoException).code === "EPERM"; + } + } + if (ownerIsAlive || attempt > 0) { + throw new Error(`another migration is already running for this source (${lockPath})`); + } + await fs.unlink(lockPath); } - throw err; } + return async () => { try { await fs.unlink(lockPath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }; } export async function listJobs(): Promise { + const directory = resolveJobsDir(); try { - const files = await fs.readdir(JOB_DIR); + const files = await fs.readdir(directory); const jobs = await Promise.all( files - .filter((f) => f.endsWith(".json")) - .map(async (f) => JSON.parse(await fs.readFile(join(JOB_DIR, f), "utf8")) as JobState), + .filter((file) => /^mig_.+\.json$/.test(file)) + .map(async (file) => JSON.parse(await fs.readFile(join(directory, file), "utf8")) as JobState), ); return jobs.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; - throw err; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; } } diff --git a/src/lib/migrator.ts b/src/lib/migrator.ts index 40d9d3c..029652d 100644 --- a/src/lib/migrator.ts +++ b/src/lib/migrator.ts @@ -1,20 +1,28 @@ -import pLimit from "p-limit"; -import { Adapter, SourceRecord } from "../adapters/index.js"; -import { JobState, newJobId, writeJob, readJob, acquireSourceLock } from "./jobs.js"; - -export interface MemoryWriter { - add(content: string, opts?: { metadata?: Record }): Promise; -} +import type { Adapter, AdapterConfig, ImportLane, SourceRecord } from "../adapters/index.js"; +import { + acquireSourceLock, + appendFailure, + appendProcessedId, + appendRemoteJob, + cancellationRequested, + clearCancellation, + loadProcessedIds, + loadRemoteJobs, + newJobId, + readJob, + writeJob, + type JobState, +} from "./jobs.js"; +import type { DestinationWriter } from "./mnemo-writer.js"; +import { sourceIdentity } from "./mnemo-writer.js"; export interface MigrateOptions { concurrency?: number; resumeJobId?: string; - /** Called after every successful write or failure. */ - onProgress?: (state: { written: number; failed: number; total: number | null }) => void; - /** Dry-run: count + sample, no writes. */ - plan?: boolean; - /** Sample size when planning. */ - planSampleSize?: number; + lane: ImportLane; + containerTag: string; + adapterConfig: AdapterConfig; + onProgress?: (state: { written: number; reused: number; failed: number; total: number | null }) => void; } export interface PlanResult { @@ -22,97 +30,204 @@ export interface PlanResult { sample: SourceRecord[]; } +type RecordOutcome = + | { kind: "success"; sourceId: string; reused: boolean; remoteJobIds: string[] } + | { kind: "failure"; sourceId: string; error: string }; + export async function planMigration(adapter: Adapter, sampleSize = 5): Promise { const total = await adapter.count(); const sample: SourceRecord[] = []; - for await (const rec of adapter.iterate()) { - sample.push(rec); + for await (const record of adapter.iterate()) { + sample.push(record); if (sample.length >= sampleSize) break; } return { total, sample }; } +async function processBatch( + records: SourceRecord[], + adapter: Adapter, + writer: DestinationWriter, + options: MigrateOptions, +): Promise { + return Promise.all( + records.map(async (record): Promise => { + const sourceId = sourceIdentity(record); + try { + const result = await writer.write(record, { + lane: options.lane, + source: adapter.name, + containerTag: options.containerTag, + }); + return { kind: "success", sourceId, ...result }; + } catch (error) { + return { + kind: "failure", + sourceId, + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); +} + +function newState(adapter: Adapter, options: MigrateOptions, total: number | null): JobState { + return { + id: newJobId(), + source: adapter.name, + lane: options.lane, + containerTag: options.containerTag, + adapterConfig: options.adapterConfig, + status: "running", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + total, + written: 0, + reused: 0, + failed: 0, + remoteCompleted: 0, + remoteFailed: 0, + remotePending: 0, + }; +} + export async function migrate( adapter: Adapter, - writer: MemoryWriter, - opts: MigrateOptions = {}, + writer: DestinationWriter, + options: MigrateOptions, ): Promise { - const concurrency = Math.max(1, opts.concurrency ?? 5); - - // Hold an exclusive per-source lock for the lifetime of the run so two - // operators can't kick off concurrent migrations against the same - // provider and create duplicate memories. - const releaseLock = await acquireSourceLock(adapter.name); + const requestedConcurrency = options.concurrency ?? 5; + const concurrency = Number.isFinite(requestedConcurrency) + ? Math.max(1, Math.min(25, Math.floor(requestedConcurrency))) + : 5; + let state = options.resumeJobId + ? await readJob(options.resumeJobId) + : newState(adapter, options, await adapter.count()); - let state: JobState; - if (opts.resumeJobId) { - state = await readJob(opts.resumeJobId); + if (options.resumeJobId) { + if (state.source !== adapter.name) throw new Error("resume source does not match the original job"); + if (state.lane !== options.lane) throw new Error("resume lane does not match the original job"); + if (state.containerTag !== options.containerTag) { + throw new Error("resume container does not match the original job"); + } state.status = "running"; - } else { - state = { - id: newJobId(), - source: adapter.name, - status: "running", - startedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - total: await adapter.count(), - written: 0, - failed: 0, - processedIds: [], - }; + state.finishedAt = undefined; + state.failed = 0; + state.lastError = undefined; } + + const lockKey = [adapter.name, options.containerTag, options.adapterConfig.file ?? "remote"].join(":"); + const releaseLock = await acquireSourceLock(lockKey); + await clearCancellation(state.id); await writeJob(state); - const seen = new Set(state.processedIds); - const limit = pLimit(concurrency); - const inflight: Promise[] = []; - let lastFlushAt = Date.now(); + const seen = await loadProcessedIds(state.id, state.processedIds ?? []); + let batch: SourceRecord[] = []; - const flush = async (): Promise => { - if (Date.now() - lastFlushAt < 500) return; - lastFlushAt = Date.now(); + const commitBatch = async (): Promise => { + if (batch.length === 0) return; + const outcomes = await processBatch(batch, adapter, writer, options); + batch = []; + for (const outcome of outcomes) { + if (outcome.kind === "success") { + state.written += 1; + if (outcome.reused) state.reused += 1; + seen.add(outcome.sourceId); + await appendProcessedId(state.id, outcome.sourceId); + for (const remoteJobId of outcome.remoteJobIds) { + await appendRemoteJob(state.id, remoteJobId); + state.remotePending += 1; + } + } else { + state.failed += 1; + state.lastError = outcome.error; + await appendFailure(state.id, { + sourceId: outcome.sourceId, + error: outcome.error, + occurredAt: new Date().toISOString(), + }); + } + options.onProgress?.({ + written: state.written, + reused: state.reused, + failed: state.failed, + total: state.total ?? null, + }); + } await writeJob(state); }; try { - for await (const rec of adapter.iterate()) { - if (rec.sourceId && seen.has(rec.sourceId)) continue; - const task = limit(async () => { - try { - await writer.add(rec.content, { metadata: rec.metadata ?? {} }); - state.written += 1; - if (rec.sourceId) { - state.processedIds.push(rec.sourceId); - seen.add(rec.sourceId); - } - } catch (err) { - state.failed += 1; - state.lastError = err instanceof Error ? err.message : String(err); - } finally { - opts.onProgress?.({ written: state.written, failed: state.failed, total: state.total ?? null }); - await flush(); - } - }); - inflight.push(task); + for await (const record of adapter.iterate()) { + if (await cancellationRequested(state.id)) { + await commitBatch(); + state.status = "paused"; + await writeJob(state); + return state; + } + const id = sourceIdentity(record); + if (seen.has(id)) continue; + batch.push(record); + if (batch.length >= concurrency) await commitBatch(); } - await Promise.all(inflight); - state.status = "completed"; + await commitBatch(); + state.status = options.lane === "document" ? "submitted" : state.failed > 0 ? "completed_with_errors" : "completed"; state.finishedAt = new Date().toISOString(); - } catch (err) { - // Wait for in-flight writes to finish before persisting the final - // state — otherwise a successful write that lands AFTER the failure - // checkpoint won't appear in `processedIds`, and a `--resume` run will - // re-import the same record and create a duplicate memory. - await Promise.allSettled(inflight); + await writeJob(state); + return state; + } catch (error) { state.status = "failed"; state.finishedAt = new Date().toISOString(); - state.lastError = err instanceof Error ? err.message : String(err); + state.lastError = error instanceof Error ? error.message : String(error); await writeJob(state); + throw error; + } finally { await releaseLock(); - throw err; } +} + +export async function reconcileMigration( + writer: DestinationWriter, + input: JobState, + timeoutMs: number, +): Promise { + const state = { ...input }; + const pending = new Set(await loadRemoteJobs(state.id)); + const deadline = Date.now() + timeoutMs; + let completed = 0; + let failed = 0; + while (pending.size > 0 && Date.now() < deadline) { + const ids = [...pending]; + for (let offset = 0; offset < ids.length; offset += 50) { + const jobs = await writer.getJobs(ids.slice(offset, offset + 50)); + for (const job of jobs) { + if (job.status === "completed") { + pending.delete(job.id); + completed += 1; + } else if (job.status === "failed") { + pending.delete(job.id); + failed += 1; + state.lastError = job.error?.message ?? `ingestion job ${job.id} failed`; + } + } + } + state.remoteCompleted = completed; + state.remoteFailed = failed; + state.remotePending = pending.size; + await writeJob(state); + if (pending.size > 0) await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + + state.remoteCompleted = completed; + state.remoteFailed = failed; + state.remotePending = pending.size; + if (pending.size === 0) { + state.status = state.failed + failed > 0 ? "completed_with_errors" : "completed"; + state.finishedAt = new Date().toISOString(); + } else { + state.status = "submitted"; + } await writeJob(state); - await releaseLock(); return state; } diff --git a/src/lib/mnemo-writer.test.ts b/src/lib/mnemo-writer.test.ts new file mode 100644 index 0000000..cbdf0d0 --- /dev/null +++ b/src/lib/mnemo-writer.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MnemoWriter, sourceIdentity, splitDocument } from "./mnemo-writer.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("MnemoWriter", () => { + it("uses a stable content identity when a source id is absent", () => { + const first = sourceIdentity({ content: "same content" }); + const second = sourceIdentity({ content: "same content" }); + expect(first).toBe(second); + expect(first).toMatch(/^sha256:/); + }); + + it("splits large documents deterministically within the API limit", () => { + const content = `${"a".repeat(449_000)}\n${"b".repeat(451_000)}`; + const chunks = splitDocument(content); + expect(chunks.length).toBe(3); + expect(chunks.every((chunk) => chunk.length <= 450_000)).toBe(true); + expect(chunks.join("")).toBe(content); + }); + + it("writes document chunks with deterministic custom ids and provenance", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init }); + return new Response(JSON.stringify({ jobId: `job_${requests.length}`, reused: false }), { + status: 202, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + const writer = new MnemoWriter({ apiKey: "key", workspaceId: "workspace", apiUrl: "https://api.test" }); + + const result = await writer.write( + { sourceId: "crm-42", content: "Customer history", metadata: { customerId: "42" } }, + { lane: "document", source: "raw-json", containerTag: "customer-import" }, + ); + const body = JSON.parse(String(requests[0]?.init?.body)) as Record; + + expect(requests[0]?.url).toBe("https://api.test/v1/documents"); + expect(body.containerTag).toBe("customer-import"); + expect(body.customId).toMatch(/^imp_[a-f0-9]{64}$/); + expect(body.metadata).toMatchObject({ + customerId: "42", + importSource: "raw-json", + importSourceId: "crm-42", + }); + expect(result.remoteJobIds).toEqual(["job_1"]); + }); + + it("retries rate limits and preserves the same request body", async () => { + const bodies: string[] = []; + const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(String(init?.body)); + if (bodies.length === 1) { + return new Response("busy", { status: 429, headers: { "retry-after": "0" } }); + } + return new Response(JSON.stringify({ id: "memory_1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + const writer = new MnemoWriter({ apiKey: "key", workspaceId: "workspace", apiUrl: "https://api.test" }); + + await writer.write( + { sourceId: "1", content: "A durable fact" }, + { lane: "memory", source: "mem0", containerTag: "import" }, + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(bodies[0]).toBe(bodies[1]); + }); + + it("splits oversized atomic memories before calling the API", async () => { + const bodies: Array<{ items: Array<{ content: string; metadata: Record }> }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as (typeof bodies)[number]); + return new Response(JSON.stringify({ id: "memory" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + ); + const writer = new MnemoWriter({ apiKey: "key", workspaceId: "workspace", apiUrl: "https://api.test" }); + + await writer.write( + { sourceId: "long", content: "a".repeat(7_000) }, + { lane: "memory", source: "mem0", containerTag: "import" }, + ); + expect(bodies).toHaveLength(2); + expect(bodies.every((body) => body.items[0]!.content.length <= 3_900)).toBe(true); + expect(bodies.map((body) => body.items[0]!.content).join("")).toBe("a".repeat(7_000)); + expect(bodies[0]?.items[0]?.metadata).toMatchObject({ importPart: 1, importParts: 2 }); + }); +}); diff --git a/src/lib/mnemo-writer.ts b/src/lib/mnemo-writer.ts new file mode 100644 index 0000000..b9c6479 --- /dev/null +++ b/src/lib/mnemo-writer.ts @@ -0,0 +1,192 @@ +import { createHash } from "node:crypto"; +import type { ImportLane, SourceRecord } from "../adapters/index.js"; + +const MAX_DOCUMENT_CHARS = 450_000; +const MAX_MEMORY_CHARS = 3_900; +const MAX_ATTEMPTS = 5; + +export interface WriteOptions { + lane: ImportLane; + source: string; + containerTag: string; +} + +export interface WriteResult { + reused: boolean; + remoteJobIds: string[]; +} + +export interface RemoteJob { + id: string; + status: string; + error?: { code?: string | null; message?: string | null } | null; +} + +export interface DestinationWriter { + write(record: SourceRecord, options: WriteOptions): Promise; + getJobs(ids: string[]): Promise; +} + +export interface MnemoWriterConfig { + apiKey: string; + workspaceId: string; + apiUrl?: string; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function sourceIdentity(record: SourceRecord): string { + return record.sourceId ?? `sha256:${sha256(record.content)}`; +} + +function splitContent(content: string, maxChars: number): string[] { + if (content.length <= maxChars) return [content]; + const chunks: string[] = []; + let offset = 0; + while (offset < content.length) { + let end = Math.min(offset + maxChars, content.length); + if (end < content.length) { + const newline = content.lastIndexOf("\n", end); + if (newline > offset + maxChars * 0.75) end = newline + 1; + } + chunks.push(content.slice(offset, end)); + offset = end; + } + return chunks; +} + +function splitDocument(content: string): string[] { + return splitContent(content, MAX_DOCUMENT_CHARS); +} + +function retryDelayMs(response: Response | null, attempt: number): number { + const retryAfter = response?.headers.get("retry-after"); + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + } + return Math.min(30_000, 500 * 2 ** attempt); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class MnemoWriter implements DestinationWriter { + private readonly apiUrl: string; + + constructor(private readonly config: MnemoWriterConfig) { + this.apiUrl = (config.apiUrl ?? "https://api.mnemohq.com").replace(/\/$/, ""); + } + + private async request(path: string, init: RequestInit = {}): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + let response: Response | null = null; + try { + response = await fetch(`${this.apiUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + "x-workspace-id": this.config.workspaceId, + Accept: "application/json", + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...init.headers, + }, + signal: AbortSignal.timeout(60_000), + }); + if (response.ok) return (await response.json()) as T; + + const body = await response.text(); + lastError = new Error(`Mnemo request failed: ${response.status} ${body.slice(0, 500)}`); + if (response.status !== 429 && response.status < 500) throw lastError; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (response && response.status !== 429 && response.status < 500) throw lastError; + } + if (attempt < MAX_ATTEMPTS - 1) await sleep(retryDelayMs(response, attempt)); + } + throw lastError ?? new Error("Mnemo request failed"); + } + + async write(record: SourceRecord, options: WriteOptions): Promise { + return options.lane === "document" + ? this.writeDocument(record, options) + : this.writeMemory(record, options); + } + + private importMetadata(record: SourceRecord, source: string): Record { + return { + ...(record.metadata ?? {}), + importSource: source, + importSourceId: sourceIdentity(record), + }; + } + + private async writeMemory(record: SourceRecord, options: WriteOptions): Promise { + const identity = sourceIdentity(record); + const chunks = splitContent(record.content, MAX_MEMORY_CHARS); + for (let index = 0; index < chunks.length; index += 1) { + const suffix = chunks.length === 1 ? "" : `:part:${index + 1}-of-${chunks.length}`; + await this.request("/v1/memories", { + method: "POST", + body: JSON.stringify({ + containerTag: options.containerTag, + items: [ + { + content: chunks[index], + idempotencyKey: `imp_${sha256(`${options.source}:${identity}${suffix}`)}`, + metadata: { + ...this.importMetadata(record, options.source), + ...(chunks.length > 1 ? { importPart: index + 1, importParts: chunks.length } : {}), + }, + source: { provider: options.source, sourceId: identity }, + }, + ], + }), + }); + } + return { reused: false, remoteJobIds: [] }; + } + + private async writeDocument(record: SourceRecord, options: WriteOptions): Promise { + const identity = sourceIdentity(record); + const chunks = splitDocument(record.content); + const remoteJobIds: string[] = []; + let reused = true; + + for (let index = 0; index < chunks.length; index += 1) { + const suffix = chunks.length === 1 ? "" : `:part:${index + 1}-of-${chunks.length}`; + const response = await this.request<{ jobId: string; reused?: boolean }>("/v1/documents", { + method: "POST", + body: JSON.stringify({ + containerTag: options.containerTag, + content: chunks[index], + contentType: record.contentType ?? "record", + customId: `imp_${sha256(`${options.source}:${identity}${suffix}`)}`, + metadata: { + ...this.importMetadata(record, options.source), + ...(chunks.length > 1 ? { importPart: index + 1, importParts: chunks.length } : {}), + }, + }), + }); + remoteJobIds.push(response.jobId); + reused = reused && response.reused === true; + } + + return { reused, remoteJobIds }; + } + + async getJobs(ids: string[]): Promise { + if (ids.length === 0) return []; + if (ids.length > 50) throw new Error("Mnemo job lookup accepts at most 50 ids"); + const response = await this.request<{ items: RemoteJob[] }>( + `/v1/jobs?ids=${encodeURIComponent(ids.join(","))}`, + ); + return response.items; + } +} + +export { splitDocument }; diff --git a/src/migrator.test.ts b/src/migrator.test.ts index 44c74e2..caa6c2c 100644 --- a/src/migrator.test.ts +++ b/src/migrator.test.ts @@ -1,101 +1,159 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; -import { homedir } from "node:os"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Adapter, SourceRecord } from "./adapters/base.js"; -import { migrate, planMigration } from "./lib/migrator.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Adapter, type SourceRecord } from "./adapters/base.js"; +import { listJobs, requestCancellation } from "./lib/jobs.js"; +import { migrate, planMigration, reconcileMigration } from "./lib/migrator.js"; +import type { + DestinationWriter, + RemoteJob, + WriteOptions, + WriteResult, +} from "./lib/mnemo-writer.js"; class MockAdapter extends Adapter { readonly name = "mock"; - private records: SourceRecord[]; + readonly defaultLane = "document" as const; - constructor(records: SourceRecord[]) { - super({}); - this.records = records; + constructor(private readonly records: SourceRecord[]) { + super({ file: "fixture.jsonl" }); } - async count(): Promise { + async count(): Promise { return this.records.length; } async *iterate(): AsyncIterable { - for (const r of this.records) yield r; + for (const record of this.records) yield record; } } -class MockWriter { - public calls: Array<{ content: string; metadata?: Record }> = []; - failOnce = false; - async add(content: string, opts?: { metadata?: Record }): Promise { - if (this.failOnce) { - this.failOnce = false; - throw new Error("transient"); +class MockWriter implements DestinationWriter { + readonly calls: Array<{ record: SourceRecord; options: WriteOptions }> = []; + readonly remoteJobs = new Map(); + active = 0; + maxActive = 0; + failIds = new Set(); + onWrite?: () => Promise; + + async write(record: SourceRecord, options: WriteOptions): Promise { + this.active += 1; + this.maxActive = Math.max(this.maxActive, this.active); + try { + await new Promise((resolve) => setTimeout(resolve, 2)); + if (this.failIds.has(record.sourceId ?? "")) throw new Error("write failed"); + this.calls.push({ record, options }); + await this.onWrite?.(); + return { + reused: record.sourceId === "2", + remoteJobIds: options.lane === "document" ? [`job_${record.sourceId}`] : [], + }; + } finally { + this.active -= 1; } - this.calls.push({ content, metadata: opts?.metadata }); - return { id: `mem_${this.calls.length}` }; + } + + async getJobs(ids: string[]): Promise { + return ids.map((id) => this.remoteJobs.get(id) ?? { id, status: "pending" }); } } -const FIXTURE_RECORDS: SourceRecord[] = [ +const RECORDS: SourceRecord[] = [ { sourceId: "1", content: "first memory", metadata: { tag: "a" } }, { sourceId: "2", content: "second memory", metadata: { tag: "b" } }, { sourceId: "3", content: "third memory", metadata: { tag: "c" } }, ]; +function options(lane: "memory" | "document" = "document") { + return { + concurrency: 2, + lane, + containerTag: "customer-import", + adapterConfig: { file: "fixture.jsonl" }, + }; +} + +let testDirectory: string; + +beforeEach(async () => { + testDirectory = await fs.mkdtemp(join(tmpdir(), "getmnemo-migrate-")); + process.env.GETMNEMO_MIGRATION_DIR = testDirectory; +}); + +afterEach(async () => { + delete process.env.GETMNEMO_MIGRATION_DIR; + await fs.rm(testDirectory, { recursive: true, force: true }); +}); + describe("planMigration", () => { - it("returns total + sample without writing", async () => { - const adapter = new MockAdapter(FIXTURE_RECORDS); - const plan = await planMigration(adapter, 2); + it("returns the total and sample without writing", async () => { + const plan = await planMigration(new MockAdapter(RECORDS), 2); expect(plan.total).toBe(3); - expect(plan.sample).toHaveLength(2); - expect(plan.sample[0]?.content).toBe("first memory"); + expect(plan.sample.map((record) => record.content)).toEqual(["first memory", "second memory"]); }); }); describe("migrate", () => { - beforeEach(async () => { - await fs.rm(join(homedir(), ".getmnemo", "migrations"), { recursive: true, force: true }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("writes every record to the writer with concurrency", async () => { - const adapter = new MockAdapter(FIXTURE_RECORDS); + it("keeps concurrency bounded and preserves record context", async () => { const writer = new MockWriter(); - const progress: Array<{ written: number; failed: number }> = []; - const job = await migrate(adapter, writer, { - concurrency: 2, - onProgress: (p) => progress.push({ written: p.written, failed: p.failed }), - }); + const job = await migrate(new MockAdapter(RECORDS), writer, options()); + expect(writer.maxActive).toBe(2); expect(writer.calls).toHaveLength(3); - expect(writer.calls.map((c) => c.content).sort()).toEqual([ - "first memory", - "second memory", - "third memory", - ]); - expect(job.status).toBe("completed"); + expect(writer.calls[0]?.options.containerTag).toBe("customer-import"); + expect(writer.calls.find((call) => call.record.sourceId === "1")?.record.metadata).toEqual({ tag: "a" }); + expect(job.status).toBe("submitted"); expect(job.written).toBe(3); - expect(job.failed).toBe(0); - expect(progress.length).toBeGreaterThan(0); + expect(job.reused).toBe(1); + expect(job.remotePending).toBe(3); + }); + + it("resumes by retrying only unresolved records", async () => { + const firstWriter = new MockWriter(); + firstWriter.failIds.add("2"); + const first = await migrate(new MockAdapter(RECORDS), firstWriter, options("memory")); + expect(first.status).toBe("completed_with_errors"); + expect(first.failed).toBe(1); + + const resumedWriter = new MockWriter(); + const resumed = await migrate(new MockAdapter(RECORDS), resumedWriter, { + ...options("memory"), + resumeJobId: first.id, + }); + expect(resumedWriter.calls.map((call) => call.record.sourceId)).toEqual(["2"]); + expect(resumed.status).toBe("completed"); + expect(resumed.written).toBe(3); + expect(resumed.failed).toBe(0); }); - it("counts failures without aborting the run", async () => { - const adapter = new MockAdapter(FIXTURE_RECORDS); + it("pauses after the current bounded batch when cancellation is requested", async () => { const writer = new MockWriter(); - writer.failOnce = true; - const job = await migrate(adapter, writer, { concurrency: 1 }); - expect(job.failed).toBe(1); - expect(job.written).toBe(2); - expect(job.status).toBe("completed"); + let requested = false; + writer.onWrite = async () => { + if (requested) return; + requested = true; + const [job] = await listJobs(); + if (!job) throw new Error("migration job was not persisted"); + await requestCancellation(job.id); + }; + + const job = await migrate(new MockAdapter(RECORDS), writer, { ...options(), concurrency: 1 }); + expect(job.status).toBe("paused"); + expect(job.written).toBe(1); }); - it("preserves metadata on writes", async () => { - const adapter = new MockAdapter([FIXTURE_RECORDS[0]!]); + it("reconciles every submitted remote job into a final report", async () => { const writer = new MockWriter(); - await migrate(adapter, writer, { concurrency: 1 }); - expect(writer.calls[0]?.metadata).toEqual({ tag: "a" }); + const submitted = await migrate(new MockAdapter(RECORDS), writer, options()); + writer.remoteJobs.set("job_1", { id: "job_1", status: "completed" }); + writer.remoteJobs.set("job_2", { id: "job_2", status: "failed", error: { message: "bad input" } }); + writer.remoteJobs.set("job_3", { id: "job_3", status: "completed" }); + + const reconciled = await reconcileMigration(writer, submitted, 100); + expect(reconciled.status).toBe("completed_with_errors"); + expect(reconciled.remoteCompleted).toBe(2); + expect(reconciled.remoteFailed).toBe(1); + expect(reconciled.remotePending).toBe(0); }); });