From 2ab62914dccfdfa2c9b8792782412b18c78bf6b0 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 03:26:30 +0900 Subject: [PATCH 01/54] fix: harden runtime boundaries and state handling --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 4 + .gitignore | 3 + bun.lock | 288 ++- drizzle/0016_scrub_backup_secrets.sql | 10 + drizzle/0017_skinny_hobgoblin.sql | 16 + drizzle/meta/0016_snapshot.json | 1315 ++++++++++++++ drizzle/meta/0017_snapshot.json | 1587 +++++++++++++++++ drizzle/meta/_journal.json | 16 +- e2e/add-memory.spec.ts | 314 ++++ package.json | 17 +- playwright.config.ts | 1 + scripts/trauma-backup-failsafe.ts | 10 +- .../backup/BackupFailsafeBanner.tsx | 87 +- src/components/memories/MemoryBrowse.tsx | 44 +- src/components/reader/MemoryReader.tsx | 342 +++- src/components/reader/reader-generation.ts | 69 + src/components/settings/SettingsPage.tsx | 182 +- src/components/settings/action-state.ts | 44 + src/entry-server.tsx | 41 +- src/routes/api/backup/failsafe/request.ts | 48 +- src/routes/api/categories.ts | 16 +- src/routes/api/flashbacks.ts | 14 +- src/routes/api/memories.ts | 21 +- src/routes/api/memories/[memoryId].ts | 20 +- src/routes/api/memories/categories.ts | 14 +- src/routes/api/memories/read-status.ts | 14 +- src/routes/api/memories/tags.ts | 16 +- src/routes/api/moments.ts | 14 +- src/routes/api/moments/[momentId].ts | 6 + src/routes/api/reader-media.ts | 7 + src/routes/api/settings/codex-auth.ts | 8 +- .../api/settings/codex-auth/device-code.ts | 8 +- .../settings/codex-auth/device-code/cancel.ts | 8 +- src/routes/api/settings/openai-auth.ts | 17 +- src/routes/api/settings/openai-auth/enable.ts | 8 +- .../api/settings/translation-language.ts | 17 +- src/routes/api/tags.ts | 16 +- src/server/backup/environment.ts | 118 +- src/server/backup/failsafe.ts | 12 +- src/server/browse/filtered-page.ts | 47 + src/server/db/bundled-migrations.ts | 12 + src/server/db/repositories.ts | 340 ++-- src/server/db/schema.ts | 4 + src/server/http/document-security.ts | 4 + src/server/http/mutation-request.ts | 173 ++ src/server/importer/runtime.ts | 110 ++ src/server/media-policy.ts | 14 +- src/server/memories/browse.ts | 60 +- src/server/psychiatrist/bounded-cache.ts | 35 + src/server/psychiatrist/jsonl.ts | 62 +- src/server/psychiatrist/request.ts | 118 +- src/server/psychiatrist/stream-store.ts | 3 +- src/server/psychiatrist/thread-route.ts | 54 +- src/server/psychiatrist/thread-store.ts | 25 +- src/server/reader/markdown-renderer.ts | 22 +- src/server/reader/media-proxy.ts | 316 ++++ src/server/settings/codex-auth.ts | 8 +- src/server/settings/codex-model-routes.ts | 23 +- .../translation/cancel-translation-route.ts | 6 + src/server/translation/codex-app-server.ts | 128 +- src/server/translation/events-route.ts | 33 +- src/server/translation/runner.ts | 60 +- .../translation/start-translation-route.ts | 20 +- tests/components/backup-failsafe.test.ts | 29 +- .../components/memory-browse-actions.test.ts | 15 + .../components/memory-reader-actions.test.ts | 80 +- tests/components/reader-generation.test.ts | 91 + .../components/reader-moment-actions.test.ts | 7 +- .../settings-action-tracker.test.ts | 39 + tests/components/settings-page.test.ts | 45 +- tests/scripts/runtime-command.test.ts | 8 + .../server/backup/backup-environment.test.ts | 49 +- tests/server/browse-loaders.test.ts | 20 +- tests/server/browse/filtered-page.test.ts | 44 + tests/server/db/repositories.test.ts | 240 ++- tests/server/db/schema.test.ts | 137 ++ tests/server/entry-server.test.ts | 15 + tests/server/http/mutation-request.test.ts | 181 ++ tests/server/importer/runtime.test.ts | 133 ++ tests/server/memories/delete-memory.test.ts | 18 +- tests/server/psychiatrist/api-routes.test.ts | 32 +- .../server/psychiatrist/bounded-cache.test.ts | 23 + tests/server/reader/markdown-renderer.test.ts | 72 +- tests/server/reader/media-proxy.test.ts | 302 ++++ .../server/routes/api-browser-import.test.ts | 4 +- .../routes/api-flashbacks-toggle.test.ts | 5 +- tests/server/routes/api-memories.test.ts | 7 +- tests/server/routes/api-memory-delete.test.ts | 9 +- tests/server/routes/api-settings.test.ts | 132 +- tests/server/settings/codex-auth.test.ts | 33 +- tests/server/translation/api-routes.test.ts | 51 + .../translation/codex-app-server.test.ts | 152 +- tests/server/translation/events-route.test.ts | 104 +- .../translation/markdown-blocks.test.ts | 78 +- tests/server/translation/runner.test.ts | 149 +- .../translation-repositories.test.ts | 110 ++ 97 files changed, 7970 insertions(+), 916 deletions(-) create mode 100644 drizzle/0016_scrub_backup_secrets.sql create mode 100644 drizzle/0017_skinny_hobgoblin.sql create mode 100644 drizzle/meta/0016_snapshot.json create mode 100644 drizzle/meta/0017_snapshot.json create mode 100644 e2e/add-memory.spec.ts create mode 100644 src/components/reader/reader-generation.ts create mode 100644 src/components/settings/action-state.ts create mode 100644 src/routes/api/reader-media.ts create mode 100644 src/server/browse/filtered-page.ts create mode 100644 src/server/http/document-security.ts create mode 100644 src/server/http/mutation-request.ts create mode 100644 src/server/importer/runtime.ts create mode 100644 src/server/psychiatrist/bounded-cache.ts create mode 100644 src/server/reader/media-proxy.ts create mode 100644 tests/components/reader-generation.test.ts create mode 100644 tests/components/settings-action-tracker.test.ts create mode 100644 tests/server/browse/filtered-page.test.ts create mode 100644 tests/server/entry-server.test.ts create mode 100644 tests/server/http/mutation-request.test.ts create mode 100644 tests/server/importer/runtime.test.ts create mode 100644 tests/server/psychiatrist/bounded-cache.test.ts create mode 100644 tests/server/reader/media-proxy.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a017ddb4..ef7e3b74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Audit production dependencies + run: bun run audit + - name: Run baseline verification run: bun run verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00fdbd19..53eadbd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,10 @@ jobs: if: steps.release_tag.outputs.valid == 'true' run: bun install --frozen-lockfile + - name: Audit production dependencies + if: steps.release_tag.outputs.valid == 'true' + run: bun run audit + - name: Run baseline verification if: steps.release_tag.outputs.valid == 'true' run: bun run verify diff --git a/.gitignore b/.gitignore index 79a5be61..3f3e99f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .superpowers/ .eda/ +.tmp/ +app.config.timestamp_*.js +.claude/settings.local.json node_modules/ .vinxi/ .output/ diff --git a/bun.lock b/bun.lock index e92f2644..d8544614 100644 --- a/bun.lock +++ b/bun.lock @@ -9,18 +9,18 @@ "@solidjs/router": "^0.15.0", "@solidjs/start": "1.3.2", "defuddle": "^0.18.0", - "drizzle-orm": "^0.44.7", + "drizzle-orm": "^0.45.2", "entities": "^7.0.1", "highlight.js": "^11.11.1", "ipaddr.js": "^2.4.0", - "linkedom": "^0.18.12", - "markdown-it": "^14.1.1", - "markdown-it-anchor": "^9.2.0", + "linkedom": "^0.18.13", + "markdown-it": "^14.3.0", + "markdown-it-anchor": "^9.2.1", "markdown-it-footnote": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", - "sanitize-html": "^2.17.3", + "sanitize-html": "^2.17.6", "solid-js": "^1.9.5", "unified": "^11.0.5", "unist-util-visit-parents": "^6.0.2", @@ -44,22 +44,28 @@ }, }, }, + "overrides": { + "@babel/core": "7.29.7", + "h3": "1.15.11", + "tar": "7.5.20", + "vite": "6.4.3", + }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], @@ -67,9 +73,9 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], @@ -93,6 +99,12 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], @@ -171,12 +183,16 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], @@ -217,6 +233,38 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/plugin-alias": ["@rollup/plugin-alias@6.0.0", "", { "peerDependencies": { "rollup": ">=4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g=="], "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg=="], @@ -349,6 +397,8 @@ "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.121.21", "", { "dependencies": { "@babel/code-frame": "7.26.2", "@babel/core": "^7.26.8", "@babel/plugin-syntax-jsx": "^7.25.9", "@babel/plugin-syntax-typescript": "^7.25.9", "@babel/template": "^7.26.8", "@babel/traverse": "^7.26.8", "@babel/types": "^7.26.8", "@tanstack/directive-functions-plugin": "1.121.21", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-a05fzK+jBGacsSAc1vE8an7lpBh4H0PyIEcivtEyHLomgSeElAJxm9E2It/0nYRZ5Lh23m0okbhzJNaYWZpAOg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -497,7 +547,7 @@ "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="], "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], @@ -583,9 +633,9 @@ "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], - "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="], - "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -595,6 +645,8 @@ "dax-sh": ["dax-sh@0.43.2", "", { "dependencies": { "@deno/shim-deno": "~0.19.0", "undici-types": "^5.26" } }, "sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg=="], + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -643,7 +695,7 @@ "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], @@ -747,7 +799,7 @@ "gzip-size": ["gzip-size@7.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA=="], - "h3": ["h3@1.15.3", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.4", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ=="], + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -857,6 +909,8 @@ "knitwork": ["knitwork@1.3.0", "", {}, "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw=="], + "launder": ["launder@1.7.1", "", { "dependencies": { "dayjs": "^1.11.7" } }, "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -883,9 +937,9 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="], + "linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="], - "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "listhen": ["listhen@1.10.0", "", { "dependencies": { "@parcel/watcher": "^2.5.6", "@parcel/watcher-wasm": "^2.5.6", "citty": "^0.2.2", "consola": "^3.4.2", "crossws": ">=0.2.0 <0.5.0", "defu": "^6.1.7", "get-port-please": "^3.2.0", "h3": "^1.15.11", "http-shutdown": "^1.2.2", "jiti": "^2.6.1", "mlly": "^1.8.2", "node-forge": "^1.4.0", "pathe": "^2.0.3", "std-env": "^4.1.0", "tinyclip": "^0.1.12", "ufo": "^1.6.4", "untun": "^0.1.3", "uqr": "^0.1.3" }, "bin": { "listen": "bin/listhen.mjs", "listhen": "bin/listhen.mjs" } }, "sha512-kfz4C0OrC6IpaVMtYDJtf6PFjurxe9NBBoDAh/o2p587INryFOO4DQ9OetbCdDrWFt1m1CJKvYrzkGsuPHw8nQ=="], @@ -907,9 +961,9 @@ "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="], - "markdown-it-anchor": ["markdown-it-anchor@9.2.0", "", { "peerDependencies": { "@types/markdown-it": "*", "markdown-it": "*" } }, "sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg=="], + "markdown-it-anchor": ["markdown-it-anchor@9.2.1", "", { "peerDependencies": { "@types/markdown-it": "*", "markdown-it": "*" } }, "sha512-p6APiLJDFAW2GEvaavDvhIBn7jrX2jLv77NkBGgNacFTurbORYc4pyYySg/mI6mpR6cHQuAtzKtmqgQr4K8dsQ=="], "markdown-it-footnote": ["markdown-it-footnote@4.0.0", "", {}, "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ=="], @@ -1053,7 +1107,7 @@ "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], - "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], @@ -1161,6 +1215,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.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" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], "rollup-plugin-visualizer": ["rollup-plugin-visualizer@7.0.1", "", { "dependencies": { "open": "^11.0.0", "picomatch": "^4.0.2", "source-map": "^0.7.4", "yargs": "^18.0.0" }, "peerDependencies": { "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rolldown", "rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg=="], @@ -1171,7 +1227,7 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "sanitize-html": ["sanitize-html@2.17.3", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg=="], + "sanitize-html": ["sanitize-html@2.17.6", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.0.0", "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA=="], "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], @@ -1259,7 +1315,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + "tar": ["tar@7.5.20", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ=="], "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], @@ -1363,7 +1419,7 @@ "vinxi": ["vinxi@0.5.11", "", { "dependencies": { "@babel/core": "^7.22.11", "@babel/plugin-syntax-jsx": "^7.22.5", "@babel/plugin-syntax-typescript": "^7.22.5", "@types/micromatch": "^4.0.2", "@vinxi/listhen": "^1.5.6", "boxen": "^8.0.1", "chokidar": "^4.0.3", "citty": "^0.1.6", "consola": "^3.4.2", "crossws": "^0.3.4", "dax-sh": "^0.43.0", "defu": "^6.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.3", "get-port-please": "^3.1.2", "h3": "1.15.3", "hookable": "^5.5.3", "http-proxy": "^1.18.1", "micromatch": "^4.0.8", "nitropack": "^2.11.10", "node-fetch-native": "^1.6.6", "path-to-regexp": "^6.2.1", "pathe": "^1.1.1", "radix3": "^1.1.2", "resolve": "^1.22.10", "serve-placeholder": "^2.0.1", "serve-static": "^1.15.0", "tinyglobby": "^0.2.14", "ufo": "^1.6.1", "unctx": "^2.4.1", "unenv": "^1.10.0", "unstorage": "^1.16.0", "vite": "^6.4.1", "zod": "^4.0.0" }, "bin": { "vinxi": "bin/cli.mjs" } }, "sha512-82Qm+EG/b2PRFBvXBbz1lgWBGcd9totIL6SJhnrZYfakjloTVG9+5l6gfO6dbCCtztm5pqWFzLY0qpZ3H3ww/w=="], - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], @@ -1409,14 +1465,38 @@ "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/core/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/core/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/core/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/generator/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/helpers/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helpers/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -1449,9 +1529,9 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@vinxi/listhen/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + "@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@vinxi/listhen/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], @@ -1479,8 +1559,14 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "css-select/domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], + + "css-select/domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + "dax-sh/undici-types": ["undici-types@5.28.4", "", {}, "sha512-3OeMF5Lyowe8VW0skf5qaIE7Or3yS9LS7fvMUI0gg4YxpIBVg0L8BxCmROw2CcYhSkpR68Epz7CGc8MPj94Uww=="], + "defuddle/linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dot-prop/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], @@ -1501,8 +1587,6 @@ "listhen/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], - "listhen/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], - "make-dir/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -1517,12 +1601,10 @@ "nitropack/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], - "nitropack/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "nitropack/esbuild": ["esbuild@0.28.1", "", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "nitropack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "nitropack/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], - "nitropack/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "nitropack/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -1539,10 +1621,14 @@ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "sanitize-html/htmlparser2": ["htmlparser2@12.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "solid-refresh/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1571,8 +1657,6 @@ "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], - "untun/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "vinxi/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -1581,6 +1665,12 @@ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "vite/postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + + "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1589,8 +1679,40 @@ "youch/cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helpers/@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helpers/@babel/template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/helpers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helpers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/template/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1641,8 +1763,6 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@vinxi/listhen/h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], - "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1655,76 +1775,88 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "css-select/domhandler/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "css-select/domutils/dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], + + "css-select/domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "defuddle/linkedom/css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "listhen/@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="], - "listhen/h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "nitropack/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "nitropack/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + "nitropack/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "nitropack/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + "nitropack/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "nitropack/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + "nitropack/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "nitropack/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + "nitropack/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "nitropack/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "nitropack/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "nitropack/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "nitropack/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "nitropack/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "nitropack/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "nitropack/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "nitropack/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "nitropack/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "nitropack/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "nitropack/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "nitropack/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "nitropack/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "nitropack/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "nitropack/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "nitropack/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "nitropack/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "nitropack/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "nitropack/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "nitropack/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "nitropack/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "nitropack/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "nitropack/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "nitropack/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "nitropack/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "nitropack/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "nitropack/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "nitropack/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "nitropack/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "nitropack/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "nitropack/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "nitropack/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "nitropack/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "nitropack/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "nitropack/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "nitropack/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "nitropack/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "nitropack/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "nitropack/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "nitropack/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "nitropack/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "nitropack/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "nitropack/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], - - "nitropack/h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + "nitropack/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "nitropack/serve-static/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "sanitize-html/htmlparser2/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "sanitize-html/htmlparser2/domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], + + "sanitize-html/htmlparser2/domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + + "sanitize-html/htmlparser2/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1783,22 +1915,40 @@ "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "unstorage/h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helpers/@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helpers/@babel/template/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "css-select/domutils/dom-serializer/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + + "defuddle/linkedom/css-select/boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "defuddle/linkedom/css-select/css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "defuddle/linkedom/css-select/nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nitropack/serve-static/send/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "sanitize-html/htmlparser2/domutils/dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], + "archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/drizzle/0016_scrub_backup_secrets.sql b/drizzle/0016_scrub_backup_secrets.sql new file mode 100644 index 00000000..d01ffafe --- /dev/null +++ b/drizzle/0016_scrub_backup_secrets.sql @@ -0,0 +1,10 @@ +UPDATE `backup_environment_stamps` +SET `git_remote_url` = 'redacted:migration-0016' +WHERE `git_remote_url` IS NOT NULL; +--> statement-breakpoint +UPDATE `backup_failsafe_alerts` +SET `git_remote_url` = NULL, + `error` = CASE + WHEN `kind` = 'backup_push_failed' THEN NULL + ELSE `error` + END; diff --git a/drizzle/0017_skinny_hobgoblin.sql b/drizzle/0017_skinny_hobgoblin.sql new file mode 100644 index 00000000..c718c3e8 --- /dev/null +++ b/drizzle/0017_skinny_hobgoblin.sql @@ -0,0 +1,16 @@ +DELETE FROM `moments` +WHERE `id` IN ( + SELECT `id` + FROM ( + SELECT + `id`, + row_number() OVER ( + PARTITION BY `memory_id`, `section_path` + ORDER BY `updated_at` DESC, `created_at` ASC, `id` ASC + ) AS `duplicate_rank` + FROM `moments` + ) + WHERE `duplicate_rank` > 1 +); +--> statement-breakpoint +CREATE UNIQUE INDEX `moments_memory_section_path_unique` ON `moments` (`memory_id`,`section_path`); diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..f1252ded --- /dev/null +++ b/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1315 @@ +{ + "id": "90a2c452-09c0-4da5-bd6a-99f43c9154fc", + "prevId": "be79309e-03a0-4a21-9830-f2adf0db7659", + "version": "6", + "dialect": "sqlite", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "translation_target_language": { + "name": "translation_target_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ja-JP'" + }, + "codex_translation_model": { + "name": "codex_translation_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codex_translation_reasoning_effort": { + "name": "codex_translation_reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "app_settings_id_check": { + "name": "app_settings_id_check", + "value": "\"app_settings\".\"id\" = 'default'" + }, + "app_settings_translation_target_language_check": { + "name": "app_settings_translation_target_language_check", + "value": "\"app_settings\".\"translation_target_language\" in ('ja-JP', 'en-US', 'en-GB', 'ko-KR', 'zh-CN', 'zh-TW', 'fr-FR', 'de-DE', 'es-ES', 'pt-BR')" + }, + "app_settings_codex_translation_reasoning_effort_check": { + "name": "app_settings_codex_translation_reasoning_effort_check", + "value": "\"app_settings\".\"codex_translation_reasoning_effort\" is null or \"app_settings\".\"codex_translation_reasoning_effort\" in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh')" + } + } + }, + "backup_environment_stamps": { + "name": "backup_environment_stamps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_path": { + "name": "project_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "store_path": { + "name": "store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote": { + "name": "git_remote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "backup_environment_stamps_id_check": { + "name": "backup_environment_stamps_id_check", + "value": "\"backup_environment_stamps\".\"id\" = 'default'" + } + } + }, + "backup_failsafe_alerts": { + "name": "backup_failsafe_alerts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_project_path": { + "name": "previous_project_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_store_path": { + "name": "previous_store_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_project_path": { + "name": "current_project_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_store_path": { + "name": "current_store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote": { + "name": "git_remote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "backup_failsafe_alerts_id_check": { + "name": "backup_failsafe_alerts_id_check", + "value": "\"backup_failsafe_alerts\".\"id\" = 'active'" + }, + "backup_failsafe_alerts_kind_check": { + "name": "backup_failsafe_alerts_kind_check", + "value": "\"backup_failsafe_alerts\".\"kind\" in ('backup_path_drift', 'backup_content_inconsistent', 'backup_repository_missing', 'backup_push_failed')" + }, + "backup_failsafe_alerts_severity_check": { + "name": "backup_failsafe_alerts_severity_check", + "value": "\"backup_failsafe_alerts\".\"severity\" in ('critical')" + } + } + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "flashbacks": { + "name": "flashbacks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "flashbacks_memory_id_idx": { + "name": "flashbacks_memory_id_idx", + "columns": [ + "memory_id" + ], + "isUnique": false + }, + "flashbacks_created_at_idx": { + "name": "flashbacks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "flashbacks_memory_id_memories_id_fk": { + "name": "flashbacks_memory_id_memories_id_fk", + "tableFrom": "flashbacks", + "columnsFrom": [ + "memory_id" + ], + "tableTo": "memories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "flashbacks_start_offset_check": { + "name": "flashbacks_start_offset_check", + "value": "\"flashbacks\".\"start_offset\" >= 0" + }, + "flashbacks_end_offset_check": { + "name": "flashbacks_end_offset_check", + "value": "\"flashbacks\".\"end_offset\" > \"flashbacks\".\"start_offset\"" + } + } + }, + "memories": { + "name": "memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_path": { + "name": "content_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extraction_error": { + "name": "extraction_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "backup_status": { + "name": "backup_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memories_url_idx": { + "name": "memories_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "memories_created_at_idx": { + "name": "memories_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "memories_extraction_status_idx": { + "name": "memories_extraction_status_idx", + "columns": [ + "extraction_status" + ], + "isUnique": false + }, + "memories_backup_status_idx": { + "name": "memories_backup_status_idx", + "columns": [ + "backup_status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "memories_extraction_status_check": { + "name": "memories_extraction_status_check", + "value": "\"memories\".\"extraction_status\" in ('pending', 'success', 'link_only', 'failed')" + }, + "memories_backup_status_check": { + "name": "memories_backup_status_check", + "value": "\"memories\".\"backup_status\" in ('pending', 'queued', 'success', 'failed', 'disabled')" + } + } + }, + "memory_categories": { + "name": "memory_categories", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memory_categories_category_id_idx": { + "name": "memory_categories_category_id_idx", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memory_categories_memory_id_memories_id_fk": { + "name": "memory_categories_memory_id_memories_id_fk", + "tableFrom": "memory_categories", + "columnsFrom": [ + "memory_id" + ], + "tableTo": "memories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "memory_categories_category_id_categories_id_fk": { + "name": "memory_categories_category_id_categories_id_fk", + "tableFrom": "memory_categories", + "columnsFrom": [ + "category_id" + ], + "tableTo": "categories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "memory_categories_memory_id_category_id_pk": { + "columns": [ + "memory_id", + "category_id" + ], + "name": "memory_categories_memory_id_category_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memory_tags": { + "name": "memory_tags", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memory_tags_tag_id_idx": { + "name": "memory_tags_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memory_tags_memory_id_memories_id_fk": { + "name": "memory_tags_memory_id_memories_id_fk", + "tableFrom": "memory_tags", + "columnsFrom": [ + "memory_id" + ], + "tableTo": "memories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "memory_tags_tag_id_tags_id_fk": { + "name": "memory_tags_tag_id_tags_id_fk", + "tableFrom": "memory_tags", + "columnsFrom": [ + "tag_id" + ], + "tableTo": "tags", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "memory_tags_memory_id_tag_id_pk": { + "columns": [ + "memory_id", + "tag_id" + ], + "name": "memory_tags_memory_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "moments": { + "name": "moments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_anchor": { + "name": "section_anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_level": { + "name": "section_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_path": { + "name": "section_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_start_offset": { + "name": "section_start_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_end_offset": { + "name": "section_end_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "moments_memory_section_anchor_unique": { + "name": "moments_memory_section_anchor_unique", + "columns": [ + "memory_id", + "section_anchor" + ], + "isUnique": true + }, + "moments_memory_id_idx": { + "name": "moments_memory_id_idx", + "columns": [ + "memory_id" + ], + "isUnique": false + }, + "moments_created_at_idx": { + "name": "moments_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "moments_memory_id_memories_id_fk": { + "name": "moments_memory_id_memories_id_fk", + "tableFrom": "moments", + "columnsFrom": [ + "memory_id" + ], + "tableTo": "memories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "moments_section_anchor_check": { + "name": "moments_section_anchor_check", + "value": "length(\"moments\".\"section_anchor\") > 0" + }, + "moments_section_title_check": { + "name": "moments_section_title_check", + "value": "length(\"moments\".\"section_title\") > 0" + }, + "moments_section_level_check": { + "name": "moments_section_level_check", + "value": "\"moments\".\"section_level\" >= 1 and \"moments\".\"section_level\" <= 6" + }, + "moments_section_offset_check": { + "name": "moments_section_offset_check", + "value": "(\"moments\".\"section_start_offset\" is null and \"moments\".\"section_end_offset\" is null) or (\"moments\".\"section_start_offset\" is not null and \"moments\".\"section_end_offset\" is not null and \"moments\".\"section_start_offset\" >= 0 and \"moments\".\"section_end_offset\" > \"moments\".\"section_start_offset\")" + } + } + }, + "openai_auth_credentials": { + "name": "openai_auth_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_reference": { + "name": "credential_reference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "openai_auth_credentials_id_check": { + "name": "openai_auth_credentials_id_check", + "value": "\"openai_auth_credentials\".\"id\" = 'default'" + } + } + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_name_unique": { + "name": "tags_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "translation_chunks": { + "name": "translation_chunks", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_chunk_hash": { + "name": "source_chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "block_ids_json": { + "name": "block_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "translated_markdown": { + "name": "translated_markdown", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "translated_hash": { + "name": "translated_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_chunks_status_idx": { + "name": "translation_chunks_status_idx", + "columns": [ + "job_id", + "status", + "chunk_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_chunks_job_id_translation_jobs_job_id_fk": { + "name": "translation_chunks_job_id_translation_jobs_job_id_fk", + "tableFrom": "translation_chunks", + "columnsFrom": [ + "job_id" + ], + "tableTo": "translation_jobs", + "columnsTo": [ + "job_id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "translation_chunks_job_id_chunk_index_pk": { + "columns": [ + "job_id", + "chunk_index" + ], + "name": "translation_chunks_job_id_chunk_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_chunks_status_check": { + "name": "translation_chunks_status_check", + "value": "\"translation_chunks\".\"status\" in ('pending', 'running', 'validating', 'retrying', 'complete', 'purged', 'failed')" + }, + "translation_chunks_source_hash_check": { + "name": "translation_chunks_source_hash_check", + "value": "\"translation_chunks\".\"source_chunk_hash\" glob 'sha256:*'" + }, + "translation_chunks_translated_hash_check": { + "name": "translation_chunks_translated_hash_check", + "value": "\"translation_chunks\".\"translated_hash\" is null or \"translation_chunks\".\"translated_hash\" glob 'sha256:*'" + }, + "translation_chunks_retry_count_check": { + "name": "translation_chunks_retry_count_check", + "value": "\"translation_chunks\".\"retry_count\" >= 0" + }, + "translation_chunks_index_check": { + "name": "translation_chunks_index_check", + "value": "\"translation_chunks\".\"chunk_index\" >= 0" + } + } + }, + "translation_jobs": { + "name": "translation_jobs", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_policy_version": { + "name": "prompt_policy_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunker_version": { + "name": "chunker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_hash": { + "name": "output_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_jobs_current_complete_idx": { + "name": "translation_jobs_current_complete_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash" + ], + "where": "\"translation_jobs\".\"status\" = 'complete'", + "isUnique": true + }, + "translation_jobs_active_idx": { + "name": "translation_jobs_active_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash" + ], + "where": "\"translation_jobs\".\"status\" in ('pending', 'running', 'cancel_requested', 'stitching', 'committing')", + "isUnique": true + }, + "translation_jobs_memory_lang_idx": { + "name": "translation_jobs_memory_lang_idx", + "columns": [ + "memory_id", + "lang_code", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_jobs_memory_id_memories_id_fk": { + "name": "translation_jobs_memory_id_memories_id_fk", + "tableFrom": "translation_jobs", + "columnsFrom": [ + "memory_id" + ], + "tableTo": "memories", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_jobs_status_check": { + "name": "translation_jobs_status_check", + "value": "\"translation_jobs\".\"status\" in ('pending', 'running', 'stale', 'cancel_requested', 'canceled', 'unavailable', 'stitching', 'committing', 'complete', 'failed')" + }, + "translation_jobs_source_hash_check": { + "name": "translation_jobs_source_hash_check", + "value": "\"translation_jobs\".\"source_hash\" glob 'sha256:*'" + }, + "translation_jobs_output_hash_check": { + "name": "translation_jobs_output_hash_check", + "value": "\"translation_jobs\".\"output_hash\" is null or \"translation_jobs\".\"output_hash\" glob 'sha256:*'" + }, + "translation_jobs_reasoning_effort_check": { + "name": "translation_jobs_reasoning_effort_check", + "value": "\"translation_jobs\".\"reasoning_effort\" is null or \"translation_jobs\".\"reasoning_effort\" in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh')" + }, + "translation_jobs_chunk_count_check": { + "name": "translation_jobs_chunk_count_check", + "value": "\"translation_jobs\".\"chunk_count\" >= 0" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0017_snapshot.json b/drizzle/meta/0017_snapshot.json new file mode 100644 index 00000000..0554500a --- /dev/null +++ b/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1587 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "175d5eb2-7534-4024-aa67-c69066d56a66", + "prevId": "90a2c452-09c0-4da5-bd6a-99f43c9154fc", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "translation_target_language": { + "name": "translation_target_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ja-JP'" + }, + "codex_translation_model": { + "name": "codex_translation_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codex_translation_reasoning_effort": { + "name": "codex_translation_reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "app_settings_id_check": { + "name": "app_settings_id_check", + "value": "\"app_settings\".\"id\" = 'default'" + }, + "app_settings_translation_target_language_check": { + "name": "app_settings_translation_target_language_check", + "value": "\"app_settings\".\"translation_target_language\" in ('ja-JP', 'en-US', 'en-GB', 'ko-KR', 'zh-CN', 'zh-TW', 'fr-FR', 'de-DE', 'es-ES', 'pt-BR')" + }, + "app_settings_codex_translation_reasoning_effort_check": { + "name": "app_settings_codex_translation_reasoning_effort_check", + "value": "\"app_settings\".\"codex_translation_reasoning_effort\" is null or \"app_settings\".\"codex_translation_reasoning_effort\" in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh')" + } + } + }, + "backup_environment_stamps": { + "name": "backup_environment_stamps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_path": { + "name": "project_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "store_path": { + "name": "store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote": { + "name": "git_remote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "backup_environment_stamps_id_check": { + "name": "backup_environment_stamps_id_check", + "value": "\"backup_environment_stamps\".\"id\" = 'default'" + } + } + }, + "backup_failsafe_alerts": { + "name": "backup_failsafe_alerts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_project_path": { + "name": "previous_project_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_store_path": { + "name": "previous_store_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_project_path": { + "name": "current_project_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_store_path": { + "name": "current_store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote": { + "name": "git_remote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "backup_failsafe_alerts_id_check": { + "name": "backup_failsafe_alerts_id_check", + "value": "\"backup_failsafe_alerts\".\"id\" = 'active'" + }, + "backup_failsafe_alerts_kind_check": { + "name": "backup_failsafe_alerts_kind_check", + "value": "\"backup_failsafe_alerts\".\"kind\" in ('backup_path_drift', 'backup_content_inconsistent', 'backup_repository_missing', 'backup_push_failed')" + }, + "backup_failsafe_alerts_severity_check": { + "name": "backup_failsafe_alerts_severity_check", + "value": "\"backup_failsafe_alerts\".\"severity\" in ('critical')" + } + } + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "flashbacks": { + "name": "flashbacks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variant_kind": { + "name": "variant_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'source'" + }, + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "translation_output_hash": { + "name": "translation_output_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "flashbacks_memory_id_idx": { + "name": "flashbacks_memory_id_idx", + "columns": [ + "memory_id" + ], + "isUnique": false + }, + "flashbacks_created_at_idx": { + "name": "flashbacks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "flashbacks_memory_variant_idx": { + "name": "flashbacks_memory_variant_idx", + "columns": [ + "memory_id", + "variant_kind", + "lang_code", + "translation_output_hash", + "start_offset" + ], + "isUnique": false + } + }, + "foreignKeys": { + "flashbacks_memory_id_memories_id_fk": { + "name": "flashbacks_memory_id_memories_id_fk", + "tableFrom": "flashbacks", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "flashbacks_variant_kind_check": { + "name": "flashbacks_variant_kind_check", + "value": "\"flashbacks\".\"variant_kind\" in ('source', 'translation')" + }, + "flashbacks_variant_scope_check": { + "name": "flashbacks_variant_scope_check", + "value": "(\"flashbacks\".\"variant_kind\" = 'source' and \"flashbacks\".\"lang_code\" is null and \"flashbacks\".\"translation_output_hash\" is null) or (\"flashbacks\".\"variant_kind\" = 'translation' and \"flashbacks\".\"lang_code\" is not null and \"flashbacks\".\"lang_code\" in ('ja-JP', 'en-US', 'en-GB', 'ko-KR', 'zh-CN', 'zh-TW', 'fr-FR', 'de-DE', 'es-ES', 'pt-BR') and \"flashbacks\".\"translation_output_hash\" is not null and \"flashbacks\".\"translation_output_hash\" glob 'sha256:*')" + }, + "flashbacks_start_offset_check": { + "name": "flashbacks_start_offset_check", + "value": "\"flashbacks\".\"start_offset\" >= 0" + }, + "flashbacks_end_offset_check": { + "name": "flashbacks_end_offset_check", + "value": "\"flashbacks\".\"end_offset\" > \"flashbacks\".\"start_offset\"" + } + } + }, + "memories": { + "name": "memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_path": { + "name": "content_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extraction_error": { + "name": "extraction_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "backup_status": { + "name": "backup_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memories_url_idx": { + "name": "memories_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "memories_created_at_idx": { + "name": "memories_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "memories_created_at_id_idx": { + "name": "memories_created_at_id_idx", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "memories_extraction_status_idx": { + "name": "memories_extraction_status_idx", + "columns": [ + "extraction_status" + ], + "isUnique": false + }, + "memories_backup_status_idx": { + "name": "memories_backup_status_idx", + "columns": [ + "backup_status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "memories_extraction_status_check": { + "name": "memories_extraction_status_check", + "value": "\"memories\".\"extraction_status\" in ('pending', 'success', 'link_only', 'failed')" + }, + "memories_backup_status_check": { + "name": "memories_backup_status_check", + "value": "\"memories\".\"backup_status\" in ('pending', 'queued', 'success', 'failed', 'disabled')" + } + } + }, + "memory_categories": { + "name": "memory_categories", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memory_categories_category_id_idx": { + "name": "memory_categories_category_id_idx", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memory_categories_memory_id_memories_id_fk": { + "name": "memory_categories_memory_id_memories_id_fk", + "tableFrom": "memory_categories", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_categories_category_id_categories_id_fk": { + "name": "memory_categories_category_id_categories_id_fk", + "tableFrom": "memory_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_categories_memory_id_category_id_pk": { + "columns": [ + "memory_id", + "category_id" + ], + "name": "memory_categories_memory_id_category_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memory_tags": { + "name": "memory_tags", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memory_tags_tag_id_idx": { + "name": "memory_tags_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memory_tags_memory_id_memories_id_fk": { + "name": "memory_tags_memory_id_memories_id_fk", + "tableFrom": "memory_tags", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_tags_tag_id_tags_id_fk": { + "name": "memory_tags_tag_id_tags_id_fk", + "tableFrom": "memory_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_tags_memory_id_tag_id_pk": { + "columns": [ + "memory_id", + "tag_id" + ], + "name": "memory_tags_memory_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "moments": { + "name": "moments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_anchor": { + "name": "section_anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_level": { + "name": "section_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_path": { + "name": "section_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_start_offset": { + "name": "section_start_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_end_offset": { + "name": "section_end_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "moments_memory_section_anchor_unique": { + "name": "moments_memory_section_anchor_unique", + "columns": [ + "memory_id", + "section_anchor" + ], + "isUnique": true + }, + "moments_memory_section_path_unique": { + "name": "moments_memory_section_path_unique", + "columns": [ + "memory_id", + "section_path" + ], + "isUnique": true + }, + "moments_memory_id_idx": { + "name": "moments_memory_id_idx", + "columns": [ + "memory_id" + ], + "isUnique": false + }, + "moments_created_at_idx": { + "name": "moments_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "moments_memory_id_memories_id_fk": { + "name": "moments_memory_id_memories_id_fk", + "tableFrom": "moments", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "moments_section_anchor_check": { + "name": "moments_section_anchor_check", + "value": "length(\"moments\".\"section_anchor\") > 0" + }, + "moments_section_title_check": { + "name": "moments_section_title_check", + "value": "length(\"moments\".\"section_title\") > 0" + }, + "moments_section_level_check": { + "name": "moments_section_level_check", + "value": "\"moments\".\"section_level\" >= 1 and \"moments\".\"section_level\" <= 6" + }, + "moments_section_offset_check": { + "name": "moments_section_offset_check", + "value": "(\"moments\".\"section_start_offset\" is null and \"moments\".\"section_end_offset\" is null) or (\"moments\".\"section_start_offset\" is not null and \"moments\".\"section_end_offset\" is not null and \"moments\".\"section_start_offset\" >= 0 and \"moments\".\"section_end_offset\" > \"moments\".\"section_start_offset\")" + } + } + }, + "openai_auth_credentials": { + "name": "openai_auth_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_reference": { + "name": "credential_reference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "openai_auth_credentials_id_check": { + "name": "openai_auth_credentials_id_check", + "value": "\"openai_auth_credentials\".\"id\" = 'default'" + } + } + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_name_unique": { + "name": "tags_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "translation_chunks": { + "name": "translation_chunks", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_chunk_hash": { + "name": "source_chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "block_ids_json": { + "name": "block_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "translated_markdown": { + "name": "translated_markdown", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "translated_hash": { + "name": "translated_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projection_spans_json": { + "name": "projection_spans_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_chunks_status_idx": { + "name": "translation_chunks_status_idx", + "columns": [ + "job_id", + "status", + "chunk_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_chunks_job_id_translation_jobs_job_id_fk": { + "name": "translation_chunks_job_id_translation_jobs_job_id_fk", + "tableFrom": "translation_chunks", + "tableTo": "translation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "job_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "translation_chunks_job_id_chunk_index_pk": { + "columns": [ + "job_id", + "chunk_index" + ], + "name": "translation_chunks_job_id_chunk_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_chunks_status_check": { + "name": "translation_chunks_status_check", + "value": "\"translation_chunks\".\"status\" in ('pending', 'running', 'validating', 'retrying', 'complete', 'purged', 'failed')" + }, + "translation_chunks_source_hash_check": { + "name": "translation_chunks_source_hash_check", + "value": "\"translation_chunks\".\"source_chunk_hash\" glob 'sha256:*'" + }, + "translation_chunks_translated_hash_check": { + "name": "translation_chunks_translated_hash_check", + "value": "\"translation_chunks\".\"translated_hash\" is null or \"translation_chunks\".\"translated_hash\" glob 'sha256:*'" + }, + "translation_chunks_retry_count_check": { + "name": "translation_chunks_retry_count_check", + "value": "\"translation_chunks\".\"retry_count\" >= 0" + }, + "translation_chunks_index_check": { + "name": "translation_chunks_index_check", + "value": "\"translation_chunks\".\"chunk_index\" >= 0" + } + } + }, + "translation_jobs": { + "name": "translation_jobs", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_policy_version": { + "name": "prompt_policy_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunker_version": { + "name": "chunker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_hash": { + "name": "output_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_jobs_current_complete_idx": { + "name": "translation_jobs_current_complete_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash" + ], + "isUnique": true, + "where": "\"translation_jobs\".\"status\" = 'complete'" + }, + "translation_jobs_active_idx": { + "name": "translation_jobs_active_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash" + ], + "isUnique": true, + "where": "\"translation_jobs\".\"status\" in ('pending', 'running', 'cancel_requested', 'stitching', 'committing')" + }, + "translation_jobs_memory_lang_idx": { + "name": "translation_jobs_memory_lang_idx", + "columns": [ + "memory_id", + "lang_code", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_jobs_memory_id_memories_id_fk": { + "name": "translation_jobs_memory_id_memories_id_fk", + "tableFrom": "translation_jobs", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_jobs_status_check": { + "name": "translation_jobs_status_check", + "value": "\"translation_jobs\".\"status\" in ('pending', 'running', 'stale', 'cancel_requested', 'canceled', 'unavailable', 'stitching', 'committing', 'complete', 'failed')" + }, + "translation_jobs_source_hash_check": { + "name": "translation_jobs_source_hash_check", + "value": "\"translation_jobs\".\"source_hash\" glob 'sha256:*'" + }, + "translation_jobs_output_hash_check": { + "name": "translation_jobs_output_hash_check", + "value": "\"translation_jobs\".\"output_hash\" is null or \"translation_jobs\".\"output_hash\" glob 'sha256:*'" + }, + "translation_jobs_reasoning_effort_check": { + "name": "translation_jobs_reasoning_effort_check", + "value": "\"translation_jobs\".\"reasoning_effort\" is null or \"translation_jobs\".\"reasoning_effort\" in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh')" + }, + "translation_jobs_chunk_count_check": { + "name": "translation_jobs_chunk_count_check", + "value": "\"translation_jobs\".\"chunk_count\" >= 0" + } + } + }, + "translation_projection_spans": { + "name": "translation_projection_spans", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "span_index": { + "name": "span_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_hash": { + "name": "output_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_markdown_start": { + "name": "source_markdown_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_markdown_end": { + "name": "source_markdown_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_markdown_start": { + "name": "translated_markdown_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_markdown_end": { + "name": "translated_markdown_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_reader_start": { + "name": "source_reader_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_reader_end": { + "name": "source_reader_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_reader_start": { + "name": "translated_reader_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_reader_end": { + "name": "translated_reader_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_projection_current_idx": { + "name": "translation_projection_current_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash", + "output_hash", + "span_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_projection_spans_job_id_translation_jobs_job_id_fk": { + "name": "translation_projection_spans_job_id_translation_jobs_job_id_fk", + "tableFrom": "translation_projection_spans", + "tableTo": "translation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "job_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "translation_projection_spans_memory_id_memories_id_fk": { + "name": "translation_projection_spans_memory_id_memories_id_fk", + "tableFrom": "translation_projection_spans", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "translation_projection_spans_job_id_span_index_pk": { + "columns": [ + "job_id", + "span_index" + ], + "name": "translation_projection_spans_job_id_span_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_projection_source_hash_check": { + "name": "translation_projection_source_hash_check", + "value": "\"translation_projection_spans\".\"source_hash\" glob 'sha256:*'" + }, + "translation_projection_output_hash_check": { + "name": "translation_projection_output_hash_check", + "value": "\"translation_projection_spans\".\"output_hash\" glob 'sha256:*'" + }, + "translation_projection_source_markdown_range_check": { + "name": "translation_projection_source_markdown_range_check", + "value": "\"translation_projection_spans\".\"source_markdown_end\" > \"translation_projection_spans\".\"source_markdown_start\"" + }, + "translation_projection_translated_markdown_range_check": { + "name": "translation_projection_translated_markdown_range_check", + "value": "\"translation_projection_spans\".\"translated_markdown_end\" > \"translation_projection_spans\".\"translated_markdown_start\"" + }, + "translation_projection_source_reader_range_check": { + "name": "translation_projection_source_reader_range_check", + "value": "\"translation_projection_spans\".\"source_reader_end\" > \"translation_projection_spans\".\"source_reader_start\"" + }, + "translation_projection_translated_reader_range_check": { + "name": "translation_projection_translated_reader_range_check", + "value": "\"translation_projection_spans\".\"translated_reader_end\" > \"translation_projection_spans\".\"translated_reader_start\"" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bef7904f..ba3009a1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -113,6 +113,20 @@ "when": 1779955000000, "tag": "0015_memory_browse_pagination", "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1784221790920, + "tag": "0016_scrub_backup_secrets", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1784223792512, + "tag": "0017_skinny_hobgoblin", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/e2e/add-memory.spec.ts b/e2e/add-memory.spec.ts new file mode 100644 index 00000000..dce7124c --- /dev/null +++ b/e2e/add-memory.spec.ts @@ -0,0 +1,314 @@ +import { watch } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, test, type Page } from "@playwright/test"; + +import { runBunFixtureScript } from "./bun-fixture"; + +const SUCCESS_URL = "https://success.import.trauma.invalid/article"; +const FALLBACK_URL = "https://fallback.import.trauma.invalid/unavailable"; +const E2E_ROOT = join(process.cwd(), ".trauma/e2e"); + +test.describe.configure({ mode: "serial" }); + +test("adds an extracted memory through the public composer and persists every store boundary", async ({ + page, +}) => { + resetAddMemoryFixture(); + + const memory = await addMemoryThroughComposer(page, SUCCESS_URL); + + await expect(page).toHaveURL(new RegExp(`/memories/${memory.id}$`)); + await expect( + page.getByRole("heading", { name: "Deterministic Import Article" }), + ).toBeVisible(); + await expect( + page.getByText("Fixture extraction stays deterministic without external network access."), + ).toBeVisible(); + + const persisted = await waitForCompletedPersistence(memory.id); + expect(persisted).toMatchObject({ + backupStatus: "success", + commitCount: 1, + extractionError: null, + extractionStatus: "success", + gitStatus: "", + id: memory.id, + title: "Deterministic Import Article", + url: SUCCESS_URL, + }); + expect(persisted.commitMessage).toBe(`e2e created memory ${memory.id}`); + expect(persisted.fileContent).toContain('extraction_status: "success"'); + expect(persisted.fileContent).toContain( + "Fixture extraction stays deterministic without external network access.", + ); + expect(persisted.trackedContent).toBe(persisted.fileContent); +}); + +test("persists a link-only memory when the deterministic import response fails", async ({ + page, +}) => { + resetAddMemoryFixture(); + + const memory = await addMemoryThroughComposer(page, FALLBACK_URL); + + await expect(page).toHaveURL(new RegExp(`/memories/${memory.id}$`)); + await expect( + page.getByRole("heading", { name: "fallback.import.trauma.invalid" }), + ).toBeVisible(); + await expect( + page.locator("[data-reader-content]").getByRole("link", { name: FALLBACK_URL }), + ).toBeVisible(); + + const persisted = await waitForCompletedPersistence(memory.id); + expect(persisted).toMatchObject({ + backupStatus: "success", + commitCount: 1, + extractionError: "fetch failed: HTTP 503", + extractionStatus: "link_only", + gitStatus: "", + id: memory.id, + title: "fallback.import.trauma.invalid", + url: FALLBACK_URL, + }); + expect(persisted.commitMessage).toBe(`e2e created memory ${memory.id}`); + expect(persisted.fileContent).toContain('extraction_status: "link_only"'); + expect(persisted.fileContent).toContain(`[${FALLBACK_URL}](<${FALLBACK_URL}>)`); + expect(persisted.trackedContent).toBe(persisted.fileContent); +}); + +async function addMemoryThroughComposer(page: Page, url: string) { + await page.goto("/memories"); + await page.getByRole("button", { name: "Add memory" }).click(); + + const composer = page.getByRole("dialog", { name: "Add memory" }); + await expect(composer).toBeVisible(); + await composer.getByLabel("URL").fill(url); + + const responsePromise = page.waitForResponse( + (response) => + response.url().endsWith("/api/memories") && + response.request().method() === "POST", + ); + await composer.getByLabel("URL").press("Enter"); + + const response = await responsePromise; + expect(response.status()).toBe(201); + return readCreatedMemory(await response.json()); +} + +function readCreatedMemory(payload: unknown): { id: string } { + if ( + typeof payload !== "object" || + payload === null || + !("memory" in payload) || + typeof payload.memory !== "object" || + payload.memory === null || + !("id" in payload.memory) || + typeof payload.memory.id !== "string" + ) { + throw new Error("Add Memory response did not include a memory id"); + } + + return { id: payload.memory.id }; +} + +function resetAddMemoryFixture(): void { + runBunFixtureScript(` + import { execFileSync } from "node:child_process"; + import { mkdir, rm, writeFile } from "node:fs/promises"; + import { dirname, join } from "node:path"; + import { initializeDatabase } from "./src/server/db/index.ts"; + + const root = join(process.cwd(), ".trauma/e2e"); + const configPath = join(root, "trauma.config.json"); + const projectPath = join(root, "project"); + const config = { + storePath: "./project/store", + projectPath: "./project", + databasePath: "./runtime/trauma.sqlite", + backup: { + git: { + enabled: true, + remote: "origin", + branch: "main", + push: false, + commitMessageTemplate: "e2e {action} {memoryId}", + }, + }, + }; + const resolvedConfig = { + configFilePath: configPath, + projectPath, + storePath: join(projectPath, "store"), + databasePath: join(root, "runtime/trauma.sqlite"), + backup: config.backup, + }; + const gitEnv = { ...process.env }; + delete gitEnv.GIT_DIR; + delete gitEnv.GIT_WORK_TREE; + delete gitEnv.GIT_INDEX_FILE; + + await rm(root, { recursive: true, force: true }); + await mkdir(dirname(configPath), { recursive: true }); + await mkdir(resolvedConfig.storePath, { recursive: true }); + await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + execFileSync("git", ["init", "--initial-branch=main"], { + cwd: projectPath, + env: gitEnv, + stdio: "ignore", + }); + for (const [key, value] of [ + ["user.name", "TRAUMA E2E"], + ["user.email", "trauma-e2e@example.invalid"], + ["commit.gpgSign", "false"], + ]) { + execFileSync("git", ["config", key, value], { + cwd: projectPath, + env: gitEnv, + stdio: "ignore", + }); + } + + const connection = initializeDatabase(resolvedConfig); + connection.close(); + `); +} + +async function waitForCompletedPersistence(memoryId: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + const databaseChanges = watch(join(E2E_ROOT, "runtime"), { + signal: controller.signal, + }); + + try { + let state = readPersistenceState(memoryId); + if (isCompletedPersistence(state)) { + return state; + } + + for await (const _event of databaseChanges) { + state = readPersistenceState(memoryId); + if (isCompletedPersistence(state)) { + return state; + } + } + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`Timed out waiting for backup persistence for ${memoryId}`); + } + throw error; + } finally { + clearTimeout(timeout); + controller.abort(); + } + + throw new Error(`Backup persistence watcher ended before ${memoryId} completed`); +} + +interface PersistenceState { + backupStatus: string | null; + commitCount: number; + commitMessage: string | null; + extractionError: string | null; + extractionStatus: string | null; + fileContent: string | null; + gitStatus: string | null; + id: string | null; + title: string | null; + trackedContent: string | null; + url: string | null; +} + +function isCompletedPersistence(state: PersistenceState) { + return ( + state.backupStatus === "success" && + state.commitCount === 1 && + state.fileContent !== null && + state.fileContent === state.trackedContent && + state.gitStatus === "" + ); +} + +function readPersistenceState(memoryId: string): PersistenceState { + const stdout = runBunFixtureScript(` + import { execFileSync } from "node:child_process"; + import { readFileSync } from "node:fs"; + import { join } from "node:path"; + import { initializeDatabase } from "./src/server/db/index.ts"; + + const root = join(process.cwd(), ".trauma/e2e"); + const projectPath = join(root, "project"); + const memoryId = ${JSON.stringify(memoryId)}; + const config = { + configFilePath: join(root, "trauma.config.json"), + projectPath, + storePath: join(projectPath, "store"), + databasePath: join(root, "runtime/trauma.sqlite"), + backup: { + git: { + enabled: true, + remote: "origin", + branch: "main", + push: false, + commitMessageTemplate: "e2e {action} {memoryId}", + }, + }, + }; + const gitEnv = { ...process.env }; + delete gitEnv.GIT_DIR; + delete gitEnv.GIT_WORK_TREE; + delete gitEnv.GIT_INDEX_FILE; + const runGit = (args) => execFileSync("git", args, { + cwd: projectPath, + encoding: "utf8", + env: gitEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + + const connection = initializeDatabase(config); + let memory; + try { + memory = await connection.repositories.memories.findById(memoryId); + } finally { + connection.close(); + } + + let commitCount = 0; + let commitMessage = null; + let fileContent = null; + let gitStatus = null; + let trackedContent = null; + if (memory !== undefined) { + fileContent = readFileSync(join(config.storePath, memory.contentPath), "utf8"); + try { + commitCount = Number.parseInt(runGit(["rev-list", "--count", "HEAD"]), 10); + commitMessage = runGit(["log", "-1", "--format=%s"]).trimEnd(); + gitStatus = runGit(["status", "--porcelain", "--", "store"]).trimEnd(); + trackedContent = runGit([ + "show", + \`HEAD:store/\${memory.contentPath}\`, + ]); + } catch { + commitCount = 0; + } + } + + process.stdout.write(JSON.stringify({ + backupStatus: memory?.backupStatus ?? null, + commitCount, + commitMessage, + extractionError: memory?.extractionError ?? null, + extractionStatus: memory?.extractionStatus ?? null, + fileContent, + gitStatus, + id: memory?.id ?? null, + title: memory?.title ?? null, + trackedContent, + url: memory?.url ?? null, + })); + `); + + return JSON.parse(stdout) as PersistenceState; +} diff --git a/package.json b/package.json index 891151b8..4a4d2091 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "vinxi build", "start": "HOST=${HOST:-127.0.0.1} bun --bun x vinxi start", "preview": "HOST=${HOST:-127.0.0.1} bun --bun x vinxi preview", + "audit": "bun audit --ignore GHSA-67mh-4wv8-2f99 --ignore GHSA-g7r4-m6w7-qqqr", "typecheck": "tsc --noEmit", "test": "bun --bun x vitest run", "test:watch": "bun --bun x vitest", @@ -23,18 +24,18 @@ "@solidjs/router": "^0.15.0", "@solidjs/start": "1.3.2", "defuddle": "^0.18.0", - "drizzle-orm": "^0.44.7", + "drizzle-orm": "^0.45.2", "entities": "^7.0.1", "highlight.js": "^11.11.1", "ipaddr.js": "^2.4.0", - "linkedom": "^0.18.12", - "markdown-it": "^14.1.1", - "markdown-it-anchor": "^9.2.0", + "linkedom": "^0.18.13", + "markdown-it": "^14.3.0", + "markdown-it-anchor": "^9.2.1", "markdown-it-footnote": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", - "sanitize-html": "^2.17.3", + "sanitize-html": "^2.17.6", "solid-js": "^1.9.5", "unified": "^11.0.5", "unist-util-visit-parents": "^6.0.2", @@ -59,5 +60,11 @@ "typescript": "^5.9.3", "vitest": "^4.0.15" }, + "overrides": { + "@babel/core": "7.29.7", + "h3": "1.15.11", + "tar": "7.5.20", + "vite": "6.4.3" + }, "packageManager": "bun@1.3.13" } diff --git a/playwright.config.ts b/playwright.config.ts index 68f95855..81b6b377 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ PORT: String(port), TRAUMA_BROWSE_FIXTURES: "1", TRAUMA_CONFIG_PATH: ".trauma/e2e/trauma.config.json", + TRAUMA_E2E_IMPORT_FIXTURES: "1", TRAUMA_HMR_PORT: String(hmrBasePort), }, reuseExistingServer: !process.env.CI, diff --git a/scripts/trauma-backup-failsafe.ts b/scripts/trauma-backup-failsafe.ts index 42db3f03..dd27c150 100644 --- a/scripts/trauma-backup-failsafe.ts +++ b/scripts/trauma-backup-failsafe.ts @@ -6,7 +6,6 @@ import { readActiveBackupFailsafeAlert, revertBackupFailsafeConfig, } from "../src/server/backup/failsafe"; -import { getBackupFailsafeStatus } from "../src/server/backup/environment"; type Command = "status" | "revert" | "migrate" | "delete-missing-record"; @@ -16,13 +15,10 @@ export async function runBackupFailsafeCli(args: readonly string[]) { const connection = initializeDatabase(config); try { if (parsed.command === "status") { - const status = await getBackupFailsafeStatus({ - config, - db: connection.db, - }); - return status.alert === null + const alert = await readActiveBackupFailsafeAlert(connection.db); + return alert === null ? "No active backup failsafe alert.\n" - : `${JSON.stringify(status.alert, null, 2)}\n`; + : `${JSON.stringify(alert, null, 2)}\n`; } if (parsed.command === "revert") { diff --git a/src/components/backup/BackupFailsafeBanner.tsx b/src/components/backup/BackupFailsafeBanner.tsx index a0250aa3..c9b749c7 100644 --- a/src/components/backup/BackupFailsafeBanner.tsx +++ b/src/components/backup/BackupFailsafeBanner.tsx @@ -46,48 +46,9 @@ export function BackupFailsafeBanner(props: BackupFailsafeBannerProps) { {describeBackupFailsafeAlert(props.alert)}

-
- - {(path) => ( -
-
Previous project path:
-
{path()}
-
- )} -
- - {(path) => ( -
-
Previous store path:
-
{path()}
-
- )} -
-
-
Current project path:
-
{props.alert.currentProjectPath}
-
-
-
Current store path:
-
{props.alert.currentStorePath}
-
- - {(message) => ( -
-
Error:
-
{message()}
-
- )} -
-
- +
- + -
-
- -
- + + +
- +
- -
- - - - -
-
- diff --git a/src/components/memories/MemoryReadStatusControl.tsx b/src/components/memories/MemoryReadStatusControl.tsx index 4182c900..33b9fa29 100644 --- a/src/components/memories/MemoryReadStatusControl.tsx +++ b/src/components/memories/MemoryReadStatusControl.tsx @@ -126,7 +126,9 @@ export function MemoryReadStatusControl(props: MemoryReadStatusControlProps) { {error() !== "" ? ( - {error()} + + {error()} + ) : null} ); diff --git a/src/components/memories/TaxonomyAddControl.tsx b/src/components/memories/TaxonomyAddControl.tsx index 594eacda..2820db72 100644 --- a/src/components/memories/TaxonomyAddControl.tsx +++ b/src/components/memories/TaxonomyAddControl.tsx @@ -19,6 +19,7 @@ import type { import { TaxonomyInlineCreateControl, normalizeTaxonomyAddName, + type TaxonomyInlineCreateCloseReason, } from "./TaxonomyInlineCreateControl"; export interface TaxonomyAddControlProps { @@ -43,7 +44,7 @@ const optionClass = "rounded-full border border-trauma-chip-border bg-trauma-chip-bg px-2.5 py-1 text-xs font-bold text-trauma-chip-ink hover:border-trauma-border-strong hover:text-trauma-text-primary aria-pressed:border-trauma-accent aria-pressed:bg-trauma-accent aria-pressed:text-trauma-accent-ink"; export function TaxonomyAddControl(props: TaxonomyAddControlProps) { - let rootRef: HTMLSpanElement | undefined; + let rootRef: HTMLDivElement | undefined; const [inlineInputOpen, setInlineInputOpen] = createSignal(false); const [pendingName, setPendingName] = createSignal(""); const orderedOptions = createMemo(() => @@ -52,8 +53,13 @@ export function TaxonomyAddControl(props: TaxonomyAddControlProps) { const label = createMemo(() => getTaxonomyAddLabel(props.kind)); const newLabel = createMemo(() => getTaxonomyNewLabel(props.kind)); const triggerClass = createMemo(() => props.triggerClass ?? addTaxonomyPillClass); - const cancelInlineInput = (): void => { + const cancelInlineInput = ( + reason?: TaxonomyInlineCreateCloseReason, + ): void => { setInlineInputOpen(false); + if (reason !== "outside-pointer") { + restoreTaxonomyAddTriggerFocus(rootRef); + } }; const attachExistingOption = async ( @@ -101,18 +107,15 @@ export function TaxonomyAddControl(props: TaxonomyAddControlProps) { const existingOption = findTaxonomyOptionByName(props.options, name); if (existingOption !== undefined) { if (isTaxonomyNameAttached(props.attachedItems, existingOption)) { - cancelInlineInput(); return; } await attachExistingOption(existingOption); - cancelInlineInput(); return; } setPendingName(name); try { await props.onAttachName(name); - cancelInlineInput(); } catch (error) { props.onError?.(readTaxonomyAddError(error)); } finally { @@ -129,7 +132,7 @@ export function TaxonomyAddControl(props: TaxonomyAddControlProps) { }; return ( - +
{ + onOpenChange={(open, reason) => { if (!open) { - cancelInlineInput(); + cancelInlineInput(reason); } }} onSubmitName={submitInlineInput} validateName={props.kind === "tag" ? readTagNameValidationError : undefined} /> - +
); } +function restoreTaxonomyAddTriggerFocus( + root: HTMLDivElement | undefined, +): void { + queueMicrotask(() => { + const trigger = root?.querySelector( + "[data-taxonomy-create-trigger]", + ); + if (trigger?.isConnected === true && !trigger.disabled) { + trigger.focus({ preventScroll: true }); + } + }); +} + export { normalizeTaxonomyAddName }; export function isTaxonomyNameAttached( diff --git a/src/components/memories/TaxonomyInlineCreateControl.tsx b/src/components/memories/TaxonomyInlineCreateControl.tsx index aabb2dff..a0e93abf 100644 --- a/src/components/memories/TaxonomyInlineCreateControl.tsx +++ b/src/components/memories/TaxonomyInlineCreateControl.tsx @@ -1,9 +1,16 @@ import { Show, createEffect, createSignal, type JSX } from "solid-js"; import { PlusIcon } from "../icons"; -import { useDismissableLayer } from "../ui/dismissable-layer"; +import { + useDismissableLayer, + type DismissableLayerDismissReason, +} from "../ui/dismissable-layer"; import { normalizeTaxonomyName } from "../../taxonomy/name-policy"; +export type TaxonomyInlineCreateCloseReason = + | DismissableLayerDismissReason + | "submit"; + export interface TaxonomyInlineCreateControlProps { class?: string; disabled?: boolean; @@ -11,7 +18,10 @@ export interface TaxonomyInlineCreateControlProps { open?: boolean; validateName?: (name: string) => string | null; onError?: (message: string) => void; - onOpenChange?: (open: boolean) => void; + onOpenChange?: ( + open: boolean, + reason?: TaxonomyInlineCreateCloseReason, + ) => void; onSubmitName: (name: string) => Promise | void; } @@ -23,20 +33,27 @@ const addTaxonomyInputClass = export function TaxonomyInlineCreateControl( props: TaxonomyInlineCreateControlProps, ) { - let rootRef: HTMLSpanElement | undefined; + let rootRef: HTMLDivElement | undefined; let inputRef: HTMLInputElement | undefined; + let triggerRef: HTMLButtonElement | undefined; const [internalOpen, setInternalOpen] = createSignal(false); const [draftName, setDraftName] = createSignal(""); const [pending, setPending] = createSignal(false); const isOpen = () => props.open ?? internalOpen(); const triggerClass = () => props.class ?? addTaxonomyPillClass; - const setOpen = (open: boolean): void => { + const setOpen = ( + open: boolean, + reason?: TaxonomyInlineCreateCloseReason, + ): void => { if (props.open === undefined) { setInternalOpen(open); } - props.onOpenChange?.(open); + props.onOpenChange?.(open, reason); if (!open) { setDraftName(""); + if (reason !== "outside-pointer") { + restoreTaxonomyTriggerFocus(triggerRef); + } } }; @@ -50,7 +67,7 @@ export function TaxonomyInlineCreateControl( useDismissableLayer({ getRoot: () => rootRef, isEnabled: isOpen, - onDismiss: () => setOpen(false), + onDismiss: (reason) => setOpen(false, reason), }); const submit = async (): Promise => { @@ -68,7 +85,7 @@ export function TaxonomyInlineCreateControl( setPending(true); try { await props.onSubmitName(name); - setOpen(false); + setOpen(false, "submit"); } catch (error) { props.onError?.( error instanceof Error ? error.message : "Failed to update taxonomy.", @@ -88,16 +105,17 @@ export function TaxonomyInlineCreateControl( ) => { if (event.key === "Escape") { event.preventDefault(); - setOpen(false); + setOpen(false, "escape"); } }; return ( - +
- +
); } +function restoreTaxonomyTriggerFocus( + trigger: HTMLButtonElement | undefined, +): void { + queueMicrotask(() => { + if (trigger?.isConnected === true && !trigger.disabled) { + trigger.focus({ preventScroll: true }); + } + }); +} + export const normalizeTaxonomyAddName = normalizeTaxonomyName; diff --git a/src/components/memories/browse-fixtures.ts b/src/components/memories/browse-fixtures.ts index 0f447503..f9f1fdf7 100644 --- a/src/components/memories/browse-fixtures.ts +++ b/src/components/memories/browse-fixtures.ts @@ -2,7 +2,7 @@ import type { BrowseMemory } from "./browse-data"; export const browseFixtureMemories: BrowseMemory[] = [ { - id: "memory-foundation", + id: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", title: "Reader Mode Notes", url: "https://example.com/reader-mode/source/with/a/very/long/path/that/should/remain/on/one/line?encoded=%E3%81%82%E3%81%AA%E3%81%9F%E3%81%AE%E4%BB%95%E4%BA%8B%E3%81%AF%E5%8B%95%E4%BD%9C%E3%81%99%E3%82%8B%E3%82%B3%E3%83%BC%E3%83%89", description: "SolidStart route data and shell architecture notes for the canonical reader.", @@ -17,7 +17,7 @@ export const browseFixtureMemories: BrowseMemory[] = [ flashbacks: [ { id: "h-foundation", - memoryId: "memory-foundation", + memoryId: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", variantKind: "source", langCode: null, translationOutputHash: null, diff --git a/src/components/moments/MomentActionMenu.tsx b/src/components/moments/MomentActionMenu.tsx index 04a7be2f..8dfbf9ef 100644 --- a/src/components/moments/MomentActionMenu.tsx +++ b/src/components/moments/MomentActionMenu.tsx @@ -68,7 +68,9 @@ export function MomentActionMenu(props: MomentActionMenuProps) { Delete moment -

{error()}

+
)} diff --git a/src/components/reader/MemoryReader.tsx b/src/components/reader/MemoryReader.tsx index b61a4c31..fdf551d8 100644 --- a/src/components/reader/MemoryReader.tsx +++ b/src/components/reader/MemoryReader.tsx @@ -175,21 +175,27 @@ const readerSourceLinkClass = export function MemoryReader(props: MemoryReaderProps) { const readyResult = () => props.result.status === "ready" ? props.result : undefined; + const readyResultIdentity = () => { + const result = readyResult(); + return result === undefined + ? undefined + : `${result.memory.id}\u0000${result.content.langCode ?? ""}`; + }; const stateMessage = () => props.result.status === "ready" ? "" : props.result.message; return ( } > - {(result) => ( + {(_identity) => ( { + queueMicrotask(() => { + if (contentRef?.isConnected === true) { + contentRef.focus({ preventScroll: true }); + } + }); + }; + const dismissSelectionMenu = () => { + closeSelectionMenu(); + focusReaderContent(); + }; + const dismissSectionMenu = () => { + closeSectionMenu(); + focusReaderContent(); + }; const resetTranslationFormToDefaults = () => { setTranslationFormLanguage(translationDefaultLanguage()); setTranslationFormModel(translationDefaultModel()); @@ -324,14 +345,6 @@ function ReadyMemoryReader(props: { setTags([...props.result.memory.tags]); setMoments([...props.result.memory.moments]); setCurrentFlashbacks([...props.result.memory.flashbacks]); - setPendingMomentKey(""); - setPendingSelectionKey(""); - setErrorMessage(""); - setTranslationProgress(undefined); - const activeTranslationEventSource = translationEventSource; - translationEventSource = undefined; - activeTranslationEventSource?.close(); - closeReaderMenus(); }); createEffect(() => { setRightRailContent( @@ -360,8 +373,13 @@ function ReadyMemoryReader(props: { onMount(() => { setIsReaderClientReady(true); const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { + if ( + event.key === "Escape" && + (selectionMenu() !== undefined || sectionMenu() !== undefined) + ) { + event.preventDefault(); closeReaderMenus(); + focusReaderContent(); } }; const closeOnPointerDown = (event: PointerEvent) => { @@ -1363,6 +1381,7 @@ function ReadyMemoryReader(props: { menuRef={(element) => { selectionMenuRef = element; }} + onDismiss={dismissSelectionMenu} position={menu().position} > + + + ); +} + +function LocalArchiveStatus() { + return ( +
+ + + + + + Local archive + + + ./data/storage + +
); } @@ -766,7 +770,9 @@ function RightRailTaxonomyList(props: { /> -

{error()}

+
); diff --git a/src/components/ui/Popup.tsx b/src/components/ui/Popup.tsx index 371dd3ff..3cba3f6d 100644 --- a/src/components/ui/Popup.tsx +++ b/src/components/ui/Popup.tsx @@ -6,7 +6,10 @@ import { type JSX, } from "solid-js"; -import { useDismissableLayer } from "./dismissable-layer"; +import { + useDismissableLayer, + type DismissableLayerDismissReason, +} from "./dismissable-layer"; export interface PopupControls { close: () => void; @@ -47,17 +50,30 @@ const placementClass = { } as const; export function Popup(props: PopupProps) { - let rootRef: HTMLSpanElement | undefined; + let rootRef: HTMLDivElement | undefined; + let activeTrigger: HTMLButtonElement | undefined; + let activeTriggerIndex = 0; let hasObservedOpenState = false; const [open, setOpen] = createSignal(props.initialOpen ?? false); const mode = () => props.mode ?? "dialog"; const placement = () => props.placement ?? "bottom-start"; - const close = () => setOpen(false); + const closePopup = (restoreFocus: boolean): void => { + setOpen(false); + if (restoreFocus) { + restorePopupTriggerFocus(activeTrigger, rootRef, activeTriggerIndex); + } + }; + const close = () => closePopup(true); const toggle = () => { if (props.disabled === true) { return; } - setOpen((value) => !value); + if (open()) { + closePopup(true); + return; + } + + setOpen(true); }; createEffect(() => { @@ -72,7 +88,8 @@ export function Popup(props: PopupProps) { useDismissableLayer({ getRoot: () => rootRef, isEnabled: open, - onDismiss: close, + onDismiss: (reason: DismissableLayerDismissReason) => + closePopup(reason === "escape"), }); const triggerProps = (): JSX.ButtonHTMLAttributes => ({ @@ -83,12 +100,17 @@ export function Popup(props: PopupProps) { onClick: (event) => { event.preventDefault(); event.stopPropagation(); + activeTrigger = event.currentTarget; + activeTriggerIndex = rootRef === undefined + ? 0 + : [...rootRef.querySelectorAll("button[aria-haspopup]")] + .indexOf(activeTrigger); toggle(); }, }); return ( - +
{props.trigger({ close, open: open(), @@ -97,16 +119,133 @@ export function Popup(props: PopupProps) { })}
focusPopupPanel(panel, mode())} aria-label={props.label} class={`${panelBaseClass} ${ props.phonePanel === true ? phonePanelClass : placementClass[placement()] } ${props.panelClass ?? ""}`} id={props.id} role={mode()} + tabIndex={-1} + onFocusOut={(event) => { + if ( + mode() === "menu" && + (!(event.relatedTarget instanceof Node) || + rootRef?.contains(event.relatedTarget) !== true) + ) { + closePopup(false); + } + }} + onKeyDown={(event) => { + if (mode() === "menu") { + handlePopupMenuKeyDown(event); + } + }} > {props.children({ close, open: open() })}
- +
+ ); +} + +const popupFocusableSelector = [ + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "a[href]", + '[tabindex]:not([tabindex="-1"])', +].join(","); + +function focusPopupPanel( + panel: HTMLDivElement, + mode: "dialog" | "menu", +): void { + queueMicrotask(() => { + if (!panel.isConnected) { + return; + } + + if (mode === "menu") { + const items = getPopupMenuItems(panel); + items.forEach((item, index) => { + item.tabIndex = index === 0 ? 0 : -1; + }); + (items[0] ?? panel).focus({ preventScroll: true }); + return; + } + + (panel.querySelector(popupFocusableSelector) ?? panel).focus({ + preventScroll: true, + }); + }); +} + +function restorePopupTriggerFocus( + trigger: HTMLButtonElement | undefined, + root: HTMLDivElement | undefined, + triggerIndex: number, +): void { + queueMicrotask(() => { + const target = trigger?.isConnected === true + ? trigger + : root?.querySelectorAll("button[aria-haspopup]")[ + Math.max(triggerIndex, 0) + ]; + if (target?.isConnected === true && !target.disabled) { + target.focus({ preventScroll: true }); + } + }); +} + +function handlePopupMenuKeyDown(event: KeyboardEvent): void { + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + + const panel = event.currentTarget; + if (!(panel instanceof HTMLDivElement)) { + return; + } + + const items = getPopupMenuItems(panel); + if (items.length === 0) { + return; + } + + event.preventDefault(); + const activeIndex = items.findIndex((item) => item === document.activeElement); + let nextIndex = 0; + if (event.key === "End") { + nextIndex = items.length - 1; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "ArrowDown") { + nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % items.length; + } else if (event.key === "ArrowUp") { + nextIndex = activeIndex <= 0 ? items.length - 1 : activeIndex - 1; + } + + focusPopupMenuItem(items, nextIndex); +} + +function getPopupMenuItems(panel: HTMLDivElement): HTMLElement[] { + return [...panel.querySelectorAll('[role="menuitem"]')].filter( + (item) => + item.getAttribute("aria-disabled") !== "true" && + (!(item instanceof HTMLButtonElement) || !item.disabled), ); } + +function focusPopupMenuItem(items: HTMLElement[], index: number): void { + items.forEach((item, itemIndex) => { + item.tabIndex = itemIndex === index ? 0 : -1; + }); + items[index]?.focus({ preventScroll: true }); +} diff --git a/src/components/ui/dismissable-layer.ts b/src/components/ui/dismissable-layer.ts index 111ac1af..a56f187f 100644 --- a/src/components/ui/dismissable-layer.ts +++ b/src/components/ui/dismissable-layer.ts @@ -2,10 +2,12 @@ import { createEffect, onCleanup } from "solid-js"; const activeLayerIds: symbol[] = []; +export type DismissableLayerDismissReason = "escape" | "outside-pointer"; + export interface DismissableLayerOptions { getRoot: () => TRoot | undefined; isEnabled?: () => boolean; - onDismiss: () => void; + onDismiss: (reason: DismissableLayerDismissReason) => void; shouldIgnoreOutsidePointerDown?: (target: EventTarget | null) => boolean; shouldSuppressOutsideClick?: (target: EventTarget | null) => boolean; } @@ -61,11 +63,12 @@ export function useDismissableLayer( ) { armOutsideClickSuppression(); } - options.onDismiss(); + options.onDismiss("outside-pointer"); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && isTopmostLayer()) { - options.onDismiss(); + event.preventDefault(); + options.onDismiss("escape"); } }; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..c4986033 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,23 @@ +import { createMiddleware } from "@solidjs/start/middleware"; + +import { + isTrustedRequestHost, + readTrustedHostnames, +} from "./server/http/trusted-host"; + +const trustedHosts = readTrustedHostnames(process.env.TRAUMA_ALLOWED_HOSTS); + +export default createMiddleware({ + onRequest(event) { + if (isTrustedRequestHost(event.request.headers.get("host"), trustedHosts)) { + return; + } + + return new Response("Untrusted request host.", { + headers: { + "content-type": "text/plain; charset=utf-8", + }, + status: 421, + }); + }, +}); diff --git a/src/routes/api/backup/failsafe.ts b/src/routes/api/backup/failsafe.ts deleted file mode 100644 index 3ee907ef..00000000 --- a/src/routes/api/backup/failsafe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server"; - -import { loadRuntimeTraumaConfig, TraumaConfigError } from "~/server/config"; -import { initializeDatabase } from "~/server/db"; -import { getBackupFailsafeStatus } from "~/server/backup/environment"; - -export async function GET(_event: APIEvent): Promise { - let config; - try { - config = loadRuntimeTraumaConfig(); - } catch (error) { - return json({ error: formatConfigError(error) }, { status: 500 }); - } - - const connection = initializeDatabase(config); - try { - const status = await getBackupFailsafeStatus({ - config, - db: connection.db, - }); - return json(status, { status: 200 }); - } finally { - connection.close(); - } -} - -function json(body: unknown, init: ResponseInit) { - return new Response(JSON.stringify(body), { - ...init, - headers: { - "content-type": "application/json", - ...init.headers, - }, - }); -} - -function formatConfigError(error: unknown) { - if (error instanceof TraumaConfigError) { - console.error(error.message); - } - return "failed to load Trauma configuration"; -} diff --git a/src/routes/api/settings/codex-auth.ts b/src/routes/api/settings/codex-auth.ts index 4539289b..a514ba3c 100644 --- a/src/routes/api/settings/codex-auth.ts +++ b/src/routes/api/settings/codex-auth.ts @@ -6,7 +6,7 @@ import { deleteCodexAuth, readCodexAuthStatus, } from "~/server/settings/codex-auth"; -import { CodexAppServerError } from "~/server/translation/codex-app-server"; +import { safeCodexAppServerErrorMessage } from "~/server/translation/codex-app-server"; export async function GET(_event: APIEvent): Promise { try { @@ -26,7 +26,12 @@ export async function DELETE(event: APIEvent): Promise { return jsonResponse(await deleteCodexAuth(), { status: 200 }); } catch (error) { return jsonResponse( - { error: error instanceof CodexAppServerError ? error.message : formatConfigError(error) }, + { + error: safeCodexAppServerErrorMessage( + error, + formatConfigError(error), + ), + }, { status: 500 }, ); } diff --git a/src/server/backup/content-integrity.ts b/src/server/backup/content-integrity.ts index 57fb7cb2..a3d27150 100644 --- a/src/server/backup/content-integrity.ts +++ b/src/server/backup/content-integrity.ts @@ -22,10 +22,16 @@ export interface InconsistentBackupContent { } const execFileAsync = promisify(execFile); +const MAX_GIT_INDEX_OUTPUT_BYTES = 64 * 1024 * 1024; + +export interface BackupContentIntegrityDependencies { + listTrackedPaths?: (projectPath: string) => Promise>; +} export async function findInconsistentSuccessfulBackupContent( config: ResolvedTraumaConfig, db: TraumaDatabase, + dependencies: BackupContentIntegrityDependencies = {}, ) { const rows = await db .select({ id: schema.memories.id, contentPath: schema.memories.contentPath }) @@ -33,6 +39,14 @@ export async function findInconsistentSuccessfulBackupContent( .where(eq(schema.memories.backupStatus, "success")) .all(); + if (rows.length === 0) { + return null; + } + + const trackedPaths = await ( + dependencies.listTrackedPaths ?? listGitTrackedPaths + )(config.projectPath); + for (const row of rows) { const resolved = resolveBackupContentPath(config, row.contentPath); if (resolved === "absolute_path" || resolved === "outside_backup_paths") { @@ -53,7 +67,7 @@ export async function findInconsistentSuccessfulBackupContent( } satisfies InconsistentBackupContent; } - if (!(await isGitTracked(config.projectPath, resolved.stagePath))) { + if (!trackedPaths.has(resolved.stagePath)) { return { memoryId: row.id, contentPath: row.contentPath, @@ -108,15 +122,16 @@ function resolveBackupContentPath( }; } -async function isGitTracked(projectPath: string, stagePath: string) { +async function listGitTrackedPaths(projectPath: string): Promise> { try { - await execFileAsync("git", ["ls-files", "--error-unmatch", "--", stagePath], { + const { stdout } = await execFileAsync("git", ["ls-files", "--no-sparse", "-z"], { cwd: projectPath, env: createGitCommandEnv(), + maxBuffer: MAX_GIT_INDEX_OUTPUT_BYTES, }); - return true; + return new Set(stdout.split("\0").filter((path) => path.length > 0)); } catch { - return false; + return new Set(); } } diff --git a/src/server/backup/environment.ts b/src/server/backup/environment.ts index 6ff81e51..c9ec437a 100644 --- a/src/server/backup/environment.ts +++ b/src/server/backup/environment.ts @@ -84,6 +84,7 @@ export async function ensureBackupEnvironment( await repositories.backupEnvironment.getBackupFailsafeAlert(); const hasMemoryData = await detectMemoryData(input.config, input.db, stamp); const currentGitRemoteUrl = await readGitRemoteUrl(input.config); + const currentGitBranch = await readGitBranch(input.config.projectPath); const pathsMatch = stamp !== undefined && stamp.projectPath === input.config.projectPath && @@ -93,7 +94,8 @@ export async function ensureBackupEnvironment( stamp.gitRemote === input.config.backup.git.remote && (stamp.gitRemoteUrl === currentGitRemoteUrl || stamp.gitRemoteUrl === LEGACY_REDACTED_REMOTE_IDENTITY) && - stamp.gitBranch === input.config.backup.git.branch; + stamp.gitBranch === input.config.backup.git.branch && + currentGitBranch === input.config.backup.git.branch; if (stamp === undefined && hasMemoryData) { return createAndReportPathAlert({ @@ -474,6 +476,20 @@ async function readGitRepositoryRoot(projectPath: string) { } } +async function readGitBranch(projectPath: string) { + if (!existsSync(projectPath)) { + return null; + } + + try { + const result = await runGit(projectPath, ["symbolic-ref", "--short", "HEAD"]); + const branch = result.stdout.trim(); + return branch === "" ? null : branch; + } catch { + return null; + } +} + async function isSamePath(left: string | null, right: string) { if (left === null) { return false; @@ -666,16 +682,16 @@ function formatPushFailureWarning(alert: BackupFailsafeAlertDetails) { ].join("\n"); } -function fingerprintGitRemote(remoteUrl: string) { +export function fingerprintGitRemote(remoteUrl: string) { return `sha256:${createHash("sha256").update(remoteUrl).digest("hex")}`; } export function redactOperationalError(error: string) { return error - .slice(0, 4_096) .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/giu, "$1[redacted]@") .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[redacted]") - .replace(/\b(token|password|secret|authorization)=([^\s&]+)/giu, "$1=[redacted]"); + .replace(/\b(token|password|secret|authorization)=([^\s&]+)/giu, "$1=[redacted]") + .slice(0, 4_096); } function shellQuote(value: string) { diff --git a/src/server/backup/failsafe.ts b/src/server/backup/failsafe.ts index 6d155450..da0d01b1 100644 --- a/src/server/backup/failsafe.ts +++ b/src/server/backup/failsafe.ts @@ -5,7 +5,6 @@ import { realpath, readFile, readdir, - writeFile, } from "node:fs/promises"; import { existsSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; @@ -17,11 +16,13 @@ import type { ResolvedTraumaConfig } from "../config"; import { loadTraumaConfig } from "../config"; import { createRepositories, type TraumaDatabase } from "../db/repositories"; import * as schema from "../db/schema"; +import { writeFileAtomically } from "../files/atomic-write"; import { findInconsistentSuccessfulBackupContent } from "./content-integrity"; import type { BackupFailsafeAlertDetails } from "./environment"; import { clearBackupPushFailureAlert, ensureBackupEnvironment, + fingerprintGitRemote, hasConfiguredRemote, recordBackupPushFailureAlert, toAlertDetails, @@ -327,7 +328,10 @@ async function rewriteConfigPaths(input: { } parsed.projectPath = input.projectPath; parsed.storePath = input.storePath; - await writeFile(input.configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); + await writeFileAtomically( + input.configPath, + `${JSON.stringify(parsed, null, 2)}\n`, + ); } async function listFiles(directory: string): Promise { @@ -580,7 +584,7 @@ async function readGitRemoteUrl(config: ResolvedTraumaConfig) { env: createGitCommandEnv(), }); const value = result.stdout.trim(); - return value === "" ? null : value; + return value === "" ? null : fingerprintGitRemote(value); } catch { return null; } diff --git a/src/server/backup/index.ts b/src/server/backup/index.ts index 9868bdc3..c00127a1 100644 --- a/src/server/backup/index.ts +++ b/src/server/backup/index.ts @@ -7,6 +7,7 @@ import { promisify } from "node:util"; import type { ResolvedTraumaConfig } from "../config"; import { initializeDatabase, + type FlashbackRepository, type TranslationRepository, type TraumaDatabaseConnection, } from "../db"; @@ -15,15 +16,22 @@ import { assertBackupRepositoryRoot, clearBackupPushFailureAlert, hasConfiguredRemote, + redactOperationalError, recordBackupPushFailureAlert, } from "./environment"; import { BACKUP_STATUSES, type BackupStatus } from "./status"; import { getSourceFlashbackMetadataExportPath, getTranslatedFlashbackMetadataExportPath, + writeFlashbackMetadataExport, } from "../flashbacks/export"; +import { + sourceFlashbackVariant, + type FlashbackVariant, +} from "../flashbacks/variant"; import { activePsychiatristTurns } from "../psychiatrist/active-turns"; import { recoverCompletedPsychiatristArtifactsForMemory } from "../psychiatrist/thread-store"; +import { recoverInterruptedMemoryOperations } from "../memories/operation-journal"; import { isSupportedLanguageCode } from "../translation/languages"; import { resolveTranslatedMemoryProjectionPath } from "../translation/paths"; @@ -93,6 +101,7 @@ export interface CreateGitMemoryBackupQueueInput { const execFileAsync = promisify(execFile); const gitQueueByConfigKey = new Map(); +const startupOperationRecoveryByConfigKey = new Map>(); export function createNoopMemoryBackupQueue(): DurableMemoryBackupQueue { return { @@ -109,6 +118,7 @@ export function getMemoryBackupQueue( config: ResolvedTraumaConfig, ): DurableMemoryBackupQueue { if (!config.backup.git.enabled) { + startDisabledBackupOperationRecovery(config); return createNoopMemoryBackupQueue(); } @@ -121,12 +131,40 @@ export function getMemoryBackupQueue( const queue = createGitMemoryBackupQueue({ config }); gitQueueByConfigKey.set(key, queue); void queue.retryEligibleBackups().catch(() => { - // Startup retry failures are recorded per memory when jobs run. If the - // retry scan itself fails, avoid making request handling depend on it. + // Do not expose filesystem or git diagnostics here; the backup failsafe + // retains operator-facing details when startup cannot prepare the scan. + console.error("failed to scan eligible memory backups during startup"); }); return queue; } +function startDisabledBackupOperationRecovery( + config: ResolvedTraumaConfig, +): void { + const key = createQueueConfigKey(config); + if (startupOperationRecoveryByConfigKey.has(key)) { + return; + } + const recovery = (async () => { + const connection = initializeDatabase(config); + try { + await recoverInterruptedMemoryOperations({ + config, + memories: connection.repositories.memories, + }); + } finally { + connection.close(); + } + })(); + startupOperationRecoveryByConfigKey.set(key, recovery); + void recovery.catch(() => { + startupOperationRecoveryByConfigKey.delete(key); + console.error( + "failed to recover interrupted memory operations during startup", + ); + }); +} + export function createGitMemoryBackupQueue( input: CreateGitMemoryBackupQueueInput, ): GitMemoryBackupQueue { @@ -223,7 +261,7 @@ export function createGitMemoryBackupQueue( await updateBackupStatus({ memoryId: job.memoryId, backupStatus: "failed", - lastBackupError: formatUnknownError(error), + lastBackupError: redactOperationalError(formatUnknownError(error)), }); } catch { // Preserve the original backup failure. A missing row or closed DB must @@ -390,6 +428,24 @@ export function createGitMemoryBackupQueue( const connection = openConnection(input.config); try { + await recoverInterruptedMemoryOperations({ + completeMissingDeletionBackup: async (deletion) => { + await assertBackupEnvironmentReady({ + config: input.config, + db: connection.db, + }); + await runJob({ + config: input.config, + job: { + ...deletion, + reason: "memory_deletion", + }, + }); + }, + config: input.config, + memories: connection.repositories.memories, + now, + }); await assertBackupEnvironmentReady({ config: input.config, db: connection.db, @@ -406,16 +462,32 @@ export function createGitMemoryBackupQueue( ) { continue; } - await enqueue({ - memoryId: backup.id, - contentPaths: await getRetryContentPaths( - input.config, - backup, - connection.repositories.translations, - ), - reason: "memory_creation", - }); - enqueued += 1; + try { + await enqueue({ + memoryId: backup.id, + contentPaths: await getRetryContentPaths( + input.config, + backup, + connection.repositories.flashbacks, + connection.repositories.translations, + ), + reason: "memory_creation", + }); + enqueued += 1; + } catch { + try { + await connection.repositories.memories.updateBackupStatus({ + id: backup.id, + backupStatus: "failed", + lastBackupAt: null, + lastBackupError: "backup retry scheduling failed", + updatedAt: now(), + }); + } catch { + // A poisoned memory must not prevent later eligible memories + // from being considered even when its status cannot be saved. + } + } } } finally { schedulingSuspensions -= 1; @@ -434,6 +506,7 @@ export function createGitMemoryBackupQueue( async function getRetryContentPaths( config: Pick, backup: { id: string; contentPath: string }, + flashbacks: FlashbackRepository, translations: TranslationRepository, ): Promise { await recoverCompletedPsychiatristArtifactsForMemory({ @@ -445,8 +518,15 @@ async function getRetryContentPaths( backup.contentPath, getSourceFlashbackMetadataExportPath(backup.id), ]; + await recoverFlashbackExportIfNeeded({ + config, + flashbacks, + memoryId: backup.id, + variant: sourceFlashbackVariant, + }); const completeTranslations = await translations.listCompleteTranslationRecordsForMemory(backup.id); + const recoveredTranslationLanguages = new Set(); for (const translation of completeTranslations) { if (translation.outputPath !== null) { paths.push(translation.outputPath); @@ -454,6 +534,22 @@ async function getRetryContentPaths( if (!isSupportedLanguageCode(translation.langCode)) { continue; } + if ( + translation.outputHash !== null && + !recoveredTranslationLanguages.has(translation.langCode) + ) { + recoveredTranslationLanguages.add(translation.langCode); + await recoverFlashbackExportIfNeeded({ + config, + flashbacks, + memoryId: backup.id, + variant: { + kind: "translation", + langCode: translation.langCode, + outputHash: translation.outputHash, + }, + }); + } paths.push( resolveTranslatedMemoryProjectionPath({ config, @@ -472,6 +568,48 @@ async function getRetryContentPaths( ))]; } +async function recoverFlashbackExportIfNeeded(input: { + config: Pick; + flashbacks: FlashbackRepository; + memoryId: string; + variant: FlashbackVariant; +}): Promise { + const rows = await input.flashbacks.listForMemoryVariant({ + memoryId: input.memoryId, + variant: input.variant, + }); + const relativePath = input.variant.kind === "source" + ? getSourceFlashbackMetadataExportPath(input.memoryId) + : getTranslatedFlashbackMetadataExportPath({ + langCode: input.variant.langCode, + memoryId: input.memoryId, + }); + if ( + rows.length === 0 && + !(await pathExists(resolve(input.config.storePath, relativePath))) + ) { + return; + } + await writeFlashbackMetadataExport({ + config: input.config, + flashbacks: rows, + memoryId: input.memoryId, + variant: input.variant, + }); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return false; + } + throw error; + } +} + function getPsychiatristRetryContentPaths( config: Pick, memoryId: string, diff --git a/src/server/browse/concurrency.ts b/src/server/browse/concurrency.ts new file mode 100644 index 00000000..fd87b629 --- /dev/null +++ b/src/server/browse/concurrency.ts @@ -0,0 +1,31 @@ +export async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + work: (item: T, index: number) => Promise, +): Promise { + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new RangeError("concurrency must be a positive safe integer"); + } + + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(concurrency, items.length); + + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + const item = items[index] as T; + results[index] = await work(item, index); + } + }); + const settledWorkers = await Promise.allSettled(workers); + const failedWorker = settledWorkers.find( + (worker): worker is PromiseRejectedResult => worker.status === "rejected", + ); + if (failedWorker !== undefined) { + throw failedWorker.reason; + } + + return results; +} diff --git a/src/server/browser-import/config.ts b/src/server/browser-import/config.ts index 54e64b85..cd893791 100644 --- a/src/server/browser-import/config.ts +++ b/src/server/browser-import/config.ts @@ -14,9 +14,17 @@ const MAX_BROWSER_IMPORT_MAX_BYTES = 20_000_000; export function loadBrowserImportConfig( env: BrowserImportConfigEnv = process.env, ): BrowserImportConfig { + const enabled = env.TRAUMA_BROWSER_IMPORT_ENABLED === "true"; + const token = normalizeOptionalText(env.TRAUMA_BROWSER_IMPORT_TOKEN); + if (enabled && !isStrongBrowserImportToken(token)) { + throw new Error( + "TRAUMA_BROWSER_IMPORT_TOKEN must contain at least 32 URL-safe characters when browser import is enabled", + ); + } + return { - enabled: env.TRAUMA_BROWSER_IMPORT_ENABLED === "true", - token: normalizeOptionalText(env.TRAUMA_BROWSER_IMPORT_TOKEN), + enabled, + token, allowedOrigins: parseAllowedOrigins( env.TRAUMA_BROWSER_IMPORT_ALLOWED_ORIGINS, ), @@ -24,6 +32,11 @@ export function loadBrowserImportConfig( }; } +function isStrongBrowserImportToken(token: string | null): token is string { + return token !== null && token.length >= 32 && token.length <= 512 && + /^[A-Za-z0-9_-]+$/u.test(token); +} + export function isBrowserImportOriginAllowed( origin: string | null, config: BrowserImportConfig, diff --git a/src/server/codex/runtime-isolation.ts b/src/server/codex/runtime-isolation.ts new file mode 100644 index 00000000..1a531fc0 --- /dev/null +++ b/src/server/codex/runtime-isolation.ts @@ -0,0 +1,25 @@ +export const CODEX_RUNTIME_ISOLATION_ENV = "TRAUMA_CODEX_RUNTIME_ISOLATION"; +export const LEGACY_CODEX_RUNTIME_ISOLATION_ENV = + "TRAUMA_PSYCHIATRIST_RUNTIME_ISOLATION"; +export const CODEX_RUNTIME_ISOLATION_ASSERTION = + "external_no_host_reads_public_http_https_only"; + +export const CODEX_RUNTIME_ISOLATION_ERROR = { + code: "runtime_isolation_required", + message: "Codex features require an externally isolated runtime.", +} as const; + +// This gate records operator intent; it cannot create or verify isolation. +export function isCodexRuntimeIsolationReady(input: { + environment?: Readonly>; + hasInjectedClient: boolean; +}): boolean { + if (input.hasInjectedClient) { + return true; + } + const environment = input.environment ?? process.env; + return [ + environment[CODEX_RUNTIME_ISOLATION_ENV], + environment[LEGACY_CODEX_RUNTIME_ISOLATION_ENV], + ].includes(CODEX_RUNTIME_ISOLATION_ASSERTION); +} diff --git a/src/server/db/bundled-migrations.ts b/src/server/db/bundled-migrations.ts index c0ccd62f..d6e2ff1f 100644 --- a/src/server/db/bundled-migrations.ts +++ b/src/server/db/bundled-migrations.ts @@ -18,6 +18,7 @@ import migration0014Sql from "../../../drizzle/0014_strict_flashback_variant_sco import migration0015Sql from "../../../drizzle/0015_memory_browse_pagination.sql?raw"; import migration0016Sql from "../../../drizzle/0016_scrub_backup_secrets.sql?raw"; import migration0017Sql from "../../../drizzle/0017_skinny_hobgoblin.sql?raw"; +import migration0018Sql from "../../../drizzle/0018_scrub_backup_diagnostics.sql?raw"; import type { RuntimeMigration } from "./migrations"; const BUNDLED_MIGRATIONS = [ @@ -111,6 +112,11 @@ const BUNDLED_MIGRATIONS = [ folderMillis: 1784223792512, bps: true, }, + { + sql: migration0018Sql, + folderMillis: 1784232000000, + bps: true, + }, ] as const; export function readBundledMigrations(): RuntimeMigration[] { diff --git a/src/server/db/connection.ts b/src/server/db/connection.ts index cce68958..8d8d0d35 100644 --- a/src/server/db/connection.ts +++ b/src/server/db/connection.ts @@ -10,6 +10,7 @@ import { createRepositories, type TraumaRepositories } from "./repositories"; import * as schema from "./schema"; const require = createRequire(import.meta.url); +export const SQLITE_BUSY_TIMEOUT_MS = 5_000; type BunDatabaseConstructor = typeof import("bun:sqlite").Database; @@ -35,6 +36,7 @@ export function initializeDatabase( const sqlite = new Database(config.databasePath, { create: true }); try { sqlite.run("PRAGMA foreign_keys = ON;"); + sqlite.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};`); sqlite.run("PRAGMA journal_mode = WAL;"); const db = createDrizzleDatabase(sqlite); diff --git a/src/server/db/repositories.ts b/src/server/db/repositories.ts index 0762eb65..e9cb479f 100644 --- a/src/server/db/repositories.ts +++ b/src/server/db/repositories.ts @@ -158,6 +158,11 @@ export interface FlashbackBrowseCursor { id: string; } +export interface MomentBrowseCursor { + createdAt: Date; + id: string; +} + export interface MomentBrowseRow { id: string; memoryId: string; @@ -262,7 +267,6 @@ export interface FlashbackRepository { variant: FlashbackVariant; flashbacks: Flashback[]; }) => Promise; - listForBrowse: () => Promise; listRecentForBrowse: (input: { cursor?: FlashbackBrowseCursor | null; limit: number; @@ -277,7 +281,10 @@ export interface MomentRepository { ) => Promise<{ moment: Moment; alreadyExists: boolean }>; deleteById: (momentId: string) => Promise; listForMemory: (memoryId: string) => Promise; - listForBrowse: () => Promise; + listPageForBrowse: (input: { + cursor?: MomentBrowseCursor | null; + limit: number; + }) => Promise; } export type TranslationJobRecord = Omit & { @@ -669,7 +676,7 @@ export function createRepositories(db: TraumaDatabase): TraumaRepositories { where: eq(schema.moments.memoryId, memoryId), orderBy: [desc(schema.moments.createdAt)], }), - listForBrowse: async () => { + listPageForBrowse: async (input) => { const rows = await db .select({ id: schema.moments.id, @@ -690,7 +697,9 @@ export function createRepositories(db: TraumaDatabase): TraumaRepositories { schema.memories, eq(schema.moments.memoryId, schema.memories.id), ) - .orderBy(desc(schema.moments.createdAt)); + .where(buildMomentBrowseCursorWhere(input.cursor ?? null)) + .orderBy(desc(schema.moments.createdAt), desc(schema.moments.id)) + .limit(normalizeBrowseLimit(input.limit)); return rows.map((row) => ({ ...row, @@ -714,12 +723,6 @@ export function createRepositories(db: TraumaDatabase): TraumaRepositories { listFlashbacksForMemoryVariant(db, input), replaceForMemoryVariant: async (input) => replaceFlashbacksForMemoryVariant(db, input), - listForBrowse: async () => { - const rows = await selectFlashbackBrowseRows(db) - .orderBy(desc(schema.flashbacks.createdAt)); - - return rows.map(formatFlashbackBrowseRow); - }, listRecentForBrowse: async (input) => { const rows = await selectFlashbackBrowseRows(db) .where(buildFlashbackBrowseCursorWhere(input.cursor ?? null)) @@ -1835,6 +1838,24 @@ function buildFlashbackBrowseCursorWhere( ); } +function buildMomentBrowseCursorWhere( + cursor: MomentBrowseCursor | null, +): SQL | undefined { + if (cursor === null) { + return undefined; + } + + return ( + or( + lt(schema.moments.createdAt, cursor.createdAt), + and( + eq(schema.moments.createdAt, cursor.createdAt), + lt(schema.moments.id, cursor.id), + ), + ) ?? sql`0 = 1` + ); +} + function memoryHasCategoryId(categoryId: string): SQL { return sql`exists ( select 1 from ${schema.memoryCategories} diff --git a/src/server/files/atomic-write.ts b/src/server/files/atomic-write.ts new file mode 100644 index 00000000..22ae0f96 --- /dev/null +++ b/src/server/files/atomic-write.ts @@ -0,0 +1,96 @@ +import { randomUUID } from "node:crypto"; +import { + open, + rename, + rm, + stat, + type FileHandle, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +type AtomicWriteFileHandle = Pick; +type AtomicWriteDirectoryHandle = Pick; + +export interface AtomicWriteFileSystem { + open: ( + path: string, + flags: "wx", + mode: number, + ) => Promise; + openDirectory: (path: string) => Promise; + rename: (source: string, destination: string) => Promise; + rm: (path: string, options: { force: boolean }) => Promise; + stat: (path: string) => Promise<{ mode: number }>; +} + +const defaultFileSystem: AtomicWriteFileSystem = { + open: (path, flags, mode) => open(path, flags, mode), + openDirectory: (path) => open(path, "r"), + rename, + rm, + stat, +}; + +export async function writeFileAtomically( + targetPath: string, + content: string, + options: { fileSystem?: AtomicWriteFileSystem } = {}, +): Promise { + const fileSystem = options.fileSystem ?? defaultFileSystem; + const directoryPath = dirname(targetPath); + const targetStats = await fileSystem.stat(targetPath); + const temporaryPath = join( + directoryPath, + `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`, + ); + let replaced = false; + let operationError: unknown; + + try { + const temporaryFile = await fileSystem.open( + temporaryPath, + "wx", + targetStats.mode & 0o777, + ); + try { + await temporaryFile.writeFile(content, "utf8"); + await temporaryFile.sync(); + } finally { + await temporaryFile.close(); + } + + await fileSystem.rename(temporaryPath, targetPath); + replaced = true; + await syncDirectoryBestEffort(directoryPath, fileSystem); + } catch (error) { + operationError = error; + throw error; + } finally { + if (!replaced) { + try { + await fileSystem.rm(temporaryPath, { force: true }); + } catch (cleanupError) { + if (operationError === undefined) { + throw cleanupError; + } + } + } + } +} + +async function syncDirectoryBestEffort( + directoryPath: string, + fileSystem: AtomicWriteFileSystem, +): Promise { + try { + const directory = await fileSystem.openDirectory(directoryPath); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } catch { + // Some filesystems do not support directory fsync. The temporary file + // fsync and same-directory atomic rename remain required. + } +} diff --git a/src/server/flashbacks/browse.ts b/src/server/flashbacks/browse.ts index 1c53527e..22d44d24 100644 --- a/src/server/flashbacks/browse.ts +++ b/src/server/flashbacks/browse.ts @@ -19,9 +19,11 @@ import { import { resolveCurrentTranslationReadOnly } from "../translation/current-translation"; import { resolveTranslatedMemoryContentPath } from "../translation/paths"; import { normalizeBrowseLimit } from "../browse/limits"; +import { mapWithConcurrency } from "../browse/concurrency"; const RECENT_FLASHBACK_SCAN_CHUNK_LIMIT = 100; const MAX_RECENT_FLASHBACK_FETCH_ROUNDS = 20; +const FLASHBACK_VARIANT_READ_CONCURRENCY = 8; export async function loadFlashbackBrowseRows(): Promise { "use server"; @@ -34,7 +36,9 @@ export async function loadFlashbackBrowseRows(): Promise { try { const config = loadRuntimeTraumaConfig(); connection = initializeDatabase(config); - const rows = await connection.repositories.flashbacks.listForBrowse(); + const rows = await listAllFlashbackBrowseRows( + connection.repositories.flashbacks, + ); return await filterRenderableFlashbackRows({ config, rows, @@ -236,21 +240,50 @@ export async function filterRenderableFlashbackRows(input: { } const renderableIds = new Set(); - await Promise.all( - [...rowsByVariant].map(async ([, rows]) => { - for (const id of await resolveRenderableFlashbackIds({ + const resolvedIds = await mapWithConcurrency( + [...rowsByVariant.values()], + FLASHBACK_VARIANT_READ_CONCURRENCY, + async (rows) => + resolveRenderableFlashbackIds({ config: input.config, rows, translationRepository: input.translationRepository, - })) { - renderableIds.add(id); - } - }), + }), ); + for (const ids of resolvedIds) { + for (const id of ids) { + renderableIds.add(id); + } + } return input.rows.filter((row) => renderableIds.has(row.id)); } +async function listAllFlashbackBrowseRows( + repository: { + listRecentForBrowse: (input: { + cursor: { createdAt: Date; id: string } | null; + limit: number; + }) => Promise; + }, +): Promise { + const rows: FlashbackBrowseRow[] = []; + let cursor: { createdAt: Date; id: string } | null = null; + + while (true) { + const page = await repository.listRecentForBrowse({ + cursor, + limit: RECENT_FLASHBACK_SCAN_CHUNK_LIMIT, + }); + rows.push(...page); + const lastRow = page[page.length - 1]; + if (page.length < RECENT_FLASHBACK_SCAN_CHUNK_LIMIT || lastRow === undefined) { + return rows; + } + cursor = { createdAt: new Date(lastRow.createdAt), id: lastRow.id }; + } +} + async function resolveRenderableFlashbackIds(input: { config: ResolvedTraumaConfig; rows: FlashbackBrowseRow[]; diff --git a/src/server/flashbacks/toggle.ts b/src/server/flashbacks/toggle.ts index 3978a7b2..366f938b 100644 --- a/src/server/flashbacks/toggle.ts +++ b/src/server/flashbacks/toggle.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import type { MemoryBackupQueue } from "../backup"; +import type { DurableMemoryBackupQueue } from "../backup"; import { assertBackupEnvironmentReady } from "../backup/environment"; import type { ResolvedTraumaConfig } from "../config"; import type { TraumaDatabase } from "../db"; @@ -52,7 +52,7 @@ export interface ToggleMemoryFlashbackInput { }; config: ResolvedTraumaConfig; db: TraumaDatabase; - backupQueue: MemoryBackupQueue; + backupQueue: DurableMemoryBackupQueue; generateId?: () => string; now?: () => Date; } @@ -177,6 +177,15 @@ async function toggleMemoryFlashbackUnlocked( ranges: nextRanges, variant, }); + const intendedExportPath = getFlashbackMetadataExportPath({ + memoryId: input.memoryId, + variant, + }); + await input.backupQueue.persistIntent({ + memoryId: input.memoryId, + contentPaths: [intendedExportPath], + reason: "flashback_update", + }); await repositories.flashbacks.replaceForMemoryVariant({ memoryId: input.memoryId, variant, diff --git a/src/server/http/trusted-host.ts b/src/server/http/trusted-host.ts new file mode 100644 index 00000000..dfcee834 --- /dev/null +++ b/src/server/http/trusted-host.ts @@ -0,0 +1,78 @@ +const DEFAULT_TRUSTED_HOSTNAMES = ["127.0.0.1", "::1", "localhost"] as const; + +export function readTrustedHostnames( + configuredHosts: string | undefined, +): ReadonlySet { + const trustedHosts = new Set(DEFAULT_TRUSTED_HOSTNAMES); + if (configuredHosts === undefined || configuredHosts.trim() === "") { + return trustedHosts; + } + + for (const entry of configuredHosts.split(",")) { + const hostname = normalizeConfiguredHostname(entry); + if (hostname === undefined) { + throw new Error( + `Invalid TRAUMA_ALLOWED_HOSTS entry: ${JSON.stringify(entry.trim())}`, + ); + } + trustedHosts.add(hostname); + } + + return trustedHosts; +} + +export function isTrustedRequestHost( + hostHeader: string | null, + trustedHosts: ReadonlySet, +): boolean { + const hostname = parseHostAuthority(hostHeader, true); + return hostname !== undefined && trustedHosts.has(hostname); +} + +function normalizeConfiguredHostname(entry: string): string | undefined { + const value = entry.trim(); + if (value === "" || value.includes("*")) { + return undefined; + } + + if (value === "::1") { + return value; + } + + return parseHostAuthority(value, false); +} + +function parseHostAuthority( + authority: string | null, + allowPort: boolean, +): string | undefined { + if ( + authority === null || + authority === "" || + authority !== authority.trim() || + /[,/\\@?#\s]/u.test(authority) + ) { + return undefined; + } + + try { + const parsed = new URL(`http://${authority}`); + if ( + parsed.username !== "" || + parsed.password !== "" || + parsed.pathname !== "/" || + parsed.search !== "" || + parsed.hash !== "" || + !allowPort && parsed.port !== "" + ) { + return undefined; + } + + return parsed.hostname + .replace(/^\[|\]$/gu, "") + .replace(/\.$/u, "") + .toLowerCase(); + } catch { + return undefined; + } +} diff --git a/src/server/memories/add-memory.ts b/src/server/memories/add-memory.ts index 0fea4217..20721ea3 100644 --- a/src/server/memories/add-memory.ts +++ b/src/server/memories/add-memory.ts @@ -1,14 +1,26 @@ import type { ResolvedTraumaConfig } from "../config"; -import type { MemoryBackupQueue } from "../backup"; -import { assertBackupEnvironmentReady } from "../backup/environment"; +import { + runSerializedGitBackupJob, + type MemoryBackupQueue, +} from "../backup"; +import { + assertBackupEnvironmentReady, + redactOperationalError, +} from "../backup/environment"; import { importUrl, type ImporterResult } from "../importer"; import type { TraumaDatabase } from "../db"; import { deleteMemoryContent, + resolveMemoryContentPath, writeMemoryContent, } from "../store/memory-content"; import { generateMemoryId } from "./id"; import { createRepositories } from "../db/repositories"; +import { + clearMemoryOperationJournal, + persistMemoryCreationJournal, + recoverInterruptedMemoryOperations, +} from "./operation-journal"; export interface MemoryImporter { importUrl: (input: { url: string }) => Promise; @@ -25,6 +37,21 @@ export interface AddMemoryInput { } export async function addMemory(input: AddMemoryInput) { + const repositories = createRepositories(input.db); + await recoverInterruptedMemoryOperations({ + completeMissingDeletionBackup: async (deletion) => { + await assertBackupEnvironmentReady({ + config: input.config, + db: input.db, + }); + await runSerializedGitBackupJob({ + config: input.config, + job: { ...deletion, reason: "memory_deletion" }, + }); + }, + config: input.config, + memories: repositories.memories, + }); await assertBackupEnvironmentReady({ config: input.config, db: input.db, @@ -38,48 +65,82 @@ export async function addMemory(input: AddMemoryInput) { imported.status === "success" ? imported.markdown : formatFallbackMarkdownLink(imported.url); - const written = await writeMemoryContent({ - config: { storePath: input.config.storePath }, - memoryId: id, - overwrite: false, - frontmatter: { - id, - url: imported.url, - title: imported.title, - capturedAt: capturedAt.toISOString(), - extractionStatus: imported.status, - }, - markdown, - }); - const repositories = createRepositories(input.db); const initialBackupStatus = input.config.backup.git.enabled ? "pending" : "disabled"; - - let memory; - try { - memory = await repositories.memories.create({ + const contentPath = resolveMemoryContentPath( + { storePath: input.config.storePath }, + id, + ); + const creationJournal = { + version: 1, + kind: "memory_creation", + memory: { id, url: imported.url, title: imported.title, description: imported.status === "success" ? imported.description : null, faviconUrl: imported.status === "success" ? imported.faviconUrl : null, - contentPath: written.relativePath, + contentPath: contentPath.relativePath, extractionStatus: imported.status, extractionError: imported.status === "link_only" ? imported.extractionError : null, read: false, backupStatus: initialBackupStatus, - lastBackupAt: null, - lastBackupError: null, + createdAt: capturedAt.toISOString(), + updatedAt: capturedAt.toISOString(), + }, + } as const; + await persistMemoryCreationJournal({ + config: input.config, + journal: creationJournal, + }); + + let memory; + let written: Awaited>; + let contentWritten = false; + try { + written = await writeMemoryContent({ + config: { storePath: input.config.storePath }, + memoryId: id, + overwrite: false, + frontmatter: { + id, + url: imported.url, + title: imported.title, + capturedAt: capturedAt.toISOString(), + extractionStatus: imported.status, + }, + markdown, + }); + contentWritten = true; + memory = await repositories.memories.create({ + ...creationJournal.memory, createdAt: capturedAt, updatedAt: capturedAt, + lastBackupAt: null, + lastBackupError: null, }); } catch (error) { - await deleteMemoryContent({ - config: { storePath: input.config.storePath }, - memoryId: id, - }); + try { + if (contentWritten) { + await deleteMemoryContent({ + config: { storePath: input.config.storePath }, + memoryId: id, + }); + } + await clearMemoryOperationJournal({ + config: input.config, + memoryId: id, + }); + } catch { + // Keep the journal when cleanup fails so startup recovery can reconcile + // the content file and SQLite row instead of leaving an orphan. + } throw error; } + await clearMemoryOperationJournal({ + config: input.config, + memoryId: id, + }).catch(() => undefined); if (input.config.backup.git.enabled) { let queued; @@ -95,7 +156,7 @@ export async function addMemory(input: AddMemoryInput) { id, backupStatus: "failed", lastBackupAt: null, - lastBackupError: formatUnknownError(error), + lastBackupError: redactOperationalError(formatUnknownError(error)), updatedAt: capturedAt, }); return { ...memory, ...backupUpdate }; diff --git a/src/server/memories/delete-memory.ts b/src/server/memories/delete-memory.ts index 5658a9a4..cc2785ff 100644 --- a/src/server/memories/delete-memory.ts +++ b/src/server/memories/delete-memory.ts @@ -6,7 +6,9 @@ import { type MemoryBackupJob, type MemoryBackupQueue, } from "../backup"; -import { assertBackupEnvironmentReady } from "../backup/environment"; +import { + assertBackupEnvironmentReady, +} from "../backup/environment"; import type { ResolvedTraumaConfig } from "../config"; import { createRepositories, @@ -14,6 +16,13 @@ import { type TraumaRepositories, } from "../db/repositories"; import { getFlashbackMetadataExportPath } from "../flashbacks/export"; +import { resolveMemoryContentPath } from "../store/memory-content"; +import { + clearMemoryOperationJournal, + persistMemoryDeletionJournal, + recoverInterruptedMemoryOperations, + resolveMemoryDeletionStagingPath, +} from "./operation-journal"; export type DeleteMemoryResult = | { status: "deleted"; warnings?: DeleteMemoryWarning[] } @@ -52,7 +61,22 @@ export async function deleteMemory(input: { memoryId: string; repositories?: DeleteMemoryRepositories; }): Promise { - const repositories = input.repositories ?? createRepositories(input.db); + const databaseRepositories = createRepositories(input.db); + await recoverInterruptedMemoryOperations({ + completeMissingDeletionBackup: async (deletion) => { + await assertBackupEnvironmentReady({ + config: input.config, + db: input.db, + }); + await runSerializedGitBackupJob({ + config: input.config, + job: { ...deletion, reason: "memory_deletion" }, + }); + }, + config: input.config, + memories: databaseRepositories.memories, + }); + const repositories = input.repositories ?? databaseRepositories; const fileSystem = { access, mkdir, @@ -112,6 +136,21 @@ export async function deleteMemory(input: { } } + try { + await persistMemoryDeletionJournal({ + config: input.config, + journal: { + version: 1, + kind: "memory_deletion", + memoryId: input.memoryId, + contentPath: target.contentPath, + stagingPath: paths.stagingRelativePath, + }, + }); + } catch (error) { + return { status: "failed", error: formatUnknownError(error) }; + } + let staged = false; try { await fileSystem.mkdir(dirname(paths.stagingDir), { recursive: true }); @@ -142,6 +181,12 @@ export async function deleteMemory(input: { deletionBackupJob, }) : undefined; + if (restoreError === undefined && backupRestoreError === undefined) { + await clearMemoryOperationJournal({ + config: input.config, + memoryId: input.memoryId, + }).catch(() => undefined); + } return { status: "failed", error: formatFailureMessage([ @@ -166,6 +211,12 @@ export async function deleteMemory(input: { deletionBackupJob, }) : undefined; + if (restoreError === undefined && backupRestoreError === undefined) { + await clearMemoryOperationJournal({ + config: input.config, + memoryId: input.memoryId, + }).catch(() => undefined); + } if (restoreError !== undefined || backupRestoreError !== undefined) { return { status: "failed", @@ -185,6 +236,12 @@ export async function deleteMemory(input: { deletionBackupJob, }) : undefined; + if (restoreError === undefined && backupRestoreError === undefined) { + await clearMemoryOperationJournal({ + config: input.config, + memoryId: input.memoryId, + }).catch(() => undefined); + } return { status: "failed", error: formatFailureMessage([ @@ -196,16 +253,31 @@ export async function deleteMemory(input: { } const warnings: DeleteMemoryWarning[] = []; + let stagedContentCleaned = true; if (staged) { try { await fileSystem.rm(paths.stagingDir, { recursive: true, force: true }); } catch (error) { + stagedContentCleaned = false; warnings.push({ kind: "content_cleanup_failed", error: `Failed to remove staged memory content at ${paths.stagingDir}: ${formatUnknownError(error)}`, }); } } + if (stagedContentCleaned) { + try { + await clearMemoryOperationJournal({ + config: input.config, + memoryId: input.memoryId, + }); + } catch (error) { + warnings.push({ + kind: "content_cleanup_failed", + error: `Failed to remove the completed memory deletion journal: ${formatUnknownError(error)}`, + }); + } + } if (!input.config.backup.git.enabled && input.backupQueue !== undefined) { try { @@ -301,10 +373,16 @@ function resolveDeletionPaths(input: { storePath: string; memoryId: string; contentPath: string; -}): { contentDir: string; stagingDir: string } { +}): { contentDir: string; stagingDir: string; stagingRelativePath: string } { const storeRoot = resolve(input.storePath); - const contentFile = resolve(storeRoot, input.contentPath); - assertPathInsideStore(storeRoot, contentFile); + const expectedContent = resolveMemoryContentPath( + { storePath: input.storePath }, + input.memoryId, + ); + if (input.contentPath !== expectedContent.relativePath) { + throw new Error("memory content path is not owned by the requested memory"); + } + const contentFile = expectedContent.absolutePath; const contentDir = dirname(contentFile); assertPathInsideStore(storeRoot, contentDir); @@ -312,14 +390,18 @@ function resolveDeletionPaths(input: { throw new Error("memory content path must resolve to a memory directory"); } - const stagingDir = resolve( - storeRoot, - ".delete-staging", - `${input.memoryId}-${Date.now()}`, - ); + const staging = resolveMemoryDeletionStagingPath({ + memoryId: input.memoryId, + storePath: input.storePath, + }); + const stagingDir = staging.absolutePath; assertPathInsideStore(storeRoot, stagingDir); - return { contentDir, stagingDir }; + return { + contentDir, + stagingDir, + stagingRelativePath: staging.relativePath, + }; } function resolveBackupDeletionPaths(input: { diff --git a/src/server/memories/operation-journal.ts b/src/server/memories/operation-journal.ts new file mode 100644 index 00000000..1a387337 --- /dev/null +++ b/src/server/memories/operation-journal.ts @@ -0,0 +1,439 @@ +import { randomUUID } from "node:crypto"; +import { + access, + mkdir, + open, + readFile, + readdir, + rename, + rm, +} from "node:fs/promises"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +import type { ResolvedTraumaConfig } from "../config"; +import type { MemoryRepository } from "../db"; +import { + isExtractionStatus, + type ExtractionStatus, +} from "../memory-status"; +import { resolveMemoryContentPath } from "../store/memory-content"; + +const OPERATION_JOURNAL_VERSION = 1; +const OPERATIONS_DIRECTORY = ".operations"; +const DELETE_STAGING_DIRECTORY = ".delete-staging"; + +export interface MemoryCreationJournal { + version: 1; + kind: "memory_creation"; + memory: { + id: string; + url: string; + title: string; + description: string | null; + faviconUrl: string | null; + contentPath: string; + extractionStatus: ExtractionStatus; + extractionError: string | null; + read: false; + backupStatus: "pending" | "disabled"; + createdAt: string; + updatedAt: string; + }; +} + +export interface MemoryDeletionJournal { + version: 1; + kind: "memory_deletion"; + memoryId: string; + contentPath: string; + stagingPath: string; +} + +type MemoryOperationJournal = MemoryCreationJournal | MemoryDeletionJournal; + +type RecoveryMemoryRepository = Pick< + MemoryRepository, + "create" | "deleteMemoryRecord" | "findById" | "updateBackupStatus" +>; + +export interface InterruptedMemoryDeletionBackup { + contentPaths: readonly string[]; + memoryId: string; +} + +interface MemoryOperationRecoveryInput { + completeMissingDeletionBackup?: ( + input: InterruptedMemoryDeletionBackup, + ) => Promise; + config: Pick; + memories: RecoveryMemoryRepository; + now?: () => Date; +} + +const recoveryByStorePath = new Map>(); + +export function resolveMemoryDeletionStagingPath(input: { + memoryId: string; + storePath: string; + uniqueSuffix?: string; +}): { absolutePath: string; relativePath: string } { + resolveMemoryContentPath({ storePath: input.storePath }, input.memoryId); + const relativePath = `${DELETE_STAGING_DIRECTORY}/${input.memoryId}-${ + input.uniqueSuffix ?? `${Date.now()}-${randomUUID()}` + }`; + return { + absolutePath: resolve(input.storePath, relativePath), + relativePath, + }; +} + +export async function persistMemoryCreationJournal(input: { + config: Pick; + journal: MemoryCreationJournal; +}): Promise { + validateCreationJournal(input.config, input.journal); + await writeJournal(input.config.storePath, input.journal.memory.id, input.journal); +} + +export async function persistMemoryDeletionJournal(input: { + config: Pick; + journal: MemoryDeletionJournal; +}): Promise { + validateDeletionJournal(input.config, input.journal); + await writeJournal(input.config.storePath, input.journal.memoryId, input.journal); +} + +export async function clearMemoryOperationJournal(input: { + config: Pick; + memoryId: string; +}): Promise { + const path = resolveJournalPath(input.config.storePath, input.memoryId); + await rm(path, { force: true }); +} + +export async function recoverInterruptedMemoryOperations( + input: MemoryOperationRecoveryInput, +): Promise { + const storePath = resolve(input.config.storePath); + const existing = recoveryByStorePath.get(storePath); + if (existing !== undefined) { + return existing; + } + + const recovery = recoverInterruptedMemoryOperationsUnlocked(input).finally(() => { + if (recoveryByStorePath.get(storePath) === recovery) { + recoveryByStorePath.delete(storePath); + } + }); + recoveryByStorePath.set(storePath, recovery); + return recovery; +} + +async function recoverInterruptedMemoryOperationsUnlocked( + input: MemoryOperationRecoveryInput, +): Promise { + const directory = resolve(input.config.storePath, OPERATIONS_DIRECTORY); + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return 0; + } + throw error; + } + + let recovered = 0; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) { + continue; + } + const path = join(directory, entry.name); + const journal = parseJournal( + input.config, + JSON.parse(await readFile(path, "utf8")) as unknown, + ); + const memoryId = journal.kind === "memory_creation" + ? journal.memory.id + : journal.memoryId; + if (entry.name !== `${memoryId}.json`) { + throw new Error("memory operation journal filename does not match its memory id"); + } + + if (journal.kind === "memory_creation") { + await recoverCreation(input, journal); + } else { + await recoverDeletion(input, journal); + } + await rm(path, { force: true }); + recovered += 1; + } + return recovered; +} + +async function recoverCreation( + input: MemoryOperationRecoveryInput, + journal: MemoryCreationJournal, +): Promise { + if (await input.memories.findById(journal.memory.id) !== undefined) { + return; + } + const content = resolveMemoryContentPath( + { storePath: input.config.storePath }, + journal.memory.id, + ); + if (!(await pathExists(content.absolutePath))) { + return; + } + await input.memories.create({ + ...journal.memory, + backupStatus: input.config.backup.git.enabled ? "pending" : "disabled", + createdAt: new Date(journal.memory.createdAt), + updatedAt: new Date(journal.memory.updatedAt), + lastBackupAt: null, + lastBackupError: null, + }); +} + +async function recoverDeletion( + input: MemoryOperationRecoveryInput, + journal: MemoryDeletionJournal, +): Promise { + const row = await input.memories.findById(journal.memoryId); + const content = resolveMemoryContentPath( + { storePath: input.config.storePath }, + journal.memoryId, + ); + const stagingPath = resolve(input.config.storePath, journal.stagingPath); + + if (row === undefined) { + await rm(stagingPath, { recursive: true, force: true }); + return; + } + if (row.contentPath !== content.relativePath) { + throw new Error("memory deletion recovery refused a non-owning content path"); + } + + const canonicalDirectoryExists = await pathExists(dirname(content.absolutePath)); + const stagingDirectoryExists = await pathExists(stagingPath); + if (!canonicalDirectoryExists && !stagingDirectoryExists) { + if (input.config.backup.git.enabled) { + if (input.completeMissingDeletionBackup === undefined) { + throw new Error( + "memory deletion recovery requires a backup-aware completion callback", + ); + } + await input.memories.updateBackupStatus({ + id: journal.memoryId, + backupStatus: "pending", + lastBackupAt: null, + lastBackupError: null, + updatedAt: (input.now ?? (() => new Date()))(), + }); + await input.completeMissingDeletionBackup({ + contentPaths: [ + content.relativePath, + `memories/${journal.memoryId}/FLASHBACKS.json`, + `memories/${journal.memoryId}`, + ], + memoryId: journal.memoryId, + }); + } + await input.memories.deleteMemoryRecord(journal.memoryId); + return; + } + + if (stagingDirectoryExists) { + if (canonicalDirectoryExists) { + throw new Error( + "memory deletion recovery found both canonical and staged content", + ); + } + await mkdir(dirname(dirname(content.absolutePath)), { recursive: true }); + await rename(stagingPath, dirname(content.absolutePath)); + } + + if (input.config.backup.git.enabled) { + await input.memories.updateBackupStatus({ + id: journal.memoryId, + backupStatus: "pending", + lastBackupAt: null, + lastBackupError: null, + updatedAt: (input.now ?? (() => new Date()))(), + }); + } +} + +async function writeJournal( + storePath: string, + memoryId: string, + journal: MemoryOperationJournal, +): Promise { + const finalPath = resolveJournalPath(storePath, memoryId); + const directory = dirname(finalPath); + const temporaryPath = join(directory, `.${memoryId}.${randomUUID()}.tmp`); + await mkdir(directory, { recursive: true }); + let file; + try { + file = await open(temporaryPath, "wx"); + await file.writeFile(`${JSON.stringify(journal, null, 2)}\n`, "utf8"); + await file.sync(); + await file.close(); + file = undefined; + await rename(temporaryPath, finalPath); + await syncDirectory(directory); + } finally { + await file?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }); + } +} + +async function syncDirectory(path: string): Promise { + let directory; + try { + directory = await open(path, "r"); + await directory.sync(); + } catch (error) { + if ( + !isNodeError(error) || + !["EINVAL", "ENOTSUP", "EBADF"].includes(error.code) + ) { + throw error; + } + } finally { + await directory?.close().catch(() => undefined); + } +} + +function resolveJournalPath(storePath: string, memoryId: string): string { + resolveMemoryContentPath({ storePath }, memoryId); + return resolve(storePath, OPERATIONS_DIRECTORY, `${memoryId}.json`); +} + +function parseJournal( + config: Pick, + value: unknown, +): MemoryOperationJournal { + if (!isRecord(value) || value.version !== OPERATION_JOURNAL_VERSION) { + throw new Error("unsupported memory operation journal"); + } + if (value.kind === "memory_creation") { + validateCreationJournal(config, value); + return value; + } + if (value.kind === "memory_deletion") { + validateDeletionJournal(config, value); + return value; + } + throw new Error("unsupported memory operation journal kind"); +} + +function validateCreationJournal( + config: Pick, + value: unknown, +): asserts value is MemoryCreationJournal { + if (!isRecord(value) || value.version !== 1 || value.kind !== "memory_creation") { + throw new Error("invalid memory creation journal"); + } + const memory = value.memory; + if ( + !isRecord(memory) || + typeof memory.id !== "string" || + typeof memory.url !== "string" || + typeof memory.title !== "string" || + !isNullableString(memory.description) || + !isNullableString(memory.faviconUrl) || + typeof memory.contentPath !== "string" || + typeof memory.extractionStatus !== "string" || + !isExtractionStatus(memory.extractionStatus) || + !isNullableString(memory.extractionError) || + memory.read !== false || + !["pending", "disabled"].includes(String(memory.backupStatus)) || + !isIsoDate(memory.createdAt) || + !isIsoDate(memory.updatedAt) + ) { + throw new Error("invalid memory creation journal payload"); + } + const expected = resolveMemoryContentPath( + { storePath: config.storePath }, + memory.id, + ); + if (memory.contentPath !== expected.relativePath) { + throw new Error( + "memory creation journal content path is not owned by its memory", + ); + } +} + +function validateDeletionJournal( + config: Pick, + value: unknown, +): asserts value is MemoryDeletionJournal { + if ( + !isRecord(value) || + value.version !== 1 || + value.kind !== "memory_deletion" || + typeof value.memoryId !== "string" || + typeof value.contentPath !== "string" || + typeof value.stagingPath !== "string" + ) { + throw new Error("invalid memory deletion journal"); + } + const expected = resolveMemoryContentPath( + { storePath: config.storePath }, + value.memoryId, + ); + if (value.contentPath !== expected.relativePath) { + throw new Error("memory deletion journal content path is not owned by its memory"); + } + const stagingRoot = resolve(config.storePath, DELETE_STAGING_DIRECTORY); + const stagingPath = resolve(config.storePath, value.stagingPath); + if (!isInside(stagingRoot, stagingPath)) { + throw new Error( + "memory deletion journal staging path escapes its staging directory", + ); + } + const stagingName = relative(stagingRoot, stagingPath).split(sep).join("/"); + if (!stagingName.startsWith(`${value.memoryId}-`) || stagingName.includes("/")) { + throw new Error( + "memory deletion journal staging path is not owned by its memory", + ); + } +} + +function isInside(root: string, candidate: string): boolean { + const relativePath = relative(resolve(root), resolve(candidate)); + return relativePath !== "" && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return false; + } + throw error; + } +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isIsoDate(value: unknown): value is string { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNodeError( + error: unknown, +): error is NodeJS.ErrnoException & { code: string } { + return isRecord(error) && typeof error.code === "string"; +} diff --git a/src/server/moments/browse.ts b/src/server/moments/browse.ts index 22e07693..43cc66ca 100644 --- a/src/server/moments/browse.ts +++ b/src/server/moments/browse.ts @@ -4,9 +4,17 @@ import { type ResolvedTraumaConfig, } from "../config"; import { initializeDatabase } from "../db"; -import type { MomentBrowseRow as StoredMomentBrowseRow } from "../db/repositories"; +import type { + MomentBrowseCursor, + MomentBrowseRow as StoredMomentBrowseRow, + MomentRepository, +} from "../db/repositories"; import { MemoryContentStoreError, readMemoryContent } from "../store"; import { renderMemoryMarkdown } from "../reader/markdown-renderer"; +import { mapWithConcurrency } from "../browse/concurrency"; + +const MOMENT_BROWSE_SCAN_CHUNK_LIMIT = 100; +const MOMENT_TOC_READ_CONCURRENCY = 8; export type MomentTargetStatus = "current" | "resolved_from_path" | "stale"; @@ -45,73 +53,124 @@ export async function loadMomentBrowseRowsForConfig( let connection: ReturnType | undefined; try { connection = initializeDatabase(config); - const rows = await connection.repositories.moments.listForBrowse(); - const tocCache = new Map< - string, - Promise["toc"] | undefined> - >(); - return Promise.all( - rows.map(async (row) => ({ - ...row, - ...await resolveMomentTarget({ - row, - loadToc: () => getMomentMemoryToc({ config, memoryId: row.memoryId, tocCache }), - }), - })), - ); + const rows = await listAllMomentBrowseRows(connection.repositories.moments); + return resolveMomentTargets({ config, rows }); } finally { connection?.close(); } } -async function resolveMomentTarget(input: { - row: StoredMomentBrowseRow; - loadToc: () => Promise["toc"] | undefined>; -}): Promise> { - const toc = await input.loadToc(); - if (toc === undefined) { - return { targetAnchor: null, targetStatus: "stale" }; +async function listAllMomentBrowseRows( + repository: Pick, +): Promise { + const rows: StoredMomentBrowseRow[] = []; + let cursor: MomentBrowseCursor | null = null; + + while (true) { + const page = await repository.listPageForBrowse({ + cursor, + limit: MOMENT_BROWSE_SCAN_CHUNK_LIMIT, + }); + rows.push(...page); + const lastRow = page[page.length - 1]; + if (page.length < MOMENT_BROWSE_SCAN_CHUNK_LIMIT || lastRow === undefined) { + return rows; + } + cursor = { createdAt: new Date(lastRow.createdAt), id: lastRow.id }; } +} + +async function resolveMomentTargets(input: { + config: Parameters[0]["config"]; + rows: StoredMomentBrowseRow[]; +}): Promise { + const rowsByMemoryId = new Map< + string, + { index: number; row: StoredMomentBrowseRow }[] + >(); + input.rows.forEach((row, index) => { + const memoryRows = rowsByMemoryId.get(row.memoryId); + const indexedRow = { index, row }; + if (memoryRows === undefined) { + rowsByMemoryId.set(row.memoryId, [indexedRow]); + } else { + memoryRows.push(indexedRow); + } + }); + + const targets = new Array>( + input.rows.length, + ); + await mapWithConcurrency( + [...rowsByMemoryId.entries()], + MOMENT_TOC_READ_CONCURRENCY, + async ([memoryId, memoryRows]) => { + const toc = await readMomentMemoryToc(input.config, memoryId); + const tocIndex = toc === undefined ? undefined : createMomentTocIndex(toc); + for (const { index, row } of memoryRows) { + targets[index] = resolveMomentTarget(row, tocIndex); + } + }, + ); + + return input.rows.map((row, index) => ({ + ...row, + ...(targets[index] ?? staleMomentTarget()), + })); +} + +type MomentToc = ReturnType["toc"]; + +interface MomentTocIndex { + anchors: Map>; + paths: Map; +} - if ( - toc.some((entry) => - entry.id === input.row.sectionAnchor && - entry.path === input.row.sectionPath - ) - ) { - return { - targetAnchor: input.row.sectionAnchor, - targetStatus: "current", - }; +function createMomentTocIndex(toc: MomentToc): MomentTocIndex { + const anchors = new Map>(); + const paths = new Map(); + for (const entry of toc) { + const anchorPaths = anchors.get(entry.id); + if (anchorPaths === undefined) { + anchors.set(entry.id, new Set([entry.path])); + } else { + anchorPaths.add(entry.path); + } + + const path = paths.get(entry.path); + paths.set(entry.path, { + anchor: path?.anchor ?? entry.id, + count: (path?.count ?? 0) + 1, + }); } + return { anchors, paths }; +} - const pathMatches = toc.filter((entry) => entry.path === input.row.sectionPath); - if (pathMatches.length === 1) { - return { - targetAnchor: pathMatches[0]?.id ?? null, - targetStatus: "resolved_from_path", - }; +function resolveMomentTarget( + row: StoredMomentBrowseRow, + toc: MomentTocIndex | undefined, +): Pick { + if (toc === undefined) { + return staleMomentTarget(); } - return { targetAnchor: null, targetStatus: "stale" }; -} + if (toc.anchors.get(row.sectionAnchor)?.has(row.sectionPath) === true) { + return { targetAnchor: row.sectionAnchor, targetStatus: "current" }; + } -async function getMomentMemoryToc(input: { - config: Parameters[0]["config"]; - memoryId: string; - tocCache: Map< - string, - Promise["toc"] | undefined> - >; -}) { - const cached = input.tocCache.get(input.memoryId); - if (cached !== undefined) { - return cached; + const path = toc.paths.get(row.sectionPath); + if (path?.count === 1) { + return { targetAnchor: path.anchor, targetStatus: "resolved_from_path" }; } - const toc = readMomentMemoryToc(input.config, input.memoryId); - input.tocCache.set(input.memoryId, toc); - return toc; + return staleMomentTarget(); +} + +function staleMomentTarget(): Pick< + MomentBrowseRow, + "targetAnchor" | "targetStatus" +> { + return { targetAnchor: null, targetStatus: "stale" }; } async function readMomentMemoryToc( diff --git a/src/server/psychiatrist/context.ts b/src/server/psychiatrist/context.ts index 89e93f76..a8322d71 100644 --- a/src/server/psychiatrist/context.ts +++ b/src/server/psychiatrist/context.ts @@ -167,18 +167,14 @@ function splitContextSections(markdown: string): PsychiatristContextSection[] { }]; } - let searchOffset = 0; - const fencedCodeRanges = findFencedCodeRanges(markdown); - const starts = rendered.toc.map((entry) => { - const startOffset = findHeadingOffset( - markdown, - entry.level, - searchOffset, - fencedCodeRanges, - ); - searchOffset = startOffset + 1; - return { entry, startOffset }; - }); + const headingOffsets = findHeadingOffsets( + markdown, + rendered.toc.map((entry) => entry.level), + ); + const starts = rendered.toc.map((entry, index) => ({ + entry, + startOffset: headingOffsets[index] ?? 0, + })); const sections: PsychiatristContextSection[] = []; const firstStart = starts[0]?.startOffset ?? 0; if (firstStart > 0 && markdown.slice(0, firstStart).trim() !== "") { @@ -208,19 +204,14 @@ function splitContextSections(markdown: string): PsychiatristContextSection[] { return sections; } -type MarkdownOffsetRange = { - endOffset: number; - startOffset: number; -}; - -function findHeadingOffset( +function findHeadingOffsets( markdown: string, - level: number, - minOffset: number, - excludedRanges: readonly MarkdownOffsetRange[], -): number { + levels: readonly number[], +): number[] { + const offsets: number[] = []; + let activeFence: { length: number; marker: string } | undefined; let lineStart = 0; - while (lineStart < markdown.length) { + while (lineStart < markdown.length && offsets.length < levels.length) { const newlineIndex = markdown.indexOf("\n", lineStart); const lineEnd = newlineIndex === -1 ? markdown.length : newlineIndex; const nextLineStart = newlineIndex === -1 ? markdown.length : newlineIndex + 1; @@ -232,16 +223,31 @@ function findHeadingOffset( ? "" : markdown.slice(nextLineStart, nextLineEnd === -1 ? markdown.length : nextLineEnd); - if (lineStart >= minOffset && !isOffsetInRanges(lineStart, excludedRanges)) { - const headingLevel = readMarkdownHeadingLevel(line, nextLine); - if (headingLevel === level) { - return lineStart; + if (activeFence !== undefined) { + if (isFenceEnd(line, activeFence)) { + activeFence = undefined; + } + } else { + const fence = readFenceStart(line); + if (fence !== undefined) { + activeFence = fence; + } else if (readMarkdownHeadingLevel(line, nextLine) === levels[offsets.length]) { + offsets.push(lineStart); } } lineStart = nextLineStart; } - return minOffset; + + // The rendered TOC and scanner consume the same Markdown. Retain the prior + // monotonic fallback if a future renderer recognizes a heading form that + // this offset scanner does not yet understand. + let fallbackOffset = 0; + return levels.map((_, index) => { + const offset = offsets[index] ?? fallbackOffset; + fallbackOffset = offset + 1; + return offset; + }); } function readMarkdownHeadingLevel(line: string, nextLine: string): number | undefined { @@ -259,43 +265,6 @@ function readMarkdownHeadingLevel(line: string, nextLine: string): number | unde return setext[1].startsWith("=") ? 1 : 2; } -function findFencedCodeRanges(markdown: string): MarkdownOffsetRange[] { - const ranges: MarkdownOffsetRange[] = []; - let activeFence: { length: number; marker: string; startOffset: number } | undefined; - let lineStart = 0; - - while (lineStart < markdown.length) { - const newlineIndex = markdown.indexOf("\n", lineStart); - const lineEnd = newlineIndex === -1 ? markdown.length : newlineIndex; - const nextLineStart = newlineIndex === -1 ? markdown.length : newlineIndex + 1; - const line = markdown.slice(lineStart, lineEnd); - - if (activeFence === undefined) { - const fence = readFenceStart(line); - if (fence !== undefined) { - activeFence = { ...fence, startOffset: lineStart }; - } - } else if (isFenceEnd(line, activeFence)) { - ranges.push({ - endOffset: nextLineStart, - startOffset: activeFence.startOffset, - }); - activeFence = undefined; - } - - lineStart = nextLineStart; - } - - if (activeFence !== undefined) { - ranges.push({ - endOffset: markdown.length, - startOffset: activeFence.startOffset, - }); - } - - return ranges; -} - function readFenceStart(line: string): { length: number; marker: string } | undefined { const match = /^(?: {0,3})(`{3,}|~{3,})/.exec(line); if (match?.[1] === undefined) { @@ -312,10 +281,3 @@ function isFenceEnd( return match?.[1]?.startsWith(fence.marker) === true && match[1].length >= fence.length; } - -function isOffsetInRanges( - offset: number, - ranges: readonly MarkdownOffsetRange[], -): boolean { - return ranges.some((range) => offset >= range.startOffset && offset < range.endOffset); -} diff --git a/src/server/psychiatrist/detached-task.ts b/src/server/psychiatrist/detached-task.ts new file mode 100644 index 00000000..e66bf361 --- /dev/null +++ b/src/server/psychiatrist/detached-task.ts @@ -0,0 +1,12 @@ +/** + * Starts a turn after its HTTP 202 response without allowing a secondary + * persistence failure to surface as a process-level unhandled rejection. + */ +export function runDetachedPsychiatristTask( + task: () => Promise, +): void { + void task().catch(() => { + // The turn runner owns user-visible safe failure persistence. Once that + // persistence itself fails there is no safe response channel left. + }); +} diff --git a/src/server/psychiatrist/message-route.ts b/src/server/psychiatrist/message-route.ts index fda9bbdb..19eed89b 100644 --- a/src/server/psychiatrist/message-route.ts +++ b/src/server/psychiatrist/message-route.ts @@ -41,6 +41,7 @@ import { import { sanitizePsychiatristSourceCitations } from "./source-citations"; import { appendPsychiatristStreamEvent } from "./stream-store"; import { activePsychiatristTurns } from "./active-turns"; +import { runDetachedPsychiatristTask } from "./detached-task"; import { appendAssistantResponse as appendAssistantResponseToStore, appendPendingPair, @@ -274,7 +275,7 @@ export async function handleSendPsychiatristMessageRequest( turnId, variantKind: context.variantKind, }); - void runPsychiatristTurn({ + runDetachedPsychiatristTask(() => runPsychiatristTurn({ appendAssistantResponse: input.appendAssistantResponse ?? appendAssistantResponseToStore, backupQueue: input.backupQueue ?? resolveBackupQueue(config), client, @@ -287,7 +288,7 @@ export async function handleSendPsychiatristMessageRequest( turnId, webSourcePolicy, ownsClient, - }); + })); return jsonResponse(toStartedResponse({ manifest: thread.manifest, pairId, diff --git a/src/server/psychiatrist/regenerate-route.ts b/src/server/psychiatrist/regenerate-route.ts index 782c7ea1..49eeff79 100644 --- a/src/server/psychiatrist/regenerate-route.ts +++ b/src/server/psychiatrist/regenerate-route.ts @@ -18,6 +18,7 @@ import { type CodexConversationClient, } from "../translation/codex-app-server"; import { activePsychiatristTurns } from "./active-turns"; +import { runDetachedPsychiatristTask } from "./detached-task"; import { buildPsychiatristPrompt, PSYCHIATRIST_PROMPT_POLICY_VERSION, @@ -235,7 +236,7 @@ export async function handleRegeneratePsychiatristResponseRequest( turnId, variantKind: loaded.contextSnapshot.variantKind, }); - void runRegenerateTurn({ + runDetachedPsychiatristTask(() => runRegenerateTurn({ appendRegeneratedAssistantResponse: input.appendRegeneratedAssistantResponse ?? appendRegeneratedAssistantResponse, appendRetriedAssistantResponse: input.appendRetriedAssistantResponse ?? @@ -249,7 +250,7 @@ export async function handleRegeneratePsychiatristResponseRequest( turnMode, turnId, webSourcePolicy, - }); + })); } catch (error) { activePsychiatristTurns.releaseThread(loaded.manifest.threadId); return safeErrorResponse( diff --git a/src/server/psychiatrist/runtime-isolation.ts b/src/server/psychiatrist/runtime-isolation.ts index 72c088d3..f328119e 100644 --- a/src/server/psychiatrist/runtime-isolation.ts +++ b/src/server/psychiatrist/runtime-isolation.ts @@ -1,23 +1,23 @@ +import { + CODEX_RUNTIME_ISOLATION_ASSERTION, + LEGACY_CODEX_RUNTIME_ISOLATION_ENV, + isCodexRuntimeIsolationReady, +} from "../codex/runtime-isolation"; + export const PSYCHIATRIST_RUNTIME_ISOLATION_ENV = - "TRAUMA_PSYCHIATRIST_RUNTIME_ISOLATION"; + LEGACY_CODEX_RUNTIME_ISOLATION_ENV; export const PSYCHIATRIST_RUNTIME_ISOLATION_ASSERTION = - "external_no_host_reads_public_http_https_only"; + CODEX_RUNTIME_ISOLATION_ASSERTION; export const PSYCHIATRIST_RUNTIME_ISOLATION_ERROR = { code: "runtime_isolation_required", message: "Psychiatrist requires an externally isolated Codex runtime.", } as const; -// This gate records operator intent; it cannot create or verify external isolation. export function isPsychiatristRuntimeIsolationReady(input: { environment?: Readonly>; hasInjectedClient: boolean; }): boolean { - if (input.hasInjectedClient) { - return true; - } - const environment = input.environment ?? process.env; - return environment[PSYCHIATRIST_RUNTIME_ISOLATION_ENV] === - PSYCHIATRIST_RUNTIME_ISOLATION_ASSERTION; + return isCodexRuntimeIsolationReady(input); } diff --git a/src/server/psychiatrist/thread-store.ts b/src/server/psychiatrist/thread-store.ts index 87913d5c..af7a9210 100644 --- a/src/server/psychiatrist/thread-store.ts +++ b/src/server/psychiatrist/thread-store.ts @@ -721,11 +721,33 @@ async function repairCompletedPairArtifacts(input: { let changed = false; let latestCompletedAt = input.manifest.updatedAt; for (const row of latestRowsByPairId.values()) { + if ( + (input.targetTurnId !== undefined && row.turn_id !== input.targetTurnId) || + input.activeTurnIds.has(row.turn_id) + ) { + continue; + } + const responsePath = join( + pairDirectory(input.config, input.manifest, row.pair_id), + "RESPONSE.md", + ); + if ( + row.revision_kind === "completed" && + row.status === "completed" && + row.assistant_response !== undefined + ) { + const existingResponse = await readOptionalFile(responsePath); + if (existingResponse !== row.assistant_response) { + await writeFileAtomic(responsePath, row.assistant_response); + changed = true; + } + } else if (await removeFileIfPresent(responsePath)) { + changed = true; + } if ( row.revision_kind !== "completed" || row.status !== "completed" || row.assistant_response === undefined || - (input.targetTurnId !== undefined && row.turn_id !== input.targetTurnId) || input.activeTurnIds.has(row.turn_id) ) { continue; @@ -806,6 +828,29 @@ async function repairCompletedPairArtifacts(input: { return changed; } +async function readOptionalFile(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return undefined; + } + throw error; + } +} + +async function removeFileIfPresent(path: string): Promise { + try { + await rm(path); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return false; + } + throw error; + } +} + async function repairCanceledTurnReplays(input: { activeTurnIds: ReadonlySet; config: Pick; diff --git a/src/server/reader/markdown-renderer.ts b/src/server/reader/markdown-renderer.ts index 28980ae1..ed73ffba 100644 --- a/src/server/reader/markdown-renderer.ts +++ b/src/server/reader/markdown-renderer.ts @@ -31,6 +31,8 @@ export interface RenderMemoryMarkdownOptions { sourceUrl?: string; } +export const MAX_HIGHLIGHTED_CODE_LENGTH = 20_000; + export function renderMemoryMarkdown( markdown: string, options: RenderMemoryMarkdownOptions = {}, @@ -79,13 +81,34 @@ function createMarkdownIt(toc: ReaderTocEntry[]) { } function highlightCode(code: string, language: string) { - const highlighted = language.trim() !== "" && hljs.getLanguage(language) - ? hljs.highlight(code, { language, ignoreIllegals: true }).value - : hljs.highlightAuto(code).value; + const normalizedLanguage = language.trim(); + const knownLanguage = normalizedLanguage !== "" && + hljs.getLanguage(normalizedLanguage) !== undefined; + let highlighted: string; + if ( + code.length > MAX_HIGHLIGHTED_CODE_LENGTH || + (normalizedLanguage !== "" && !knownLanguage) + ) { + highlighted = escapeCodeHtml(code); + } else if (knownLanguage) { + highlighted = hljs.highlight(code, { + language: normalizedLanguage, + ignoreIllegals: true, + }).value; + } else { + highlighted = hljs.highlightAuto(code).value; + } return `
${highlighted}
`; } +function escapeCodeHtml(code: string): string { + return code + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + function sanitizeReaderHtml(html: string, options: RenderMemoryMarkdownOptions) { const htmlWithoutSrcdocIframes = html.replace( /]*\ssrcdoc\s*=)[\s\S]*?<\/iframe>/gi, diff --git a/src/server/reader/media-proxy.ts b/src/server/reader/media-proxy.ts index d845bb16..c0934703 100644 --- a/src/server/reader/media-proxy.ts +++ b/src/server/reader/media-proxy.ts @@ -12,6 +12,7 @@ import { const DEFAULT_READER_MEDIA_MAX_BYTES = 5_000_000; const DEFAULT_READER_MEDIA_TIMEOUT_MS = 10_000; +const DEFAULT_READER_MEDIA_MAX_CONCURRENCY = 8; const MAX_READER_MEDIA_REDIRECTS = 5; const MAX_READER_MEDIA_URL_LENGTH = 8_192; const READER_MEDIA_ACCEPT = [ @@ -30,21 +31,69 @@ const ALLOWED_READER_MEDIA_TYPES = new Set([ ]); export interface ReaderMediaRequestOptions { + concurrencyLimiter?: ReaderMediaConcurrencyLimiter; fetchAddress?: PinnedAddressFetch; maxBytes?: number; resolveHostname?: HostResolver; timeoutMs?: number; } +export interface ReaderMediaConcurrencyLimiter { + tryAcquire: () => (() => void) | undefined; +} + +export function createReaderMediaConcurrencyLimiter( + maximum: number, +): ReaderMediaConcurrencyLimiter { + if (!Number.isSafeInteger(maximum) || maximum < 1) { + throw new Error("reader media concurrency must be a positive integer"); + } + + let active = 0; + return { + tryAcquire() { + if (active >= maximum) { + return undefined; + } + active += 1; + let released = false; + return () => { + if (!released) { + released = true; + active -= 1; + } + }; + }, + }; +} + +const readerMediaConcurrencyLimiter = createReaderMediaConcurrencyLimiter( + DEFAULT_READER_MEDIA_MAX_CONCURRENCY, +); + export async function handleReaderMediaRequest( request: Request, options: ReaderMediaRequestOptions = {}, ): Promise { + const browserBoundaryError = guardReaderMediaBrowserRequest(request); + if (browserBoundaryError !== undefined) { + return errorResponse(browserBoundaryError); + } + const target = readTargetUrl(request); if (target instanceof ReaderMediaError) { return errorResponse(target); } + const releaseConcurrency = ( + options.concurrencyLimiter ?? readerMediaConcurrencyLimiter + ).tryAcquire(); + if (releaseConcurrency === undefined) { + return errorResponse( + new ReaderMediaError(429, "reader media proxy is busy"), + ); + } + const maxBytes = options.maxBytes ?? DEFAULT_READER_MEDIA_MAX_BYTES; const timeoutMs = options.timeoutMs ?? DEFAULT_READER_MEDIA_TIMEOUT_MS; const controller = new AbortController(); @@ -128,7 +177,24 @@ export async function handleReaderMediaRequest( } finally { clearTimeout(timeout); request.signal.removeEventListener("abort", abortForRequest); + releaseConcurrency(); + } +} + +function guardReaderMediaBrowserRequest( + request: Request, +): ReaderMediaError | undefined { + const fetchSite = request.headers.get("sec-fetch-site")?.trim().toLowerCase(); + if (fetchSite !== "same-origin") { + return new ReaderMediaError(403, "same-origin reader media request required"); } + + const origin = request.headers.get("origin"); + if (origin !== null && origin !== new URL(request.url).origin) { + return new ReaderMediaError(403, "same-origin reader media request required"); + } + + return undefined; } interface FetchValidatedRedirectsInput { diff --git a/src/server/translation/runner.ts b/src/server/translation/runner.ts index d8ccf26c..6bbf5171 100644 --- a/src/server/translation/runner.ts +++ b/src/server/translation/runner.ts @@ -1,7 +1,7 @@ import { MemoryContentStoreError } from "../store"; import { getMemoryBackupQueue, - type MemoryBackupQueue, + type DurableMemoryBackupQueue, } from "../backup"; import { loadRuntimeTraumaConfig, @@ -54,6 +54,10 @@ import { type TranslationJobStaleData, } from "./types"; import { isSupportedLanguageCode, type SupportedLanguageCode } from "./languages"; +import { + CODEX_RUNTIME_ISOLATION_ERROR, + isCodexRuntimeIsolationReady, +} from "../codex/runtime-isolation"; export type StartTranslationJobResult = | { @@ -99,7 +103,7 @@ export interface TranslationJobSnapshot { } interface StartTranslationJobInput { - backupQueue?: MemoryBackupQueue; + backupQueue?: DurableMemoryBackupQueue; client?: TranslationClient; config?: ResolvedTraumaConfig; createClient?: () => TranslationClient; @@ -114,7 +118,7 @@ interface StartTranslationJobInput { } interface TranslationRunOptions { - backupQueue?: MemoryBackupQueue; + backupQueue?: DurableMemoryBackupQueue; cancelGraceMs?: number; closeClientAfterRun?: boolean; client?: TranslationClient; @@ -222,6 +226,18 @@ export async function startTranslationJob( }); } + if ( + !isCodexRuntimeIsolationReady({ + hasInjectedClient: + input.client !== undefined || input.createClient !== undefined, + }) + ) { + throw new TranslationApiError( + CODEX_RUNTIME_ISOLATION_ERROR.code, + "Brilliant translation requires an externally isolated Codex runtime.", + ); + } + const active = await connection.repositories.translations.findActiveTranslationJob( input.memoryId, langCode, @@ -387,6 +403,16 @@ export async function runTranslationJob( jobId: string, options: TranslationRunOptions = {}, ): Promise { + if ( + !isCodexRuntimeIsolationReady({ + hasInjectedClient: options.client !== undefined, + }) + ) { + throw new TranslationApiError( + CODEX_RUNTIME_ISOLATION_ERROR.code, + "Brilliant translation requires an externally isolated Codex runtime.", + ); + } const config = options.config ?? loadRuntimeTraumaConfig(); const openConnection = options.openConnection ?? initializeDatabase; const backupQueue = options.backupQueue ?? getMemoryBackupQueue(config); diff --git a/src/server/translation/start-translation-route.ts b/src/server/translation/start-translation-route.ts index b60aebf6..f43b78e7 100644 --- a/src/server/translation/start-translation-route.ts +++ b/src/server/translation/start-translation-route.ts @@ -156,6 +156,7 @@ function statusForTranslationError(error: TranslationApiError): number { case "translation_language_mismatch": case "translation_model_unavailable": case "translation_reasoning_effort_unavailable": + case "runtime_isolation_required": case "cancellation_conflict": return 409; case "auth_required": diff --git a/src/server/translation/stitching.ts b/src/server/translation/stitching.ts index 3af815f7..a218b5ff 100644 --- a/src/server/translation/stitching.ts +++ b/src/server/translation/stitching.ts @@ -1,7 +1,7 @@ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import type { MemoryBackupQueue } from "../backup"; +import type { DurableMemoryBackupQueue } from "../backup"; import type { ResolvedTraumaConfig } from "../config"; import type { TranslationChunkRecord, @@ -39,7 +39,7 @@ export interface StaleTranslationCommitResult { } export async function commitTranslatedContent(input: { - backupQueue: MemoryBackupQueue; + backupQueue: DurableMemoryBackupQueue; config: ResolvedTraumaConfig; chunks: TranslationChunkRecord[]; job: TranslationJobRecord; @@ -75,6 +75,24 @@ export async function commitTranslatedContent(input: { expectedFrontmatter: frontmatter, output, }); + const intendedOutputPath = resolveTranslatedMemoryContentPath({ + config: input.config, + langCode: input.job.langCode, + memoryId: input.job.memoryId, + }); + const projectionPath = resolveTranslatedMemoryProjectionPath({ + config: input.config, + langCode: input.job.langCode, + memoryId: input.job.memoryId, + }); + await input.backupQueue.persistIntent({ + contentPaths: [ + intendedOutputPath.relativePath, + projectionPath.relativePath, + ], + memoryId: input.job.memoryId, + reason: "translation_update", + }); const outputPath = await writeTranslatedContentAtomically({ config: input.config, jobId: input.job.jobId, @@ -94,11 +112,6 @@ export async function commitTranslatedContent(input: { outputHash, sourceHash: input.job.sourceHash, }); - const projectionPath = resolveTranslatedMemoryProjectionPath({ - config: input.config, - langCode: input.job.langCode, - memoryId: input.job.memoryId, - }); await writeFile( projectionPath.absolutePath, serializeTranslationProjectionSidecar({ diff --git a/src/server/translation/types.ts b/src/server/translation/types.ts index 4c8732ce..6d733fdd 100644 --- a/src/server/translation/types.ts +++ b/src/server/translation/types.ts @@ -81,6 +81,7 @@ export type TranslationErrorCode = | "missing_source_content" | "auth_required" | "setup_required" + | "runtime_isolation_required" | "app_server_unavailable" | "app_server_protocol_error" | "stale_source" diff --git a/tests/components/app-shell.test.ts b/tests/components/app-shell.test.ts index 6dc99678..b60a3fb6 100644 --- a/tests/components/app-shell.test.ts +++ b/tests/components/app-shell.test.ts @@ -299,6 +299,23 @@ describe("refined app shell contract", () => { expect(settingsIndex).toBeGreaterThan(themeIndex); }); + it("keeps the local archive identity as a non-interactive status surface", () => { + const statusStart = appShellSource.indexOf("function LocalArchiveStatus"); + const statusEnd = appShellSource.indexOf( + "function AddMemoryComposerButton", + statusStart, + ); + const statusSource = appShellSource.slice(statusStart, statusEnd); + + expect(statusStart).toBeGreaterThan(-1); + expect(statusEnd).toBeGreaterThan(statusStart); + expect(statusSource).toContain(" { expect(addMemoryFormSource).toContain("text-trauma-text-muted"); expect(addMemoryFormSource).not.toContain("text-[#4e5a48]"); diff --git a/tests/components/async-feedback-accessibility.test.ts b/tests/components/async-feedback-accessibility.test.ts new file mode 100644 index 00000000..4664e757 --- /dev/null +++ b/tests/components/async-feedback-accessibility.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const readSource = (path: string) => readFileSync(path, "utf8"); + +describe("async action feedback accessibility", () => { + it("announces action failures without requiring visual discovery", () => { + const sources = [ + "src/components/memories/MemoryReadStatusControl.tsx", + "src/components/memories/MemoryBrowse.tsx", + "src/components/memories/MemoryActionMenu.tsx", + "src/components/flashbacks/FlashbackActionMenu.tsx", + "src/components/moments/MomentActionMenu.tsx", + ].map(readSource); + + for (const source of sources) { + expect(source).toContain('role="alert"'); + } + }); + + it("announces settings success messages politely", () => { + const source = readSource("src/components/settings/SettingsPage.tsx"); + const successMessageStart = source.indexOf(""); + const errorMessageStart = source.indexOf( + "", + successMessageStart, + ); + const successMessageSource = source.slice( + successMessageStart, + errorMessageStart, + ); + + expect(successMessageStart).toBeGreaterThan(-1); + expect(successMessageSource).toContain('role="status"'); + }); +}); diff --git a/tests/components/button-hints.test.tsx b/tests/components/button-hints.test.tsx index f99eb87b..af151dd6 100644 --- a/tests/components/button-hints.test.tsx +++ b/tests/components/button-hints.test.tsx @@ -99,7 +99,6 @@ describe("button hover hints", () => { it("wires hints into common shell, browse, composer, and reader action buttons", () => { expect(appShellSource).toContain('title="Theme settings"'); - expect(appShellSource).toContain('title="Local archive"'); expect(appShellSource).toContain('hint="Add memory"'); expect(appShellSource).toContain('hint="Use sun theme"'); expect(memoryBrowseSource).toContain("MemoryReadStateTabs"); diff --git a/tests/components/dismissable-layer.test.ts b/tests/components/dismissable-layer.test.ts index 36b5a32f..e8bef7d0 100644 --- a/tests/components/dismissable-layer.test.ts +++ b/tests/components/dismissable-layer.test.ts @@ -49,7 +49,9 @@ describe("dismissable popup layers", () => { it("is shared by inline taxonomy creation controls", () => { expect(taxonomyInlineCreateSource).toContain("useDismissableLayer"); expect(taxonomyInlineCreateSource).toContain("isEnabled: isOpen"); - expect(taxonomyInlineCreateSource).toContain("onDismiss: () => setOpen(false)"); + expect(taxonomyInlineCreateSource).toContain( + "onDismiss: (reason) => setOpen(false, reason)", + ); }); it("keeps anchored popover dismissal inside the shared Popup shell", () => { diff --git a/tests/components/memory-browse-actions.test.ts b/tests/components/memory-browse-actions.test.ts index 3f821442..45f8cbdb 100644 --- a/tests/components/memory-browse-actions.test.ts +++ b/tests/components/memory-browse-actions.test.ts @@ -10,8 +10,12 @@ import { detachTagFromMemoryByName, isBackupFailsafeMemoryActionError, MemoryItem, + settleCurrentBrowsePageRequest, } from "../../src/components/memories/MemoryBrowse"; -import type { BrowseMemory } from "../../src/components/memories/browse-data"; +import type { + BrowseMemory, + BrowseMemoryPage, +} from "../../src/components/memories/browse-data"; const memory = { id: "memory-1", @@ -357,6 +361,34 @@ describe("memory browse actions", () => { expect(loadNextPageSource).not.toContain("throw error"); }); + it("ignores every stale load-more completion path", async () => { + const staleSuccess = createDeferred(); + const staleFailure = createDeferred(); + const events: string[] = []; + let current = true; + const callbacks = { + isCurrent: () => current, + onError: () => events.push("error"), + onPage: () => events.push("page"), + onSettled: () => events.push("settled"), + }; + + const successRequest = settleCurrentBrowsePageRequest({ + ...callbacks, + loadPage: () => staleSuccess.promise, + }); + const failureRequest = settleCurrentBrowsePageRequest({ + ...callbacks, + loadPage: () => staleFailure.promise, + }); + current = false; + staleSuccess.resolve({ memories: [], nextCursor: null }); + staleFailure.reject(new Error("stale failure")); + + await Promise.all([successRequest, failureRequest]); + expect(events).toEqual([]); + }); + it("clears appended pages after card mutations revalidate browse data", () => { expect(browseSource).toContain("clearAdditionalBrowsePages"); expect(browseSource).toContain("onMemoryMutated={clearAdditionalBrowsePages}"); @@ -440,3 +472,22 @@ describe("memory browse actions", () => { expect(isBackupFailsafeMemoryActionError(caught)).toBe(true); }); }); + +function createDeferred(): { + promise: Promise; + reject: (error: unknown) => void; + resolve: (value: T) => void; +} { + let rejectPromise: ((error: unknown) => void) | undefined; + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve, reject) => { + rejectPromise = reject; + resolvePromise = resolve; + }); + + return { + promise, + reject: (error) => rejectPromise?.(error), + resolve: (value) => resolvePromise?.(value), + }; +} diff --git a/tests/components/memory-reader-actions.test.ts b/tests/components/memory-reader-actions.test.ts index c24dc8bf..980e01bd 100644 --- a/tests/components/memory-reader-actions.test.ts +++ b/tests/components/memory-reader-actions.test.ts @@ -257,6 +257,24 @@ describe("memory reader actions", () => { ); }); + it("preserves reader-local UI state across same-identity revalidation", () => { + expect(memoryReaderSource).toContain("const readyResultIdentity = () =>"); + expect(memoryReaderSource).toContain("when={readyResultIdentity()}"); + expect(memoryReaderSource).toContain("result={readyResult()!}"); + expect(memoryReaderSource).not.toContain("when={readyResult()}"); + const resultSyncStart = memoryReaderSource.indexOf( + " createEffect(() => {\n readerGenerationGuard.activate({", + ); + const resultSyncEnd = memoryReaderSource.indexOf( + " createEffect(() => {", + resultSyncStart + 1, + ); + const resultSync = memoryReaderSource.slice(resultSyncStart, resultSyncEnd); + expect(resultSync).toContain("setCurrentFlashbacks"); + expect(resultSync).not.toContain("setTranslationProgress"); + expect(resultSync).not.toContain("translationEventSource"); + }); + it("treats terminal translation snapshots as terminal SSE progress", () => { expect(memoryReaderSource).toContain("TERMINAL_TRANSLATION_SNAPSHOT_STATUSES"); expect(memoryReaderSource).toContain("isCompletedTranslationEnvelope(envelope)"); @@ -525,6 +543,19 @@ describe("memory reader actions", () => { ); }); + it("uses valid keyboard-operable reader action and TOC semantics", () => { + expect(memoryReaderSource).toContain('role="toolbar"'); + expect(memoryReaderSource).toContain('aria-orientation="horizontal"'); + expect(memoryReaderSource).toContain("focusReaderToolbarButton"); + expect(memoryReaderSource).toContain('event.key === "ArrowRight"'); + expect(memoryReaderSource).toContain('event.key === "ArrowLeft"'); + expect(memoryReaderSource).toContain("focus-visible:opacity-100"); + expect(memoryReaderSource).toContain('role="presentation"'); + expect(memoryReaderSource).not.toMatch( + / { expect(memoryReaderSource).toContain("canonicalTranslationModel"); expect(memoryReaderSource).toContain("submitCodexTranslationDefaults"); diff --git a/tests/components/popup.test.tsx b/tests/components/popup.test.tsx index 4ba806fd..35c23f62 100644 --- a/tests/components/popup.test.tsx +++ b/tests/components/popup.test.tsx @@ -64,4 +64,16 @@ describe("popup shell", () => { expect(popupSource).toContain("animate-trauma-pop-bounce"); expect(popupSource).toContain("shadow-trauma-2"); }); + + it("owns focus transfer, focus return, and the menu keyboard model", () => { + expect(popupSource).toContain("focusPopupPanel"); + expect(popupSource).toContain("restorePopupTriggerFocus"); + expect(popupSource).toContain('event.key === "ArrowDown"'); + expect(popupSource).toContain('event.key === "ArrowUp"'); + expect(popupSource).toContain('event.key === "Home"'); + expect(popupSource).toContain('event.key === "End"'); + expect(popupSource).toContain('reason === "escape"'); + expect(popupSource).toContain("
{ it("renders the existing dashed taxonomy pill as the trigger surface", () => { @@ -40,4 +44,12 @@ describe("taxonomy inline create control", () => { expect(inlineCreateSource).toContain("validateName"); expect(inlineCreateSource).toContain("validationError"); }); + + it("returns focus after keyboard dismissal without stealing outside-pointer focus", () => { + expect(inlineCreateSource).toContain("TaxonomyInlineCreateCloseReason"); + expect(inlineCreateSource).toContain('reason !== "outside-pointer"'); + expect(inlineCreateSource).toContain("restoreTaxonomyTriggerFocus"); + expect(taxonomyAddSource).toContain("restoreTaxonomyAddTriggerFocus"); + expect(taxonomyAddSource).toContain('reason !== "outside-pointer"'); + }); }); diff --git a/tests/memories/browse-data.test.ts b/tests/memories/browse-data.test.ts index c2e98a36..2ccbbcde 100644 --- a/tests/memories/browse-data.test.ts +++ b/tests/memories/browse-data.test.ts @@ -24,7 +24,7 @@ import { buildMemoryVariantAnchorHref } from "../../src/components/memories/memo const fixtures: BrowseMemory[] = [ { - id: "memory-foundation", + id: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", title: "Reader Mode Notes", url: "https://example.com/reader-mode", description: "SolidStart route data and shell architecture notes.", @@ -39,7 +39,7 @@ const fixtures: BrowseMemory[] = [ flashbacks: [ { id: "h-foundation", - memoryId: "memory-foundation", + memoryId: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", variantKind: "source", langCode: null, translationOutputHash: null, @@ -79,7 +79,7 @@ describe("browse query state", () => { const query = parseBrowseQuery("?q=reader"); const cursor: BrowseMemoryCursor = { createdAt: "2026-05-09T12:00:00.000Z", - id: "memory-foundation", + id: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", }; expect(createNextBrowseMemoryPageRequest(query, cursor)).toEqual({ @@ -171,7 +171,7 @@ describe("browse query state", () => { it("filters fielded search terms inside the q parameter", () => { expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=title:{Reader Mode}")).map((memory) => memory.id)).toEqual([ - "memory-foundation", + "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", ]); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=url:{local-hosting}")).map((memory) => memory.id)).toEqual([ "memory-ops", @@ -180,10 +180,10 @@ describe("browse query state", () => { "memory-ops", ]); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=category:{Research}")).map((memory) => memory.id)).toEqual([ - "memory-foundation", + "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", ]); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=flashback:{repository fixtures}")).map((memory) => memory.id)).toEqual([ - "memory-foundation", + "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", ]); }); @@ -192,11 +192,11 @@ describe("browse query state", () => { "memory-ops", ]); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=tag=solidstart%26reader")).map((memory) => memory.id)).toEqual([ - "memory-foundation", + "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", ]); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=tag=solidstart%26sqlite"))).toHaveLength(0); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=category=Research")).map((memory) => memory.id)).toEqual([ - "memory-foundation", + "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", ]); }); @@ -236,7 +236,7 @@ describe("browse query state", () => { "memory-ops", ]); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=unread")).map((memory) => memory.id)).toEqual([ - "memory-foundation", + "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", ]); expect(filterBrowseMemories(fixtures, parseBrowseQuery("?q=read+unread"))).toHaveLength(0); }); @@ -265,7 +265,7 @@ describe("browse query state", () => { expect( filterBrowseMemories(fixtures, parseBrowseQuery("?q=route+tag:solidstart&category=research")) .map((memory) => memory.id), - ).toEqual(["memory-foundation"]); + ).toEqual(["018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901"]); expect( filterBrowseMemories(fixtures, parseBrowseQuery("?q=route+tag:sqlite&category=research")), ).toHaveLength(0); @@ -277,7 +277,7 @@ describe("browse query state", () => { flashbacks: [ { id: "h-first", - memoryId: "memory-foundation", + memoryId: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", variantKind: "source", langCode: null, translationOutputHash: null, @@ -288,7 +288,7 @@ describe("browse query state", () => { }, { id: "h-selected", - memoryId: "memory-foundation", + memoryId: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", variantKind: "source", langCode: null, translationOutputHash: null, @@ -312,7 +312,7 @@ describe("browse query state", () => { const flashbacks = [ { id: "h-first", - memoryId: "memory-foundation", + memoryId: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", variantKind: "source", langCode: null, translationOutputHash: null, @@ -323,7 +323,7 @@ describe("browse query state", () => { }, { id: "h-selected", - memoryId: "memory-foundation", + memoryId: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", variantKind: "source", langCode: null, translationOutputHash: null, @@ -350,7 +350,7 @@ describe("browse query state", () => { flashbacks: [ { id: "h-newer-memory-old-flashback", - memoryId: "memory-foundation", + memoryId: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", variantKind: "source", langCode: null, translationOutputHash: null, @@ -382,7 +382,7 @@ describe("browse query state", () => { expect(getRecentFlashbacks(memories).map((flashback) => [flashback.id, flashback.memoryId])).toEqual([ ["h-older-memory-new-flashback", "memory-ops"], - ["h-newer-memory-old-flashback", "memory-foundation"], + ["h-newer-memory-old-flashback", "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901"], ]); }); diff --git a/tests/scripts/repository-hygiene.test.ts b/tests/scripts/repository-hygiene.test.ts new file mode 100644 index 00000000..0a197a8c --- /dev/null +++ b/tests/scripts/repository-hygiene.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const browseShellSpec = readFileSync("e2e/browse-shell.spec.ts", "utf8"); +const readme = readFileSync("README.md", "utf8"); + +describe("repository hygiene", () => { + it("waits for observable application state instead of network idle", () => { + expect(browseShellSpec).not.toContain('waitForLoadState("networkidle")'); + }); + + it("keeps the README preview heading and logo markup clean", () => { + const logo = readme.match(/]*Trauma Logo[^>]*\/>/)?.[0]; + + expect(logo).toBeDefined(); + expect(logo?.match(/\balt=/g)).toHaveLength(1); + expect(readme).toContain("## Previews"); + expect(readme).not.toContain("## Proves"); + }); +}); diff --git a/tests/scripts/runtime-command.test.ts b/tests/scripts/runtime-command.test.ts index 44714926..30d35868 100644 --- a/tests/scripts/runtime-command.test.ts +++ b/tests/scripts/runtime-command.test.ts @@ -9,6 +9,7 @@ interface PackageJson { const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as PackageJson; const playwrightConfig = readFileSync("playwright.config.ts", "utf8"); +const appConfig = readFileSync("app.config.ts", "utf8"); const devSmokeScript = readFileSync("scripts/dev-smoke.ts", "utf8"); const envExample = readFileSync(".env.example", "utf8"); @@ -30,6 +31,15 @@ describe("runtime command contract", () => { expect(playwrightConfig).toContain("bun --bun .output/server/index.mjs"); }); + it("never reuses an unverified server for destructive browser tests", () => { + expect(playwrightConfig).toContain("reuseExistingServer: false"); + expect(playwrightConfig).not.toContain("reuseExistingServer: !process.env.CI"); + }); + + it("runs trusted-host validation before application routing", () => { + expect(appConfig).toContain('middleware: "./src/middleware.ts"'); + }); + it("runs the smoke-check Vinxi child process through Bun runtime", () => { expect(devSmokeScript).toContain('"--bun",'); expect(devSmokeScript).toContain('"x", "vinxi", "dev"'); diff --git a/tests/server/backup/backup-environment.test.ts b/tests/server/backup/backup-environment.test.ts index 51a471f4..6f2f03e1 100644 --- a/tests/server/backup/backup-environment.test.ts +++ b/tests/server/backup/backup-environment.test.ts @@ -10,6 +10,7 @@ import { assertBackupEnvironmentReady, ensureBackupEnvironment, getBackupFailsafeStatus, + redactOperationalError, recordBackupPushFailureAlert, } from "../../../src/server/backup/environment"; import { initializeDatabase } from "../../../src/server/db"; @@ -102,6 +103,17 @@ describe("backup environment failsafe", () => { } }); + it("redacts credentials before bounding long operational errors", () => { + const credential = "s".repeat(4_096); + const redacted = redactOperationalError( + `fatal: https://backup-user:${credential}@example.com/private.git`, + ); + + expect(redacted.length).toBeLessThanOrEqual(4_096); + expect(redacted).toContain("https://[redacted]@example.com/private.git"); + expect(redacted).not.toContain(credential.slice(0, 64)); + }); + it("creates a critical alert when existing content has no trusted stamp", async () => { const root = await makeRoot(); const config = createConfig(root); @@ -291,6 +303,76 @@ describe("backup environment failsafe", () => { } }); + it("checks backup identity after a journaled missing file is marked pending", async () => { + const root = await makeRoot(); + const config = createConfig(root); + await mkdir(config.projectPath, { recursive: true }); + initializeGitRepository(config.projectPath); + const connection = initializeDatabase(config); + try { + await connection.repositories.memories.create({ + ...createMemoryRow(), + backupStatus: "success", + }); + await connection.repositories.backupEnvironment.upsertBackupEnvironmentStamp({ + id: "default", + projectPath: config.projectPath, + storePath: config.storePath, + gitRemote: config.backup.git.remote, + gitRemoteUrl: null, + gitBranch: config.backup.git.branch, + createdAt: now, + updatedAt: now, + }); + await connection.repositories.memories.updateBackupStatus({ + id: "memory-1", + backupStatus: "pending", + lastBackupAt: null, + lastBackupError: null, + updatedAt: now, + }); + + await expect(assertBackupEnvironmentReady({ + config, + db: connection.db, + })).resolves.toBeUndefined(); + await expect( + connection.repositories.backupEnvironment.getBackupFailsafeAlert(), + ).resolves.toBeUndefined(); + } finally { + connection.close(); + } + }); + + it("rejects deletion recovery when the checked-out backup branch drifted", async () => { + const root = await makeRoot(); + const config = createConfig(root); + await mkdir(config.projectPath, { recursive: true }); + initializeGitRepository(config.projectPath); + const connection = initializeDatabase(config); + try { + await connection.repositories.memories.create(createMemoryRow()); + await connection.repositories.backupEnvironment.upsertBackupEnvironmentStamp({ + id: "default", + projectPath: config.projectPath, + storePath: config.storePath, + gitRemote: config.backup.git.remote, + gitRemoteUrl: null, + gitBranch: config.backup.git.branch, + createdAt: now, + updatedAt: now, + }); + runGit(config.projectPath, ["symbolic-ref", "HEAD", "refs/heads/other"]); + + await expect(assertBackupEnvironmentReady({ + config, + db: connection.db, + })).rejects.toBeInstanceOf(BackupEnvironmentFailsafeError); + } finally { + connection.close(); + } + }); + it("prints delete recovery commands for missing successful content records", async () => { const root = await makeRoot(); const config = createConfig(root); diff --git a/tests/server/backup/backup-failsafe-cli.test.ts b/tests/server/backup/backup-failsafe-cli.test.ts index 79eea35c..0d664855 100644 --- a/tests/server/backup/backup-failsafe-cli.test.ts +++ b/tests/server/backup/backup-failsafe-cli.test.ts @@ -288,6 +288,15 @@ describe("backup failsafe CLI", () => { recursive: true, }); await writeFile(join(config.storePath, "memories/memory-1/CONTENT.md"), "# Current\n", "utf8"); + await mkdir(config.projectPath, { recursive: true }); + git(config.projectPath, ["init", "--initial-branch=main"]); + const recoveryCredential = "recovery-secret"; + git(config.projectPath, [ + "remote", + "add", + "origin", + `https://user:${recoveryCredential}@example.com/archive.git`, + ]); await seedUnstampedCurrentDataAlert(configPath); const output = await withGitIdentity(() => @@ -305,11 +314,14 @@ describe("backup failsafe CLI", () => { try { expect(await connection.repositories.backupEnvironment.getBackupFailsafeAlert()) .toBeUndefined(); - expect(await connection.repositories.backupEnvironment.getBackupEnvironmentStamp()) - .toMatchObject({ + const stamp = + await connection.repositories.backupEnvironment.getBackupEnvironmentStamp(); + expect(stamp).toMatchObject({ projectPath: config.projectPath, storePath: config.storePath, }); + expect(stamp?.gitRemoteUrl).toMatch(/^sha256:[a-f0-9]{64}$/u); + expect(JSON.stringify(stamp)).not.toContain(recoveryCredential); } finally { connection.close(); } diff --git a/tests/server/backup/content-integrity.test.ts b/tests/server/backup/content-integrity.test.ts new file mode 100644 index 00000000..913063a0 --- /dev/null +++ b/tests/server/backup/content-integrity.test.ts @@ -0,0 +1,95 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { findInconsistentSuccessfulBackupContent } from "../../../src/server/backup/content-integrity"; +import type { ResolvedTraumaConfig } from "../../../src/server/config"; +import { initializeDatabase } from "../../../src/server/db"; + +const tempDirs: string[] = []; +const now = new Date("2026-07-17T00:00:00.000Z"); + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ), + ); +}); + +describe("successful backup content integrity", () => { + it("loads the tracked Git index once for every successful memory", async () => { + const root = await mkdtemp(join(tmpdir(), "trauma-content-integrity-")); + tempDirs.push(root); + const config = createConfig(root); + const rows = ["memory-one", "memory-two", "memory-three"].map( + (memoryId, index) => ({ + contentPath: `memories/${memoryId}/CONTENT.md`, + memoryId, + createdAt: new Date(now.getTime() + index), + }), + ); + await Promise.all( + rows.map(async ({ contentPath }) => { + const absolutePath = join(config.storePath, contentPath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, "# Backed up\n", "utf8"); + }), + ); + + const connection = initializeDatabase(config); + try { + for (const row of rows) { + await connection.repositories.memories.create({ + id: row.memoryId, + url: `https://example.com/${row.memoryId}`, + title: row.memoryId, + description: null, + faviconUrl: null, + contentPath: row.contentPath, + extractionStatus: "success", + extractionError: null, + backupStatus: "success", + lastBackupAt: now, + lastBackupError: null, + createdAt: row.createdAt, + updatedAt: row.createdAt, + }); + } + + const listTrackedPaths = vi.fn(async () => + new Set(rows.map(({ contentPath }) => `store/${contentPath}`)) + ); + + await expect( + findInconsistentSuccessfulBackupContent(config, connection.db, { + listTrackedPaths, + }), + ).resolves.toBeNull(); + expect(listTrackedPaths).toHaveBeenCalledTimes(1); + expect(listTrackedPaths).toHaveBeenCalledWith(config.projectPath); + } finally { + connection.close(); + } + }); +}); + +function createConfig(root: string): ResolvedTraumaConfig { + return { + configFilePath: join(root, "trauma.config.json"), + projectPath: join(root, "project"), + storePath: join(root, "project", "store"), + databasePath: join(root, ".trauma", "trauma.sqlite"), + backup: { + git: { + enabled: true, + remote: "origin", + branch: "main", + push: false, + commitMessageTemplate: "backup memory {memoryId}", + }, + }, + }; +} diff --git a/tests/server/backup/git-backup.test.ts b/tests/server/backup/git-backup.test.ts index 2172f93e..5d5915c5 100644 --- a/tests/server/backup/git-backup.test.ts +++ b/tests/server/backup/git-backup.test.ts @@ -475,6 +475,105 @@ describe("git backup runner", () => { }); describe("git memory backup queue", () => { + it("isolates a poisoned retry candidate and continues with later memories", async () => { + const root = await makeRoot("trauma-git-backup-retry-isolation-"); + const projectPath = join(root, "project"); + const storePath = join(projectPath, "store"); + const config = createConfig({ root, projectPath, storePath, push: false }); + const poisonedMemoryId = "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef800"; + const healthyContentPath = `memories/${memoryId}/CONTENT.md`; + await mkdir(join(storePath, "memories", memoryId), { recursive: true }); + await writeFile(join(storePath, healthyContentPath), "# Healthy retry\n", "utf8"); + initializeGitRepository(projectPath); + const connection = initializeDatabase(config); + try { + await connection.repositories.backupEnvironment.upsertBackupEnvironmentStamp({ + id: "default", + projectPath, + storePath, + gitRemote: "origin", + gitRemoteUrl: null, + gitBranch: "main", + createdAt: new Date(capturedAt), + updatedAt: new Date(capturedAt), + }); + for (const candidate of [ + { id: poisonedMemoryId, contentPath: "../outside/CONTENT.md" }, + { id: memoryId, contentPath: healthyContentPath }, + ]) { + await connection.repositories.memories.create({ + id: candidate.id, + url: `https://example.com/${candidate.id}`, + title: candidate.id, + description: null, + faviconUrl: null, + contentPath: candidate.contentPath, + extractionStatus: "success", + extractionError: null, + backupStatus: "pending", + lastBackupAt: null, + lastBackupError: null, + createdAt: new Date(capturedAt), + updatedAt: new Date(capturedAt), + }); + } + await connection.repositories.flashbacks.replaceForMemoryVariant({ + memoryId, + variant: { kind: "source" }, + flashbacks: [{ + id: "retry-flashback", + memoryId, + variantKind: "source", + langCode: null, + translationOutputHash: null, + text: "Healthy", + prefix: "", + suffix: " retry", + startOffset: 2, + endOffset: 9, + contentHash: null, + createdAt: new Date(capturedAt), + updatedAt: new Date(capturedAt), + }], + }); + } finally { + connection.close(); + } + + const processed: Array<{ memoryId: string; flashbackExport: string }> = []; + const queue = createGitMemoryBackupQueue({ + config, + runJob: async ({ job }) => { + processed.push({ + memoryId: job.memoryId, + flashbackExport: await readFile( + join(storePath, "memories", job.memoryId, "FLASHBACKS.json"), + "utf8", + ), + }); + }, + }); + await expect(queue.retryEligibleBackups()).resolves.toBe(1); + await queue.drain(); + + expect(processed).toEqual([{ + memoryId, + flashbackExport: expect.stringContaining("retry-flashback"), + }]); + const check = initializeDatabase(config); + try { + await expect(check.repositories.memories.findById(poisonedMemoryId)) + .resolves.toMatchObject({ + backupStatus: "failed", + lastBackupError: "backup retry scheduling failed", + }); + await expect(check.repositories.memories.findById(memoryId)) + .resolves.toMatchObject({ backupStatus: "success" }); + } finally { + check.close(); + } + }); + it("recovers a durable intent after simulated process loss without running an incomplete job", async () => { const root = await makeRoot("trauma-git-backup-intent-"); const projectPath = join(root, "project"); @@ -665,6 +764,41 @@ describe("git memory backup queue", () => { ); }); + it("redacts credentials before persisting backup worker failures", async () => { + const root = await makeRoot("trauma-git-backup-"); + const projectPath = join(root, "project"); + const storePath = join(projectPath, "store"); + const config = createConfig({ root, projectPath, storePath, push: false }); + const contentPath = `memories/${memoryId}/CONTENT.md`; + await seedBackupQueueMemory({ config, contentPath }); + const credential = ["worker", "credential"].join("-"); + const queue = createGitMemoryBackupQueue({ + config, + runJob: async () => { + throw new Error( + `git push failed for https://backup-user:${credential}@example.com/private.git token=${credential}`, + ); + }, + }); + + await queue.enqueue({ + memoryId, + contentPaths: [contentPath], + reason: "memory_creation", + }); + await queue.drain(); + + const connection = initializeDatabase(config); + try { + const stored = await connection.repositories.memories.findById(memoryId); + expect(stored?.lastBackupError).toContain("[redacted]"); + expect(stored?.lastBackupError).not.toContain(credential); + expect(stored?.lastBackupError?.length).toBeLessThanOrEqual(4_096); + } finally { + connection.close(); + } + }); + it("marks backup failure without removing the memory row or markdown content", async () => { const root = await makeRoot("trauma-git-backup-"); const output = runBunScript( diff --git a/tests/server/browse-loaders.test.ts b/tests/server/browse-loaders.test.ts index ef31ec63..1fd8d1de 100644 --- a/tests/server/browse-loaders.test.ts +++ b/tests/server/browse-loaders.test.ts @@ -873,6 +873,55 @@ describe("server browse loaders", () => { }, ]); }); + + it("preserves all Moment browse rows across bounded repository pages", async () => { + const config = await createRuntimeConfig(); + await seedMemory(config); + const sectionCount = 101; + const sections = Array.from( + { length: sectionCount }, + (_, index) => `## Section ${index + 1}\n\nBody ${index + 1}.`, + ); + await writeMemoryContent({ + config, + memoryId, + frontmatter: { + id: memoryId, + url: `https://example.com/${memoryId}`, + title: "Loader Memory", + capturedAt: now.toISOString(), + extractionStatus: "success", + }, + markdown: ["# Loader Memory", ...sections].join("\n\n"), + }); + const connection = initializeDatabase(config); + try { + for (let index = 0; index < sectionCount; index += 1) { + const ordinal = index + 1; + const createdAt = new Date(now.getTime() + index); + await connection.repositories.moments.create({ + id: `moment-page-${String(ordinal).padStart(3, "0")}`, + memoryId, + sectionAnchor: `section-${ordinal}`, + sectionTitle: `Section ${ordinal}`, + sectionLevel: 2, + sectionPath: `1/${ordinal}`, + sectionStartOffset: null, + sectionEndOffset: null, + contentHash: null, + createdAt, + updatedAt: createdAt, + }); + } + } finally { + connection.close(); + } + + const rows = await loadMomentBrowseRows(); + expect(rows).toHaveLength(sectionCount); + expect(rows.every((row) => row.targetStatus === "current")).toBe(true); + expect(new Set(rows.map((row) => row.id)).size).toBe(sectionCount); + }); }); async function createRuntimeConfig(): Promise { diff --git a/tests/server/browse/concurrency.test.ts b/tests/server/browse/concurrency.test.ts new file mode 100644 index 00000000..622749dc --- /dev/null +++ b/tests/server/browse/concurrency.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { mapWithConcurrency } from "../../../src/server/browse/concurrency"; + +describe("browse bounded concurrency", () => { + it("preserves input order without exceeding the worker limit", async () => { + let active = 0; + let maximumActive = 0; + const release: (() => void)[] = []; + + const pending = mapWithConcurrency([1, 2, 3, 4, 5], 2, async (value) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => release.push(resolve)); + active -= 1; + return value * 10; + }); + + await waitFor(() => release.length === 2); + while (release.length > 0) { + release.shift()?.(); + await waitFor(() => release.length > 0 || active === 0); + } + + await expect(pending).resolves.toEqual([10, 20, 30, 40, 50]); + expect(maximumActive).toBe(2); + }); + + it("waits for remaining workers before surfacing a worker failure", async () => { + const completed: number[] = []; + + await expect( + mapWithConcurrency([1, 2, 3], 2, async (value) => { + if (value === 1) { + throw new Error("worker failed"); + } + await Promise.resolve(); + completed.push(value); + return value; + }), + ).rejects.toThrow("worker failed"); + + expect(completed).toEqual([2, 3]); + }); +}); + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) { + return; + } + await Promise.resolve(); + } + throw new Error("timed out waiting for bounded browse work"); +} diff --git a/tests/server/browser-import/config.test.ts b/tests/server/browser-import/config.test.ts index 1a3bd241..51810d25 100644 --- a/tests/server/browser-import/config.test.ts +++ b/tests/server/browser-import/config.test.ts @@ -6,6 +6,8 @@ import { } from "../../../src/server/browser-import"; describe("browser import config", () => { + const strongToken = "0123456789abcdef0123456789abcdef"; + it("defaults to disabled local extension-only origin handling", () => { const config = loadBrowserImportConfig({}); @@ -26,7 +28,7 @@ describe("browser import config", () => { it("honors exact configured extension origins", () => { const config = loadBrowserImportConfig({ TRAUMA_BROWSER_IMPORT_ENABLED: "true", - TRAUMA_BROWSER_IMPORT_TOKEN: " local-token ", + TRAUMA_BROWSER_IMPORT_TOKEN: ` ${strongToken} `, TRAUMA_BROWSER_IMPORT_ALLOWED_ORIGINS: "chrome-extension://allowed, chrome-extension://second", TRAUMA_BROWSER_IMPORT_MAX_BYTES: "1000000", @@ -34,7 +36,7 @@ describe("browser import config", () => { expect(config).toEqual({ enabled: true, - token: "local-token", + token: strongToken, allowedOrigins: ["chrome-extension://allowed", "chrome-extension://second"], maxBytes: 1_000_000, }); @@ -46,6 +48,15 @@ describe("browser import config", () => { ); }); + it("rejects weak tokens when browser import is enabled", () => { + expect(() => + loadBrowserImportConfig({ + TRAUMA_BROWSER_IMPORT_ENABLED: "true", + TRAUMA_BROWSER_IMPORT_TOKEN: "local-token", + }) + ).toThrow("at least 32 URL-safe characters"); + }); + it("rejects ordinary web origins even when they are configured", () => { const config = loadBrowserImportConfig({ TRAUMA_BROWSER_IMPORT_ALLOWED_ORIGINS: diff --git a/tests/server/codex/runtime-isolation.test.ts b/tests/server/codex/runtime-isolation.test.ts new file mode 100644 index 00000000..6cd68287 --- /dev/null +++ b/tests/server/codex/runtime-isolation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { + CODEX_RUNTIME_ISOLATION_ASSERTION, + CODEX_RUNTIME_ISOLATION_ENV, + LEGACY_CODEX_RUNTIME_ISOLATION_ENV, + isCodexRuntimeIsolationReady, +} from "../../../src/server/codex/runtime-isolation"; + +describe("Codex runtime isolation", () => { + it("rejects production clients without the exact operator assertion", () => { + for (const assertion of [undefined, "", "true", "externally_enforced"]) { + expect( + isCodexRuntimeIsolationReady({ + environment: { [CODEX_RUNTIME_ISOLATION_ENV]: assertion }, + hasInjectedClient: false, + }), + ).toBe(false); + } + }); + + it("accepts the shared assertion and the legacy Psychiatrist name", () => { + expect( + isCodexRuntimeIsolationReady({ + environment: { + [CODEX_RUNTIME_ISOLATION_ENV]: CODEX_RUNTIME_ISOLATION_ASSERTION, + }, + hasInjectedClient: false, + }), + ).toBe(true); + expect( + isCodexRuntimeIsolationReady({ + environment: { + [LEGACY_CODEX_RUNTIME_ISOLATION_ENV]: + CODEX_RUNTIME_ISOLATION_ASSERTION, + }, + hasInjectedClient: false, + }), + ).toBe(true); + }); + + it("keeps dependency-injected clients testable", () => { + expect( + isCodexRuntimeIsolationReady({ environment: {}, hasInjectedClient: true }), + ).toBe(true); + }); +}); diff --git a/tests/server/db/repositories.test.ts b/tests/server/db/repositories.test.ts index 41bf7e33..4196aadf 100644 --- a/tests/server/db/repositories.test.ts +++ b/tests/server/db/repositories.test.ts @@ -539,7 +539,10 @@ describe("memory and taxonomy repositories", () => { createdAt: later, updatedAt: later, }); - const listed = await connection.repositories.moments.listForBrowse(); + const listed = await connection.repositories.moments.listPageForBrowse({ + cursor: null, + limit: 100, + }); const deleted = await connection.repositories.moments.deleteById("moment-1"); const missingDeleted = await connection.repositories.moments.deleteById("missing-moment"); @@ -630,8 +633,9 @@ describe("memory and taxonomy repositories", () => { const secondConnection = initializeDatabase(config); try { - firstConnection.sqlite.run("PRAGMA busy_timeout = 5000"); - secondConnection.sqlite.run("PRAGMA busy_timeout = 5000"); + const busyTimeouts = [firstConnection, secondConnection].map( + (connection) => connection.sqlite.prepare("PRAGMA busy_timeout").get().timeout, + ); const memoryId = "018f04a2-3c6f-7c88-9a8b-8c99a9b7f107"; const now = new Date("2026-05-10T01:00:00.000Z"); const later = new Date("2026-05-10T02:00:00.000Z"); @@ -752,6 +756,7 @@ describe("memory and taxonomy repositories", () => { ]); process.stdout.write(JSON.stringify({ + busyTimeouts, first, second, rows: firstConnection.sqlite @@ -773,6 +778,7 @@ describe("memory and taxonomy repositories", () => { ); expect(JSON.parse(output)).toMatchObject({ + busyTimeouts: [5000, 5000], first: { alreadyExists: false, moment: { diff --git a/tests/server/db/schema.test.ts b/tests/server/db/schema.test.ts index 3e0c6741..d3270b64 100644 --- a/tests/server/db/schema.test.ts +++ b/tests/server/db/schema.test.ts @@ -15,6 +15,7 @@ const STRICT_FLASHBACK_VARIANT_SCOPE_MIGRATION_FOLDER_MILLIS = 1779449500000; const MEMORY_BROWSE_PAGINATION_MIGRATION_FOLDER_MILLIS = 1779955000000; const SCRUB_BACKUP_SECRETS_MIGRATION_FOLDER_MILLIS = 1784221790920; const MOMENT_PATH_IDENTITY_MIGRATION_FOLDER_MILLIS = 1784223792512; +const SCRUB_MEMORY_BACKUP_ERRORS_MIGRATION_FOLDER_MILLIS = 1784232000000; describe("db foundation", () => { it("exports all foundation tables", () => { @@ -483,7 +484,52 @@ describe("db foundation", () => { expect(output).not.toContain("migration-secret"); }); -+ it("deterministically reconciles legacy Moment path duplicates before enforcing identity", () => { + it("scrubs legacy per-memory backup diagnostics", () => { + const root = mkdtempSync(join(tmpdir(), "trauma-db-")); + const output = runBunScript( + ` + import { join } from "node:path"; + import { Database } from "bun:sqlite"; + import { readBundledMigrations } from "./src/server/db/bundled-migrations.ts"; + import { applyRuntimeMigrations } from "./src/server/db/migrations.ts"; + + const root = process.env.TRAUMA_TEST_DB_ROOT; + if (!root) throw new Error("TRAUMA_TEST_DB_ROOT is required"); + const sqlite = new Database(join(root, "trauma.sqlite")); + const migrations = readBundledMigrations(); + const previous = migrations.filter( + (migration) => migration.folderMillis < ${SCRUB_MEMORY_BACKUP_ERRORS_MIGRATION_FOLDER_MILLIS}, + ); + applyRuntimeMigrations(sqlite, previous, "bundled"); + const credential = ["migration", "credential"].join("-"); + const now = Date.now(); + sqlite.prepare("insert into memories (id, url, title, content_path, extraction_status, backup_status, last_backup_error, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("memory-secret", "https://example.com", "Secret", "memories/memory-secret/CONTENT.md", "success", "failed", "fatal https://user:" + credential + "@example.com/repo.git", now, now); + sqlite.prepare("insert into backup_environment_stamps (id, project_path, store_path, git_remote, git_remote_url, git_branch, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)") + .run("default", "/project", "/project/store", "origin", "https://user:" + credential + "@example.com/repo.git", "main", now, now); + applyRuntimeMigrations(sqlite, migrations, "bundled"); + const memory = sqlite.prepare("select last_backup_error from memories where id = 'memory-secret'").get(); + const stamp = sqlite.prepare("select git_remote_url from backup_environment_stamps where id = 'default'").get(); + const migrationRecorded = sqlite.prepare("select count(*) as count from __drizzle_migrations where created_at = ?") + .get(${SCRUB_MEMORY_BACKUP_ERRORS_MIGRATION_FOLDER_MILLIS}).count; + sqlite.close(); + process.stdout.write(JSON.stringify({ memory, stamp, migrationRecorded })); + `, + { + cwd: process.cwd(), + env: { ...process.env, TRAUMA_TEST_DB_ROOT: root }, + }, + ); + + expect(JSON.parse(output)).toEqual({ + memory: { last_backup_error: null }, + stamp: { git_remote_url: "redacted:migration-0016" }, + migrationRecorded: 1, + }); + expect(output).not.toContain("migration-credential"); + }); + + it("deterministically reconciles legacy Moment path duplicates before enforcing identity", () => { const root = mkdtempSync(join(tmpdir(), "trauma-db-")); const output = runBunScript( ` @@ -980,9 +1026,14 @@ describe("db foundation", () => { id text PRIMARY KEY NOT NULL, url text NOT NULL, title text NOT NULL, + description text, + favicon_url text, content_path text NOT NULL, extraction_status text NOT NULL, + extraction_error text, backup_status text NOT NULL, + last_backup_at integer, + last_backup_error text, created_at integer NOT NULL, updated_at integer NOT NULL ) diff --git a/tests/server/files/atomic-write.test.ts b/tests/server/files/atomic-write.test.ts new file mode 100644 index 00000000..7881e55f --- /dev/null +++ b/tests/server/files/atomic-write.test.ts @@ -0,0 +1,153 @@ +import { + access, + chmod, + mkdtemp, + open, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + writeFileAtomically, + type AtomicWriteFileSystem, +} from "../../../src/server/files/atomic-write"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ), + ); +}); + +describe("durable atomic file replacement", () => { + it("syncs the temporary file before same-directory rename", async () => { + const root = await makeRoot(); + const targetPath = join(root, "trauma.config.json"); + await writeFile(targetPath, "old\n", "utf8"); + await chmod(targetPath, 0o600); + const calls: string[] = []; + let temporaryPath = ""; + const fileSystem = createInstrumentedFileSystem({ + onOpen: (path) => { + temporaryPath = path; + calls.push("open"); + }, + onSync: () => calls.push("sync"), + onClose: () => calls.push("close"), + onRename: (source, destination) => { + calls.push("rename"); + expect(dirname(source)).toBe(dirname(destination)); + }, + }); + + await writeFileAtomically(targetPath, "new\n", { fileSystem }); + + expect(await readFile(targetPath, "utf8")).toBe("new\n"); + expect((await stat(targetPath)).mode & 0o777).toBe(0o600); + expect(calls.slice(0, 4)).toEqual(["open", "sync", "close", "rename"]); + await expect(access(temporaryPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("keeps the original file and removes the temporary file when rename fails", async () => { + const root = await makeRoot(); + const targetPath = join(root, "trauma.config.json"); + await writeFile(targetPath, "old\n", "utf8"); + let temporaryPath = ""; + const fileSystem = createInstrumentedFileSystem({ + onOpen: (path) => { + temporaryPath = path; + }, + renameError: new Error("rename failed"), + }); + + await expect( + writeFileAtomically(targetPath, "new\n", { fileSystem }), + ).rejects.toThrow("rename failed"); + + expect(await readFile(targetPath, "utf8")).toBe("old\n"); + await expect(access(temporaryPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("keeps the original file when the temporary file cannot be synced", async () => { + const root = await makeRoot(); + const targetPath = join(root, "trauma.config.json"); + await writeFile(targetPath, "old\n", "utf8"); + let temporaryPath = ""; + const fileSystem = createInstrumentedFileSystem({ + onOpen: (path) => { + temporaryPath = path; + }, + syncError: new Error("sync failed"), + }); + + await expect( + writeFileAtomically(targetPath, "new\n", { fileSystem }), + ).rejects.toThrow("sync failed"); + + expect(await readFile(targetPath, "utf8")).toBe("old\n"); + await expect(access(temporaryPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +async function makeRoot() { + const root = await mkdtemp(join(tmpdir(), "trauma-atomic-write-")); + tempDirs.push(root); + return root; +} + +function createInstrumentedFileSystem(input: { + onClose?: () => void; + onOpen?: (path: string) => void; + onRename?: (source: string, destination: string) => void; + onSync?: () => void; + renameError?: Error; + syncError?: Error; +}): AtomicWriteFileSystem { + return { + open: async (path, flags, mode) => { + input.onOpen?.(path); + const handle = await open(path, flags, mode); + return { + writeFile: (data, options) => handle.writeFile(data, options), + sync: async () => { + if (input.syncError !== undefined) { + throw input.syncError; + } + await handle.sync(); + input.onSync?.(); + }, + close: async () => { + await handle.close(); + input.onClose?.(); + }, + }; + }, + rename: async (source, destination) => { + input.onRename?.(source, destination); + if (input.renameError !== undefined) { + throw input.renameError; + } + await import("node:fs/promises").then((module) => + module.rename(source, destination) + ); + }, + openDirectory: async (path) => { + const handle = await open(path, "r"); + return { + sync: () => handle.sync(), + close: () => handle.close(), + }; + }, + rm: (path, options) => rm(path, options), + stat: (path) => import("node:fs/promises").then((module) => module.stat(path)), + }; +} diff --git a/tests/server/flashbacks/repository.test.ts b/tests/server/flashbacks/repository.test.ts index c99740cf..6830b871 100644 --- a/tests/server/flashbacks/repository.test.ts +++ b/tests/server/flashbacks/repository.test.ts @@ -82,7 +82,10 @@ describe("flashback repository", () => { .prepare("insert into flashbacks (id, memory_id, text, prefix, suffix, start_offset, end_offset, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)") .run("flashback-old", "018f04a2-3c6f-7c88-9a8b-8c99a9b7f202", "older text", "old before", "old after", 4, 14, older, older); - const rows = await connection.repositories.flashbacks.listForBrowse(); + const rows = await connection.repositories.flashbacks.listRecentForBrowse({ + cursor: null, + limit: 100, + }); process.stdout.write(JSON.stringify(rows)); } finally { connection.close(); @@ -192,7 +195,10 @@ describe("flashback repository", () => { .prepare("insert into flashbacks (id, memory_id, variant_kind, lang_code, translation_output_hash, text, prefix, suffix, start_offset, end_offset, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") .run("translated-new", memoryId, "translation", "ja-JP", outputHash, "translated text", "", "", 0, 15, now, now); - const rows = await connection.repositories.flashbacks.listForBrowse(); + const rows = await connection.repositories.flashbacks.listRecentForBrowse({ + cursor: null, + limit: 100, + }); process.stdout.write(JSON.stringify(rows)); } finally { connection.close(); @@ -275,6 +281,16 @@ describe("flashback repository", () => { .run("flashback-new-translated", "memory-c", "translation", "ja-JP", outputHash, "translated text", "", "", 0, 15, now, now); const recent = await connection.repositories.flashbacks.listRecentForBrowse({ limit: 2 }); + const cursorRow = recent.at(-1); + const olderPage = cursorRow === undefined + ? [] + : await connection.repositories.flashbacks.listRecentForBrowse({ + cursor: { + createdAt: new Date(cursorRow.createdAt), + id: cursorRow.id, + }, + limit: 2, + }); const memoryRows = await connection.repositories.flashbacks.listForBrowseMemoryIds({ memoryIds: ["memory-a", "memory-c"], }); @@ -284,6 +300,7 @@ describe("flashback repository", () => { process.stdout.write(JSON.stringify({ recent: recent.map((row) => row.id), + olderPage: olderPage.map((row) => row.id), memoryRows: memoryRows.map((row) => [row.id, row.memoryId]), selectedSource, selected, @@ -298,6 +315,7 @@ describe("flashback repository", () => { expect(JSON.parse(output)).toEqual({ recent: ["flashback-new-translated", "flashback-middle"], + olderPage: ["flashback-old"], memoryRows: [ ["flashback-new-translated", "memory-c"], ["flashback-old", "memory-a"], diff --git a/tests/server/flashbacks/toggle.test.ts b/tests/server/flashbacks/toggle.test.ts index 8ce7eb30..3cbe10dc 100644 --- a/tests/server/flashbacks/toggle.test.ts +++ b/tests/server/flashbacks/toggle.test.ts @@ -94,7 +94,10 @@ describe("toggleMemoryFlashback", () => { }, config, db: connection.db, - backupQueue: { enqueue: async () => ({ backupStatus: "disabled" }) }, + backupQueue: { + persistIntent: async () => ({ backupStatus: "disabled" }), + enqueue: async () => ({ backupStatus: "disabled" }), + }, generateId: () => "translated-created", now: () => now, }); @@ -201,7 +204,12 @@ describe("toggleMemoryFlashback", () => { }; const connection = initializeDatabase(config); const enqueuedJobs = []; + const persistedJobs = []; const backupQueue = { + persistIntent: async (job) => { + persistedJobs.push(job); + return { backupStatus: "pending" }; + }, enqueue: async (job) => { const contentPaths = job.contentPaths ?? (job.contentPath === undefined ? [] : [job.contentPath]); const contents = await Promise.all( @@ -338,6 +346,7 @@ describe("toggleMemoryFlashback", () => { rowsAfterRemove, exportAfterRemove, enqueuedJobs, + persistedJobs, memory, staleError, })); @@ -412,6 +421,22 @@ describe("toggleMemoryFlashback", () => { containsMarkedContent: false, }, ]); + expect(result.persistedJobs).toEqual([ + { + memoryId: "018f04a2-3c6f-7c88-9a8b-8c99a9b7f301", + reason: "flashback_update", + contentPaths: [ + "memories/018f04a2-3c6f-7c88-9a8b-8c99a9b7f301/FLASHBACKS.json", + ], + }, + { + memoryId: "018f04a2-3c6f-7c88-9a8b-8c99a9b7f301", + reason: "flashback_update", + contentPaths: [ + "memories/018f04a2-3c6f-7c88-9a8b-8c99a9b7f301/FLASHBACKS.json", + ], + }, + ]); expect(result.memory).toEqual({ backup_status: "queued" }); expect(result.staleError).toEqual({ name: "FlashbackToggleError", @@ -495,6 +520,7 @@ describe("toggleMemoryFlashback", () => { config, db: connection.db, backupQueue: { + persistIntent: async () => ({ backupStatus: "pending" }), enqueue: async () => ({ backupStatus: "queued" }), }, generateId: () => "flashback-rendered", @@ -628,6 +654,7 @@ describe("toggleMemoryFlashback", () => { config, db: connection.db, backupQueue: { + persistIntent: async () => ({ backupStatus: "pending" }), enqueue: async () => { throw new Error("queue offline"); }, @@ -774,6 +801,7 @@ describe("toggleMemoryFlashback", () => { config, db: connection.db, backupQueue: { + persistIntent: async () => ({ backupStatus: "pending" }), enqueue: async () => ({ backupStatus: "disabled" }), }, generateId: () => "flashback-new", diff --git a/tests/server/http/trusted-host.test.ts b/tests/server/http/trusted-host.test.ts new file mode 100644 index 00000000..45c96111 --- /dev/null +++ b/tests/server/http/trusted-host.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { + isTrustedRequestHost, + readTrustedHostnames, +} from "../../../src/server/http/trusted-host"; + +describe("trusted request host boundary", () => { + it("allows only loopback hostnames by default", () => { + const trustedHosts = readTrustedHostnames(undefined); + + expect(isTrustedRequestHost("127.0.0.1:4173", trustedHosts)).toBe(true); + expect(isTrustedRequestHost("localhost:4173", trustedHosts)).toBe(true); + expect(isTrustedRequestHost("[::1]:4173", trustedHosts)).toBe(true); + expect(isTrustedRequestHost("attacker.example", trustedHosts)).toBe(false); + }); + + it("adds exact normalized reverse-proxy hostnames", () => { + const trustedHosts = readTrustedHostnames("archive.example, READER.EXAMPLE."); + + expect(isTrustedRequestHost("archive.example:443", trustedHosts)).toBe(true); + expect(isTrustedRequestHost("reader.example", trustedHosts)).toBe(true); + expect(isTrustedRequestHost("sub.archive.example", trustedHosts)).toBe(false); + }); + + it("rejects malformed or ambiguous Host headers", () => { + const trustedHosts = readTrustedHostnames(undefined); + + for (const host of [ + null, + "", + "localhost, attacker.example", + "localhost/path", + "user@localhost", + "localhost:invalid", + ]) { + expect(isTrustedRequestHost(host, trustedHosts)).toBe(false); + } + }); + + it("fails closed on invalid configured hostnames", () => { + expect(() => readTrustedHostnames("https://archive.example")).toThrow( + "Invalid TRAUMA_ALLOWED_HOSTS entry", + ); + expect(() => readTrustedHostnames("*.example.com")).toThrow( + "Invalid TRAUMA_ALLOWED_HOSTS entry", + ); + }); +}); diff --git a/tests/server/memories/add-memory.test.ts b/tests/server/memories/add-memory.test.ts index 3d378a34..cc4ac621 100644 --- a/tests/server/memories/add-memory.test.ts +++ b/tests/server/memories/add-memory.test.ts @@ -434,7 +434,13 @@ describe("add memory orchestration", () => { }, backupQueue: { enqueue: async () => { - throw new Error("queue unavailable"); + const credential = ["queue", "credential"].join("-"); + throw new Error( + "queue unavailable for https://backup-user:" + + credential + + "@example.com/private.git Bearer " + + credential, + ); }, }, generateId: () => ${JSON.stringify("018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef113")}, @@ -471,8 +477,11 @@ describe("add memory orchestration", () => { expect(result).toMatchObject({ id: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef113", backupStatus: "failed", - lastBackupError: "queue unavailable", }); + expect(result.lastBackupError).toContain("queue unavailable"); + expect(result.lastBackupError).toContain("[redacted]"); + expect(result.lastBackupError).not.toContain("queue-credential"); + expect(result.lastBackupError.length).toBeLessThanOrEqual(4_096); }); it("returns the created memory when queued-status persistence fails after insert", async () => { diff --git a/tests/server/memories/browse.test.ts b/tests/server/memories/browse.test.ts index 1da43e7e..8cda949b 100644 --- a/tests/server/memories/browse.test.ts +++ b/tests/server/memories/browse.test.ts @@ -50,7 +50,7 @@ describe("browse memory loader error policy", () => { expect(firstPage).toMatchObject({ memories: [ - { id: "memory-foundation", flashbacks: [] }, + { id: "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef901", flashbacks: [] }, { id: "memory-ops", flashbacks: [] }, ], nextCursor: { createdAt: "2026-05-08", id: "memory-ops" }, diff --git a/tests/server/memories/delete-memory.test.ts b/tests/server/memories/delete-memory.test.ts index fc906fe7..29e33647 100644 --- a/tests/server/memories/delete-memory.test.ts +++ b/tests/server/memories/delete-memory.test.ts @@ -39,6 +39,67 @@ afterEach(async () => { }); describe("delete memory service", () => { + it("refuses to delete content owned by a different memory row", async () => { + const root = await makeRoot(); + const config = loadRouteConfig(await writeRouteConfig(root)); + const otherMemoryId = "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef992"; + await seedRouteMemory(config); + await writeMemoryContent({ + config, + memoryId: otherMemoryId, + frontmatter: { + id: otherMemoryId, + url: "https://example.com/other", + title: "Other memory", + capturedAt: routeNow.toISOString(), + extractionStatus: "success", + }, + markdown: "# Other memory\n\nMust remain.", + }); + const connection = initializeDatabase(config); + try { + await connection.repositories.memories.create({ + id: otherMemoryId, + url: "https://example.com/other", + title: "Other memory", + description: null, + faviconUrl: null, + contentPath: `memories/${otherMemoryId}/CONTENT.md`, + extractionStatus: "success", + extractionError: null, + read: false, + backupStatus: "disabled", + lastBackupAt: null, + lastBackupError: null, + createdAt: routeNow, + updatedAt: routeNow, + }); + connection.sqlite + .prepare("update memories set content_path = ? where id = ?") + .run(`memories/${otherMemoryId}/CONTENT.md`, routeMemoryId); + + await expect(deleteMemory({ + config, + db: connection.db, + memoryId: routeMemoryId, + })).resolves.toEqual({ + status: "failed", + error: "memory content path is not owned by the requested memory", + }); + expect( + connection.sqlite + .prepare("select count(*) as count from memories where id in (?, ?)") + .get(routeMemoryId, otherMemoryId), + ).toEqual({ count: 2 }); + } finally { + connection.close(); + } + await expect(readFile( + join(config.storePath, "memories", otherMemoryId, "CONTENT.md"), + "utf8", + )).resolves.toContain("Must remain."); + }); + it("queues deleted memory content and Flashback export paths for git backup", async () => { const root = await makeRoot(); const config = loadRouteConfig(await writeRouteConfig(root)); diff --git a/tests/server/memories/operation-journal.test.ts b/tests/server/memories/operation-journal.test.ts new file mode 100644 index 00000000..7d121ffd --- /dev/null +++ b/tests/server/memories/operation-journal.test.ts @@ -0,0 +1,441 @@ +import { + access, + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { ResolvedTraumaConfig } from "../../../src/server/config"; +import { initializeDatabase } from "../../../src/server/db"; +import { + persistMemoryCreationJournal, + persistMemoryDeletionJournal, + recoverInterruptedMemoryOperations, + resolveMemoryDeletionStagingPath, +} from "../../../src/server/memories/operation-journal"; +import { + resolveMemoryContentPath, + writeMemoryContent, +} from "../../../src/server/store/memory-content"; + +const memoryId = "018f2d6d-7cbd-7a4c-8d32-9f0b5f0ef991"; +const now = new Date("2026-07-17T00:00:00.000Z"); +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((path) => + rm(path, { recursive: true, force: true }) + )); +}); + +describe("memory operation journal recovery", () => { + it("reconstructs a missing SQLite row for content written before create crashed", async () => { + const config = await createConfig(false); + const connection = initializeDatabase(config); + const contentPath = resolveMemoryContentPath(config, memoryId); + try { + await persistMemoryCreationJournal({ + config, + journal: { + version: 1, + kind: "memory_creation", + memory: { + id: memoryId, + url: "https://example.com/recovered", + title: "Recovered creation", + description: "Recovered metadata", + faviconUrl: null, + contentPath: contentPath.relativePath, + extractionStatus: "success", + extractionError: null, + read: false, + backupStatus: "disabled", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + }, + }); + await writeMemoryContent({ + config, + memoryId, + overwrite: false, + frontmatter: { + id: memoryId, + url: "https://example.com/recovered", + title: "Recovered creation", + capturedAt: now.toISOString(), + extractionStatus: "success", + }, + markdown: "# Recovered creation", + }); + + await expect(recoverInterruptedMemoryOperations({ + config, + memories: connection.repositories.memories, + })).resolves.toBe(1); + await expect(connection.repositories.memories.findById(memoryId)).resolves.toMatchObject({ + id: memoryId, + contentPath: contentPath.relativePath, + backupStatus: "disabled", + }); + await expect(readFile(contentPath.absolutePath, "utf8")).resolves.toContain( + "# Recovered creation", + ); + await expect(access(join(config.storePath, ".operations", `${memoryId}.json`))) + .rejects.toMatchObject({ code: "ENOENT" }); + } finally { + connection.close(); + } + }); + + it("restores staged content and a pending backup when delete crashed before row removal", async () => { + const config = await createConfig(true); + const connection = initializeDatabase(config); + const contentPath = resolveMemoryContentPath(config, memoryId); + const stagingPath = resolveMemoryDeletionStagingPath({ + memoryId, + storePath: config.storePath, + uniqueSuffix: "test-crash", + }); + try { + await connection.repositories.memories.create({ + id: memoryId, + url: "https://example.com/delete-recovery", + title: "Delete recovery", + description: null, + faviconUrl: null, + contentPath: contentPath.relativePath, + extractionStatus: "success", + extractionError: null, + read: false, + backupStatus: "success", + lastBackupAt: now, + lastBackupError: null, + createdAt: now, + updatedAt: now, + }); + await writeMemoryContent({ + config, + memoryId, + frontmatter: { + id: memoryId, + url: "https://example.com/delete-recovery", + title: "Delete recovery", + capturedAt: now.toISOString(), + extractionStatus: "success", + }, + markdown: "# Restore me", + }); + await persistMemoryDeletionJournal({ + config, + journal: { + version: 1, + kind: "memory_deletion", + memoryId, + contentPath: contentPath.relativePath, + stagingPath: stagingPath.relativePath, + }, + }); + await mkdir(dirname(stagingPath.absolutePath), { recursive: true }); + await rename(dirname(contentPath.absolutePath), stagingPath.absolutePath); + + await expect(recoverInterruptedMemoryOperations({ + config, + memories: connection.repositories.memories, + now: () => now, + })).resolves.toBe(1); + await expect(readFile(contentPath.absolutePath, "utf8")).resolves.toContain( + "# Restore me", + ); + await expect(connection.repositories.memories.findById(memoryId)).resolves.toMatchObject({ + backupStatus: "pending", + lastBackupAt: null, + }); + await expect(access(stagingPath.absolutePath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + connection.close(); + } + }); + + it("removes staged plaintext when delete crashed after its SQLite row was removed", async () => { + const config = await createConfig(false); + const connection = initializeDatabase(config); + const contentPath = resolveMemoryContentPath(config, memoryId); + const stagingPath = resolveMemoryDeletionStagingPath({ + memoryId, + storePath: config.storePath, + uniqueSuffix: "row-removed", + }); + try { + await mkdir(stagingPath.absolutePath, { recursive: true }); + await writeFile(join(stagingPath.absolutePath, "CONTENT.md"), "plaintext", "utf8"); + await persistMemoryDeletionJournal({ + config, + journal: { + version: 1, + kind: "memory_deletion", + memoryId, + contentPath: contentPath.relativePath, + stagingPath: stagingPath.relativePath, + }, + }); + + await expect(recoverInterruptedMemoryOperations({ + config, + memories: connection.repositories.memories, + })).resolves.toBe(1); + await expect(access(stagingPath.absolutePath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(join(config.storePath, ".operations", `${memoryId}.json`))) + .rejects.toMatchObject({ code: "ENOENT" }); + } finally { + connection.close(); + } + }); + + it("finishes a deletion when its row remains after both content locations disappeared", async () => { + const config = await createConfig(false); + const connection = initializeDatabase(config); + const contentPath = resolveMemoryContentPath(config, memoryId); + const stagingPath = resolveMemoryDeletionStagingPath({ + memoryId, + storePath: config.storePath, + uniqueSuffix: "missing-before-row-delete", + }); + try { + await connection.repositories.memories.create({ + id: memoryId, + url: "https://example.com/missing-delete", + title: "Missing delete", + description: null, + faviconUrl: null, + contentPath: contentPath.relativePath, + extractionStatus: "success", + extractionError: null, + read: false, + backupStatus: "disabled", + lastBackupAt: null, + lastBackupError: null, + createdAt: now, + updatedAt: now, + }); + await persistMemoryDeletionJournal({ + config, + journal: { + version: 1, + kind: "memory_deletion", + memoryId, + contentPath: contentPath.relativePath, + stagingPath: stagingPath.relativePath, + }, + }); + + await expect(recoverInterruptedMemoryOperations({ + config, + memories: connection.repositories.memories, + })).resolves.toBe(1); + await expect(connection.repositories.memories.findById(memoryId)) + .resolves.toBeUndefined(); + await expect(access(join(config.storePath, ".operations", `${memoryId}.json`))) + .rejects.toMatchObject({ code: "ENOENT" }); + } finally { + connection.close(); + } + }); + + it("keeps a missing-content deletion journal until its backup completes", async () => { + const config = await createConfig(true); + const connection = initializeDatabase(config); + const contentPath = resolveMemoryContentPath(config, memoryId); + const stagingPath = resolveMemoryDeletionStagingPath({ + memoryId, + storePath: config.storePath, + uniqueSuffix: "missing-before-backup", + }); + const journalPath = join(config.storePath, ".operations", `${memoryId}.json`); + const attemptedBackups: unknown[] = []; + const observedBackupStatuses: unknown[] = []; + try { + await connection.repositories.memories.create({ + id: memoryId, + url: "https://example.com/missing-delete-backup", + title: "Missing delete backup", + description: null, + faviconUrl: null, + contentPath: contentPath.relativePath, + extractionStatus: "success", + extractionError: null, + read: false, + backupStatus: "success", + lastBackupAt: now, + lastBackupError: null, + createdAt: now, + updatedAt: now, + }); + await persistMemoryDeletionJournal({ + config, + journal: { + version: 1, + kind: "memory_deletion", + memoryId, + contentPath: contentPath.relativePath, + stagingPath: stagingPath.relativePath, + }, + }); + + await expect(recoverInterruptedMemoryOperations({ + completeMissingDeletionBackup: async (deletion) => { + attemptedBackups.push(deletion); + observedBackupStatuses.push( + (await connection.repositories.memories.findById(memoryId)) + ?.backupStatus, + ); + throw new Error("backup unavailable"); + }, + config, + memories: connection.repositories.memories, + })).rejects.toThrow("backup unavailable"); + await expect(connection.repositories.memories.findById(memoryId)) + .resolves.toMatchObject({ + id: memoryId, + backupStatus: "pending", + lastBackupAt: null, + }); + await expect(access(journalPath)).resolves.toBeNull(); + + await expect(recoverInterruptedMemoryOperations({ + completeMissingDeletionBackup: async (deletion) => { + attemptedBackups.push(deletion); + observedBackupStatuses.push( + (await connection.repositories.memories.findById(memoryId)) + ?.backupStatus, + ); + }, + config, + memories: connection.repositories.memories, + })).resolves.toBe(1); + expect(attemptedBackups).toEqual([ + { + contentPaths: [ + `memories/${memoryId}/CONTENT.md`, + `memories/${memoryId}/FLASHBACKS.json`, + `memories/${memoryId}`, + ], + memoryId, + }, + { + contentPaths: [ + `memories/${memoryId}/CONTENT.md`, + `memories/${memoryId}/FLASHBACKS.json`, + `memories/${memoryId}`, + ], + memoryId, + }, + ]); + expect(observedBackupStatuses).toEqual(["pending", "pending"]); + await expect(connection.repositories.memories.findById(memoryId)) + .resolves.toBeUndefined(); + await expect(access(journalPath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + connection.close(); + } + }); + + it("preserves both copies when deletion recovery finds ambiguous content", async () => { + const config = await createConfig(false); + const connection = initializeDatabase(config); + const contentPath = resolveMemoryContentPath(config, memoryId); + const stagingPath = resolveMemoryDeletionStagingPath({ + memoryId, + storePath: config.storePath, + uniqueSuffix: "ambiguous", + }); + try { + await connection.repositories.memories.create({ + id: memoryId, + url: "https://example.com/ambiguous", + title: "Ambiguous recovery", + description: null, + faviconUrl: null, + contentPath: contentPath.relativePath, + extractionStatus: "success", + extractionError: null, + read: false, + backupStatus: "disabled", + lastBackupAt: null, + lastBackupError: null, + createdAt: now, + updatedAt: now, + }); + await writeMemoryContent({ + config, + memoryId, + frontmatter: { + id: memoryId, + url: "https://example.com/ambiguous", + title: "Ambiguous recovery", + capturedAt: now.toISOString(), + extractionStatus: "success", + }, + markdown: "# Canonical copy", + }); + await mkdir(stagingPath.absolutePath, { recursive: true }); + await writeFile( + join(stagingPath.absolutePath, "CONTENT.md"), + "# Staged copy", + "utf8", + ); + await persistMemoryDeletionJournal({ + config, + journal: { + version: 1, + kind: "memory_deletion", + memoryId, + contentPath: contentPath.relativePath, + stagingPath: stagingPath.relativePath, + }, + }); + + await expect(recoverInterruptedMemoryOperations({ + config, + memories: connection.repositories.memories, + })).rejects.toThrow("both canonical and staged content"); + await expect(readFile(contentPath.absolutePath, "utf8")) + .resolves.toContain("Canonical copy"); + await expect(readFile(join(stagingPath.absolutePath, "CONTENT.md"), "utf8")) + .resolves.toContain("Staged copy"); + await expect(access(join(config.storePath, ".operations", `${memoryId}.json`))) + .resolves.toBeNull(); + } finally { + connection.close(); + } + }); +}); + +async function createConfig( + backupEnabled: boolean, +): Promise { + const root = await mkdtemp(join(tmpdir(), "trauma-operation-journal-")); + tempDirs.push(root); + return { + configFilePath: join(root, "trauma.config.json"), + projectPath: join(root, "data"), + storePath: join(root, "data", "store"), + databasePath: join(root, ".trauma", "trauma.sqlite"), + backup: { + git: { + enabled: backupEnabled, + remote: "origin", + branch: "main", + push: false, + commitMessageTemplate: "backup memory {memoryId}", + }, + }, + }; +} diff --git a/tests/server/psychiatrist/detached-task.test.ts b/tests/server/psychiatrist/detached-task.test.ts new file mode 100644 index 00000000..3a6f6a98 --- /dev/null +++ b/tests/server/psychiatrist/detached-task.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { runDetachedPsychiatristTask } from "../../../src/server/psychiatrist/detached-task"; + +describe("detached Psychiatrist task", () => { + it("contains a rejection after the HTTP request has detached", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown) => unhandled.push(error); + process.on("unhandledRejection", onUnhandled); + try { + runDetachedPsychiatristTask(async () => { + throw new Error("secondary persistence failed"); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); diff --git a/tests/server/psychiatrist/thread-store.test.ts b/tests/server/psychiatrist/thread-store.test.ts index ae57d618..01c85d20 100644 --- a/tests/server/psychiatrist/thread-store.test.ts +++ b/tests/server/psychiatrist/thread-store.test.ts @@ -20,6 +20,7 @@ import { markPsychiatristTurnFailed, markPsychiatristThreadStale, reconcileInactivePsychiatristTurns, + recoverCompletedPsychiatristArtifactsForMemory, recordPsychiatristTurnStarted, } from "../../../src/server/psychiatrist/thread-store"; import { PSYCHIATRIST_PROMPT_POLICY_VERSION } from "../../../src/server/psychiatrist/prompt"; @@ -161,6 +162,75 @@ describe("Psychiatrist thread store", () => { expect(manifestJson.updated_at).not.toBe("2026-06-01T00:00:00.000Z"); }); + it("restores RESPONSE.md from the latest durable completed pair revision", async () => { + const storePath = await mkdtemp(join(tmpdir(), "trauma-psychiatrist-response-recovery-")); + await createPsychiatristThread({ config: { storePath }, manifest: manifest() }); + await appendPendingPair({ + config: { storePath }, + contextSnapshot: contextSnapshot(), + pairId: PAIR_ID, + prompt: "What is durable?", + threadId: THREAD_ID, + turnId: TURN_ID, + }); + await appendAssistantResponse({ + assistantResponse: "The pair revision is durable.", + citations: [], + config: { storePath }, + pairId: PAIR_ID, + threadId: THREAD_ID, + }); + const responsePath = join( + storePath, + "memories", + MEMORY_ID, + "threads", + THREAD_ID, + "pairs", + PAIR_ID, + "RESPONSE.md", + ); + await writeFile(responsePath, "uncommitted regenerated response", "utf8"); + + await expect(recoverCompletedPsychiatristArtifactsForMemory({ + config: { storePath }, + memoryId: MEMORY_ID, + })).resolves.toBe(1); + await expect(readFile(responsePath, "utf8")).resolves.toBe( + "The pair revision is durable.", + ); + }); + + it("removes an orphan RESPONSE.md when no completed pair revision exists", async () => { + const storePath = await mkdtemp(join(tmpdir(), "trauma-psychiatrist-orphan-response-")); + await createPsychiatristThread({ config: { storePath }, manifest: manifest() }); + await appendPendingPair({ + config: { storePath }, + contextSnapshot: contextSnapshot(), + pairId: PAIR_ID, + prompt: "What is durable?", + threadId: THREAD_ID, + turnId: TURN_ID, + }); + const responsePath = join( + storePath, + "memories", + MEMORY_ID, + "threads", + THREAD_ID, + "pairs", + PAIR_ID, + "RESPONSE.md", + ); + await writeFile(responsePath, "response written before its pair row", "utf8"); + + await expect(recoverCompletedPsychiatristArtifactsForMemory({ + config: { storePath }, + memoryId: MEMORY_ID, + })).resolves.toBe(1); + await expect(readFile(responsePath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("recovers a torn final pair revision before appending the next row", async () => { const storePath = await mkdtemp(join(tmpdir(), "trauma-psychiatrist-pairs-torn-tail-")); await createPsychiatristThread({ diff --git a/tests/server/reader/markdown-renderer.test.ts b/tests/server/reader/markdown-renderer.test.ts index 81c37add..a38bfe44 100644 --- a/tests/server/reader/markdown-renderer.test.ts +++ b/tests/server/reader/markdown-renderer.test.ts @@ -1,8 +1,36 @@ -import { describe, expect, it } from "vitest"; +import hljs from "highlight.js"; +import { describe, expect, it, vi } from "vitest"; import { renderMemoryMarkdown } from "../../../src/server/reader/markdown-renderer"; describe("renderMemoryMarkdown", () => { + it("renders oversized and unknown code as escaped plain text", () => { + const highlight = vi.spyOn(hljs, "highlight"); + const highlightAuto = vi.spyOn(hljs, "highlightAuto"); + const oversized = ` target"; diff --git a/tests/server/translation/chunker.test.ts b/tests/server/translation/chunker.test.ts index df2b526c..b63cfb56 100644 --- a/tests/server/translation/chunker.test.ts +++ b/tests/server/translation/chunker.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "vitest"; -import { createTranslationChunks } from "../../../src/server/translation/chunker"; +import { + createTranslationChunks, + DEFAULT_TRANSLATION_CHUNK_CONFIG, +} from "../../../src/server/translation/chunker"; +import { estimateRoughTokens } from "../../../src/server/translation/hash"; import { parseMarkdownTranslationBlocks } from "../../../src/server/translation/markdown-blocks"; +import { + BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES, + buildTranslationPrompt, +} from "../../../src/server/translation/prompt"; import type { TranslationSourceSnapshot } from "../../../src/server/translation/types"; describe("translation chunker", () => { @@ -43,4 +51,168 @@ describe("translation chunker", () => { "s000004", ]); }); + + it.each([ + ["paragraph", `# Large paragraph\n\n${"Translatable sentence. ".repeat(700)}`], + [ + "paragraph with inline code", + `# Large protected paragraph\n\n${"Translate around `stable_id` safely. ".repeat(700)}`, + ], + [ + "list", + `# Large list\n\n${Array.from( + { length: 700 }, + (_, index) => `- Ordered item ${index} remains in place.\n`, + ).join("")}`, + ], + ])("splits one oversized %s block into stable hard-bounded chunks", (_, markdown) => { + const source = translationSource(markdown); + const chunks = createTranslationChunks({ + blocks: parseMarkdownTranslationBlocks(markdown).blocks, + jobId: "job-large", + langCode: "ja-JP", + memoryId: source.memoryId, + source, + }); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.map((chunk) => chunk.sourceMarkdown).join("")) + .toBe(parseMarkdownTranslationBlocks(markdown).bodyMarkdown); + expect( + chunks.every((chunk) => + estimateRoughTokens(chunk.sourceMarkdown) <= + DEFAULT_TRANSLATION_CHUNK_CONFIG.maxRoughTokens + ), + ).toBe(true); + expect( + chunks.every((chunk) => + Buffer.byteLength( + buildTranslationPrompt({ chunk, targetLanguage: "ja-JP" }), + "utf8", + ) <= BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES + ), + ).toBe(true); + }); + + it.each([ + ["multibyte sentence stream", `# 多言語\n\n${"界。".repeat(4_000)}`], + ["CRLF paragraph", `# CRLF\r\n\r\n${"Ordered sentence.\r\n".repeat(700)}`], + ])("preserves %s bytes, offsets, and deterministic fragment ids", (_, markdown) => { + const source = translationSource(markdown); + const create = () => + createTranslationChunks({ + blocks: parseMarkdownTranslationBlocks(markdown).blocks, + jobId: "job-stable", + langCode: "ja-JP", + memoryId: source.memoryId, + source, + }); + const chunks = create(); + const body = parseMarkdownTranslationBlocks(markdown).bodyMarkdown; + const fragments = chunks.flatMap((chunk) => chunk.sourceBlocks); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.map((chunk) => chunk.sourceMarkdown).join("")).toBe(body); + expect(fragments.map((fragment) => fragment.id)) + .toEqual(create().flatMap((chunk) => chunk.blockIds)); + for (const fragment of fragments) { + expect(body.slice(fragment.sourceStart, fragment.sourceEnd)) + .toBe(fragment.markdown); + } + expect(chunks.every((chunk) => + Buffer.byteLength( + buildTranslationPrompt({ chunk, targetLanguage: "ja-JP" }), + "utf8", + ) <= BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES + )).toBe(true); + }); + + it("rejects an oversized structurally indivisible fenced block before chunk creation", () => { + const markdown = `# Source\n\n\`\`\`text\n${"protected-code-line\n".repeat(700)}\`\`\`\n`; + const source = translationSource(markdown); + + expect(() => + createTranslationChunks({ + blocks: parseMarkdownTranslationBlocks(markdown).blocks, + jobId: "job-fence", + langCode: "ja-JP", + memoryId: source.memoryId, + source, + }) + ).toThrow(/cannot be safely split/i); + }); + + it("bounds a large dense-boundary paragraph and list without losing order", () => { + const paragraph = "dense boundary ".repeat(8_000); + const list = Array.from( + { length: 800 }, + (_, index) => + `- dense list item ${String(index).padStart(5, "0")} ${"x".repeat(64)}\n`, + ).join(""); + const markdown = `# Dense source\n\n${paragraph}\n\n${list}`; + const source = translationSource(markdown); + + const chunks = createTranslationChunks({ + blocks: parseMarkdownTranslationBlocks(markdown).blocks, + jobId: "job-dense", + langCode: "ja-JP", + memoryId: source.memoryId, + source, + }); + + expect(chunks.length).toBeGreaterThan(10); + expect(chunks.map((chunk) => chunk.sourceMarkdown).join("")) + .toBe(parseMarkdownTranslationBlocks(markdown).bodyMarkdown); + expect(chunks.every((chunk) => + estimateRoughTokens(chunk.sourceMarkdown) <= + DEFAULT_TRANSLATION_CHUNK_CONFIG.maxRoughTokens + )).toBe(true); + }); + + it("bounds every prompt-oversized group in one ordered multibyte pass", () => { + const groupCount = 48; + const paragraph = `${"界".repeat(249)}。`.repeat(39); + const markdown = Array.from( + { length: groupCount }, + (_, index) => `${String(index).padStart(2, "0")}: ${paragraph}`, + ).join("\n\n"); + const source = translationSource(markdown); + const styleProfile = "dense-style ".repeat(400); + + const chunks = createTranslationChunks({ + blocks: parseMarkdownTranslationBlocks(markdown).blocks, + jobId: "job-multi-group", + langCode: "ja-JP", + memoryId: source.memoryId, + source, + styleProfile, + }); + + expect(chunks).toHaveLength(groupCount * 2); + expect(chunks.map((chunk) => chunk.sourceMarkdown).join("")).toBe(markdown); + expect(chunks.map((chunk) => chunk.chunkIndex)).toEqual( + Array.from({ length: chunks.length }, (_, index) => index), + ); + expect(chunks.every((chunk) => chunk.chunkCount === chunks.length)).toBe(true); + expect(chunks.every((chunk) => + Buffer.byteLength( + buildTranslationPrompt({ chunk, targetLanguage: "ja-JP" }), + "utf8", + ) <= BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES + )).toBe(true); + }); }); + +function translationSource(markdown: string): TranslationSourceSnapshot { + return { + byteSize: Buffer.byteLength(markdown, "utf8"), + documentType: "article", + memoryId: "018f04a2-3c6f-7c88-9a8b-8c99a9b7f903", + roughTokenEstimate: estimateRoughTokens(markdown), + sourceHash: "sha256:source", + sourceMarkdown: markdown, + sourcePath: "memories/018f04a2-3c6f-7c88-9a8b-8c99a9b7f903/CONTENT.md", + sourceUrl: "https://example.com", + title: "Large source", + }; +} diff --git a/tests/server/translation/prompt.test.ts b/tests/server/translation/prompt.test.ts index d1262e8a..926540b5 100644 --- a/tests/server/translation/prompt.test.ts +++ b/tests/server/translation/prompt.test.ts @@ -119,6 +119,31 @@ describe("Brilliant translation prompt and validation", () => { expect(prompt).not.toContain("\"translated_markdown\""); }); + it("rejects an outbound prompt above the fixed UTF-8 byte cap as non-retryable", () => { + const chunk = createPromptChunk("Translatable body."); + const oversizedText = "界".repeat(24_000); + const oversizedChunk: TranslationChunk = { + ...chunk, + segments: chunk.segments.map((segment) => ({ + ...segment, + text: oversizedText, + })), + sourceMarkdown: oversizedText, + }; + + try { + buildTranslationPrompt({ + chunk: oversizedChunk, + targetLanguage: "ja-JP", + }); + throw new Error("expected translation prompt byte validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(TranslationOutputValidationError); + expect((error as TranslationOutputValidationError).retryable).toBe(false); + expect((error as Error).message).toMatch(/prompt.*UTF-8 byte limit/i); + } + }); + it("adds safe validation diagnostics to retry prompts without raw failed output", () => { const chunk = createPromptChunk("Read [docs](https://example.com/docs) and `AGENTS.md`.\n"); const prompt = buildTranslationPrompt({ diff --git a/tests/server/translation/runner.test.ts b/tests/server/translation/runner.test.ts index 3cb48e6a..7d38f9b0 100644 --- a/tests/server/translation/runner.test.ts +++ b/tests/server/translation/runner.test.ts @@ -14,7 +14,11 @@ import { runTranslationJob, startTranslationJob, } from "../../../src/server/translation/runner"; -import { BRILLIANT_MAX_TRANSLATED_SEGMENT_BYTES } from "../../../src/server/translation/prompt"; +import { + BRILLIANT_MAX_TRANSLATED_SEGMENT_BYTES, + BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES, +} from "../../../src/server/translation/prompt"; +import { splitFrontmatter } from "../../../src/server/translation/markdown-blocks"; import { resolveTranslatedMemoryContentPath, resolveTranslatedMemoryProjectionPath, @@ -134,6 +138,91 @@ describe("translation runner", () => { expect(atomicCreateCalls).toBe(1); }); + it("rejects an oversized indivisible source block before durable scheduling", async () => { + const config = await createConfig(); + const markdown = `# Source\n\n\`\`\`text\n${"protected-code-line\n".repeat(700)}\`\`\`\n`; + await writeSourceContent(config, markdown); + await createMemoryRow(config); + const client = new FakeTranslationClient(); + const jobId = "019e3906-0000-7000-8000-000000000112"; + let scheduled = false; + + await expect( + startTranslationJob({ + client, + config, + generateJobId: () => jobId, + memoryId, + now, + schedule: () => { + scheduled = true; + }, + }), + ).rejects.toMatchObject({ + action: "open_source_reader", + code: "validation_failed", + }); + + expect(scheduled).toBe(false); + expect(client.inputs).toHaveLength(0); + const connection = initializeDatabase(config); + try { + await expect( + connection.repositories.translations.getTranslationJob(jobId), + ).resolves.toBeNull(); + } finally { + connection.close(); + } + }); + + it("stitches bounded paragraph and list fragments in exact Markdown order", async () => { + const config = await createConfig(); + const markdown = [ + "# Ordered source", + "", + Array.from( + { length: 700 }, + (_, index) => `Paragraph marker ${String(index).padStart(4, "0")}.`, + ).join(" "), + "", + ...Array.from( + { length: 700 }, + (_, index) => `- List marker ${String(index).padStart(4, "0")} remains ordered.`, + ), + "", + ].join("\n"); + await writeSourceContent(config, markdown); + await createMemoryRow(config); + const client = new IdentityTranslationClient(); + const started = await startTranslationJob({ + client, + config, + generateJobId: () => "019e3906-0000-7000-8000-000000000113", + memoryId, + now, + schedule: () => undefined, + }); + + await runTranslationJob(started.job_id, { client, config }); + + expect(client.inputs.length).toBeGreaterThan(2); + expect(client.inputs.every((input) => + Buffer.byteLength(input.prompt, "utf8") <= + BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES + )).toBe(true); + const sourcePath = join(config.storePath, "memories", memoryId, "CONTENT.md"); + const outputPath = join( + config.storePath, + "memories", + memoryId, + "ja-JP", + "CONTENT.md", + ); + const sourceBody = splitFrontmatter(await readFile(sourcePath, "utf8")).bodyMarkdown; + const outputBody = splitFrontmatter(await readFile(outputPath, "utf8")).bodyMarkdown; + expect(outputBody).toBe(sourceBody); + }); + it("starts a job, translates chunks, commits output, and reuses current output", async () => { const config = await createConfig(); await writeSourceContent(config); @@ -1309,6 +1398,53 @@ describe("translation runner", () => { ).rejects.toMatchObject({ code: "ENOENT" }); }); + it("fails a prompt byte-limit violation before client send without retrying", async () => { + const config = await createConfig(); + await writeSourceContent(config); + await createMemoryRow(config); + const client = new FakeTranslationClient(); + const jobId = "019e3906-0000-7000-8000-000000000111"; + const started = await startTranslationJob({ + client, + config, + generateJobId: () => jobId, + memoryId, + now, + schedule: () => undefined, + }); + + await runTranslationJob(started.job_id, { + client, + config, + promptByteLimit: 1, + }); + + expect(client.inputs).toHaveLength(0); + const connection = initializeDatabase(config); + try { + await expect( + connection.repositories.translations.getTranslationJob(jobId), + ).resolves.toMatchObject({ + error: { + action: "none", + code: "validation_failed", + }, + status: "failed", + }); + await expect( + connection.repositories.translations.getTranslationChunks(jobId), + ).resolves.toEqual([ + expect.objectContaining({ + retryCount: 0, + status: "failed", + translatedMarkdown: null, + }), + ]); + } finally { + connection.close(); + } + }); + it("closes a translation client when the scheduled run owns it", async () => { const config = await createConfig(); await writeSourceContent(config); @@ -1810,7 +1946,7 @@ describe("translation runner", () => { }, { mismatch: "chunker version", - mutation: "update translation_jobs set chunker_version = 'chunker-old'", + mutation: "update translation_jobs set chunker_version = 'chunker-segments-v1'", }, { mismatch: "runtime chunk count", @@ -2021,6 +2157,22 @@ class FakeTranslationClient implements TranslationClient { } } +class IdentityTranslationClient extends FakeTranslationClient { + override async translateChunk( + input: TranslateChunkInput, + ): Promise { + this.inputs.push(input); + return { + chunk_index: input.chunk.chunkIndex, + segments: input.chunk.segments.map((segment) => ({ + id: segment.id, + translated_text: segment.text, + })), + warnings: [], + }; + } +} + class FlatTranslationClient implements TranslationClient { async probe(): Promise {} From 84cf261593e3dde9fa64ea1efbdfdd477bd09f89 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 14:51:15 +0900 Subject: [PATCH 20/54] fix: bound and align translation workloads --- docs/architecture/flows.md | 13 +- .../coding-standards/security-boundaries.md | 5 + src/server/store/flashback-markers.ts | 70 +++++++--- src/server/translation/chunker.ts | 128 ++++++++++++++---- src/server/translation/limits.ts | 49 +++++++ src/server/translation/projection-map.ts | 6 +- src/server/translation/runner.ts | 19 +++ src/server/translation/source-projection.ts | 64 +++++++++ .../translation/translation-segments.ts | 9 +- .../flashbacks/flashback-markers.test.ts | 55 ++++++++ tests/server/translation/chunker.test.ts | 91 +++++++++++++ tests/server/translation/runner.test.ts | 119 ++++++++++++++++ .../translation/source-projection.test.ts | 51 +++++++ 13 files changed, 626 insertions(+), 53 deletions(-) create mode 100644 src/server/translation/source-projection.ts create mode 100644 tests/server/translation/source-projection.test.ts diff --git a/docs/architecture/flows.md b/docs/architecture/flows.md index 6e4e86b8..bc3d0d03 100644 --- a/docs/architecture/flows.md +++ b/docs/architecture/flows.md @@ -202,7 +202,11 @@ remain compatible for their current clients. paragraphs and lists only at Markdown-safe ordered boundaries. Every source chunk is at most 2,500 rough tokens and its complete initial prompt is at most 64 KiB by serialized UTF-8 bytes. An oversized fenced or other structurally - indivisible block fails with `validation_failed` before scheduling. + indivisible block fails with `validation_failed` before scheduling. Aggregate + admission also runs before client creation and durable rows: the complete + source is at most 20 MiB, with at most 16,384 translation segments and 4,096 + chunks. These limits retain the supported import ceiling and ordinary long + articles while bounding short-sentence expansion and SQLite job fan-out. 4. The runner claims `pending` work or resumes `running`, `stitching`, or `committing` work. It re-reads the source and marks the job stale when the source hash changed. Before reusing any chunk, it requires the persisted @@ -225,6 +229,13 @@ remain compatible for their current clients. and retry. A 64-KiB event or any cumulative overflow stops callbacks, interrupts the active turn best-effort, and fails without retry through the existing safe unknown error contract. + Chunk partition passes reuse already-built segment-manifest payloads for + unchanged groups. Source and translated projection maps retain the canonical + reader text and protected-offset semantics. A translation-local compact + boundary map only remaps normalized LF source offsets back to raw CRLF + positions; hidden paragraph separators remain collapsed exactly as they are + in canonical reader text. The legacy Flashback reader projection contract is + unchanged. 6. Cancellation is checked before and after chunk work. Pending jobs cancel immediately; running jobs move to `cancel_requested` and interrupt the active Codex turn. Stitching, committing, and terminal jobs reject cancellation to diff --git a/docs/references/coding-standards/security-boundaries.md b/docs/references/coding-standards/security-boundaries.md index 1b211ddb..32d1f826 100644 --- a/docs/references/coding-standards/security-boundaries.md +++ b/docs/references/coding-standards/security-boundaries.md @@ -114,6 +114,11 @@ source offsets; structurally indivisible overflow must fail before durable scheduling. Recheck every initial or retry prompt before the app-server client call, and never automatically retry a prompt-limit failure. +- MUST reject new or resumed Brilliant work above 20 MiB of total source, 16,384 + translation segments, or 4,096 chunks before any Codex client call. New work + must also fail before client creation or durable job insertion. Aggregate + overflow is a non-retryable `validation_failed` result; tests may inject + smaller limits but production limits are fixed code constants. - MUST reject translated output above 1 MiB per segment or 4 MiB per chunk by serialized UTF-8 bytes before projection or translated payload persistence. Absolute output overflow is terminal for that chunk attempt and is not diff --git a/src/server/store/flashback-markers.ts b/src/server/store/flashback-markers.ts index 71d4f9cd..043635d0 100644 --- a/src/server/store/flashback-markers.ts +++ b/src/server/store/flashback-markers.ts @@ -908,28 +908,62 @@ function findProjectedSpanForSourceRange( projectedMarkdown: ProjectedMarkdownText, range: MarkdownRange, ): { startOffset: number; endOffset: number } | undefined { - let startOffset: number | undefined; - let endOffset: number | undefined; + const startOffset = lowerBoundOffset( + projectedMarkdown.sourceOffsets, + range.startOffset, + ); + const endOffset = upperBoundOffset( + projectedMarkdown.sourceEndOffsets, + range.endOffset, + ); + if (startOffset >= endOffset) { + return undefined; + } + const firstSourceStart = projectedMarkdown.sourceOffsets[startOffset]; + const firstSourceEnd = projectedMarkdown.sourceEndOffsets[startOffset]; + const lastSourceStart = projectedMarkdown.sourceOffsets[endOffset - 1]; + const lastSourceEnd = projectedMarkdown.sourceEndOffsets[endOffset - 1]; + if ( + firstSourceStart === undefined || + firstSourceEnd === undefined || + lastSourceStart === undefined || + lastSourceEnd === undefined || + firstSourceStart < range.startOffset || + firstSourceEnd > range.endOffset || + lastSourceStart < range.startOffset || + lastSourceEnd > range.endOffset + ) { + return undefined; + } + return { endOffset, startOffset }; +} - for (let index = 0; index < projectedMarkdown.text.length; index += 1) { - const sourceStartOffset = projectedMarkdown.sourceOffsets[index]; - const sourceEndOffset = projectedMarkdown.sourceEndOffsets[index]; - if ( - sourceStartOffset === undefined || - sourceEndOffset === undefined || - sourceStartOffset < range.startOffset || - sourceEndOffset > range.endOffset - ) { - continue; +function lowerBoundOffset(offsets: readonly number[], target: number): number { + let lower = 0; + let upper = offsets.length; + while (lower < upper) { + const middle = lower + Math.floor((upper - lower) / 2); + if ((offsets[middle] ?? Number.POSITIVE_INFINITY) < target) { + lower = middle + 1; + } else { + upper = middle; } - - startOffset ??= index; - endOffset = index + 1; } + return lower; +} - return startOffset === undefined || endOffset === undefined - ? undefined - : { startOffset, endOffset }; +function upperBoundOffset(offsets: readonly number[], target: number): number { + let lower = 0; + let upper = offsets.length; + while (lower < upper) { + const middle = lower + Math.floor((upper - lower) / 2); + if ((offsets[middle] ?? Number.POSITIVE_INFINITY) <= target) { + lower = middle + 1; + } else { + upper = middle; + } + } + return lower; } function findRenderedContextStart( diff --git a/src/server/translation/chunker.ts b/src/server/translation/chunker.ts index 8acac43d..26c0f226 100644 --- a/src/server/translation/chunker.ts +++ b/src/server/translation/chunker.ts @@ -1,13 +1,17 @@ import { createSha256ContentHash, estimateRoughTokens } from "./hash"; -import { projectMarkdownToReaderText } from "../store/flashback-markers"; import { TranslationOutputValidationError } from "./errors"; import { parseMarkdownTranslationBlocks } from "./markdown-blocks"; import { parseTranslationMarkdownAst } from "./markdown-parser"; import { + assertTranslationManifestAdmission, + assertTranslationSourceAdmission, BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES, DEFAULT_TRANSLATION_CHUNK_CONFIG, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + type TranslationWorkloadLimits, } from "./limits"; import { measureTranslationPromptBytes } from "./prompt"; +import { projectTranslationMarkdownToReaderText } from "./source-projection"; import { createTranslationSegmentManifest } from "./translation-segments"; import type { SupportedLanguageCode } from "./languages"; import type { Node, Parent } from "unist"; @@ -27,60 +31,126 @@ export interface CreateTranslationChunksInput { source: TranslationSourceSnapshot; styleProfile?: string | null; glossary?: Record; + workloadLimits?: TranslationWorkloadLimits; } +type TranslationChunkManifestPayload = Omit< + TranslationChunk, + "chunkCount" | "chunkIndex" +>; + export function createTranslationChunks( input: CreateTranslationChunksInput, ): TranslationChunk[] { + const workloadLimits = + input.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS; + assertTranslationSourceAdmission( + Buffer.byteLength(input.source.sourceMarkdown, "utf8"), + workloadLimits, + ); const boundedBlocks = input.blocks.flatMap(splitOversizedBlock); const initialGroups = groupBlocks(boundedBlocks); - const sourceReaderProjection = projectMarkdownToReaderText( + const sourceReaderProjection = projectTranslationMarkdownToReaderText( boundedBlocks.map((block) => block.markdown).join(""), ); - return boundGroupsByPromptBytes(input, initialGroups, sourceReaderProjection); + return boundGroupsByPromptBytes( + input, + initialGroups, + sourceReaderProjection, + new Map(), + workloadLimits, + ); } function createChunksFromGroups( input: CreateTranslationChunksInput, groups: TranslationBlock[][], - sourceReaderProjection: ReturnType, + sourceReaderProjection: ReturnType, + manifestPayloads: Map, + workloadLimits: TranslationWorkloadLimits, ): TranslationChunk[] { - return groups.map((blocks, index) => { - const sourceMarkdown = blocks.map((block) => block.markdown).join(""); - const segmentManifest = createTranslationSegmentManifest(sourceMarkdown, { - sourceDocumentOffset: blocks[0]?.sourceStart ?? 0, - sourceReaderProjection, - }); - return { - blockIds: blocks.map((block) => block.id), + assertTranslationManifestAdmission( + { chunkCount: groups.length, segmentCount: 0 }, + workloadLimits, + ); + const chunks: TranslationChunk[] = []; + let segmentCount = 0; + for (const [index, blocks] of groups.entries()) { + const cacheKey = createManifestPayloadCacheKey(blocks); + let payload = manifestPayloads.get(cacheKey); + if (payload === undefined) { + payload = createTranslationChunkManifestPayload( + input, + blocks, + sourceReaderProjection, + ); + manifestPayloads.set(cacheKey, payload); + } + segmentCount += payload.segments.length; + assertTranslationManifestAdmission( + { chunkCount: groups.length, segmentCount }, + workloadLimits, + ); + chunks.push({ + ...payload, chunkCount: groups.length, chunkIndex: index, - docTitle: input.source.title, - documentType: input.source.documentType, - glossary: input.glossary ?? {}, - jobId: input.jobId, - langCode: input.langCode, - memoryId: input.memoryId, - sectionPath: blocks[0]?.sectionPath ?? [], - sourceBlocks: blocks, - sourceChunkHash: createSha256ContentHash(sourceMarkdown), - sourceHash: input.source.sourceHash, - sourceMarkdown, - sourceUrl: input.source.sourceUrl, - segments: segmentManifest.segments, - styleProfile: input.styleProfile ?? null, - }; + }); + } + return chunks; +} + +function createTranslationChunkManifestPayload( + input: CreateTranslationChunksInput, + blocks: TranslationBlock[], + sourceReaderProjection: ReturnType, +): TranslationChunkManifestPayload { + const sourceMarkdown = blocks.map((block) => block.markdown).join(""); + const segmentManifest = createTranslationSegmentManifest(sourceMarkdown, { + sourceDocumentOffset: blocks[0]?.sourceStart ?? 0, + sourceReaderProjection, }); + return { + blockIds: blocks.map((block) => block.id), + docTitle: input.source.title, + documentType: input.source.documentType, + glossary: input.glossary ?? {}, + jobId: input.jobId, + langCode: input.langCode, + memoryId: input.memoryId, + sectionPath: blocks[0]?.sectionPath ?? [], + sourceBlocks: blocks, + sourceChunkHash: createSha256ContentHash(sourceMarkdown), + sourceHash: input.source.sourceHash, + sourceMarkdown, + sourceUrl: input.source.sourceUrl, + segments: segmentManifest.segments, + styleProfile: input.styleProfile ?? null, + }; +} + +function createManifestPayloadCacheKey(blocks: readonly TranslationBlock[]): string { + return blocks.map((block) => + `${block.id}:${block.sourceStart}:${block.sourceEnd}` + ).join("|"); } function boundGroupsByPromptBytes( input: CreateTranslationChunksInput, initialGroups: TranslationBlock[][], - sourceReaderProjection: ReturnType, + sourceReaderProjection: ReturnType, + manifestPayloads: Map, + workloadLimits: TranslationWorkloadLimits, ): TranslationChunk[] { let groups = initialGroups.map((group) => [...group]); while (true) { - const chunks = createChunksFromGroups(input, groups, sourceReaderProjection); + const chunks = createChunksFromGroups( + input, + groups, + sourceReaderProjection, + manifestPayloads, + workloadLimits, + ); const nextGroups: TranslationBlock[][] = []; let splitCount = 0; diff --git a/src/server/translation/limits.ts b/src/server/translation/limits.ts index 8586b015..7a799d2f 100644 --- a/src/server/translation/limits.ts +++ b/src/server/translation/limits.ts @@ -1,3 +1,5 @@ +import { TranslationOutputValidationError } from "./errors"; + export const DEFAULT_TRANSLATION_CHUNK_CONFIG = { maxBlocks: 80, maxRetries: 3, @@ -8,3 +10,50 @@ export const DEFAULT_TRANSLATION_CHUNK_CONFIG = { } as const; export const BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES = 64 * 1_024; +export const BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES = 20 * 1_024 * 1_024; +export const BRILLIANT_MAX_TRANSLATION_SEGMENTS = 16_384; +export const BRILLIANT_MAX_TRANSLATION_CHUNKS = 4_096; + +export interface TranslationWorkloadLimits { + maxChunks: number; + maxSegments: number; + maxSourceBytes: number; +} + +export const DEFAULT_TRANSLATION_WORKLOAD_LIMITS: TranslationWorkloadLimits = + Object.freeze({ + maxChunks: BRILLIANT_MAX_TRANSLATION_CHUNKS, + maxSegments: BRILLIANT_MAX_TRANSLATION_SEGMENTS, + maxSourceBytes: BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES, + }); + +export function assertTranslationSourceAdmission( + sourceBytes: number, + limits: TranslationWorkloadLimits, +): void { + if (sourceBytes <= limits.maxSourceBytes) { + return; + } + throw new TranslationOutputValidationError( + "Translation source exceeds the total source byte limit.", + { retryable: false }, + ); +} + +export function assertTranslationManifestAdmission( + input: { chunkCount: number; segmentCount: number }, + limits: TranslationWorkloadLimits, +): void { + if (input.segmentCount > limits.maxSegments) { + throw new TranslationOutputValidationError( + "Translation manifest exceeds the total segment count limit.", + { retryable: false }, + ); + } + if (input.chunkCount > limits.maxChunks) { + throw new TranslationOutputValidationError( + "Translation manifest exceeds the total chunk count limit.", + { retryable: false }, + ); + } +} diff --git a/src/server/translation/projection-map.ts b/src/server/translation/projection-map.ts index e2e7b637..3904f385 100644 --- a/src/server/translation/projection-map.ts +++ b/src/server/translation/projection-map.ts @@ -8,9 +8,9 @@ import { import type { SupportedLanguageCode } from "./languages"; import { mapMarkdownSourceRangeToReaderRange, - projectMarkdownToReaderText, } from "../store/flashback-markers"; import { TranslationOutputValidationError } from "./errors"; +import { projectTranslationMarkdownToReaderText } from "./source-projection"; import type { TranslationChunkProjectionSpan, TranslationProjectionSpan, @@ -42,7 +42,9 @@ export function buildTranslationProjectionSpans(input: { outputHash: string; sourceHash: string; }): TranslationProjectionSpan[] { - const translatedProjection = projectMarkdownToReaderText(input.body); + const translatedProjection = projectTranslationMarkdownToReaderText( + input.body, + ); const spans: TranslationProjectionSpan[] = []; let translatedChunkStart = 0; diff --git a/src/server/translation/runner.ts b/src/server/translation/runner.ts index 8ca6ab52..b59e9814 100644 --- a/src/server/translation/runner.ts +++ b/src/server/translation/runner.ts @@ -35,6 +35,11 @@ import { type TranslationCodexEventAdmissionLimits, } from "./event-admission"; import { createSha256ContentHash } from "./hash"; +import { + assertTranslationSourceAdmission, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + type TranslationWorkloadLimits, +} from "./limits"; import { parseMarkdownTranslationBlocks } from "./markdown-blocks"; import { BRILLIANT_CHUNKER_VERSION, @@ -124,6 +129,7 @@ interface StartTranslationJobInput { openConnection?: (config: ResolvedTraumaConfig) => TraumaDatabaseConnection; reasoningEffort?: string | null; schedule?: (jobId: string, options: TranslationRunOptions) => void; + workloadLimits?: TranslationWorkloadLimits; } interface TranslationRunOptions { @@ -136,6 +142,7 @@ interface TranslationRunOptions { createClient?: () => TranslationClient; openConnection?: (config: ResolvedTraumaConfig) => TraumaDatabaseConnection; promptByteLimit?: number; + workloadLimits?: TranslationWorkloadLimits; } let queue: Promise = Promise.resolve(); @@ -260,6 +267,10 @@ export async function startTranslationJob( return createActiveTranslationResult(active, input.memoryId, langCode); } + assertTranslationSourceAdmission( + source.byteSize, + input.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + ); const manifest = parseMarkdownTranslationBlocks(source.sourceMarkdown); const jobId = input.generateJobId === undefined ? generateMemoryId(now) @@ -270,6 +281,7 @@ export async function startTranslationJob( langCode, memoryId: input.memoryId, source, + workloadLimits: input.workloadLimits, }); if (chunks.length === 0) { throw new TranslationApiError( @@ -372,6 +384,7 @@ export async function startTranslationJob( backupQueue: input.backupQueue, config, openConnection, + workloadLimits: input.workloadLimits, }); return { @@ -504,6 +517,10 @@ export async function runTranslationJob( return; } + assertTranslationSourceAdmission( + source.byteSize, + options.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + ); const manifest = parseMarkdownTranslationBlocks(source.sourceMarkdown); const runtimeChunks = createTranslationChunks({ blocks: manifest.blocks, @@ -511,6 +528,7 @@ export async function runTranslationJob( langCode: job.langCode, memoryId: job.memoryId, source, + workloadLimits: options.workloadLimits, }); const persistedChunks = await connection.repositories.translations .getTranslationChunks(jobId); @@ -701,6 +719,7 @@ function scheduleRecoverableActiveJob( backupQueue: input.backupQueue, config, openConnection, + workloadLimits: input.workloadLimits, }); } diff --git a/src/server/translation/source-projection.ts b/src/server/translation/source-projection.ts new file mode 100644 index 00000000..878b6bca --- /dev/null +++ b/src/server/translation/source-projection.ts @@ -0,0 +1,64 @@ +import { + projectMarkdownToReaderText, + type ProjectedMarkdownText, +} from "../store/flashback-markers"; + +export function projectTranslationMarkdownToReaderText( + rawMarkdown: string, +): ProjectedMarkdownText { + if (!rawMarkdown.includes("\r")) { + return projectMarkdownToReaderText(rawMarkdown); + } + + const normalizedMarkdown = rawMarkdown.replace(/\r\n?/g, "\n"); + const rawBoundaries = createRawBoundaryMap( + rawMarkdown, + normalizedMarkdown.length, + ); + const projection = projectMarkdownToReaderText(normalizedMarkdown); + remapOffsetsInPlace(projection.sourceOffsets, rawBoundaries); + remapOffsetsInPlace(projection.sourceEndOffsets, rawBoundaries); + return projection; +} + +function createRawBoundaryMap( + rawMarkdown: string, + normalizedLength: number, +): Uint32Array { + const rawBoundaries = new Uint32Array(normalizedLength + 1); + let rawOffset = 0; + let normalizedOffset = 0; + + while (rawOffset < rawMarkdown.length) { + if (rawMarkdown[rawOffset] === "\r") { + rawOffset += rawMarkdown[rawOffset + 1] === "\n" ? 2 : 1; + } else { + rawOffset += 1; + } + normalizedOffset += 1; + rawBoundaries[normalizedOffset] = rawOffset; + } + + if (normalizedOffset !== normalizedLength) { + throw new RangeError("translation line-ending normalization length mismatch"); + } + return rawBoundaries; +} + +function remapOffsetsInPlace( + normalizedOffsets: number[], + rawBoundaries: Uint32Array, +): void { + for (let index = 0; index < normalizedOffsets.length; index += 1) { + const normalizedOffset = normalizedOffsets[index]; + const rawOffset = normalizedOffset === undefined + ? undefined + : rawBoundaries[normalizedOffset]; + if (rawOffset === undefined) { + throw new RangeError( + "translation projection offset is outside normalized Markdown", + ); + } + normalizedOffsets[index] = rawOffset; + } +} diff --git a/src/server/translation/translation-segments.ts b/src/server/translation/translation-segments.ts index 0749549d..0997efc4 100644 --- a/src/server/translation/translation-segments.ts +++ b/src/server/translation/translation-segments.ts @@ -4,10 +4,10 @@ import { visitParents } from "unist-util-visit-parents"; import { TranslationOutputValidationError } from "./errors"; import { mapMarkdownSourceRangeToReaderRange, - projectMarkdownToReaderText, type ProjectedMarkdownText, } from "../store/flashback-markers"; import { parseTranslationMarkdownAst } from "./markdown-parser"; +import { projectTranslationMarkdownToReaderText } from "./source-projection"; import type { TranslationChunkProjectionSpan, TranslationProtectedRange, @@ -49,7 +49,8 @@ export function createTranslationSegmentManifest( const blockRanges = readTopLevelBlockRanges(parsed.tree, parsed.bodyOffset); const sourceDocumentOffset = options.sourceDocumentOffset ?? 0; const sourceReaderProjection = - options.sourceReaderProjection ?? projectMarkdownToReaderText(sourceMarkdown); + options.sourceReaderProjection ?? + projectTranslationMarkdownToReaderText(sourceMarkdown); const segments: TranslationTextSegment[] = []; const protectedRanges: TranslationProtectedRange[] = []; @@ -172,7 +173,9 @@ export function applyTranslatedSegmentsWithProjection(input: { } translatedMarkdown += input.manifest.sourceMarkdown.slice(cursor); - const translatedProjection = projectMarkdownToReaderText(translatedMarkdown); + const translatedProjection = projectTranslationMarkdownToReaderText( + translatedMarkdown, + ); return { projectionSpans: projectionSpans.map((span) => { diff --git a/tests/server/flashbacks/flashback-markers.test.ts b/tests/server/flashbacks/flashback-markers.test.ts index f6fa76bc..d3122e5c 100644 --- a/tests/server/flashbacks/flashback-markers.test.ts +++ b/tests/server/flashbacks/flashback-markers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyFlashbackMarkers, + mapMarkdownSourceRangeToReaderRange, projectMarkdownToReaderText, readRenderedMarkdownRangeText, resolveFlashbackSelection, @@ -685,6 +686,35 @@ describe("flashback markdown markers", () => { expect(projectMarkdownToReaderText(markdown).text).toBe(expected); }); + it("maps source ranges equivalently across representative Markdown projections", () => { + const corpus = [ + "Plain text with two sentences. Next sentence.", + "# Heading\n\n- first item\n- second item\n", + "Read [the docs](https://example.com) & continue.", + "
Visible HTML
\n", + "| Left | Right |\n| --- | --- |\n| A | B |\n", + "Escaped \\*literal\\* and `protected code`.\n", + "First sentence.\r\n\r\nSecond sentence.", + ]; + + for (const markdown of corpus) { + const projection = projectMarkdownToReaderText(markdown); + const maximumSourceOffset = projection.sourceEndOffsets.at(-1) ?? 0; + for (let startOffset = 0; startOffset < maximumSourceOffset; startOffset += 1) { + for ( + let endOffset = startOffset + 1; + endOffset <= maximumSourceOffset; + endOffset += 1 + ) { + const range = { endOffset, startOffset }; + expect(mapMarkdownSourceRangeToReaderRange(projection, range)).toEqual( + scanProjectedSpanForSourceRange(projection, range), + ); + } + } + } + }); + it("skips sanitized raw HTML block contents before resolving duplicate text", () => { const markdown = " target"; @@ -1017,3 +1047,28 @@ describe("flashback markdown markers", () => { ).toThrow("Selected markdown code cannot be flashbacked"); }); }); + +function scanProjectedSpanForSourceRange( + projection: ReturnType, + range: { endOffset: number; startOffset: number }, +): { endOffset: number; startOffset: number } | undefined { + let startOffset: number | undefined; + let endOffset: number | undefined; + for (let index = 0; index < projection.text.length; index += 1) { + const sourceStartOffset = projection.sourceOffsets[index]; + const sourceEndOffset = projection.sourceEndOffsets[index]; + if ( + sourceStartOffset === undefined || + sourceEndOffset === undefined || + sourceStartOffset < range.startOffset || + sourceEndOffset > range.endOffset + ) { + continue; + } + startOffset ??= index; + endOffset = index + 1; + } + return startOffset === undefined || endOffset === undefined + ? undefined + : { endOffset, startOffset }; +} diff --git a/tests/server/translation/chunker.test.ts b/tests/server/translation/chunker.test.ts index b63cfb56..b6610ed1 100644 --- a/tests/server/translation/chunker.test.ts +++ b/tests/server/translation/chunker.test.ts @@ -5,6 +5,15 @@ import { DEFAULT_TRANSLATION_CHUNK_CONFIG, } from "../../../src/server/translation/chunker"; import { estimateRoughTokens } from "../../../src/server/translation/hash"; +import { TranslationOutputValidationError } from "../../../src/server/translation/errors"; +import { + assertTranslationManifestAdmission, + assertTranslationSourceAdmission, + BRILLIANT_MAX_TRANSLATION_CHUNKS, + BRILLIANT_MAX_TRANSLATION_SEGMENTS, + BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, +} from "../../../src/server/translation/limits"; import { parseMarkdownTranslationBlocks } from "../../../src/server/translation/markdown-blocks"; import { BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES, @@ -13,6 +22,61 @@ import { import type { TranslationSourceSnapshot } from "../../../src/server/translation/types"; describe("translation chunker", () => { + it("enforces the exact aggregate translation admission boundaries", () => { + expect(BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES).toBe(20 * 1_024 * 1_024); + expect(BRILLIANT_MAX_TRANSLATION_SEGMENTS).toBe(16_384); + expect(BRILLIANT_MAX_TRANSLATION_CHUNKS).toBe(4_096); + + expect(() => + assertTranslationSourceAdmission( + BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + ) + ).not.toThrow(); + expect(() => + assertTranslationManifestAdmission( + { + chunkCount: BRILLIANT_MAX_TRANSLATION_CHUNKS, + segmentCount: BRILLIANT_MAX_TRANSLATION_SEGMENTS, + }, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + ) + ).not.toThrow(); + + for (const admit of [ + () => + assertTranslationSourceAdmission( + BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES + 1, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + ), + () => + assertTranslationManifestAdmission( + { + chunkCount: BRILLIANT_MAX_TRANSLATION_CHUNKS + 1, + segmentCount: BRILLIANT_MAX_TRANSLATION_SEGMENTS, + }, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + ), + () => + assertTranslationManifestAdmission( + { + chunkCount: BRILLIANT_MAX_TRANSLATION_CHUNKS, + segmentCount: BRILLIANT_MAX_TRANSLATION_SEGMENTS + 1, + }, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + ), + ]) { + try { + admit(); + throw new Error("expected aggregate translation admission to fail"); + } catch (error) { + expect(error).toBeInstanceOf(TranslationOutputValidationError); + expect((error as TranslationOutputValidationError).retryable).toBe(false); + expect((error as Error).message).toMatch(/translation.*limit/i); + } + } + }); + it("groups contiguous block ids and hashes each chunk", () => { const manifest = parseMarkdownTranslationBlocks( "# One\n\nSmall body.\n\n# Two\n\nSecond body.", @@ -169,6 +233,33 @@ describe("translation chunker", () => { )).toBe(true); }); + it("builds a large admitted short-sentence manifest without losing order", { + timeout: 5_000, + }, () => { + const sentenceCount = 15_000; + const markdown = + "Short sentence with bounded projection work. ".repeat(sentenceCount); + const source = translationSource(markdown); + + const chunks = createTranslationChunks({ + blocks: parseMarkdownTranslationBlocks(markdown).blocks, + jobId: "job-short-sentences", + langCode: "ja-JP", + memoryId: source.memoryId, + source, + }); + + expect(chunks.map((chunk) => chunk.sourceMarkdown).join("")).toBe(markdown); + const segmentCount = chunks.reduce( + (total, chunk) => total + chunk.segments.length, + 0, + ); + expect(segmentCount).toBeGreaterThanOrEqual(sentenceCount); + expect(segmentCount).toBeLessThanOrEqual( + BRILLIANT_MAX_TRANSLATION_SEGMENTS, + ); + }); + it("bounds every prompt-oversized group in one ordered multibyte pass", () => { const groupCount = 48; const paragraph = `${"界".repeat(249)}。`.repeat(39); diff --git a/tests/server/translation/runner.test.ts b/tests/server/translation/runner.test.ts index 7d38f9b0..bf7e45f4 100644 --- a/tests/server/translation/runner.test.ts +++ b/tests/server/translation/runner.test.ts @@ -19,6 +19,7 @@ import { BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES, } from "../../../src/server/translation/prompt"; import { splitFrontmatter } from "../../../src/server/translation/markdown-blocks"; +import { DEFAULT_TRANSLATION_WORKLOAD_LIMITS } from "../../../src/server/translation/limits"; import { resolveTranslatedMemoryContentPath, resolveTranslatedMemoryProjectionPath, @@ -175,6 +176,74 @@ describe("translation runner", () => { } }); + it.each([ + { + jobId: "019e3906-0000-7000-8000-000000000115", + label: "source bytes", + markdown: "Small source.", + workloadLimits: { + ...DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + maxSourceBytes: 1, + }, + }, + { + jobId: "019e3906-0000-7000-8000-000000000116", + label: "segment count", + markdown: "Short sentence. ".repeat(20_000), + workloadLimits: DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + }, + { + jobId: "019e3906-0000-7000-8000-000000000117", + label: "chunk count", + markdown: "Chunked sentence. ".repeat(700), + workloadLimits: { + ...DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + maxChunks: 1, + }, + }, + ])("rejects aggregate $label before client creation or durable scheduling", async ({ + jobId, + markdown, + workloadLimits, + }) => { + const config = await createConfig(); + await writeSourceContent(config, markdown); + await createMemoryRow(config); + let clientCreations = 0; + let scheduled = false; + + await expect( + startTranslationJob({ + config, + createClient: () => { + clientCreations += 1; + return new FakeTranslationClient(); + }, + generateJobId: () => jobId, + memoryId, + now, + schedule: () => { + scheduled = true; + }, + workloadLimits, + }), + ).rejects.toMatchObject({ + action: "open_source_reader", + code: "validation_failed", + }); + + expect(clientCreations).toBe(0); + expect(scheduled).toBe(false); + const connection = initializeDatabase(config); + try { + await expect( + connection.repositories.translations.getTranslationJob(jobId), + ).resolves.toBeNull(); + } finally { + connection.close(); + } + }); + it("stitches bounded paragraph and list fragments in exact Markdown order", async () => { const config = await createConfig(); const markdown = [ @@ -330,6 +399,56 @@ describe("translation runner", () => { }); }); + it("persists CRLF source spans in canonical reader coordinates", async () => { + const config = await createConfig(); + await writeSourceContent( + config, + "First sentence.\r\n\r\nSecond sentence.", + ); + await createMemoryRow(config); + const client = new IdentityTranslationClient(); + const started = await startTranslationJob({ + client, + config, + generateJobId: () => "019e3906-0000-7000-8000-000000000114", + memoryId, + now, + schedule: () => undefined, + }); + + await runTranslationJob(started.job_id, { client, config }); + + const projectionPath = resolveTranslatedMemoryProjectionPath({ + config, + langCode: "ja-JP", + memoryId, + }); + const sidecar = JSON.parse( + await readFile(projectionPath.absolutePath, "utf8"), + ) as { + spans: Array<{ + segmentId: string; + sourceMarkdownEnd: number; + sourceMarkdownStart: number; + sourceReaderEnd: number; + sourceReaderStart: number; + translatedReaderEnd: number; + translatedReaderStart: number; + }>; + }; + + expect(sidecar.spans).toHaveLength(2); + expect(sidecar.spans[1]).toMatchObject({ + segmentId: "s000002", + sourceMarkdownEnd: 35, + sourceMarkdownStart: 19, + sourceReaderEnd: 32, + sourceReaderStart: 16, + translatedReaderEnd: 32, + translatedReaderStart: 16, + }); + }); + it("loads persisted chunk state once before translating a multi-chunk job", async () => { const config = await createConfig(); await writeSourceContent( diff --git a/tests/server/translation/source-projection.test.ts b/tests/server/translation/source-projection.test.ts new file mode 100644 index 00000000..176c80d7 --- /dev/null +++ b/tests/server/translation/source-projection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + projectMarkdownToReaderText, + readCanonicalReaderText, +} from "../../../src/server/store/flashback-markers"; +import { + projectTranslationMarkdownToReaderText, +} from "../../../src/server/translation/source-projection"; + +describe("translation source projection", () => { + it.each([ + ["LF", "First sentence.\nSecond sentence."], + ["CRLF", "First sentence.\r\nSecond sentence."], + ["lone CR", "First sentence.\rSecond sentence."], + ["LF hidden paragraph separators", "First sentence.\n\nSecond sentence."], + ["CRLF hidden paragraph separators", "First sentence.\r\n\r\nSecond sentence."], + ["entities", "Fish & chips © 2026."], + [ + "Markdown", + "# Heading\r\n\r\nRead [the docs](https://example.com) and `code`.\r\n", + ], + ])("preserves canonical reader text and protection for %s", (_, markdown) => { + const canonical = projectMarkdownToReaderText(markdown); + const projected = projectTranslationMarkdownToReaderText(markdown); + + expect(projected.text).toBe(readCanonicalReaderText(markdown)); + expect(projected.text).toBe(canonical.text); + expect(projected.protectedOffsets).toEqual(canonical.protectedOffsets); + expect(projected.sourceOffsets).toHaveLength(canonical.text.length); + expect(projected.sourceEndOffsets).toHaveLength(canonical.text.length); + }); + + it("keeps a representative multi-megabyte CRLF source aligned to raw offsets", { + timeout: 15_000, + }, () => { + const rawLine = "Large source line.\r\n"; + const readerLine = "Large source line.\n"; + const lineCount = 131_072; + const markdown = rawLine.repeat(lineCount); + + const projected = projectTranslationMarkdownToReaderText(markdown); + + expect(projected.text).toBe(readerLine.repeat(lineCount)); + expect(projected.sourceOffsets).toHaveLength(projected.text.length); + expect(projected.sourceEndOffsets).toHaveLength(projected.text.length); + expect(projected.sourceOffsets[readerLine.length]).toBe(rawLine.length); + expect(projected.sourceOffsets.at(-1)).toBe(markdown.length - 2); + expect(projected.sourceEndOffsets.at(-1)).toBe(markdown.length); + }); +}); From 8a222cbd55c25fe23e917ea92f68a60cc89a81be Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 15:24:45 +0900 Subject: [PATCH 21/54] fix: bound translation source reads --- docs/architecture/flows.md | 17 +- .../coding-standards/security-boundaries.md | 8 +- src/server/translation/runner.ts | 14 +- src/server/translation/source-loader.ts | 152 +++++++++++- src/server/translation/stitching.ts | 2 + tests/server/translation/runner.test.ts | 183 +++++++++++++- .../translation/source-and-current.test.ts | 228 ++++++++++++++++++ 7 files changed, 588 insertions(+), 16 deletions(-) diff --git a/docs/architecture/flows.md b/docs/architecture/flows.md index bc3d0d03..69d02d67 100644 --- a/docs/architecture/flows.md +++ b/docs/architecture/flows.md @@ -188,8 +188,13 @@ direct or stale callers. Existing language-only and Codex-only settings routes remain compatible for their current clients. 1. `POST /api/memories/:memoryId/translations` validates the request, resolves - the configured target language and Codex model/effort, loads source - `CONTENT.md`, and hashes it. + the configured target language and Codex model/effort, then opens source + `CONTENT.md` and reads at most the 20-MiB limit plus one byte into + demand-sized, fixed-capacity chunks. Source admission does not trust a prior + file size: it continues positional reads through short reads, closes the + file handle on success or failure, and rejects overflow before streaming + UTF-8 decoding, Markdown/frontmatter parsing, document-type inference, or + incremental raw-byte hashing. 2. If a completed translation and file are current, the route returns `status: current`. If the same source/language already has active work, it returns `status: active` and reschedules that job when recoverable. @@ -208,8 +213,12 @@ remain compatible for their current clients. chunks. These limits retain the supported import ceiling and ordinary long articles while bounding short-sentence expansion and SQLite job fan-out. 4. The runner claims `pending` work or resumes `running`, `stitching`, or - `committing` work. It re-reads the source and marks the job stale when the - source hash changed. Before reusing any chunk, it requires the persisted + `committing` work. It re-reads the source through the same bounded admission + before creating a client and marks the job stale when the source hash + changed. The final commit reload applies that same source-byte limit again, + so growth between resume admission and publication cannot reach decoding, + parsing, hashing, output publication, or backup. Before reusing any chunk, + it requires the persisted prompt-policy and chunker versions, chunk count, chunk indexes, source chunk hashes, and ordered block IDs to match the current runtime manifest exactly. An incompatible job fails terminally and permits a fresh attempt; it cannot diff --git a/docs/references/coding-standards/security-boundaries.md b/docs/references/coding-standards/security-boundaries.md index 32d1f826..6f28bafe 100644 --- a/docs/references/coding-standards/security-boundaries.md +++ b/docs/references/coding-standards/security-boundaries.md @@ -118,7 +118,13 @@ translation segments, or 4,096 chunks before any Codex client call. New work must also fail before client creation or durable job insertion. Aggregate overflow is a non-retryable `validation_failed` result; tests may inject - smaller limits but production limits are fixed code constants. + smaller limits but production limits are fixed code constants. Source loading + must not trust `stat`: open the file, read positionally through short reads, + retain demand-sized fixed-capacity chunks totaling at most the source-byte + limit plus one probe byte, and always close the handle. Reject the probe-byte + overflow before streaming UTF-8 decoding, Markdown or frontmatter parsing, + document-type inference, or incremental raw-byte hashing. Resume and final + commit reloads must receive the same workload source-byte limit. - MUST reject translated output above 1 MiB per segment or 4 MiB per chunk by serialized UTF-8 bytes before projection or translated payload persistence. Absolute output overflow is terminal for that chunk attempt and is not diff --git a/src/server/translation/runner.ts b/src/server/translation/runner.ts index b59e9814..dc66c0d9 100644 --- a/src/server/translation/runner.ts +++ b/src/server/translation/runner.ts @@ -215,6 +215,9 @@ export async function startTranslationJob( const source = await loadTranslationSourceSnapshot({ config, + maxSourceBytes: + (input.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS) + .maxSourceBytes, memoryId: input.memoryId, }); const current = await resolveCurrentTranslationReadOnly({ @@ -445,7 +448,7 @@ export async function runTranslationJob( const config = options.config ?? loadRuntimeTraumaConfig(); const openConnection = options.openConnection ?? initializeDatabase; const backupQueue = options.backupQueue ?? getMemoryBackupQueue(config); - const client = options.client ?? options.createClient?.() ?? new CodexAppServerClient(); + let client = options.client; const codexEventAdmission = new TranslationCodexEventAdmission( options.codexEventLimits, ); @@ -482,6 +485,9 @@ export async function runTranslationJob( const source = await loadTranslationSourceSnapshot({ config, + maxSourceBytes: + (options.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS) + .maxSourceBytes, memoryId: job.memoryId, }); if (source.sourceHash !== job.sourceHash) { @@ -549,6 +555,7 @@ export async function runTranslationJob( if (record?.status === "complete" || record?.status === "purged") { continue; } + client ??= options.createClient?.() ?? new CodexAppServerClient(); const chunkResult = await translateAndPersistChunk({ cancelGraceMs: options.cancelGraceMs ?? BRILLIANT_CANCEL_GRACE_MS, chunk, @@ -621,6 +628,9 @@ export async function runTranslationJob( chunks: await connection.repositories.translations.getTranslationChunks(jobId), config, job, + maxSourceBytes: + (options.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS) + .maxSourceBytes, repository: connection.repositories.translations, }); if ("status" in result && result.status === "stale") { @@ -659,7 +669,7 @@ export async function runTranslationJob( await failRunningJob(connection, jobId, error); } finally { connection.close(); - if (closeClientAfterRun) { + if (closeClientAfterRun && client !== undefined) { await closeTranslationClient(client); } } diff --git a/src/server/translation/source-loader.ts b/src/server/translation/source-loader.ts index f6e2bc1a..cb314853 100644 --- a/src/server/translation/source-loader.ts +++ b/src/server/translation/source-loader.ts @@ -1,4 +1,5 @@ -import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { open } from "node:fs/promises"; import { TextDecoder } from "node:util"; import type { ResolvedTraumaConfig } from "../config"; @@ -7,19 +8,49 @@ import { parseMemoryContentFixture, resolveMemoryContentPath, } from "../store"; -import { createSha256ContentHash, estimateRoughTokens } from "./hash"; +import { estimateRoughTokens } from "./hash"; +import { + assertTranslationSourceAdmission, + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, +} from "./limits"; import type { TranslationSourceSnapshot } from "./types"; -const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const TRANSLATION_SOURCE_READ_CHUNK_BYTES = 64 * 1_024; + +interface TranslationSourceBytes { + byteLength: number; + chunks: Buffer[]; +} + +export interface TranslationSourceFileHandle { + close(): Promise; + read( + buffer: Buffer, + offset: number, + length: number, + position: number, + ): Promise<{ bytesRead: number }>; +} + +export type OpenTranslationSourceFile = ( + absolutePath: string, +) => Promise; export async function loadTranslationSourceSnapshot(input: { config: Pick; + maxSourceBytes?: number; memoryId: string; + openFile?: OpenTranslationSourceFile; }): Promise { const resolvedPath = resolveMemoryContentPath(input.config, input.memoryId); - let bytes: Buffer; + let bytes: TranslationSourceBytes; try { - bytes = await readFile(resolvedPath.absolutePath); + bytes = await readTranslationSourceBytes({ + absolutePath: resolvedPath.absolutePath, + maxBytes: input.maxSourceBytes ?? + DEFAULT_TRANSLATION_WORKLOAD_LIMITS.maxSourceBytes, + openFile: input.openFile ?? openTranslationSourceFile, + }); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { throw new MemoryContentStoreError( @@ -29,7 +60,7 @@ export async function loadTranslationSourceSnapshot(input: { } throw error; } - const rawContent = utf8Decoder.decode(bytes); + const { rawContent, sourceHash } = decodeAndHashTranslationSource(bytes); const content = parseMemoryContentFixture( rawContent, resolvedPath.relativePath, @@ -44,7 +75,7 @@ export async function loadTranslationSourceSnapshot(input: { }), memoryId: input.memoryId, roughTokenEstimate: estimateRoughTokens(rawContent), - sourceHash: createSha256ContentHash(bytes), + sourceHash, sourceMarkdown: rawContent, sourcePath: resolvedPath.relativePath, sourceUrl: content.frontmatter.url, @@ -52,6 +83,113 @@ export async function loadTranslationSourceSnapshot(input: { }; } +async function readTranslationSourceBytes(input: { + absolutePath: string; + maxBytes: number; + openFile: OpenTranslationSourceFile; +}): Promise { + if ( + !Number.isSafeInteger(input.maxBytes) || + input.maxBytes < 0 || + input.maxBytes === Number.MAX_SAFE_INTEGER + ) { + throw new RangeError("Translation source byte limit must be a safe integer."); + } + + const handle = await input.openFile(input.absolutePath); + let operationFailed = false; + try { + const chunks: Buffer[] = []; + const readLimit = input.maxBytes + 1; + let byteLength = 0; + let reachedEndOfFile = false; + while (byteLength < readLimit && !reachedEndOfFile) { + const chunk = Buffer.allocUnsafe( + Math.min( + TRANSLATION_SOURCE_READ_CHUNK_BYTES, + readLimit - byteLength, + ), + ); + let chunkByteLength = 0; + while (chunkByteLength < chunk.byteLength) { + const remaining = chunk.byteLength - chunkByteLength; + const result = await handle.read( + chunk, + chunkByteLength, + remaining, + byteLength + chunkByteLength, + ); + if ( + !Number.isSafeInteger(result.bytesRead) || + result.bytesRead < 0 || + result.bytesRead > remaining + ) { + throw new Error( + "Translation source file returned an invalid read length.", + ); + } + if (result.bytesRead === 0) { + reachedEndOfFile = true; + break; + } + chunkByteLength += result.bytesRead; + } + if (chunkByteLength > 0) { + chunks.push(chunk.subarray(0, chunkByteLength)); + byteLength += chunkByteLength; + } + } + + assertTranslationSourceAdmission(byteLength, { + ...DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + maxSourceBytes: input.maxBytes, + }); + return { byteLength, chunks }; + } catch (error) { + operationFailed = true; + throw error; + } finally { + try { + await handle.close(); + } catch (closeError) { + if (!operationFailed) { + throw closeError; + } + } + } +} + +function decodeAndHashTranslationSource(input: TranslationSourceBytes): { + rawContent: string; + sourceHash: string; +} { + const decoder = new TextDecoder("utf-8", { fatal: true }); + const decodedChunks: string[] = []; + const hash = createHash("sha256"); + for (const chunk of input.chunks) { + hash.update(chunk); + decodedChunks.push(decoder.decode(chunk, { stream: true })); + } + decodedChunks.push(decoder.decode()); + return { + rawContent: decodedChunks.join(""), + sourceHash: `sha256:${hash.digest("hex")}`, + }; +} + +async function openTranslationSourceFile( + absolutePath: string, +): Promise { + const handle = await open(absolutePath, "r"); + return { + close: () => handle.close(), + read: async (buffer, offset, length, position) => { + const result = await handle.read(buffer, offset, length, position); + return { bytesRead: result.bytesRead }; + }, + }; +} + function inferDocumentType(input: { markdown: string; url: string; diff --git a/src/server/translation/stitching.ts b/src/server/translation/stitching.ts index 93490c4b..76ca6614 100644 --- a/src/server/translation/stitching.ts +++ b/src/server/translation/stitching.ts @@ -55,6 +55,7 @@ type CommitTranslatedContentInput = { config: ResolvedTraumaConfig; chunks: TranslationChunkRecord[]; job: TranslationJobRecord; + maxSourceBytes?: number; now?: Date; publishProjectionSidecar?: ( absolutePath: string, @@ -86,6 +87,7 @@ async function commitTranslatedContentReserved( > { const source = await loadTranslationSourceSnapshot({ config: input.config, + maxSourceBytes: input.maxSourceBytes, memoryId: input.job.memoryId, }); const now = input.now ?? new Date(); diff --git a/tests/server/translation/runner.test.ts b/tests/server/translation/runner.test.ts index bf7e45f4..97f8f074 100644 --- a/tests/server/translation/runner.test.ts +++ b/tests/server/translation/runner.test.ts @@ -180,7 +180,7 @@ describe("translation runner", () => { { jobId: "019e3906-0000-7000-8000-000000000115", label: "source bytes", - markdown: "Small source.", + markdown: Buffer.from([0xff, 0xff]), workloadLimits: { ...DEFAULT_TRANSLATION_WORKLOAD_LIMITS, maxSourceBytes: 1, @@ -244,6 +244,181 @@ describe("translation runner", () => { } }); + it("rejects an oversized resumed source before decoding or creating a client", async () => { + const config = await createConfig(); + await writeSourceContent(config); + await createMemoryRow(config); + const started = await startTranslationJob({ + client: new FakeTranslationClient(), + config, + generateJobId: () => "019e3906-0000-7000-8000-000000000118", + memoryId, + now, + schedule: () => undefined, + }); + await writeSourceContent(config, Buffer.from([0xff, 0xff])); + let clientCreations = 0; + + await runTranslationJob(started.job_id, { + config, + createClient: () => { + clientCreations += 1; + return new FakeTranslationClient(); + }, + workloadLimits: { + ...DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + maxSourceBytes: 1, + }, + }); + + expect(clientCreations).toBe(0); + const connection = initializeDatabase(config); + try { + await expect( + connection.repositories.translations.getTranslationJob(started.job_id), + ).resolves.toMatchObject({ + error: { + action: "none", + code: "validation_failed", + message: "Translation source exceeds the total source byte limit.", + }, + status: "failed", + }); + } finally { + connection.close(); + } + }); + + it("rechecks the source byte limit during commit reload without creating a client", async () => { + const config = await createConfig(); + await writeSourceContent(config); + await createMemoryRow(config); + const started = await startTranslationJob({ + client: new FakeTranslationClient(), + config, + generateJobId: () => "019e3906-0000-7000-8000-000000000119", + memoryId, + now, + schedule: () => undefined, + }); + let blockStitchingOnce = true; + await runTranslationJob(started.job_id, { + client: new FakeTranslationClient(), + config, + openConnection: (connectionConfig) => { + const connection = initializeDatabase(connectionConfig); + const translations = connection.repositories.translations; + return { + ...connection, + repositories: { + ...connection.repositories, + translations: { + ...translations, + transitionTranslationJobStatus: async (...args) => { + if ( + blockStitchingOnce && + args[1] === "running" && + args[2] === "stitching" + ) { + blockStitchingOnce = false; + return false; + } + return translations.transitionTranslationJobStatus(...args); + }, + }, + }, + }; + }, + }); + + const sourcePath = join( + config.storePath, + "memories", + memoryId, + "CONTENT.md", + ); + const sourceBytes = await readFile(sourcePath); + const transitionConnection = initializeDatabase(config); + try { + await transitionConnection.repositories.translations.updateTranslationJobStatus( + started.job_id, + "committing", + { updatedAt: now }, + ); + } finally { + transitionConnection.close(); + } + + let chunkReads = 0; + let clientCreations = 0; + let backupCalls = 0; + const backupQueue: DurableMemoryBackupQueue = { + enqueue: async () => { + backupCalls += 1; + return { backupStatus: "queued" }; + }, + persistIntent: async () => { + backupCalls += 1; + return { backupStatus: "pending" }; + }, + }; + await runTranslationJob(started.job_id, { + backupQueue, + config, + createClient: () => { + clientCreations += 1; + return new FakeTranslationClient(); + }, + openConnection: (connectionConfig) => { + const connection = initializeDatabase(connectionConfig); + const translations = connection.repositories.translations; + return { + ...connection, + repositories: { + ...connection.repositories, + translations: { + ...translations, + getTranslationChunks: async (...args) => { + const chunks = await translations.getTranslationChunks(...args); + chunkReads += 1; + if (chunkReads === 2) { + await writeFile( + sourcePath, + Buffer.concat([sourceBytes, Buffer.from([0xff])]), + ); + } + return chunks; + }, + }, + }, + }; + }, + workloadLimits: { + ...DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + maxSourceBytes: sourceBytes.byteLength, + }, + }); + + expect(chunkReads).toBe(2); + expect(clientCreations).toBe(0); + expect(backupCalls).toBe(0); + const verifyConnection = initializeDatabase(config); + try { + await expect( + verifyConnection.repositories.translations.getTranslationJob(started.job_id), + ).resolves.toMatchObject({ + error: { + action: "none", + code: "validation_failed", + message: "Translation source exceeds the total source byte limit.", + }, + status: "failed", + }); + } finally { + verifyConnection.close(); + } + }); + it("stitches bounded paragraph and list fragments in exact Markdown order", async () => { const config = await createConfig(); const markdown = [ @@ -2742,10 +2917,14 @@ async function createMemoryRow(config: ResolvedTraumaConfig): Promise { async function writeSourceContent( config: ResolvedTraumaConfig, - markdown = "# Brilliant Source\n\nBody.", + markdown: string | Uint8Array = "# Brilliant Source\n\nBody.", ): Promise { const filePath = join(config.storePath, "memories", memoryId, "CONTENT.md"); await mkdir(dirname(filePath), { recursive: true }); + if (typeof markdown !== "string") { + await writeFile(filePath, markdown); + return; + } await writeFile( filePath, createMemoryContentFixture({ diff --git a/tests/server/translation/source-and-current.test.ts b/tests/server/translation/source-and-current.test.ts index 6f2ea9e9..f8083082 100644 --- a/tests/server/translation/source-and-current.test.ts +++ b/tests/server/translation/source-and-current.test.ts @@ -19,6 +19,9 @@ import { BRILLIANT_CHUNKER_VERSION, BRILLIANT_PROMPT_POLICY_VERSION, } from "../../../src/server/translation/prompt"; +import { + TranslationOutputValidationError, +} from "../../../src/server/translation/errors"; import { loadTranslationSourceSnapshot } from "../../../src/server/translation/source-loader"; import { writeTranslatedContentAtomically } from "../../../src/server/translation/stitching"; @@ -208,6 +211,216 @@ describe("translation source and current output", () => { } }); + it("admits the exact source byte limit and rejects limit plus one before decoding", async () => { + const config = await createConfig(); + const sourceContent = createSourceContent(); + const sourceBytes = Buffer.from(sourceContent); + await writeSourceContent(config, sourceContent); + + await expect(loadTranslationSourceSnapshot({ + config, + maxSourceBytes: sourceBytes.byteLength, + memoryId, + })).resolves.toMatchObject({ + byteSize: sourceBytes.byteLength, + }); + + const filePath = join(config.storePath, "memories", memoryId, "CONTENT.md"); + await writeFile( + filePath, + Buffer.concat([sourceBytes, Buffer.from([0xff])]), + ); + await expect(loadTranslationSourceSnapshot({ + config, + maxSourceBytes: sourceBytes.byteLength, + memoryId, + })).rejects.toMatchObject({ + name: "TranslationOutputValidationError", + retryable: false, + }); + }); + + it("continues short positional reads and closes the admitted source handle", async () => { + const config = await createConfig(); + const sourceBytes = Buffer.from(createSourceContent()); + const positions: number[] = []; + const readEnds: number[] = []; + const readBufferLengths: number[] = []; + let closeCalls = 0; + + const source = await loadTranslationSourceSnapshot({ + config, + maxSourceBytes: sourceBytes.byteLength, + memoryId, + openFile: async () => ({ + close: async () => { + closeCalls += 1; + }, + read: async (buffer, offset, length, position) => { + positions.push(position); + readEnds.push(position + length); + readBufferLengths.push(buffer.byteLength); + if (position >= sourceBytes.byteLength) { + return { bytesRead: 0 }; + } + const bytesRead = Math.min( + 7, + length, + sourceBytes.byteLength - position, + ); + sourceBytes.copy(buffer, offset, position, position + bytesRead); + return { bytesRead }; + }, + }), + }); + + expect(source.byteSize).toBe(sourceBytes.byteLength); + expect(source.sourceHash).toBe( + `sha256:${createHash("sha256").update(sourceBytes).digest("hex")}`, + ); + expect(positions[0]).toBe(0); + expect(positions.at(-1)).toBe(sourceBytes.byteLength); + expect(Math.max(...readEnds)).toBe(sourceBytes.byteLength + 1); + expect(new Set(readBufferLengths)).toEqual( + new Set([sourceBytes.byteLength + 1]), + ); + expect(closeCalls).toBe(1); + }); + + it("uses fixed-size demand buffers for a tiny source under a large limit", async () => { + const config = await createConfig(); + const sourceBytes = Buffer.from(createSourceContent()); + const readBufferLengths: number[] = []; + + await expect(loadTranslationSourceSnapshot({ + config, + maxSourceBytes: 20 * 1_024 * 1_024, + memoryId, + openFile: async () => ({ + close: async () => undefined, + read: async (buffer, offset, length, position) => { + readBufferLengths.push(buffer.byteLength); + if (position >= sourceBytes.byteLength) { + return { bytesRead: 0 }; + } + const bytesRead = Math.min( + length, + sourceBytes.byteLength - position, + ); + sourceBytes.copy(buffer, offset, position, position + bytesRead); + return { bytesRead }; + }, + }), + })).resolves.toMatchObject({ byteSize: sourceBytes.byteLength }); + + expect(Math.max(...readBufferLengths)).toBeLessThanOrEqual(64 * 1_024); + }); + + it("decodes multibyte UTF-8 split across retained source chunks", async () => { + const config = await createConfig(); + const marker = "界"; + const markerBytes = Buffer.from(marker); + const unpaddedContent = createSourceContent(marker); + const unpaddedMarkerOffset = Buffer.from(unpaddedContent).indexOf(markerBytes); + const paddingLength = 64 * 1_024 - 1 - unpaddedMarkerOffset; + expect(paddingLength).toBeGreaterThan(0); + const sourceContent = createSourceContent( + `${"a".repeat(paddingLength)}${marker}\n`, + ); + const sourceBytes = Buffer.from(sourceContent); + expect(sourceBytes.indexOf(markerBytes)).toBe(64 * 1_024 - 1); + await writeSourceContent(config, sourceContent); + + const source = await loadTranslationSourceSnapshot({ + config, + maxSourceBytes: sourceBytes.byteLength, + memoryId, + }); + + expect(source.sourceMarkdown).toBe(sourceContent); + expect(source.sourceHash).toBe( + `sha256:${createHash("sha256").update(sourceBytes).digest("hex")}`, + ); + }); + + it("detects a source that grows by one byte during bounded positional reads", async () => { + const config = await createConfig(); + const initialBytes = Buffer.from("safe"); + const grownByte = Buffer.from([0xff]); + const positions: number[] = []; + let closeCalls = 0; + + await expect(loadTranslationSourceSnapshot({ + config, + maxSourceBytes: initialBytes.byteLength, + memoryId, + openFile: async () => ({ + close: async () => { + closeCalls += 1; + }, + read: async (buffer, offset, length, position) => { + positions.push(position); + if (position < initialBytes.byteLength) { + const bytesRead = Math.min( + 2, + length, + initialBytes.byteLength - position, + ); + initialBytes.copy(buffer, offset, position, position + bytesRead); + return { bytesRead }; + } + if (position === initialBytes.byteLength) { + grownByte.copy(buffer, offset); + return { bytesRead: 1 }; + } + return { bytesRead: 0 }; + }, + }), + })).rejects.toBeInstanceOf(TranslationOutputValidationError); + + expect(positions).toEqual([0, 2, 4]); + expect(closeCalls).toBe(1); + }); + + it("closes after a read error and preserves that primary error", async () => { + const config = await createConfig(); + const readError = new Error("source read failed"); + let closeCalls = 0; + + await expect(loadTranslationSourceSnapshot({ + config, + maxSourceBytes: 1, + memoryId, + openFile: async () => ({ + close: async () => { + closeCalls += 1; + throw new Error("source close failed"); + }, + read: async () => { + throw readError; + }, + }), + })).rejects.toBe(readError); + expect(closeCalls).toBe(1); + }); + + it("surfaces a close error after an otherwise successful bounded read", async () => { + const config = await createConfig(); + const closeError = new Error("source close failed"); + + await expect(loadTranslationSourceSnapshot({ + config, + maxSourceBytes: 1, + memoryId, + openFile: async () => ({ + close: async () => { + throw closeError; + }, + read: async () => ({ bytesRead: 0 }), + }), + })).rejects.toBe(closeError); + }); + it("propagates non-missing translated output read failures", async () => { const config = await createConfig(); const sourceContent = createMemoryContentFixture({ @@ -420,6 +633,21 @@ async function writeSourceContent( await writeFile(filePath, content, "utf8"); } +function createSourceContent( + markdown = "# Brilliant Source\n\nBody text.", +): string { + return createMemoryContentFixture({ + frontmatter: { + capturedAt: now.toISOString(), + extractionStatus: "success", + id: memoryId, + title: "Brilliant Source", + url: "https://example.com/brilliant", + }, + markdown, + }); +} + async function createConfig(): Promise { const root = await mkdtemp(join(tmpdir(), "trauma-translation-current-")); tempRoots.push(root); From 5c627fa4c025af42878407b45c4e9b63c5d152fd Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 15:55:16 +0900 Subject: [PATCH 22/54] fix: make Codex catalog recovery retryable --- .../interaction-and-accessibility.md | 7 + src/components/settings/SettingsPage.tsx | 103 ++++++++-- .../settings/codex-model-catalog-state.ts | 108 +++++++++++ src/components/settings/settings-submit.ts | 2 + .../async-feedback-accessibility.test.ts | 9 +- tests/components/settings-page.test.ts | 181 ++++++++++++++++++ 6 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 src/components/settings/codex-model-catalog-state.ts diff --git a/docs/references/design-system/interaction-and-accessibility.md b/docs/references/design-system/interaction-and-accessibility.md index 68d3a314..04e7d6d7 100644 --- a/docs/references/design-system/interaction-and-accessibility.md +++ b/docs/references/design-system/interaction-and-accessibility.md @@ -200,3 +200,10 @@ save feedback uses a polite `role="status"` region. Do not rely on colour or visual placement alone to announce completion or failure. Reader model-catalog failures follow the same assertive alert contract. Aborted Codex-auth polling is cancellation rather than failure and must not announce stale feedback. + +Settings model-catalog failures keep the current model and reasoning selections +intact and expose an in-page Retry action. Only one catalog request may be in +flight; Retry and dependent controls remain disabled while it is pending, and +unmounted or superseded requests must not publish stale results. A successful +retry may move focus from the removed Retry control to Model only when the user +has not moved focus elsewhere. diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 26f91752..85b617ee 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -30,6 +30,7 @@ import { createAsyncActionTracker, type AsyncActionToken, } from "./action-state"; +import { createCodexModelCatalogController } from "./codex-model-catalog-state"; import { RouteHeader } from "../layout/RouteHeader"; export interface SettingsPageProps { @@ -78,6 +79,7 @@ export function SettingsPage(props: SettingsPageProps) { props.initialCodexModelCatalog?.models ?? [], ); const [codexCatalogError, setCodexCatalogError] = createSignal(""); + const [codexCatalogPending, setCodexCatalogPending] = createSignal(false); const [codexAuth, setCodexAuth] = createSignal(props.initialSettings.openaiAuth); const pendingCodexAuth = () => codexAuth().status === "login_started" @@ -89,6 +91,17 @@ export function SettingsPage(props: SettingsPageProps) { const [message, setMessage] = createSignal(""); const [error, setError] = createSignal(""); const authPollControllers = new Set(); + let codexModelSelect: HTMLSelectElement | undefined; + let settingsPageActive = true; + const codexCatalogController = createCodexModelCatalogController({ + initialModels: codexModels(), + onStateChange: (state) => { + setCodexModels(state.models); + setCodexCatalogError(state.error); + setCodexCatalogPending(state.pending); + }, + readCatalog: ({ signal }) => submitReadCodexModels({ signal }), + }); const actionTracker = createAsyncActionTracker( setPendingActions, ); @@ -129,6 +142,8 @@ export function SettingsPage(props: SettingsPageProps) { }; onCleanup(() => { + settingsPageActive = false; + codexCatalogController.dispose(); abortCodexAuthPolls(); }); @@ -179,18 +194,21 @@ export function SettingsPage(props: SettingsPageProps) { } }; - const refreshCodexModels = async (): Promise => { - setCodexCatalogError(""); - try { - const catalog = await submitReadCodexModels(); - setCodexModels(catalog.models); - } catch (error) { - setCodexCatalogError( - error instanceof Error - ? error.message - : "Failed to read Codex model catalog.", - ); - } + const refreshCodexModels = (): Promise<"error" | "ignored" | "success"> => + codexCatalogController.refresh(); + + const retryCodexModels = (retryButton: HTMLButtonElement): void => { + const shouldRestoreFocus = captureCodexCatalogRetryFocusIntent(retryButton); + void refreshCodexModels().then((outcome) => { + if (outcome !== "success" || !settingsPageActive) { + return; + } + queueMicrotask(() => { + if (settingsPageActive && shouldRestoreFocus()) { + codexModelSelect?.focus(); + } + }); + }); }; const saveCodexDefaults: JSX.EventHandler = ( @@ -446,7 +464,11 @@ export function SettingsPage(props: SettingsPageProps) {
-
+

Codex Translation

@@ -454,7 +476,8 @@ export function SettingsPage(props: SettingsPageProps) { Model setCodexEffort( @@ -507,15 +530,20 @@ export function SettingsPage(props: SettingsPageProps) {
- - {(value) => } + +

Loading Codex model catalog...

+
@@ -623,6 +651,45 @@ export function SettingsPage(props: SettingsPageProps) { ); } +export function CodexCatalogFeedback(props: { + error: string; + pending: boolean; + retry: (button: HTMLButtonElement) => void; +}) { + return ( + + + + ); +} + +export function captureCodexCatalogRetryFocusIntent( + retryButton: HTMLButtonElement, + readActiveElement: () => Element | null = () => + typeof document === "undefined" ? null : document.activeElement, + readBody: () => HTMLElement | undefined = () => + typeof document === "undefined" ? undefined : document.body, +): () => boolean { + const retryOwnedFocus = readActiveElement() === retryButton; + return () => { + if (!retryOwnedFocus) { + return false; + } + const activeElement = readActiveElement(); + return activeElement === retryButton || activeElement === readBody(); + }; +} + export function readSafeVerificationUrl(value: string): string | undefined { try { const url = new URL(value); diff --git a/src/components/settings/codex-model-catalog-state.ts b/src/components/settings/codex-model-catalog-state.ts new file mode 100644 index 00000000..677dab32 --- /dev/null +++ b/src/components/settings/codex-model-catalog-state.ts @@ -0,0 +1,108 @@ +import type { + CodexModelCatalog, + CodexModelInfo, +} from "../../server/translation/codex-app-server"; + +export interface CodexModelCatalogState { + error: string; + models: CodexModelInfo[]; + pending: boolean; +} + +export interface CodexModelCatalogController { + dispose: () => void; + refresh: () => Promise<"error" | "ignored" | "success">; +} + +export function createCodexModelCatalogController(input: { + initialModels: CodexModelInfo[]; + onStateChange: (state: CodexModelCatalogState) => void; + readCatalog: (input: { signal: AbortSignal }) => Promise; +}): CodexModelCatalogController { + let active = true; + let generation = 0; + let state: CodexModelCatalogState = { + error: "", + models: input.initialModels, + pending: false, + }; + let currentRequest: + | { + controller: AbortController; + generation: number; + promise: Promise<"error" | "ignored" | "success">; + } + | undefined; + + const publish = (next: CodexModelCatalogState): void => { + state = next; + input.onStateChange(next); + }; + + const isCurrent = (requestGeneration: number): boolean => + active && generation === requestGeneration; + const clearCurrentRequest = (requestGeneration: number): void => { + if (currentRequest?.generation === requestGeneration) { + currentRequest = undefined; + } + }; + + return { + dispose() { + active = false; + generation += 1; + currentRequest?.controller.abort(); + currentRequest = undefined; + }, + refresh() { + if (!active) { + return Promise.resolve("ignored"); + } + if (currentRequest !== undefined) { + return currentRequest.promise; + } + + generation += 1; + const requestGeneration = generation; + const controller = new AbortController(); + publish({ ...state, pending: true }); + const promise = (async (): Promise<"error" | "ignored" | "success"> => { + try { + const catalog = await input.readCatalog({ signal: controller.signal }); + if (!isCurrent(requestGeneration) || controller.signal.aborted) { + return "ignored"; + } + publish({ error: "", models: catalog.models, pending: false }); + return "success"; + } catch (error) { + if ( + !isCurrent(requestGeneration) || + controller.signal.aborted || + isAbortError(error) + ) { + return "ignored"; + } + publish({ + ...state, + error: error instanceof Error + ? error.message + : "Failed to read Codex model catalog.", + pending: false, + }); + return "error"; + } finally { + clearCurrentRequest(requestGeneration); + } + })(); + currentRequest = { controller, generation: requestGeneration, promise }; + return promise; + }, + }; +} + +function isAbortError(error: unknown): boolean { + return typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError"; +} diff --git a/src/components/settings/settings-submit.ts b/src/components/settings/settings-submit.ts index 53844f87..46d50b3e 100644 --- a/src/components/settings/settings-submit.ts +++ b/src/components/settings/settings-submit.ts @@ -40,10 +40,12 @@ export async function submitTranslationTargetLanguage(input: { export async function submitReadCodexModels(input: { fetch?: FetchFunction; + signal?: AbortSignal; } = {}): Promise { const requestFetch = input.fetch ?? fetch; const response = await requestFetch("/api/settings/codex-models", { method: "GET", + signal: input.signal, }); if (!response.ok) { throw new Error(await readErrorMessage(response, "failed to read Codex models")); diff --git a/tests/components/async-feedback-accessibility.test.ts b/tests/components/async-feedback-accessibility.test.ts index 49a76329..55a8e4d8 100644 --- a/tests/components/async-feedback-accessibility.test.ts +++ b/tests/components/async-feedback-accessibility.test.ts @@ -60,14 +60,19 @@ describe("async action feedback accessibility", () => { it("announces Codex catalog failures assertively", () => { const source = readSource("src/components/settings/SettingsPage.tsx"); const catalogErrorStart = source.indexOf( - "", + "export function CodexCatalogFeedback", ); const catalogErrorSource = source.slice( catalogErrorStart, - source.indexOf("", catalogErrorStart) + "".length, + source.indexOf( + "export function captureCodexCatalogRetryFocusIntent", + catalogErrorStart, + ), ); expect(catalogErrorStart).toBeGreaterThan(-1); expect(catalogErrorSource).toContain('role="alert"'); + expect(catalogErrorSource).toContain('type="button"'); + expect(catalogErrorSource).toContain("Retrying..."); }); }); diff --git a/tests/components/settings-page.test.ts b/tests/components/settings-page.test.ts index b37250fb..94e3fea4 100644 --- a/tests/components/settings-page.test.ts +++ b/tests/components/settings-page.test.ts @@ -3,10 +3,19 @@ import { readFileSync } from "node:fs"; import { createComponent, renderToString } from "solid-js/web"; import { describe, expect, it } from "vitest"; +import type { + CodexModelInfo, +} from "../../src/server/translation/codex-app-server"; import { + CodexCatalogFeedback, SettingsPage, + captureCodexCatalogRetryFocusIntent, readSafeVerificationUrl, } from "../../src/components/settings/SettingsPage"; +import { + createCodexModelCatalogController, + type CodexModelCatalogState, +} from "../../src/components/settings/codex-model-catalog-state"; import { submitCodexTranslationDefaults, submitTranslationDefaults, @@ -26,6 +35,151 @@ const settingsPageSource = readFileSync( ); describe("settings page", () => { + it("offers an accessible in-page retry while the Codex catalog recovers", async () => { + const retry = () => undefined; + const failedHtml = renderToString(() => + createComponent(CodexCatalogFeedback, { + error: "Catalog temporarily unavailable.", + pending: false, + retry, + }) + ); + const retryingHtml = renderToString(() => + createComponent(CodexCatalogFeedback, { + error: "Catalog temporarily unavailable.", + pending: true, + retry, + }) + ); + + expect(failedHtml).toContain('role="alert"'); + expect(failedHtml).toContain("Catalog temporarily unavailable."); + expect(failedHtml).toContain(">Retry"); + expect(retryingHtml).toContain("disabled"); + expect(retryingHtml).toContain("Retrying..."); + + const savedModel = createModel("saved-model", "Saved model"); + const restoredModel = createModel("restored-model", "Restored model"); + let state: CodexModelCatalogState = { + error: "", + models: [savedModel], + pending: false, + }; + const requests: Array<{ + reject: (error: unknown) => void; + resolve: (catalog: { models: typeof state.models }) => void; + signal: AbortSignal; + }> = []; + const readCatalog = ({ signal }: { signal: AbortSignal }) => + new Promise<{ models: typeof state.models }>((resolve, reject) => { + requests.push({ reject, resolve, signal }); + }); + const controller = createCodexModelCatalogController({ + initialModels: state.models, + onStateChange: (next) => { + state = next; + }, + readCatalog, + }); + + const initial = controller.refresh(); + expect(requests).toHaveLength(1); + requests[0]!.reject(new Error("Catalog temporarily unavailable.")); + await initial; + expect(state).toEqual({ + error: "Catalog temporarily unavailable.", + models: [savedModel], + pending: false, + }); + + const retryRequest = controller.refresh(); + const duplicateRetry = controller.refresh(); + expect(requests).toHaveLength(2); + expect(duplicateRetry).toBe(retryRequest); + expect(state).toEqual({ + error: "Catalog temporarily unavailable.", + models: [savedModel], + pending: true, + }); + requests[1]!.resolve({ models: [restoredModel] }); + await retryRequest; + expect(state).toEqual({ + error: "", + models: [restoredModel], + pending: false, + }); + + const recoveredHtml = renderToString(() => + createComponent(SettingsPage, { + initialCodexModelCatalog: { models: state.models }, + initialSettings: { + translationTargetLanguage: "en-US", + codexTranslationModel: "saved-model", + codexTranslationReasoningEffort: "high", + openaiAuth: { + status: "setup_required", + provider: "codex", + reason: "codex_app_server_unavailable", + }, + }, + }) + ); + expect(recoveredHtml).toContain("Restored model"); + expect(recoveredHtml).toContain('value="restored-model"'); + expect(recoveredHtml).toContain('value="saved-model"'); + expect(recoveredHtml).toContain('value="high"'); + }); + + it("aborts catalog loading and ignores a stale completion after disposal", async () => { + const updates: CodexModelCatalogState[] = []; + let resolveCatalog: ((catalog: { models: ReturnType[] }) => void) = + () => undefined; + let requestSignal: AbortSignal | undefined; + const controller = createCodexModelCatalogController({ + initialModels: [], + onStateChange: (state) => updates.push(state), + readCatalog: ({ signal }) => { + requestSignal = signal; + return new Promise((resolve) => { + resolveCatalog = resolve; + }); + }, + }); + + const request = controller.refresh(); + const updateCountBeforeDispose = updates.length; + controller.dispose(); + expect(requestSignal?.aborted).toBe(true); + resolveCatalog({ models: [createModel("stale-model", "Stale model")] }); + await request; + + expect(updates).toHaveLength(updateCountBeforeDispose); + }); + + it("does not steal focus when the user moves away during a catalog retry", () => { + const retryButton = {} as HTMLButtonElement; + const body = {} as HTMLBodyElement; + const anotherControl = {} as HTMLButtonElement; + let activeElement: Element | null = retryButton; + const shouldRestoreFocus = captureCodexCatalogRetryFocusIntent( + retryButton, + () => activeElement, + () => body, + ); + + activeElement = anotherControl; + expect(shouldRestoreFocus()).toBe(false); + + activeElement = retryButton; + const retryStillOwnedFocus = captureCodexCatalogRetryFocusIntent( + retryButton, + () => activeElement, + () => body, + ); + activeElement = body; + expect(retryStillOwnedFocus()).toBe(true); + }); + it("renders only credential-free HTTPS device verification links", () => { expect(readSafeVerificationUrl("https://example.com/device")) .toBe("https://example.com/device"); @@ -346,6 +500,21 @@ describe("settings page", () => { })).rejects.toThrow("Catalog temporarily unavailable."); }); + it("forwards cancellation to Codex catalog requests", async () => { + const controller = new AbortController(); + let requestSignal: AbortSignal | null | undefined; + + await expect(submitReadCodexModels({ + fetch: async (_input, init) => { + requestSignal = init?.signal; + return jsonResponse({ models: [createModel("frontier", "Frontier")] }); + }, + signal: controller.signal, + })).resolves.toMatchObject({ models: [{ model: "frontier" }] }); + + expect(requestSignal).toBe(controller.signal); + }); + it("surfaces Codex auth device-code failures", async () => { await expect( submitEnableOpenAiAuth({ @@ -575,6 +744,18 @@ describe("settings page", () => { }); }); +function createModel(model: string, displayName: string): CodexModelInfo { + return { + id: model, + model, + displayName, + description: `${displayName} description`, + isDefault: false, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["low", "medium", "high"], + }; +} + function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, From b6b7d400234be3676a33908f6e2b12e0ee9b14c1 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 16:16:47 +0900 Subject: [PATCH 23/54] fix: preserve Settings catalog selections --- e2e/settings.spec.ts | 159 +++++++++++++++++++++-- src/components/settings/SettingsPage.tsx | 25 +++- tests/components/settings-page.test.ts | 8 ++ 3 files changed, 176 insertions(+), 16 deletions(-) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 4e67e47c..6a12d73d 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,25 +1,113 @@ import { expect, test } from "@playwright/test"; -test("surfaces a stable error for a malformed successful Codex catalog", async ({ +import { runBunFixtureScript } from "./bun-fixture"; + +test("recovers a malformed Codex catalog without losing saved defaults", async ({ page, }) => { + seedCodexTranslationDefaults({ + model: "gpt-5.5", + reasoningEffort: "high", + }); + let catalogRequestCount = 0; + let releaseRetryRequest: () => void = () => undefined; + const retryRequestGate = new Promise((resolve) => { + releaseRetryRequest = resolve; + }); await page.route("**/api/settings/codex-models", async (route) => { + catalogRequestCount += 1; + if (catalogRequestCount === 1) { + await route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ models: null }), + }); + return; + } + + await retryRequestGate; await route.fulfill({ contentType: "application/json", status: 200, - body: JSON.stringify({ models: null }), + body: JSON.stringify({ + models: [ + { + id: "frontier", + model: "gpt-5.5", + displayName: "GPT-5.5", + description: "Frontier model", + isDefault: true, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["low", "medium", "high"], + }, + { + id: "fast", + model: "gpt-5.3", + displayName: "GPT-5.3", + description: "Fast model", + isDefault: false, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["medium", "high"], + }, + ], + }), }); }); - await page.goto("/settings"); + try { + await page.goto("/settings"); - await expect(page.getByRole("alert")).toHaveText( - "Codex model catalog response was invalid.", - ); - await expect(page.getByRole("combobox", { name: "Model", exact: true })) - .toBeEnabled(); - await expect(page.getByRole("button", { name: "Save Codex defaults" })) - .toBeEnabled(); + const alert = page.getByRole("alert"); + const retry = alert.getByRole("button", { name: /^Retry/ }); + const model = page.getByRole("combobox", { name: "Model", exact: true }); + const effort = page.getByRole("combobox", { + name: "Reasoning effort", + exact: true, + }); + const save = page.getByRole("button", { name: "Save Codex defaults" }); + + await expect( + alert.getByText("Codex model catalog response was invalid.", { + exact: true, + }), + ).toBeVisible(); + await expect(retry).toBeEnabled(); + await expect(retry).toHaveAccessibleName("Retry"); + await expect(model).toBeEnabled(); + await expect(model).toHaveValue("gpt-5.5"); + await expect(effort).toHaveValue("high"); + await expect(save).toBeEnabled(); + expect(catalogRequestCount).toBe(1); + + await retry.click(); + await expect.poll(() => catalogRequestCount).toBe(2); + await expect(retry).toBeDisabled(); + await expect(retry).toHaveAccessibleName("Retrying..."); + await expect(model).toBeDisabled(); + await expect(model).toHaveValue("gpt-5.5"); + await expect(effort).toBeDisabled(); + await expect(effort).toHaveValue("high"); + await expect(save).toBeDisabled(); + + await retry.evaluate((button: HTMLButtonElement) => button.click()); + releaseRetryRequest(); + + await expect(alert).toHaveCount(0); + await expect(model).toBeEnabled(); + await expect(model).toHaveValue("gpt-5.5"); + await expect(effort).toBeEnabled(); + await expect(effort).toHaveValue("high"); + await expect(save).toBeEnabled(); + await expect(model).toBeFocused(); + expect(catalogRequestCount).toBe(2); + + await model.selectOption("gpt-5.3"); + await effort.selectOption("medium"); + await expect(model).toHaveValue("gpt-5.3"); + await expect(effort).toHaveValue("medium"); + } finally { + releaseRetryRequest(); + } }); test("aborts an in-flight Codex auth poll without restoring stale controls", async ({ @@ -139,3 +227,54 @@ test("aborts an in-flight Codex auth poll without restoring stale controls", asy ); await expect(page.getByRole("button", { name: "Enabled" })).toHaveCount(0); }); + +function seedCodexTranslationDefaults(input: { + model: string | null; + reasoningEffort: + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | null; +}): void { + runBunFixtureScript(` + import { Database } from "bun:sqlite"; + import { join } from "node:path"; + + const database = new Database( + join(process.cwd(), ".trauma/e2e/runtime/trauma.sqlite"), + ); + const now = Date.parse("2026-05-28T00:00:00.000Z"); + try { + database.exec("PRAGMA busy_timeout = 5000"); + database + .query(\` + insert into app_settings ( + id, + translation_target_language, + codex_translation_model, + codex_translation_reasoning_effort, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?) + on conflict(id) do update set + translation_target_language = excluded.translation_target_language, + codex_translation_model = excluded.codex_translation_model, + codex_translation_reasoning_effort = excluded.codex_translation_reasoning_effort, + updated_at = excluded.updated_at + \`) + .run( + "default", + "ja-JP", + ${JSON.stringify(input.model)}, + ${JSON.stringify(input.reasoningEffort)}, + now, + now, + ); + } finally { + database.close(); + } + `); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 85b617ee..4b64aa5c 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -481,7 +481,9 @@ export function SettingsPage(props: SettingsPageProps) { value={codexModel()} onChange={(event) => setCodexModel(event.currentTarget.value)} > - + - + {(model) => ( - )} @@ -513,17 +520,23 @@ export function SettingsPage(props: SettingsPageProps) { ) } > - + - + - {(effort) => } + {(effort) => ( + + )} diff --git a/tests/components/settings-page.test.ts b/tests/components/settings-page.test.ts index 94e3fea4..95922bfb 100644 --- a/tests/components/settings-page.test.ts +++ b/tests/components/settings-page.test.ts @@ -128,6 +128,8 @@ describe("settings page", () => { expect(recoveredHtml).toContain('value="restored-model"'); expect(recoveredHtml).toContain('value="saved-model"'); expect(recoveredHtml).toContain('value="high"'); + expectSelectedOption(recoveredHtml, "saved-model"); + expectSelectedOption(recoveredHtml, "high"); }); it("aborts catalog loading and ignores a stale completion after disposal", async () => { @@ -756,6 +758,12 @@ function createModel(model: string, displayName: string): CodexModelInfo { }; } +function expectSelectedOption(html: string, value: string): void { + expect(html).toMatch( + new RegExp(`]*value="${value}")(?=[^>]*selected)[^>]*>`), + ); +} + function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, From 4235f2c97f02ea161f72e57161be0fb538722816 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 16:34:52 +0900 Subject: [PATCH 24/54] test: isolate Settings E2E runtime --- e2e/bun-fixture.ts | 55 +++++++++++++++++++++++++++++++++++ e2e/settings.spec.ts | 9 +++++- tests/e2e/bun-fixture.test.ts | 49 +++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/bun-fixture.test.ts diff --git a/e2e/bun-fixture.ts b/e2e/bun-fixture.ts index 39c0d663..c1a3e384 100644 --- a/e2e/bun-fixture.ts +++ b/e2e/bun-fixture.ts @@ -1,6 +1,61 @@ import { execFileSync } from "node:child_process"; import { accessSync } from "node:fs"; import { homedir } from "node:os"; +import { resolve } from "node:path"; + +export function ensureE2eRuntimeFixture( + root = resolve(process.cwd(), ".trauma/e2e"), +): void { + runBunFixtureScript(` + import { access, mkdir, writeFile } from "node:fs/promises"; + import { dirname, join } from "node:path"; + import { loadTraumaConfig } from "./src/server/config/index.ts"; + import { initializeDatabase } from "./src/server/db/connection.ts"; + + const root = ${JSON.stringify(resolve(root))}; + const configPath = join(root, "trauma.config.json"); + try { + await access(configPath); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") { + throw error; + } + + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { + storePath: "./project/store", + projectPath: "./project", + databasePath: "./runtime/trauma.sqlite", + backup: { + git: { + enabled: false, + remote: "origin", + branch: "main", + push: false, + commitMessageTemplate: "backup memory {memoryId}", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + } + + const config = loadTraumaConfig({ configPath }); + await Promise.all([ + mkdir(config.projectPath, { recursive: true }), + mkdir(config.storePath, { recursive: true }), + mkdir(dirname(config.databasePath), { recursive: true }), + ]); + const connection = initializeDatabase(config); + connection.close(); + `); +} export function runBunFixtureScript(script: string): string { return execFileSync(resolveBunExecutable(), ["-e", script], { diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 6a12d73d..d7978ea9 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,6 +1,13 @@ import { expect, test } from "@playwright/test"; -import { runBunFixtureScript } from "./bun-fixture"; +import { + ensureE2eRuntimeFixture, + runBunFixtureScript, +} from "./bun-fixture"; + +test.beforeEach(() => { + ensureE2eRuntimeFixture(); +}); test("recovers a malformed Codex catalog without losing saved defaults", async ({ page, diff --git a/tests/e2e/bun-fixture.test.ts b/tests/e2e/bun-fixture.test.ts new file mode 100644 index 00000000..e6818725 --- /dev/null +++ b/tests/e2e/bun-fixture.test.ts @@ -0,0 +1,49 @@ +import { Database } from "bun:sqlite"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { ensureE2eRuntimeFixture } from "../../e2e/bun-fixture"; + +describe("ensureE2eRuntimeFixture", () => { + it("creates and migrates an isolated E2E runtime from an empty root", async () => { + const root = await mkdtemp(join(tmpdir(), "trauma-e2e-runtime-")); + + try { + ensureE2eRuntimeFixture(root); + + const configPath = join(root, "trauma.config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")) as { + databasePath: string; + projectPath: string; + storePath: string; + }; + expect(config).toMatchObject({ + databasePath: "./runtime/trauma.sqlite", + projectPath: "./project", + storePath: "./project/store", + }); + + const database = new Database(join(root, "runtime/trauma.sqlite"), { + readonly: true, + }); + try { + expect( + database + .query("select name from sqlite_master where type = 'table' and name = 'app_settings'") + .get(), + ).toEqual({ name: "app_settings" }); + } finally { + database.close(); + } + + const initialConfig = await readFile(configPath, "utf8"); + ensureE2eRuntimeFixture(root); + await expect(readFile(configPath, "utf8")).resolves.toBe(initialConfig); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); From 8ef5798fc02c24c2a6e677d42e28f7511f59fee2 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 16:59:30 +0900 Subject: [PATCH 25/54] fix: preserve taxonomy dismissal focus --- docs/quality/verification.md | 4 ++ e2e/browse-shell.spec.ts | 63 +++++++++++++++++++ .../memories/TaxonomyInlineCreateControl.tsx | 40 +++++++++++- .../taxonomy-inline-create-control.test.tsx | 15 +++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/docs/quality/verification.md b/docs/quality/verification.md index 99fd100b..7fba66b4 100644 --- a/docs/quality/verification.md +++ b/docs/quality/verification.md @@ -52,6 +52,10 @@ store, and git paths rather than external websites or real application data. - `e2e/reader.spec.ts`: source reader, translation controls, deletion, Flashbacks, Moments, table-of-contents behavior, and Psychiatrist streaming/resume/cancel/regenerate/permission flows. +- `e2e/security-boundaries.spec.ts`: hostile Host rejection, configured + loopback acceptance, and backup reconciliation GET non-exposure. +- `e2e/settings.spec.ts`: model-catalog recovery, saved-default preservation, + retry focus, and cancellation-safe Codex auth polling. The Add Memory fixture seam is owned by `src/server/importer/runtime.ts`. It is enabled only by Playwright's three fixed E2E guards and synthesizes results for diff --git a/e2e/browse-shell.spec.ts b/e2e/browse-shell.spec.ts index 96c67b2e..529f938a 100644 --- a/e2e/browse-shell.spec.ts +++ b/e2e/browse-shell.spec.ts @@ -437,6 +437,69 @@ test("closes taxonomy creation controls on outside clicks", async ({ page }) => await expect(page.locator("#reader-state-title")).toHaveCount(0); }); +test("keeps outside focus when a dismissed taxonomy submission later resolves", async ({ + page, +}) => { + let tagRequestCount = 0; + let releaseTagRequest: () => void = () => undefined; + const tagRequestGate = new Promise((resolve) => { + releaseTagRequest = resolve; + }); + await page.route("**/api/tags", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + + tagRequestCount += 1; + await tagRequestGate; + await route.fulfill({ + body: JSON.stringify({ + tag: { + id: "late-focus-tag-id", + name: "late-focus-tag", + }, + }), + contentType: "application/json", + status: 201, + }); + }); + + try { + await page.goto("/memories"); + + const filters = page.getByRole("complementary", { name: "Browse filters" }); + await filters.getByRole("button", { name: "New tag" }).click(); + const input = filters.getByRole("textbox", { name: "New tag" }); + await input.fill("late-focus-tag"); + await input.press("Enter"); + await expect.poll(() => tagRequestCount).toBe(1); + await expect(input).toBeDisabled(); + + const outsideTarget = await installDismissableClickProbe(page); + await outsideTarget.click(); + await expect(input).toHaveCount(0); + await expect(outsideTarget).toBeFocused(); + + const completedTagRequest = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === "/api/tags" && response.request().method() === "POST"; + }); + releaseTagRequest(); + const tagResponse = await completedTagRequest; + expect(tagResponse.ok()).toBe(true); + await tagResponse.finished(); + await page.evaluate( + () => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ), + ); + await expect(outsideTarget).toBeFocused(); + } finally { + releaseTagRequest(); + } +}); + test("uses bottom primary tabs without drawer chrome on phone viewports", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/memories"); diff --git a/src/components/memories/TaxonomyInlineCreateControl.tsx b/src/components/memories/TaxonomyInlineCreateControl.tsx index a0e93abf..2424971e 100644 --- a/src/components/memories/TaxonomyInlineCreateControl.tsx +++ b/src/components/memories/TaxonomyInlineCreateControl.tsx @@ -1,4 +1,10 @@ -import { Show, createEffect, createSignal, type JSX } from "solid-js"; +import { + Show, + createEffect, + createSignal, + onCleanup, + type JSX, +} from "solid-js"; import { PlusIcon } from "../icons"; import { @@ -25,6 +31,24 @@ export interface TaxonomyInlineCreateControlProps { onSubmitName: (name: string) => Promise | void; } +export function createTaxonomyOpenInstanceTracker() { + let activeInstance: symbol | undefined; + + return { + capture: () => { + const capturedInstance = activeInstance; + return () => + capturedInstance !== undefined && activeInstance === capturedInstance; + }, + close: () => { + activeInstance = undefined; + }, + open: () => { + activeInstance ??= Symbol("taxonomy-open-instance"); + }, + }; +} + const addTaxonomyPillClass = "inline-flex items-center gap-1 rounded-full border border-dashed border-trauma-border-strong px-2.5 py-1 text-xs font-bold text-trauma-text-muted hover:text-trauma-text-primary"; const addTaxonomyInputClass = @@ -39,12 +63,18 @@ export function TaxonomyInlineCreateControl( const [internalOpen, setInternalOpen] = createSignal(false); const [draftName, setDraftName] = createSignal(""); const [pending, setPending] = createSignal(false); + const openInstanceTracker = createTaxonomyOpenInstanceTracker(); const isOpen = () => props.open ?? internalOpen(); const triggerClass = () => props.class ?? addTaxonomyPillClass; const setOpen = ( open: boolean, reason?: TaxonomyInlineCreateCloseReason, ): void => { + if (open) { + openInstanceTracker.open(); + } else { + openInstanceTracker.close(); + } if (props.open === undefined) { setInternalOpen(open); } @@ -59,10 +89,13 @@ export function TaxonomyInlineCreateControl( createEffect(() => { if (!isOpen()) { + openInstanceTracker.close(); return; } + openInstanceTracker.open(); queueMicrotask(() => inputRef?.focus()); }); + onCleanup(() => openInstanceTracker.close()); useDismissableLayer({ getRoot: () => rootRef, @@ -82,10 +115,13 @@ export function TaxonomyInlineCreateControl( return; } + const submittingInstanceStillOwned = openInstanceTracker.capture(); setPending(true); try { await props.onSubmitName(name); - setOpen(false, "submit"); + if (submittingInstanceStillOwned()) { + setOpen(false, "submit"); + } } catch (error) { props.onError?.( error instanceof Error ? error.message : "Failed to update taxonomy.", diff --git a/tests/components/taxonomy-inline-create-control.test.tsx b/tests/components/taxonomy-inline-create-control.test.tsx index 135e3b18..8b443eed 100644 --- a/tests/components/taxonomy-inline-create-control.test.tsx +++ b/tests/components/taxonomy-inline-create-control.test.tsx @@ -4,6 +4,7 @@ import { createComponent, renderToString } from "solid-js/web"; import { describe, expect, it } from "vitest"; import { + createTaxonomyOpenInstanceTracker, normalizeTaxonomyAddName, TaxonomyInlineCreateControl, } from "../../src/components/memories/TaxonomyInlineCreateControl"; @@ -52,4 +53,18 @@ describe("taxonomy inline create control", () => { expect(taxonomyAddSource).toContain("restoreTaxonomyAddTriggerFocus"); expect(taxonomyAddSource).toContain('reason !== "outside-pointer"'); }); + + it("does not let a settled submit adopt a dismissed or reopened instance", () => { + const tracker = createTaxonomyOpenInstanceTracker(); + tracker.open(); + const submittingInstanceStillOwned = tracker.capture(); + + expect(submittingInstanceStillOwned()).toBe(true); + tracker.close(); + expect(submittingInstanceStillOwned()).toBe(false); + + tracker.open(); + expect(submittingInstanceStillOwned()).toBe(false); + expect(tracker.capture()()).toBe(true); + }); }); From 99c03f960b97b6908b23d7b5fd31be10dcac2803 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Fri, 17 Jul 2026 17:28:10 +0900 Subject: [PATCH 26/54] fix: add collection page recovery --- e2e/collection-pagination.spec.ts | 133 ++++++++++++++++++ .../collections/CollectionPageRetry.tsx | 60 ++++++++ .../flashbacks/flashbacks-loader.ts | 4 + src/components/moments/MomentBrowse.tsx | 46 +++++- src/components/moments/moments-loader.ts | 4 + src/components/reader/MemoryReader.tsx | 36 ++++- src/routes/flashbacks/index.tsx | 41 +++++- .../components/collection-page-retry.test.tsx | 47 +++++++ tests/components/flashbacks-loader.test.ts | 14 +- .../components/flashbacks-route-state.test.ts | 4 + tests/components/moment-route.test.ts | 2 + tests/components/moments-loader.test.ts | 38 +++++ .../components/reader-flashback-tabs.test.ts | 12 ++ 13 files changed, 422 insertions(+), 19 deletions(-) create mode 100644 src/components/collections/CollectionPageRetry.tsx create mode 100644 tests/components/collection-page-retry.test.tsx create mode 100644 tests/components/moments-loader.test.ts diff --git a/e2e/collection-pagination.spec.ts b/e2e/collection-pagination.spec.ts index f2a33d42..d9a96b62 100644 --- a/e2e/collection-pagination.spec.ts +++ b/e2e/collection-pagination.spec.ts @@ -5,6 +5,10 @@ import { runBunFixtureScript } from "./bun-fixture"; const PAGINATION_MEMORY_ID = "018f04a2-3c6f-7c88-9a8b-8c99a9b7f177"; const PAGE_SIZE = 30; const ARCHIVE_SIZE = 37; +const FLASHBACK_PAGE_QUERY_ID = + "src_components_flashbacks_flashbacks-loader_ts--getFlashbackBrowsePage_query"; +const MOMENT_PAGE_QUERY_ID = + "src_components_moments_moments-loader_ts--getMomentBrowsePage_query"; test.describe.configure({ mode: "serial" }); @@ -97,6 +101,109 @@ test("keeps Reader All Flashbacks on one bounded rail-local page", async ({ page await expect(listBody.getByRole("link")).toHaveCount(PAGE_SIZE); }); +test("retries failed first Flashback and Moment pages without resetting the URL", async ({ + page, +}) => { + seedLargeCollectionArchive(); + + for (const surface of [ + { + failureTitle: "Failed to load flashbacks", + navLabel: "Flashbacks", + pathname: "/flashbacks", + queryId: FLASHBACK_PAGE_QUERY_ID, + regionName: "Flashback page results", + retryName: "Retry flashbacks", + retryingName: "Retrying flashbacks...", + }, + { + failureTitle: "Failed to load Moments", + navLabel: "Moments", + pathname: "/moments", + queryId: MOMENT_PAGE_QUERY_ID, + regionName: "Moment page results", + retryName: "Retry Moments", + retryingName: "Retrying Moments...", + }, + ] as const) { + await page.goto("/memories"); + const interception = await interceptCollectionPageRetry(page, surface.queryId); + try { + await page + .getByRole("navigation", { name: "Primary sections" }) + .getByRole("link", { name: surface.navLabel }) + .click(); + + const region = page.getByRole("region", { name: surface.regionName }); + const alert = region.getByRole("alert"); + await expect(alert.getByRole("heading", { name: surface.failureTitle })) + .toBeVisible(); + expect(new URL(page.url()).pathname).toBe(surface.pathname); + expect(new URL(page.url()).search).toBe(""); + + const retry = alert.getByRole("button"); + await expect(retry).toHaveAccessibleName(surface.retryName); + await retry.click(); + await expect.poll(interception.attempts).toBe(2); + await expect(retry).toBeDisabled(); + await expect(retry).toHaveAccessibleName(surface.retryingName); + await retry.evaluate((button: HTMLButtonElement) => button.click()); + + interception.release(); + await expect(region.locator("[data-collection-row]")).toHaveCount(PAGE_SIZE); + expect(interception.attempts()).toBe(2); + expect(new URL(page.url()).pathname).toBe(surface.pathname); + expect(new URL(page.url()).search).toBe(""); + await expect(region).toBeFocused(); + } finally { + interception.release(); + await interception.remove(); + } + } +}); + +test("retries a failed Reader All page without changing its rail-local cursor", async ({ + page, +}) => { + seedLargeCollectionArchive(); + const interception = await interceptCollectionPageRetry( + page, + FLASHBACK_PAGE_QUERY_ID, + ); + try { + await page.setViewportSize({ width: 1440, height: 760 }); + await page.goto(`/memories/${PAGINATION_MEMORY_ID}`); + await waitForReaderReady(page); + const flashbackSection = page + .getByRole("heading", { name: "Flashbacks", exact: true }) + .locator("xpath=.."); + await flashbackSection.getByRole("button", { name: "All", exact: true }).click(); + + const region = flashbackSection.getByRole("region", { + name: "All flashbacks page", + }); + const alert = region.getByRole("alert"); + await expect(alert).toContainText("Failed to load flashbacks."); + const retry = alert.getByRole("button"); + await expect(retry).toHaveAccessibleName("Retry all flashbacks"); + + await retry.click(); + await expect.poll(interception.attempts).toBe(2); + await expect(retry).toBeDisabled(); + await expect(retry).toHaveAccessibleName("Retrying all flashbacks..."); + await retry.evaluate((button: HTMLButtonElement) => button.click()); + + interception.release(); + await expect(region.getByRole("link")).toHaveCount(PAGE_SIZE); + expect(interception.attempts()).toBe(2); + expect(new URL(page.url()).pathname).toBe(`/memories/${PAGINATION_MEMORY_ID}`); + await expect(region).toBeFocused(); + } finally { + interception.release(); + await interception.remove(); + } +}); + async function expectCollectionPage( page: Page, rowCount: number, @@ -113,6 +220,32 @@ async function waitForReaderReady(page: Page): Promise { ); } +async function interceptCollectionPageRetry(page: Page, queryId: string) { + let requestAttempts = 0; + let releaseRetryRequest: () => void = () => undefined; + const retryRequestGate = new Promise((resolve) => { + releaseRetryRequest = resolve; + }); + const matchesQuery = (url: URL): boolean => + url.pathname === "/_server/" && url.searchParams.get("id") === queryId; + await page.route(matchesQuery, async (route) => { + requestAttempts += 1; + if (requestAttempts === 1) { + await route.abort("failed"); + return; + } + + await retryRequestGate; + await route.continue(); + }); + + return { + attempts: () => requestAttempts, + release: () => releaseRetryRequest(), + remove: () => page.unroute(matchesQuery), + }; +} + function seedLargeCollectionArchive(): void { runBunFixtureScript(` import { mkdir, rm, writeFile } from "node:fs/promises"; diff --git a/src/components/collections/CollectionPageRetry.tsx b/src/components/collections/CollectionPageRetry.tsx new file mode 100644 index 00000000..c576a63f --- /dev/null +++ b/src/components/collections/CollectionPageRetry.tsx @@ -0,0 +1,60 @@ +import { createSignal } from "solid-js"; + +export function CollectionPageRetry(props: { + getFocusTarget: () => HTMLElement | undefined; + onRetry: () => Promise | unknown; + subject: string; +}) { + const [pending, setPending] = createSignal(false); + let retryButton!: HTMLButtonElement; + + const retry = async (): Promise => { + if (pending()) { + return; + } + + const shouldRestoreFocus = captureCollectionPageRetryFocusIntent(retryButton); + setPending(true); + try { + await props.onRetry(); + } catch { + // The owning page keeps its existing error state available for another retry. + } finally { + setPending(false); + queueMicrotask(() => { + if (!shouldRestoreFocus()) { + return; + } + const target = props.getFocusTarget(); + if (target?.isConnected) { + target.focus({ preventScroll: true }); + } + }); + } + }; + + return ( + + ); +} + +export function captureCollectionPageRetryFocusIntent( + retryButton: HTMLButtonElement, + readActiveElement: () => Element | null = () => document.activeElement, + readBody: () => HTMLElement | null = () => document.body, +): () => boolean { + const retryOwnedFocus = readActiveElement() === retryButton; + return () => + retryOwnedFocus && + (readActiveElement() === retryButton || readActiveElement() === readBody()); +} diff --git a/src/components/flashbacks/flashbacks-loader.ts b/src/components/flashbacks/flashbacks-loader.ts index 0c6b86f7..e2c1f537 100644 --- a/src/components/flashbacks/flashbacks-loader.ts +++ b/src/components/flashbacks/flashbacks-loader.ts @@ -37,6 +37,10 @@ export const getBrowseFlashbacksForMemories = query(async (input: { return loadBrowseFlashbacksForMemories(input); }, "browse-flashbacks-for-memories"); +export function revalidateFlashbackBrowsePage(cursor: string | null) { + return revalidate(getFlashbackBrowsePage.keyFor({ cursor })); +} + export async function revalidateFlashbackBrowseRows() { await Promise.all([ revalidate(getFlashbackBrowsePage.key), diff --git a/src/components/moments/MomentBrowse.tsx b/src/components/moments/MomentBrowse.tsx index 836a86fd..2b4df39e 100644 --- a/src/components/moments/MomentBrowse.tsx +++ b/src/components/moments/MomentBrowse.tsx @@ -1,7 +1,8 @@ import { createAsync, useLocation } from "@solidjs/router"; -import { For, Show, createMemo, createSignal } from "solid-js"; +import { For, Show, createMemo, createSignal, type JSX } from "solid-js"; import type { MomentBrowseRow } from "~/server/moments/browse"; +import { CollectionPageRetry } from "../collections/CollectionPageRetry"; import { buildCollectionPageHref, readCollectionPageCursor, @@ -9,7 +10,11 @@ import { } from "../collections/page-state"; import { deleteMomentById } from "./moment-action-requests"; import { MomentActionMenu } from "./MomentActionMenu"; -import { getMomentBrowsePage, revalidateMomentBrowseRows } from "./moments-loader"; +import { + getMomentBrowsePage, + revalidateMomentBrowsePage, + revalidateMomentBrowseRows, +} from "./moments-loader"; import { buildMemoryAnchorHref } from "../memories/memory-anchor-hrefs"; import { revalidateReaderMemory } from "../reader/reader-memory-loader"; import { RouteHeader } from "../layout/RouteHeader"; @@ -21,6 +26,7 @@ const rowBase = "trauma-route-row grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-trauma-border px-6 py-[22px] transition hover:bg-trauma-bg-tint"; export function MomentBrowse() { + let pageRegionRef: HTMLDivElement | undefined; const location = useLocation(); const cursor = createMemo(() => readCollectionPageCursor(location.search)); const loadedPage = createAsync(() => { @@ -30,6 +36,7 @@ export function MomentBrowse() { ); }); const [deletedMomentIds, setDeletedMomentIds] = createSignal(new Set()); + const [isRetryingPage, setIsRetryingPage] = createSignal(false); const currentPageState = createMemo(() => { const state = loadedPage(); return state?.cursor === cursor() ? state : undefined; @@ -46,6 +53,14 @@ export function MomentBrowse() { return currentMoments.filter((moment) => !deletedMomentIds().has(moment.id)); }; + const retryCurrentPage = async (): Promise => { + setIsRetryingPage(true); + try { + await revalidateMomentBrowsePage(cursor()); + } finally { + setIsRetryingPage(false); + } + }; const deleteMoment = async ( momentId: string, memoryId: string, @@ -60,19 +75,34 @@ export function MomentBrowse() { return (
-
+
} > + > + pageRegionRef} + onRetry={retryCurrentPage} + subject="Moments" + /> + } > {(message) =>

{message()}

}
+ {props.children}
); } diff --git a/src/components/moments/moments-loader.ts b/src/components/moments/moments-loader.ts index 43b3dcf4..fe5504e6 100644 --- a/src/components/moments/moments-loader.ts +++ b/src/components/moments/moments-loader.ts @@ -20,6 +20,10 @@ export const getMomentBrowseRows = query(async () => { return loadMomentBrowseRows(); }, "moment-browse-rows"); +export function revalidateMomentBrowsePage(cursor: string | null) { + return revalidate(getMomentBrowsePage.keyFor({ cursor })); +} + export function revalidateMomentBrowseRows() { return Promise.all([ revalidate(getMomentBrowsePage.key), diff --git a/src/components/reader/MemoryReader.tsx b/src/components/reader/MemoryReader.tsx index e5b66392..2a6ce368 100644 --- a/src/components/reader/MemoryReader.tsx +++ b/src/components/reader/MemoryReader.tsx @@ -32,8 +32,10 @@ import type { FlashbackBrowseRow } from "../../server/db/repositories"; import { FlashbackShortcutList } from "../flashbacks/FlashbackShortcutList"; import { getFlashbackBrowsePage, + revalidateFlashbackBrowsePage, revalidateFlashbackBrowseRows, } from "../flashbacks/flashbacks-loader"; +import { CollectionPageRetry } from "../collections/CollectionPageRetry"; import { settleCollectionPage } from "../collections/page-state"; import { MemoryActionMenu } from "../memories/MemoryActionMenu"; import { MemoryReadStatusControl } from "../memories/MemoryReadStatusControl"; @@ -2369,6 +2371,7 @@ export function ReaderFlashbackTabs(props: { initialTab?: "all" | "memory"; memoryId: string; }) { + let allPageRegionRef: HTMLDivElement | undefined; const [activeTab, setActiveTab] = createSignal<"all" | "memory">( props.initialTab ?? "memory", ); @@ -2377,6 +2380,7 @@ export function ReaderFlashbackTabs(props: { const [cursorHistory, setCursorHistory] = createSignal( [], ); + const [isRetryingAllPage, setIsRetryingAllPage] = createSignal(false); const lazyAllPageState = createAsync(async () => { if (props.allFlashbacks !== undefined || !shouldLoadAll()) { return undefined; @@ -2404,6 +2408,14 @@ export function ReaderFlashbackTabs(props: { shouldLoadAll() && currentAllPageState() === undefined; const hasAllPageError = () => currentAllPageState()?.status === "error"; + const retryAllPage = async (): Promise => { + setIsRetryingAllPage(true); + try { + await revalidateFlashbackBrowsePage(allCursor()); + } finally { + setIsRetryingAllPage(false); + } + }; const activateAllTab = (): void => { setShouldLoadAll(true); setActiveTab("all"); @@ -2454,13 +2466,27 @@ export function ReaderFlashbackTabs(props: { +
- Failed to load flashbacks. -

+
+

+ Failed to load flashbacks. +

+ allPageRegionRef} + onRetry={retryAllPage} + subject="all flashbacks" + /> +
} > readCollectionPageCursor(location.search)); const loadedPage = createAsync(() => { @@ -39,6 +42,7 @@ export default function FlashbacksIndex() { const [removedFlashbackIds, setRemovedFlashbackIds] = createSignal>( new Set(), ); + const [isRetryingPage, setIsRetryingPage] = createSignal(false); const currentPageState = createMemo(() => { const state = loadedPage(); return state?.cursor === cursor() ? state : undefined; @@ -55,6 +59,14 @@ export default function FlashbacksIndex() { return page.flashbacks.filter((row) => !removedFlashbackIds().has(row.id)); }); + const retryCurrentPage = async (): Promise => { + setIsRetryingPage(true); + try { + await revalidateFlashbackBrowsePage(cursor()); + } finally { + setIsRetryingPage(false); + } + }; const deleteFlashback = async (flashback: FlashbackActionMenuItem) => { await deleteFlashbackBySelection({ flashback }); setRemovedFlashbackIds((current) => new Set([...current, flashback.id])); @@ -69,21 +81,36 @@ export default function FlashbacksIndex() {
Flashbacks | TRAUMA -
+
} > + > + pageRegionRef} + onRetry={retryCurrentPage} + subject="flashbacks" + /> + } >

{props.title}

{(message) =>

{message()}

}
+ {props.children}
); } diff --git a/tests/components/collection-page-retry.test.tsx b/tests/components/collection-page-retry.test.tsx new file mode 100644 index 00000000..c6541f0d --- /dev/null +++ b/tests/components/collection-page-retry.test.tsx @@ -0,0 +1,47 @@ +import { createComponent, renderToString } from "solid-js/web"; +import { describe, expect, it } from "vitest"; + +import { + CollectionPageRetry, + captureCollectionPageRetryFocusIntent, +} from "../../src/components/collections/CollectionPageRetry"; + +describe("collection page retry", () => { + it("renders a native retry action with surface-specific context", () => { + const html = renderToString(() => + createComponent(CollectionPageRetry, { + getFocusTarget: () => undefined, + onRetry: async () => undefined, + subject: "flashbacks", + }), + ); + + expect(html).toContain("Retry"); + }); + + it("hands focus off only while the retry action still owns it", () => { + const retryButton = {} as HTMLButtonElement; + const body = {} as HTMLBodyElement; + const anotherControl = {} as HTMLButtonElement; + let activeElement: Element | null = retryButton; + const shouldRestoreFocus = captureCollectionPageRetryFocusIntent( + retryButton, + () => activeElement, + () => body, + ); + + activeElement = anotherControl; + expect(shouldRestoreFocus()).toBe(false); + + activeElement = retryButton; + const retryStillOwnedFocus = captureCollectionPageRetryFocusIntent( + retryButton, + () => activeElement, + () => body, + ); + activeElement = body; + expect(retryStillOwnedFocus()).toBe(true); + }); +}); diff --git a/tests/components/flashbacks-loader.test.ts b/tests/components/flashbacks-loader.test.ts index 4cd4ff07..889fe8d0 100644 --- a/tests/components/flashbacks-loader.test.ts +++ b/tests/components/flashbacks-loader.test.ts @@ -4,7 +4,7 @@ const routerMocks = vi.hoisted(() => ({ query: vi.fn((fn: () => unknown, name: string) => Object.assign(fn, { key: name, - keyFor: () => name, + keyFor: (...args: unknown[]) => `${name}:${JSON.stringify(args)}`, }), ), revalidate: vi.fn(), @@ -23,6 +23,7 @@ const { getFlashbackBrowsePage, getFlashbackBrowseRows, getRecentFlashbackBrowseRows, + revalidateFlashbackBrowsePage, revalidateFlashbackBrowseRows, } = await import("../../src/components/flashbacks/flashbacks-loader"); @@ -41,4 +42,15 @@ describe("flashbacks loader", () => { expect(routerMocks.revalidate).toHaveBeenCalledWith(getRecentFlashbackBrowseRows.key); expect(routerMocks.revalidate).toHaveBeenCalledWith(getBrowseFlashbacksForMemories.key); }); + + it("revalidates only the requested page cursor for an in-place retry", async () => { + routerMocks.revalidate.mockResolvedValue(undefined); + + await revalidateFlashbackBrowsePage("opaque-cursor"); + + expect(routerMocks.revalidate).toHaveBeenCalledOnce(); + expect(routerMocks.revalidate).toHaveBeenCalledWith( + 'flashback-browse-page:[{"cursor":"opaque-cursor"}]', + ); + }); }); diff --git a/tests/components/flashbacks-route-state.test.ts b/tests/components/flashbacks-route-state.test.ts index 6a8df53c..b8321ab5 100644 --- a/tests/components/flashbacks-route-state.test.ts +++ b/tests/components/flashbacks-route-state.test.ts @@ -99,5 +99,9 @@ describe("flashbacks route state", () => { expect(flashbacksRouteSource).toContain("useLocation"); expect(flashbacksRouteSource).toContain("nextCursor"); expect(flashbacksRouteSource).toContain('href="/flashbacks"'); + expect(flashbacksRouteSource).toContain("CollectionPageRetry"); + expect(flashbacksRouteSource).toContain( + "revalidateFlashbackBrowsePage(cursor())", + ); }); }); diff --git a/tests/components/moment-route.test.ts b/tests/components/moment-route.test.ts index b27bd9ab..d126705f 100644 --- a/tests/components/moment-route.test.ts +++ b/tests/components/moment-route.test.ts @@ -37,5 +37,7 @@ describe("Moment route", () => { expect(browseSource).toContain("No Moments yet"); expect(browseSource).toContain("Saved reader sections will appear here."); expect(browseSource).not.toContain("Saved sections"); + expect(browseSource).toContain("CollectionPageRetry"); + expect(browseSource).toContain("revalidateMomentBrowsePage(cursor())"); }); }); diff --git a/tests/components/moments-loader.test.ts b/tests/components/moments-loader.test.ts new file mode 100644 index 00000000..7a9ba73d --- /dev/null +++ b/tests/components/moments-loader.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const routerMocks = vi.hoisted(() => ({ + query: vi.fn((fn: () => unknown, name: string) => + Object.assign(fn, { + key: name, + keyFor: (...args: unknown[]) => `${name}:${JSON.stringify(args)}`, + }), + ), + revalidate: vi.fn(), +})); + +vi.mock("@solidjs/router", () => routerMocks); +vi.mock("~/server/moments/browse", () => ({ + loadMomentBrowsePage: vi.fn(), + loadMomentBrowseRows: vi.fn(), +})); + +const { + revalidateMomentBrowsePage, +} = await import("../../src/components/moments/moments-loader"); + +describe("moments loader", () => { + beforeEach(() => { + routerMocks.revalidate.mockReset(); + }); + + it("revalidates only the requested page cursor for an in-place retry", async () => { + routerMocks.revalidate.mockResolvedValue(undefined); + + await revalidateMomentBrowsePage(null); + + expect(routerMocks.revalidate).toHaveBeenCalledOnce(); + expect(routerMocks.revalidate).toHaveBeenCalledWith( + 'moment-browse-page:[{"cursor":null}]', + ); + }); +}); diff --git a/tests/components/reader-flashback-tabs.test.ts b/tests/components/reader-flashback-tabs.test.ts index e1fed0c6..60f70d8e 100644 --- a/tests/components/reader-flashback-tabs.test.ts +++ b/tests/components/reader-flashback-tabs.test.ts @@ -192,6 +192,18 @@ describe("reader flashback tabs", () => { expect(html).toContain("No flashbacks for this memory yet"); }); + + it("revalidates the current All cursor through an accessible retry action", () => { + const source = readFileSync("src/components/reader/MemoryReader.tsx", "utf8"); + const tabsSource = source.slice( + source.indexOf("export function ReaderFlashbackTabs"), + source.indexOf("function getReaderSelectionKey"), + ); + + expect(tabsSource).toContain("CollectionPageRetry"); + expect(tabsSource).toContain("revalidateFlashbackBrowsePage(allCursor())"); + expect(tabsSource).toContain('subject="all flashbacks"'); + }); }); function renderTabs(input: { From 632139b12be4bc03cc896c72f6c4ab28b46bb249 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Mon, 20 Jul 2026 16:27:34 +0900 Subject: [PATCH 27/54] fix: scope collection retry ownership --- e2e/collection-pagination.spec.ts | 185 +++++++++++++++--- .../collections/CollectionPageRetry.tsx | 86 +++++++- src/components/moments/MomentBrowse.tsx | 31 +-- src/components/reader/MemoryReader.tsx | 28 +-- src/routes/flashbacks/index.tsx | 31 +-- .../components/collection-page-retry.test.tsx | 77 +++++++- .../components/flashbacks-route-state.test.ts | 4 +- tests/components/moment-route.test.ts | 4 +- .../components/reader-flashback-tabs.test.ts | 6 +- 9 files changed, 373 insertions(+), 79 deletions(-) diff --git a/e2e/collection-pagination.spec.ts b/e2e/collection-pagination.spec.ts index d9a96b62..f9cbb24b 100644 --- a/e2e/collection-pagination.spec.ts +++ b/e2e/collection-pagination.spec.ts @@ -9,6 +9,30 @@ const FLASHBACK_PAGE_QUERY_ID = "src_components_flashbacks_flashbacks-loader_ts--getFlashbackBrowsePage_query"; const MOMENT_PAGE_QUERY_ID = "src_components_moments_moments-loader_ts--getMomentBrowsePage_query"; +const ROUTE_RETRY_SURFACES = [ + { + failureTitle: "Failed to load flashbacks", + navLabel: "Flashbacks", + pageNavLabel: "Flashback pages", + pathname: "/flashbacks", + queryId: FLASHBACK_PAGE_QUERY_ID, + regionName: "Flashback page results", + retryName: "Retry flashbacks", + retryingName: "Retrying flashbacks...", + visibleFirstPageText: "Flashback selection 37", + }, + { + failureTitle: "Failed to load Moments", + navLabel: "Moments", + pageNavLabel: "Moment pages", + pathname: "/moments", + queryId: MOMENT_PAGE_QUERY_ID, + regionName: "Moment page results", + retryName: "Retry Moments", + retryingName: "Retrying Moments...", + visibleFirstPageText: "Moment Section 37", + }, +] as const; test.describe.configure({ mode: "serial" }); @@ -106,26 +130,7 @@ test("retries failed first Flashback and Moment pages without resetting the URL" }) => { seedLargeCollectionArchive(); - for (const surface of [ - { - failureTitle: "Failed to load flashbacks", - navLabel: "Flashbacks", - pathname: "/flashbacks", - queryId: FLASHBACK_PAGE_QUERY_ID, - regionName: "Flashback page results", - retryName: "Retry flashbacks", - retryingName: "Retrying flashbacks...", - }, - { - failureTitle: "Failed to load Moments", - navLabel: "Moments", - pathname: "/moments", - queryId: MOMENT_PAGE_QUERY_ID, - regionName: "Moment page results", - retryName: "Retry Moments", - retryingName: "Retrying Moments...", - }, - ] as const) { + for (const surface of ROUTE_RETRY_SURFACES) { await page.goto("/memories"); const interception = await interceptCollectionPageRetry(page, surface.queryId); try { @@ -204,6 +209,127 @@ test("retries a failed Reader All page without changing its rail-local cursor", } }); +for (const surface of ROUTE_RETRY_SURFACES) { + test(`does not render an abandoned ${surface.navLabel} retry after navigating to First`, async ({ + page, + }) => { + seedLargeCollectionArchive(); + await page.goto(surface.pathname); + await expectCollectionPage(page, PAGE_SIZE, surface.visibleFirstPageText); + const interception = await interceptCollectionPageRetry(page, surface.queryId); + + try { + const pageNavigation = page.getByRole("navigation", { + name: surface.pageNavLabel, + }); + await pageNavigation.getByRole("link", { name: "Next" }).click(); + await expect(page).toHaveURL(new RegExp(`${surface.pathname}\\?cursor=`)); + + const region = page.getByRole("region", { name: surface.regionName }); + await expect(region.getByRole("alert")).toBeVisible(); + const retry = region.getByRole("button"); + await retry.click(); + await expect.poll(interception.attempts).toBe(2); + await expect(retry).toBeDisabled(); + + await pageNavigation.getByRole("link", { name: "First" }).click(); + await expect.poll(() => new URL(page.url()).pathname).toBe(surface.pathname); + expect(new URL(page.url()).search).toBe(""); + await expectCollectionPage(page, PAGE_SIZE, surface.visibleFirstPageText); + await expect(region.getByRole("alert")).toHaveCount(0); + + await interception.releaseAndWait(); + expect(new URL(page.url()).search).toBe(""); + await expectCollectionPage(page, PAGE_SIZE, surface.visibleFirstPageText); + await expect(region.getByRole("alert")).toHaveCount(0); + } finally { + interception.release(); + await interception.remove(); + } + }); +} + +test("does not render an abandoned Reader All retry after navigating to Previous", async ({ + page, +}) => { + seedLargeCollectionArchive(); + await page.setViewportSize({ width: 1440, height: 760 }); + await page.goto(`/memories/${PAGINATION_MEMORY_ID}`); + await waitForReaderReady(page); + const flashbackSection = page + .getByRole("heading", { name: "Flashbacks", exact: true }) + .locator("xpath=.."); + await flashbackSection.getByRole("button", { name: "All", exact: true }).click(); + const region = flashbackSection.getByRole("region", { + name: "All flashbacks page", + }); + await expect(region.getByRole("link")).toHaveCount(PAGE_SIZE); + const interception = await interceptCollectionPageRetry( + page, + FLASHBACK_PAGE_QUERY_ID, + ); + + try { + const pageControls = region.getByRole("navigation", { + name: "All Flashback pages", + }); + await pageControls.getByRole("button", { name: "Next" }).click(); + await expect(region.getByRole("alert")).toBeVisible(); + const retry = region.getByRole("button", { name: "Retry all flashbacks" }); + await retry.click(); + await expect.poll(interception.attempts).toBe(2); + + await pageControls.getByRole("button", { name: "Previous" }).click(); + await expect(region.getByRole("link")).toHaveCount(PAGE_SIZE); + await expect(region.getByText("Flashback selection 37", { exact: false })) + .toBeVisible(); + await expect(region.getByRole("alert")).toHaveCount(0); + + await interception.releaseAndWait(); + await expect(region.getByRole("link")).toHaveCount(PAGE_SIZE); + await expect(region.getByRole("alert")).toHaveCount(0); + await expect(pageControls.getByRole("button", { name: "Previous" })) + .toBeDisabled(); + } finally { + interception.release(); + await interception.remove(); + } +}); + +test("returns keyboard focus to Retry after repeated recovery failures", async ({ + page, +}) => { + seedLargeCollectionArchive(); + await page.goto("/memories"); + const interception = await interceptCollectionPageRetry( + page, + FLASHBACK_PAGE_QUERY_ID, + { mode: "fail-all" }, + ); + + try { + await page + .getByRole("navigation", { name: "Primary sections" }) + .getByRole("link", { name: "Flashbacks" }) + .click(); + const region = page.getByRole("region", { name: "Flashback page results" }); + const retry = region.getByRole("button"); + await expect(retry).toHaveAccessibleName("Retry flashbacks"); + + for (const attempt of [2, 3]) { + await retry.focus(); + await retry.press("Enter"); + await expect.poll(interception.attempts).toBe(attempt); + await expect(retry).toHaveAccessibleName("Retry flashbacks"); + await expect(retry).toBeEnabled(); + await expect(retry).toBeFocused(); + } + } finally { + interception.release(); + await interception.remove(); + } +}); + async function expectCollectionPage( page: Page, rowCount: number, @@ -220,7 +346,11 @@ async function waitForReaderReady(page: Page): Promise { ); } -async function interceptCollectionPageRetry(page: Page, queryId: string) { +async function interceptCollectionPageRetry( + page: Page, + queryId: string, + options: { mode?: "fail-all" | "hold-retry" } = {}, +) { let requestAttempts = 0; let releaseRetryRequest: () => void = () => undefined; const retryRequestGate = new Promise((resolve) => { @@ -230,18 +360,27 @@ async function interceptCollectionPageRetry(page: Page, queryId: string) { url.pathname === "/_server/" && url.searchParams.get("id") === queryId; await page.route(matchesQuery, async (route) => { requestAttempts += 1; - if (requestAttempts === 1) { + if (requestAttempts === 1 || options.mode === "fail-all") { await route.abort("failed"); return; } - await retryRequestGate; + if (requestAttempts === 2) { + await retryRequestGate; + } await route.continue(); }); return { attempts: () => requestAttempts, release: () => releaseRetryRequest(), + releaseAndWait: async () => { + const response = page.waitForResponse((candidate) => + matchesQuery(new URL(candidate.url())) + ); + releaseRetryRequest(); + await response; + }, remove: () => page.unroute(matchesQuery), }; } diff --git a/src/components/collections/CollectionPageRetry.tsx b/src/components/collections/CollectionPageRetry.tsx index c576a63f..84fe3372 100644 --- a/src/components/collections/CollectionPageRetry.tsx +++ b/src/components/collections/CollectionPageRetry.tsx @@ -1,8 +1,10 @@ import { createSignal } from "solid-js"; +export type CollectionPageRetryOutcome = "error" | "success" | "superseded"; + export function CollectionPageRetry(props: { getFocusTarget: () => HTMLElement | undefined; - onRetry: () => Promise | unknown; + onRetry: () => CollectionPageRetryOutcome | Promise; subject: string; }) { const [pending, setPending] = createSignal(false); @@ -15,20 +17,20 @@ export function CollectionPageRetry(props: { const shouldRestoreFocus = captureCollectionPageRetryFocusIntent(retryButton); setPending(true); + let outcome: CollectionPageRetryOutcome = "error"; try { - await props.onRetry(); + outcome = await props.onRetry(); } catch { - // The owning page keeps its existing error state available for another retry. + outcome = "error"; } finally { setPending(false); queueMicrotask(() => { - if (!shouldRestoreFocus()) { - return; - } - const target = props.getFocusTarget(); - if (target?.isConnected) { - target.focus({ preventScroll: true }); - } + restoreCollectionPageRetryFocus({ + focusTarget: props.getFocusTarget(), + outcome, + retryButton, + shouldRestoreFocus, + }); }); } }; @@ -58,3 +60,67 @@ export function captureCollectionPageRetryFocusIntent( retryOwnedFocus && (readActiveElement() === retryButton || readActiveElement() === readBody()); } + +export function restoreCollectionPageRetryFocus(input: { + focusTarget: HTMLElement | undefined; + outcome: CollectionPageRetryOutcome; + retryButton: HTMLButtonElement; + shouldRestoreFocus: () => boolean; +}): void { + if (!input.shouldRestoreFocus() || input.outcome === "superseded") { + return; + } + + const target = input.outcome === "success" ? input.focusTarget : input.retryButton; + if (target?.isConnected) { + target.focus({ preventScroll: true }); + } +} + +export function createCollectionPageRetryController(input: { + getCurrentCursor: () => string | null; + isPageReady: (cursor: string | null) => boolean; + revalidatePage: (cursor: string | null) => Promise | unknown; +}) { + let nextGeneration = 0; + const [activeRetry, setActiveRetry] = createSignal<{ + cursor: string | null; + generation: number; + }>(); + + const isRetryingCurrentPage = (): boolean => + activeRetry()?.cursor === input.getCurrentCursor(); + + const retryCurrentPage = async (): Promise => { + const attempt = { + cursor: input.getCurrentCursor(), + generation: ++nextGeneration, + }; + setActiveRetry(attempt); + + let revalidationFailed = false; + try { + await input.revalidatePage(attempt.cursor); + } catch { + revalidationFailed = true; + } + + const ownsRetry = activeRetry()?.generation === attempt.generation; + const outcome: CollectionPageRetryOutcome = !ownsRetry || + input.getCurrentCursor() !== attempt.cursor + ? "superseded" + : !revalidationFailed && input.isPageReady(attempt.cursor) + ? "success" + : "error"; + + if (ownsRetry) { + setActiveRetry(undefined); + } + return outcome; + }; + + return { + isRetryingCurrentPage, + retryCurrentPage, + }; +} diff --git a/src/components/moments/MomentBrowse.tsx b/src/components/moments/MomentBrowse.tsx index 2b4df39e..1166efe3 100644 --- a/src/components/moments/MomentBrowse.tsx +++ b/src/components/moments/MomentBrowse.tsx @@ -2,7 +2,10 @@ import { createAsync, useLocation } from "@solidjs/router"; import { For, Show, createMemo, createSignal, type JSX } from "solid-js"; import type { MomentBrowseRow } from "~/server/moments/browse"; -import { CollectionPageRetry } from "../collections/CollectionPageRetry"; +import { + CollectionPageRetry, + createCollectionPageRetryController, +} from "../collections/CollectionPageRetry"; import { buildCollectionPageHref, readCollectionPageCursor, @@ -36,11 +39,18 @@ export function MomentBrowse() { ); }); const [deletedMomentIds, setDeletedMomentIds] = createSignal(new Set()); - const [isRetryingPage, setIsRetryingPage] = createSignal(false); const currentPageState = createMemo(() => { const state = loadedPage(); return state?.cursor === cursor() ? state : undefined; }); + const pageRetry = createCollectionPageRetryController({ + getCurrentCursor: cursor, + isPageReady: (requestedCursor) => { + const state = loadedPage(); + return state?.cursor === requestedCursor && state.status === "ready"; + }, + revalidatePage: revalidateMomentBrowsePage, + }); const currentPage = createMemo(() => { const state = currentPageState(); return state?.status === "ready" ? state.page : undefined; @@ -53,14 +63,6 @@ export function MomentBrowse() { return currentMoments.filter((moment) => !deletedMomentIds().has(moment.id)); }; - const retryCurrentPage = async (): Promise => { - setIsRetryingPage(true); - try { - await revalidateMomentBrowsePage(cursor()); - } finally { - setIsRetryingPage(false); - } - }; const deleteMoment = async ( momentId: string, memoryId: string, @@ -77,18 +79,19 @@ export function MomentBrowse() {
} > pageRegionRef} - onRetry={retryCurrentPage} + onRetry={pageRetry.retryCurrentPage} subject="Moments" /> diff --git a/src/components/reader/MemoryReader.tsx b/src/components/reader/MemoryReader.tsx index 2a6ce368..b975ea21 100644 --- a/src/components/reader/MemoryReader.tsx +++ b/src/components/reader/MemoryReader.tsx @@ -35,7 +35,10 @@ import { revalidateFlashbackBrowsePage, revalidateFlashbackBrowseRows, } from "../flashbacks/flashbacks-loader"; -import { CollectionPageRetry } from "../collections/CollectionPageRetry"; +import { + CollectionPageRetry, + createCollectionPageRetryController, +} from "../collections/CollectionPageRetry"; import { settleCollectionPage } from "../collections/page-state"; import { MemoryActionMenu } from "../memories/MemoryActionMenu"; import { MemoryReadStatusControl } from "../memories/MemoryReadStatusControl"; @@ -2380,7 +2383,6 @@ export function ReaderFlashbackTabs(props: { const [cursorHistory, setCursorHistory] = createSignal( [], ); - const [isRetryingAllPage, setIsRetryingAllPage] = createSignal(false); const lazyAllPageState = createAsync(async () => { if (props.allFlashbacks !== undefined || !shouldLoadAll()) { return undefined; @@ -2395,6 +2397,14 @@ export function ReaderFlashbackTabs(props: { const state = lazyAllPageState(); return state?.cursor === allCursor() ? state : undefined; }); + const allPageRetry = createCollectionPageRetryController({ + getCurrentCursor: allCursor, + isPageReady: (requestedCursor) => { + const state = lazyAllPageState(); + return state?.cursor === requestedCursor && state.status === "ready"; + }, + revalidatePage: revalidateFlashbackBrowsePage, + }); const allPage = createMemo(() => { if (props.allFlashbacks !== undefined) { return { flashbacks: props.allFlashbacks, nextCursor: null }; @@ -2408,14 +2418,6 @@ export function ReaderFlashbackTabs(props: { shouldLoadAll() && currentAllPageState() === undefined; const hasAllPageError = () => currentAllPageState()?.status === "error"; - const retryAllPage = async (): Promise => { - setIsRetryingAllPage(true); - try { - await revalidateFlashbackBrowsePage(allCursor()); - } finally { - setIsRetryingAllPage(false); - } - }; const activateAllTab = (): void => { setShouldLoadAll(true); setActiveTab("all"); @@ -2468,14 +2470,14 @@ export function ReaderFlashbackTabs(props: { fallback={

@@ -2483,7 +2485,7 @@ export function ReaderFlashbackTabs(props: {

allPageRegionRef} - onRetry={retryAllPage} + onRetry={allPageRetry.retryCurrentPage} subject="all flashbacks" />
diff --git a/src/routes/flashbacks/index.tsx b/src/routes/flashbacks/index.tsx index 49bbfbf9..bdf0df48 100644 --- a/src/routes/flashbacks/index.tsx +++ b/src/routes/flashbacks/index.tsx @@ -2,7 +2,10 @@ import { Title } from "@solidjs/meta"; import { createAsync, useLocation } from "@solidjs/router"; import { For, Show, createMemo, createSignal, type JSX } from "solid-js"; -import { CollectionPageRetry } from "~/components/collections/CollectionPageRetry"; +import { + CollectionPageRetry, + createCollectionPageRetryController, +} from "~/components/collections/CollectionPageRetry"; import { buildCollectionPageHref, readCollectionPageCursor, @@ -42,11 +45,18 @@ export default function FlashbacksIndex() { const [removedFlashbackIds, setRemovedFlashbackIds] = createSignal>( new Set(), ); - const [isRetryingPage, setIsRetryingPage] = createSignal(false); const currentPageState = createMemo(() => { const state = loadedPage(); return state?.cursor === cursor() ? state : undefined; }); + const pageRetry = createCollectionPageRetryController({ + getCurrentCursor: cursor, + isPageReady: (requestedCursor) => { + const state = loadedPage(); + return state?.cursor === requestedCursor && state.status === "ready"; + }, + revalidatePage: revalidateFlashbackBrowsePage, + }); const currentPage = createMemo(() => { const state = currentPageState(); return state?.status === "ready" ? state.page : undefined; @@ -59,14 +69,6 @@ export default function FlashbacksIndex() { return page.flashbacks.filter((row) => !removedFlashbackIds().has(row.id)); }); - const retryCurrentPage = async (): Promise => { - setIsRetryingPage(true); - try { - await revalidateFlashbackBrowsePage(cursor()); - } finally { - setIsRetryingPage(false); - } - }; const deleteFlashback = async (flashback: FlashbackActionMenuItem) => { await deleteFlashbackBySelection({ flashback }); setRemovedFlashbackIds((current) => new Set([...current, flashback.id])); @@ -83,20 +85,21 @@ export default function FlashbacksIndex() {
} > pageRegionRef} - onRetry={retryCurrentPage} + onRetry={pageRetry.retryCurrentPage} subject="flashbacks" /> diff --git a/tests/components/collection-page-retry.test.tsx b/tests/components/collection-page-retry.test.tsx index c6541f0d..8010c31d 100644 --- a/tests/components/collection-page-retry.test.tsx +++ b/tests/components/collection-page-retry.test.tsx @@ -1,9 +1,11 @@ import { createComponent, renderToString } from "solid-js/web"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CollectionPageRetry, captureCollectionPageRetryFocusIntent, + createCollectionPageRetryController, + restoreCollectionPageRetryFocus, } from "../../src/components/collections/CollectionPageRetry"; describe("collection page retry", () => { @@ -11,7 +13,7 @@ describe("collection page retry", () => { const html = renderToString(() => createComponent(CollectionPageRetry, { getFocusTarget: () => undefined, - onRetry: async () => undefined, + onRetry: () => "success", subject: "flashbacks", }), ); @@ -44,4 +46,75 @@ describe("collection page retry", () => { activeElement = body; expect(retryStillOwnedFocus()).toBe(true); }); + + it("keeps retry ownership scoped to its captured cursor and generation", async () => { + let cursor: string | null = "cursor-a"; + let readyCursor: string | null | undefined; + const releases = new Map void>(); + const controller = createCollectionPageRetryController({ + getCurrentCursor: () => cursor, + isPageReady: (requestedCursor) => readyCursor === requestedCursor, + revalidatePage: (requestedCursor) => + new Promise((resolve) => { + releases.set(requestedCursor ?? "first", resolve); + }), + }); + + const firstRetry = controller.retryCurrentPage(); + expect(controller.isRetryingCurrentPage()).toBe(true); + + cursor = "cursor-b"; + expect(controller.isRetryingCurrentPage()).toBe(false); + const secondRetry = controller.retryCurrentPage(); + expect(controller.isRetryingCurrentPage()).toBe(true); + + releases.get("cursor-a")?.(); + await expect(firstRetry).resolves.toBe("superseded"); + expect(controller.isRetryingCurrentPage()).toBe(true); + + readyCursor = "cursor-b"; + releases.get("cursor-b")?.(); + await expect(secondRetry).resolves.toBe("success"); + expect(controller.isRetryingCurrentPage()).toBe(false); + }); + + it("returns an unsuccessful retry to its button without stealing moved focus", () => { + const retryButton = { + focus: vi.fn(), + isConnected: true, + } as unknown as HTMLButtonElement; + const results = { + focus: vi.fn(), + isConnected: true, + } as unknown as HTMLElement; + + restoreCollectionPageRetryFocus({ + focusTarget: results, + outcome: "error", + retryButton, + shouldRestoreFocus: () => true, + }); + expect(retryButton.focus).toHaveBeenCalledWith({ preventScroll: true }); + expect(results.focus).not.toHaveBeenCalled(); + + vi.mocked(retryButton.focus).mockClear(); + restoreCollectionPageRetryFocus({ + focusTarget: results, + outcome: "success", + retryButton, + shouldRestoreFocus: () => true, + }); + expect(results.focus).toHaveBeenCalledWith({ preventScroll: true }); + expect(retryButton.focus).not.toHaveBeenCalled(); + + vi.mocked(results.focus).mockClear(); + restoreCollectionPageRetryFocus({ + focusTarget: results, + outcome: "error", + retryButton, + shouldRestoreFocus: () => false, + }); + expect(retryButton.focus).not.toHaveBeenCalled(); + expect(results.focus).not.toHaveBeenCalled(); + }); }); diff --git a/tests/components/flashbacks-route-state.test.ts b/tests/components/flashbacks-route-state.test.ts index b8321ab5..7cdfc30c 100644 --- a/tests/components/flashbacks-route-state.test.ts +++ b/tests/components/flashbacks-route-state.test.ts @@ -100,8 +100,10 @@ describe("flashbacks route state", () => { expect(flashbacksRouteSource).toContain("nextCursor"); expect(flashbacksRouteSource).toContain('href="/flashbacks"'); expect(flashbacksRouteSource).toContain("CollectionPageRetry"); + expect(flashbacksRouteSource).toContain("createCollectionPageRetryController"); + expect(flashbacksRouteSource).toContain("isRetryingCurrentPage()"); expect(flashbacksRouteSource).toContain( - "revalidateFlashbackBrowsePage(cursor())", + "revalidatePage: revalidateFlashbackBrowsePage", ); }); }); diff --git a/tests/components/moment-route.test.ts b/tests/components/moment-route.test.ts index d126705f..cf8982f1 100644 --- a/tests/components/moment-route.test.ts +++ b/tests/components/moment-route.test.ts @@ -38,6 +38,8 @@ describe("Moment route", () => { expect(browseSource).toContain("Saved reader sections will appear here."); expect(browseSource).not.toContain("Saved sections"); expect(browseSource).toContain("CollectionPageRetry"); - expect(browseSource).toContain("revalidateMomentBrowsePage(cursor())"); + expect(browseSource).toContain("createCollectionPageRetryController"); + expect(browseSource).toContain("isRetryingCurrentPage()"); + expect(browseSource).toContain("revalidatePage: revalidateMomentBrowsePage"); }); }); diff --git a/tests/components/reader-flashback-tabs.test.ts b/tests/components/reader-flashback-tabs.test.ts index 60f70d8e..bace2d3c 100644 --- a/tests/components/reader-flashback-tabs.test.ts +++ b/tests/components/reader-flashback-tabs.test.ts @@ -7,6 +7,7 @@ const flashbackLoaderMocks = vi.hoisted(() => ({ getFlashbackBrowsePage: vi.fn< (_input: { cursor: string | null }) => Promise >(), + revalidateFlashbackBrowsePage: vi.fn(), revalidateFlashbackBrowseRows: vi.fn(), })); @@ -66,6 +67,7 @@ describe("reader flashback tabs", () => { flashbackLoaderMocks.getFlashbackBrowsePage.mockReturnValue( new Promise(() => {}), ); + flashbackLoaderMocks.revalidateFlashbackBrowsePage.mockReset(); flashbackLoaderMocks.revalidateFlashbackBrowseRows.mockReset(); }); @@ -201,7 +203,9 @@ describe("reader flashback tabs", () => { ); expect(tabsSource).toContain("CollectionPageRetry"); - expect(tabsSource).toContain("revalidateFlashbackBrowsePage(allCursor())"); + expect(tabsSource).toContain("createCollectionPageRetryController"); + expect(tabsSource).toContain("isRetryingCurrentPage()"); + expect(tabsSource).toContain("revalidatePage: revalidateFlashbackBrowsePage"); expect(tabsSource).toContain('subject="all flashbacks"'); }); }); From b20eb75e23ef9d6763cfff57fdb4305e008153c0 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Mon, 20 Jul 2026 16:41:58 +0900 Subject: [PATCH 28/54] fix: enforce browser import host policy --- .../browser-import/import-browser-capture.ts | 38 ++++++--- .../import-browser-capture.test.ts | 78 +++++++++++++++++-- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/server/browser-import/import-browser-capture.ts b/src/server/browser-import/import-browser-capture.ts index 26ba9a63..adf0e71a 100644 --- a/src/server/browser-import/import-browser-capture.ts +++ b/src/server/browser-import/import-browser-capture.ts @@ -1,7 +1,11 @@ import type { MemoryBackupQueue } from "../backup"; import type { ResolvedTraumaConfig } from "../config"; import type { TraumaDatabase } from "../db"; -import type { ImporterResult } from "../importer"; +import { + type HostResolver, + type ImporterResult, + validateImportUrl, +} from "../importer"; import { extractArticleInWorker, runExtractorWithTimeout, @@ -32,13 +36,16 @@ export interface ImportBrowserCaptureInput { backupQueue: MemoryBackupQueue; extractArticle?: ArticleExtractor; extractionTimeoutMs?: number; + resolveHostname?: HostResolver; createMemory?: (input: AddMemoryInput) => Promise<{ id: string }>; } const DEFAULT_BROWSER_IMPORT_EXTRACTION_TIMEOUT_MS = 10_000; export async function importBrowserCapture(input: ImportBrowserCaptureInput) { - const selectedUrl = selectCaptureUrl(input.payload); + const selectedUrl = await selectCaptureUrl(input.payload, { + resolveHostname: input.resolveHostname, + }); const extractionInput = { html: createExtractionDocumentHtml(input.payload), @@ -89,9 +96,14 @@ export async function importBrowserCapture(input: ImportBrowserCaptureInput) { }); } -function selectCaptureUrl(payload: BrowserImportPayload) { - const sourceUrl = normalizeCaptureUrl(payload.sourceUrl); - if (sourceUrl === null) { +async function selectCaptureUrl( + payload: BrowserImportPayload, + options: { resolveHostname?: HostResolver }, +) { + let sourceUrl: URL; + try { + sourceUrl = new URL(await validateImportUrl(payload.sourceUrl, options)); + } catch { throw new BrowserImportError("source URL is not allowed"); } @@ -99,7 +111,7 @@ function selectCaptureUrl(payload: BrowserImportPayload) { return sourceUrl.toString(); } - const canonicalUrl = normalizeCaptureUrl(payload.canonicalUrl); + const canonicalUrl = parseCaptureUrl(payload.canonicalUrl); if ( canonicalUrl === null || !isTrustedCanonicalHostname(sourceUrl, canonicalUrl.hostname) @@ -107,10 +119,14 @@ function selectCaptureUrl(payload: BrowserImportPayload) { return sourceUrl.toString(); } - return canonicalUrl.toString(); + try { + return await validateImportUrl(canonicalUrl.toString(), options); + } catch { + return sourceUrl.toString(); + } } -function normalizeCaptureUrl(value: string) { +function parseCaptureUrl(value: string) { try { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") { @@ -121,8 +137,10 @@ function normalizeCaptureUrl(value: string) { return null; } - url.username = ""; - url.password = ""; + if (url.username !== "" || url.password !== "") { + return null; + } + return url; } catch { return null; diff --git a/tests/server/browser-import/import-browser-capture.test.ts b/tests/server/browser-import/import-browser-capture.test.ts index b0afb58f..a4c8e2a7 100644 --- a/tests/server/browser-import/import-browser-capture.test.ts +++ b/tests/server/browser-import/import-browser-capture.test.ts @@ -4,6 +4,7 @@ import { BrowserImportError, importBrowserCapture, type BrowserImportPayload, + type ImportBrowserCaptureInput, } from "../../../src/server/browser-import"; describe("browser capture import", () => { @@ -24,7 +25,7 @@ describe("browser capture import", () => { }); const observedUrls: string[] = []; - const memory = await importBrowserCapture({ + const memory = await importCapture({ payload, config: createConfig(), db: {} as never, @@ -50,7 +51,7 @@ describe("browser capture import", () => { it("rejects empty Defuddle markdown without building a browser-capture markdown fallback", async () => { await expect( - importBrowserCapture({ + importCapture({ payload: createPayload({ articleHtml: `

Captured Article

@@ -80,7 +81,7 @@ describe("browser capture import", () => { it("bounds browser capture extraction time before persisting a memory", async () => { await expect( - importBrowserCapture({ + importCapture({ payload: createPayload({ articleHtml: `

Captured Article

@@ -115,7 +116,7 @@ describe("browser capture import", () => { }); const observedMarkdown: string[] = []; - const memory = await importBrowserCapture({ + const memory = await importCapture({ payload, config: createConfig(), db: {} as never, @@ -145,7 +146,7 @@ describe("browser capture import", () => { const startedAt = Date.now(); await expect( - importBrowserCapture({ + importCapture({ payload: createPayload({ articleHtml: `

Captured Article

@@ -186,7 +187,7 @@ describe("browser capture import", () => { it("falls back to the captured source URL when canonical URL is private", async () => { const observedUrls: string[] = []; - const memory = await importBrowserCapture({ + const memory = await importCapture({ payload: createPayload({ canonicalUrl: "http://127.0.0.1/admin", articleHtml: `
@@ -215,7 +216,7 @@ describe("browser capture import", () => { it("falls back to the captured source URL when canonical URL is a different public IP", async () => { const observedUrls: string[] = []; - const memory = await importBrowserCapture({ + const memory = await importCapture({ payload: createPayload({ canonicalUrl: "https://93.184.216.34/canonical", articleHtml: `
@@ -249,9 +250,63 @@ describe("browser capture import", () => { expect(observedUrls).toEqual(["https://example.com/source"]); }); + it("rejects source hostnames that resolve to a private address", async () => { + await expect( + importCapture({ + payload: createPayload({ + sourceUrl: "https://split-horizon.example/article", + }), + config: createConfig(), + db: {} as never, + backupQueue: { enqueue: async () => ({ backupStatus: "pending" }) }, + resolveHostname: async () => ["127.0.0.1"], + extractArticle: async () => ({ + title: "Captured Article", + description: null, + faviconUrl: null, + markdown: "# Captured Article\n\nPrivate content must not be imported.", + wordCount: 8, + }), + createMemory: async () => ({ id: "must-not-be-created" }), + }), + ).rejects.toEqual(new BrowserImportError("source URL is not allowed")); + }); + + it("falls back to a validated source when canonical DNS becomes private", async () => { + const observedUrls: string[] = []; + let resolutionCount = 0; + + await importCapture({ + payload: createPayload({ + canonicalUrl: "https://example.com/canonical", + }), + config: createConfig(), + db: {} as never, + backupQueue: { enqueue: async () => ({ backupStatus: "pending" }) }, + resolveHostname: async () => { + resolutionCount += 1; + return resolutionCount === 1 ? ["93.184.216.34"] : ["127.0.0.1"]; + }, + extractArticle: async () => ({ + title: "Captured Article", + description: null, + faviconUrl: null, + markdown: "# Captured Article\n\nOnly validated public URLs may persist.", + wordCount: 8, + }), + createMemory: async (input) => { + observedUrls.push(input.url); + return { id: "memory-id" }; + }, + }); + + expect(resolutionCount).toBe(2); + expect(observedUrls).toEqual(["https://example.com/source"]); + }); + it("rejects localhost subdomain source URLs", async () => { await expect( - importBrowserCapture({ + importCapture({ payload: createPayload({ sourceUrl: "http://app.localhost:5173/article", articleHtml: `
@@ -272,6 +327,13 @@ describe("browser capture import", () => { }); }); +function importCapture(input: ImportBrowserCaptureInput) { + return importBrowserCapture({ + resolveHostname: async () => ["93.184.216.34"], + ...input, + }); +} + function createPayload( overrides: Partial = {}, ): BrowserImportPayload { From 58cda78091be815ec63fcd7aa7b05f23c6ab0363 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Mon, 20 Jul 2026 16:52:29 +0900 Subject: [PATCH 29/54] fix: bound aggregate translation output --- docs/architecture/flows.md | 8 ++- .../coding-standards/security-boundaries.md | 9 +-- src/server/translation/limits.ts | 51 ++++++++++++++++ src/server/translation/runner.ts | 27 ++++++-- src/server/translation/stitching.ts | 49 +++++++++++---- tests/server/translation/chunker.test.ts | 24 ++++++++ tests/server/translation/runner.test.ts | 61 +++++++++++++++++++ tests/server/translation/stitching.test.ts | 15 +++++ 8 files changed, 220 insertions(+), 24 deletions(-) diff --git a/docs/architecture/flows.md b/docs/architecture/flows.md index 69d02d67..e098e75a 100644 --- a/docs/architecture/flows.md +++ b/docs/architecture/flows.md @@ -230,8 +230,12 @@ remain compatible for their current clients. client is called. Prompt overflow cannot reach the app-server WebSocket and terminally fails the chunk without automatic retry. Translated text is admitted by UTF-8 bytes before projection or payload - persistence: at most 1 MiB per segment and 4 MiB across one chunk. Overflow - is a terminal, non-auto-retried `validation_failed` attempt. + persistence: at most 1 MiB per segment, 4 MiB across one chunk, and 56 MiB + for the complete translated `CONTENT.md`, including preserved frontmatter. + The runner counts resumed chunks and rejects aggregate overflow before the + next chunk is persisted; final stitching rechecks the same bound before + joining or publishing output. Overflow is a terminal, non-auto-retried + `validation_failed` attempt. Codex events pass fixed serialized UTF-8 admission before any delta reaches replay or SSE. The 4,096-event/4-MiB chunk-attempt budget resets for each attempt; the 262,144-event/32-MiB job budget accumulates across every chunk diff --git a/docs/references/coding-standards/security-boundaries.md b/docs/references/coding-standards/security-boundaries.md index 6f28bafe..7fad5b62 100644 --- a/docs/references/coding-standards/security-boundaries.md +++ b/docs/references/coding-standards/security-boundaries.md @@ -125,10 +125,11 @@ overflow before streaming UTF-8 decoding, Markdown or frontmatter parsing, document-type inference, or incremental raw-byte hashing. Resume and final commit reloads must receive the same workload source-byte limit. -- MUST reject translated output above 1 MiB per segment or 4 MiB per chunk by - serialized UTF-8 bytes before projection or translated payload persistence. - Absolute output overflow is terminal for that chunk attempt and is not - automatically retried. +- MUST reject translated output above 1 MiB per segment, 4 MiB per chunk, or + 56 MiB for the complete translated `CONTENT.md` by serialized UTF-8 bytes. + Count preserved frontmatter and resumed chunks, reject aggregate overflow + before chunk persistence, and recheck before final stitching or publication. + Absolute output overflow is terminal and is not automatically retried. - MUST admit Brilliant translation Codex events against fixed server-side serialized UTF-8 budgets: 64 KiB per event, 4,096 events or 4 MiB per chunk attempt, and 262,144 events or 32 MiB for the whole job. Job admission is diff --git a/src/server/translation/limits.ts b/src/server/translation/limits.ts index 7a799d2f..f8d4f788 100644 --- a/src/server/translation/limits.ts +++ b/src/server/translation/limits.ts @@ -11,11 +11,13 @@ export const DEFAULT_TRANSLATION_CHUNK_CONFIG = { export const BRILLIANT_MAX_TRANSLATION_PROMPT_BYTES = 64 * 1_024; export const BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES = 20 * 1_024 * 1_024; +export const BRILLIANT_MAX_TRANSLATION_OUTPUT_BYTES = 56 * 1_024 * 1_024; export const BRILLIANT_MAX_TRANSLATION_SEGMENTS = 16_384; export const BRILLIANT_MAX_TRANSLATION_CHUNKS = 4_096; export interface TranslationWorkloadLimits { maxChunks: number; + maxOutputBytes: number; maxSegments: number; maxSourceBytes: number; } @@ -23,10 +25,46 @@ export interface TranslationWorkloadLimits { export const DEFAULT_TRANSLATION_WORKLOAD_LIMITS: TranslationWorkloadLimits = Object.freeze({ maxChunks: BRILLIANT_MAX_TRANSLATION_CHUNKS, + maxOutputBytes: BRILLIANT_MAX_TRANSLATION_OUTPUT_BYTES, maxSegments: BRILLIANT_MAX_TRANSLATION_SEGMENTS, maxSourceBytes: BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES, }); +export class TranslationOutputAdmission { + private readonly chunkBytes = new Map(); + private totalBytes: number; + + constructor( + private readonly limits: { + initialBytes?: number; + maxOutputBytes: number; + }, + ) { + const initialBytes = limits.initialBytes ?? 0; + assertNonNegativeSafeInteger(initialBytes, "initial output bytes"); + assertNonNegativeSafeInteger(limits.maxOutputBytes, "output byte limit"); + this.totalBytes = initialBytes; + if (this.totalBytes > limits.maxOutputBytes) { + throw translationOutputLimitError(); + } + } + + get admittedBytes(): number { + return this.totalBytes; + } + + admitChunk(chunkIndex: number, translatedMarkdown: string): void { + const nextChunkBytes = Buffer.byteLength(translatedMarkdown, "utf8"); + const previousChunkBytes = this.chunkBytes.get(chunkIndex) ?? 0; + const nextTotalBytes = this.totalBytes - previousChunkBytes + nextChunkBytes; + if (nextTotalBytes > this.limits.maxOutputBytes) { + throw translationOutputLimitError(); + } + this.chunkBytes.set(chunkIndex, nextChunkBytes); + this.totalBytes = nextTotalBytes; + } +} + export function assertTranslationSourceAdmission( sourceBytes: number, limits: TranslationWorkloadLimits, @@ -57,3 +95,16 @@ export function assertTranslationManifestAdmission( ); } } + +function assertNonNegativeSafeInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`Translation ${label} must be a non-negative safe integer.`); + } +} + +function translationOutputLimitError(): TranslationOutputValidationError { + return new TranslationOutputValidationError( + "Translation output exceeds the total output byte limit.", + { retryable: false }, + ); +} diff --git a/src/server/translation/runner.ts b/src/server/translation/runner.ts index dc66c0d9..827df431 100644 --- a/src/server/translation/runner.ts +++ b/src/server/translation/runner.ts @@ -38,6 +38,7 @@ import { createSha256ContentHash } from "./hash"; import { assertTranslationSourceAdmission, DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + TranslationOutputAdmission, type TranslationWorkloadLimits, } from "./limits"; import { parseMarkdownTranslationBlocks } from "./markdown-blocks"; @@ -446,6 +447,8 @@ export async function runTranslationJob( ); } const config = options.config ?? loadRuntimeTraumaConfig(); + const workloadLimits = + options.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS; const openConnection = options.openConnection ?? initializeDatabase; const backupQueue = options.backupQueue ?? getMemoryBackupQueue(config); let client = options.client; @@ -486,8 +489,7 @@ export async function runTranslationJob( const source = await loadTranslationSourceSnapshot({ config, maxSourceBytes: - (options.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS) - .maxSourceBytes, + workloadLimits.maxSourceBytes, memoryId: job.memoryId, }); if (source.sourceHash !== job.sourceHash) { @@ -525,7 +527,7 @@ export async function runTranslationJob( assertTranslationSourceAdmission( source.byteSize, - options.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + workloadLimits, ); const manifest = parseMarkdownTranslationBlocks(source.sourceMarkdown); const runtimeChunks = createTranslationChunks({ @@ -543,6 +545,15 @@ export async function runTranslationJob( persistedChunks, runtimeChunks, }); + const outputAdmission = new TranslationOutputAdmission({ + initialBytes: Buffer.byteLength(manifest.frontmatter, "utf8"), + maxOutputBytes: workloadLimits.maxOutputBytes, + }); + for (const chunk of persistedChunks) { + if (chunk.status === "complete" && chunk.translatedMarkdown !== null) { + outputAdmission.admitChunk(chunk.chunkIndex, chunk.translatedMarkdown); + } + } const persistedChunksByIndex = new Map( persistedChunks.map((chunk) => [chunk.chunkIndex, chunk] as const), ); @@ -565,6 +576,7 @@ export async function runTranslationJob( jobLangCode: job.langCode, jobMemoryId: job.memoryId, model: job.model, + outputAdmission, promptByteLimit: options.promptByteLimit, reasoningEffort: job.reasoningEffort, }); @@ -629,8 +641,8 @@ export async function runTranslationJob( config, job, maxSourceBytes: - (options.workloadLimits ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS) - .maxSourceBytes, + workloadLimits.maxSourceBytes, + maxOutputBytes: workloadLimits.maxOutputBytes, repository: connection.repositories.translations, }); if ("status" in result && result.status === "stale") { @@ -850,6 +862,7 @@ async function translateAndPersistChunk(input: { jobLangCode: string; jobMemoryId: string; model: string | null; + outputAdmission: TranslationOutputAdmission; promptByteLimit?: number; reasoningEffort: CodexReasoningEffort | null; }): Promise<{ status: "completed" } | { status: "canceled" }> { @@ -1013,6 +1026,10 @@ async function translateAndPersistChunk(input: { output: rawOutput, }); const translatedMarkdown = stringifyCodexChunkOutput(output); + input.outputAdmission.admitChunk( + input.chunk.chunkIndex, + translatedMarkdown, + ); await input.connection.repositories.translations.updateTranslationChunk( input.chunk.jobId, input.chunk.chunkIndex, diff --git a/src/server/translation/stitching.ts b/src/server/translation/stitching.ts index 76ca6614..9d0cdfdc 100644 --- a/src/server/translation/stitching.ts +++ b/src/server/translation/stitching.ts @@ -13,6 +13,10 @@ import type { TranslationRepository, } from "../db/repositories"; import { createSha256ContentHash } from "./hash"; +import { + DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + TranslationOutputAdmission, +} from "./limits"; import { parseMarkdownTranslationBlocks, splitFrontmatter, @@ -55,6 +59,7 @@ type CommitTranslatedContentInput = { config: ResolvedTraumaConfig; chunks: TranslationChunkRecord[]; job: TranslationJobRecord; + maxOutputBytes?: number; maxSourceBytes?: number; now?: Date; publishProjectionSidecar?: ( @@ -116,8 +121,12 @@ async function commitTranslatedContentReserved( }; } - const body = stitchCompletedChunks(input.chunks); const { frontmatter } = splitFrontmatter(source.sourceMarkdown); + const body = stitchCompletedChunks(input.chunks, { + initialBytes: Buffer.byteLength(frontmatter, "utf8"), + maxOutputBytes: + input.maxOutputBytes ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS.maxOutputBytes, + }); const output = `${frontmatter}${body}`; validateFinalTranslatedContent({ body, @@ -226,19 +235,33 @@ async function commitTranslatedContentReserved( } export function stitchCompletedChunks( - chunks: readonly TranslationChunkRecord[], + chunks: readonly Pick< + TranslationChunkRecord, + "chunkIndex" | "status" | "translatedMarkdown" + >[], + options: { + initialBytes?: number; + maxOutputBytes?: number; + } = {}, ): string { - return [...chunks] - .sort((left, right) => left.chunkIndex - right.chunkIndex) - .map((chunk) => { - if (chunk.status !== "complete" || chunk.translatedMarkdown === null) { - throw new TranslationStitchingError( - `chunk ${chunk.chunkIndex} is not complete`, - ); - } - return chunk.translatedMarkdown; - }) - .join(""); + const admission = new TranslationOutputAdmission({ + initialBytes: options.initialBytes, + maxOutputBytes: + options.maxOutputBytes ?? DEFAULT_TRANSLATION_WORKLOAD_LIMITS.maxOutputBytes, + }); + const translatedChunks: string[] = []; + for (const chunk of [...chunks].sort( + (left, right) => left.chunkIndex - right.chunkIndex, + )) { + if (chunk.status !== "complete" || chunk.translatedMarkdown === null) { + throw new TranslationStitchingError( + `chunk ${chunk.chunkIndex} is not complete`, + ); + } + admission.admitChunk(chunk.chunkIndex, chunk.translatedMarkdown); + translatedChunks.push(chunk.translatedMarkdown); + } + return translatedChunks.join(""); } export function validateFinalTranslatedContent(input: { diff --git a/tests/server/translation/chunker.test.ts b/tests/server/translation/chunker.test.ts index b6610ed1..c5f31d81 100644 --- a/tests/server/translation/chunker.test.ts +++ b/tests/server/translation/chunker.test.ts @@ -10,9 +10,11 @@ import { assertTranslationManifestAdmission, assertTranslationSourceAdmission, BRILLIANT_MAX_TRANSLATION_CHUNKS, + BRILLIANT_MAX_TRANSLATION_OUTPUT_BYTES, BRILLIANT_MAX_TRANSLATION_SEGMENTS, BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES, DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + TranslationOutputAdmission, } from "../../../src/server/translation/limits"; import { parseMarkdownTranslationBlocks } from "../../../src/server/translation/markdown-blocks"; import { @@ -24,6 +26,7 @@ import type { TranslationSourceSnapshot } from "../../../src/server/translation/ describe("translation chunker", () => { it("enforces the exact aggregate translation admission boundaries", () => { expect(BRILLIANT_MAX_TRANSLATION_SOURCE_BYTES).toBe(20 * 1_024 * 1_024); + expect(BRILLIANT_MAX_TRANSLATION_OUTPUT_BYTES).toBe(56 * 1_024 * 1_024); expect(BRILLIANT_MAX_TRANSLATION_SEGMENTS).toBe(16_384); expect(BRILLIANT_MAX_TRANSLATION_CHUNKS).toBe(4_096); @@ -77,6 +80,27 @@ describe("translation chunker", () => { } }); + it("rejects aggregate translated UTF-8 bytes without corrupting admission state", () => { + const admission = new TranslationOutputAdmission({ + initialBytes: 1, + maxOutputBytes: 8, + }); + + admission.admitChunk(0, "界"); + admission.admitChunk(1, "界"); + expect(admission.admittedBytes).toBe(7); + + try { + admission.admitChunk(2, "é"); + throw new Error("expected aggregate translation output admission to fail"); + } catch (error) { + expect(error).toBeInstanceOf(TranslationOutputValidationError); + expect((error as TranslationOutputValidationError).retryable).toBe(false); + expect((error as Error).message).toMatch(/translation output.*limit/i); + } + expect(admission.admittedBytes).toBe(7); + }); + it("groups contiguous block ids and hashes each chunk", () => { const manifest = parseMarkdownTranslationBlocks( "# One\n\nSmall body.\n\n# Two\n\nSecond body.", diff --git a/tests/server/translation/runner.test.ts b/tests/server/translation/runner.test.ts index 97f8f074..727fe9b4 100644 --- a/tests/server/translation/runner.test.ts +++ b/tests/server/translation/runner.test.ts @@ -1692,6 +1692,67 @@ describe("translation runner", () => { ).rejects.toMatchObject({ code: "ENOENT" }); }); + it("fails aggregate multi-chunk output before persisting the overflowing chunk", async () => { + const config = await createConfig(); + await writeSourceContent(config, "Chunked sentence. ".repeat(700)); + await createMemoryRow(config); + const client = new IdentityTranslationClient(); + const jobId = "019e3906-0000-7000-8000-000000000120"; + const started = await startTranslationJob({ + client, + config, + generateJobId: () => jobId, + memoryId, + now, + schedule: () => undefined, + }); + const sourceBytes = await readFile( + join(config.storePath, "memories", memoryId, "CONTENT.md"), + ); + + await runTranslationJob(started.job_id, { + client, + config, + workloadLimits: { + ...DEFAULT_TRANSLATION_WORKLOAD_LIMITS, + maxOutputBytes: sourceBytes.byteLength - 1, + }, + }); + + expect(client.inputs.length).toBeGreaterThan(1); + const connection = initializeDatabase(config); + try { + await expect( + connection.repositories.translations.getTranslationJob(jobId), + ).resolves.toMatchObject({ + error: { + action: "none", + code: "validation_failed", + message: "Translation output exceeds the total output byte limit.", + }, + status: "failed", + }); + const chunks = await connection.repositories.translations + .getTranslationChunks(jobId); + expect(chunks.filter((chunk) => chunk.status === "complete").length) + .toBeGreaterThan(0); + expect(chunks).toContainEqual(expect.objectContaining({ + projectionSpansJson: null, + retryCount: 0, + status: "failed", + translatedMarkdown: null, + })); + } finally { + connection.close(); + } + await expect( + readFile( + join(config.storePath, "memories", memoryId, "ja-JP", "CONTENT.md"), + "utf8", + ), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("fails a prompt byte-limit violation before client send without retrying", async () => { const config = await createConfig(); await writeSourceContent(config); diff --git a/tests/server/translation/stitching.test.ts b/tests/server/translation/stitching.test.ts index 01ba3f64..fe00d71c 100644 --- a/tests/server/translation/stitching.test.ts +++ b/tests/server/translation/stitching.test.ts @@ -11,6 +11,7 @@ import { createMemoryContentFixture } from "../../../src/server/store"; import { loadTranslationSourceSnapshot } from "../../../src/server/translation/source-loader"; import { commitTranslatedContent, + stitchCompletedChunks, validateFinalTranslatedContent, } from "../../../src/server/translation/stitching"; @@ -435,6 +436,20 @@ describe("translation stitching and atomic commit", () => { }), ).toThrow("Translated document body is empty."); }); + + it("rejects aggregate chunk bytes before allocating the stitched document", () => { + const chunks = [ + { chunkIndex: 0, status: "complete" as const, translatedMarkdown: "abc" }, + { chunkIndex: 1, status: "complete" as const, translatedMarkdown: "defg" }, + ]; + + expect(() => + stitchCompletedChunks(chunks, { + initialBytes: 4, + maxOutputBytes: 10, + }) + ).toThrow("Translation output exceeds the total output byte limit."); + }); }); async function writeSourceContent(config: ResolvedTraumaConfig): Promise { From cf885e5fb2b050c8f3e0e1094c23548252a167ac Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Mon, 20 Jul 2026 17:00:23 +0900 Subject: [PATCH 30/54] perf: index collection browse cursors --- drizzle/0021_large_dazzler.sql | 4 + drizzle/meta/0021_snapshot.json | 1635 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/server/db/bundled-migrations.ts | 6 + src/server/db/repositories.ts | 30 +- src/server/db/schema.ts | 4 +- tests/server/db/schema.test.ts | 170 +++ 7 files changed, 1833 insertions(+), 23 deletions(-) create mode 100644 drizzle/0021_large_dazzler.sql create mode 100644 drizzle/meta/0021_snapshot.json diff --git a/drizzle/0021_large_dazzler.sql b/drizzle/0021_large_dazzler.sql new file mode 100644 index 00000000..14259295 --- /dev/null +++ b/drizzle/0021_large_dazzler.sql @@ -0,0 +1,4 @@ +DROP INDEX `flashbacks_created_at_idx`;--> statement-breakpoint +CREATE INDEX `flashbacks_created_at_id_idx` ON `flashbacks` (`created_at`,`id`);--> statement-breakpoint +DROP INDEX `moments_created_at_idx`;--> statement-breakpoint +CREATE INDEX `moments_created_at_id_idx` ON `moments` (`created_at`,`id`); \ No newline at end of file diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json new file mode 100644 index 00000000..1e786da4 --- /dev/null +++ b/drizzle/meta/0021_snapshot.json @@ -0,0 +1,1635 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d5b6f31e-4733-431b-a196-90b4970ebcf7", + "prevId": "25e64f56-1098-4a25-a8c3-8358318d8510", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "translation_target_language": { + "name": "translation_target_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ja-JP'" + }, + "codex_translation_model": { + "name": "codex_translation_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codex_translation_reasoning_effort": { + "name": "codex_translation_reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "app_settings_id_check": { + "name": "app_settings_id_check", + "value": "\"app_settings\".\"id\" = 'default'" + }, + "app_settings_translation_target_language_check": { + "name": "app_settings_translation_target_language_check", + "value": "\"app_settings\".\"translation_target_language\" in ('ja-JP', 'en-US', 'en-GB', 'ko-KR', 'zh-CN', 'zh-TW', 'fr-FR', 'de-DE', 'es-ES', 'pt-BR')" + }, + "app_settings_codex_translation_reasoning_effort_check": { + "name": "app_settings_codex_translation_reasoning_effort_check", + "value": "\"app_settings\".\"codex_translation_reasoning_effort\" is null or \"app_settings\".\"codex_translation_reasoning_effort\" in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh')" + } + } + }, + "backup_environment_stamps": { + "name": "backup_environment_stamps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_path": { + "name": "project_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "store_path": { + "name": "store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote": { + "name": "git_remote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "backup_environment_stamps_id_check": { + "name": "backup_environment_stamps_id_check", + "value": "\"backup_environment_stamps\".\"id\" = 'default'" + } + } + }, + "backup_failsafe_alerts": { + "name": "backup_failsafe_alerts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_project_path": { + "name": "previous_project_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "previous_store_path": { + "name": "previous_store_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_project_path": { + "name": "current_project_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_store_path": { + "name": "current_store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote": { + "name": "git_remote", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "backup_failsafe_alerts_id_check": { + "name": "backup_failsafe_alerts_id_check", + "value": "\"backup_failsafe_alerts\".\"id\" = 'active'" + }, + "backup_failsafe_alerts_kind_check": { + "name": "backup_failsafe_alerts_kind_check", + "value": "\"backup_failsafe_alerts\".\"kind\" in ('backup_path_drift', 'backup_content_inconsistent', 'backup_repository_missing', 'backup_push_failed')" + }, + "backup_failsafe_alerts_severity_check": { + "name": "backup_failsafe_alerts_severity_check", + "value": "\"backup_failsafe_alerts\".\"severity\" in ('critical')" + } + } + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": [ + "lower(\"name\")" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "flashbacks": { + "name": "flashbacks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variant_kind": { + "name": "variant_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'source'" + }, + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "translation_output_hash": { + "name": "translation_output_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "flashbacks_memory_id_idx": { + "name": "flashbacks_memory_id_idx", + "columns": [ + "memory_id" + ], + "isUnique": false + }, + "flashbacks_created_at_id_idx": { + "name": "flashbacks_created_at_id_idx", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "flashbacks_memory_variant_idx": { + "name": "flashbacks_memory_variant_idx", + "columns": [ + "memory_id", + "variant_kind", + "lang_code", + "translation_output_hash", + "start_offset" + ], + "isUnique": false + } + }, + "foreignKeys": { + "flashbacks_memory_id_memories_id_fk": { + "name": "flashbacks_memory_id_memories_id_fk", + "tableFrom": "flashbacks", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "flashbacks_variant_kind_check": { + "name": "flashbacks_variant_kind_check", + "value": "\"flashbacks\".\"variant_kind\" in ('source', 'translation')" + }, + "flashbacks_variant_scope_check": { + "name": "flashbacks_variant_scope_check", + "value": "(\"flashbacks\".\"variant_kind\" = 'source' and \"flashbacks\".\"lang_code\" is null and \"flashbacks\".\"translation_output_hash\" is null) or (\"flashbacks\".\"variant_kind\" = 'translation' and \"flashbacks\".\"lang_code\" is not null and \"flashbacks\".\"lang_code\" in ('ja-JP', 'en-US', 'en-GB', 'ko-KR', 'zh-CN', 'zh-TW', 'fr-FR', 'de-DE', 'es-ES', 'pt-BR') and \"flashbacks\".\"translation_output_hash\" is not null and \"flashbacks\".\"translation_output_hash\" glob 'sha256:*')" + }, + "flashbacks_start_offset_check": { + "name": "flashbacks_start_offset_check", + "value": "\"flashbacks\".\"start_offset\" >= 0" + }, + "flashbacks_end_offset_check": { + "name": "flashbacks_end_offset_check", + "value": "\"flashbacks\".\"end_offset\" > \"flashbacks\".\"start_offset\"" + } + } + }, + "memories": { + "name": "memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_path": { + "name": "content_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extraction_error": { + "name": "extraction_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "backup_status": { + "name": "backup_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memories_url_idx": { + "name": "memories_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "memories_created_at_idx": { + "name": "memories_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "memories_created_at_id_idx": { + "name": "memories_created_at_id_idx", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "memories_extraction_status_idx": { + "name": "memories_extraction_status_idx", + "columns": [ + "extraction_status" + ], + "isUnique": false + }, + "memories_backup_status_idx": { + "name": "memories_backup_status_idx", + "columns": [ + "backup_status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "memories_extraction_status_check": { + "name": "memories_extraction_status_check", + "value": "\"memories\".\"extraction_status\" in ('pending', 'success', 'link_only', 'failed')" + }, + "memories_backup_status_check": { + "name": "memories_backup_status_check", + "value": "\"memories\".\"backup_status\" in ('pending', 'queued', 'success', 'failed', 'disabled')" + } + } + }, + "memory_categories": { + "name": "memory_categories", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memory_categories_category_id_idx": { + "name": "memory_categories_category_id_idx", + "columns": [ + "category_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memory_categories_memory_id_memories_id_fk": { + "name": "memory_categories_memory_id_memories_id_fk", + "tableFrom": "memory_categories", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_categories_category_id_categories_id_fk": { + "name": "memory_categories_category_id_categories_id_fk", + "tableFrom": "memory_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_categories_memory_id_category_id_pk": { + "columns": [ + "memory_id", + "category_id" + ], + "name": "memory_categories_memory_id_category_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memory_creation_idempotency": { + "name": "memory_creation_idempotency", + "columns": { + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_url": { + "name": "request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memory_tags": { + "name": "memory_tags", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memory_tags_tag_id_idx": { + "name": "memory_tags_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "memory_tags_memory_id_memories_id_fk": { + "name": "memory_tags_memory_id_memories_id_fk", + "tableFrom": "memory_tags", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_tags_tag_id_tags_id_fk": { + "name": "memory_tags_tag_id_tags_id_fk", + "tableFrom": "memory_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_tags_memory_id_tag_id_pk": { + "columns": [ + "memory_id", + "tag_id" + ], + "name": "memory_tags_memory_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "moments": { + "name": "moments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_anchor": { + "name": "section_anchor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_level": { + "name": "section_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_path": { + "name": "section_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_start_offset": { + "name": "section_start_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_end_offset": { + "name": "section_end_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "moments_memory_section_anchor_unique": { + "name": "moments_memory_section_anchor_unique", + "columns": [ + "memory_id", + "section_anchor" + ], + "isUnique": true + }, + "moments_memory_section_path_unique": { + "name": "moments_memory_section_path_unique", + "columns": [ + "memory_id", + "section_path" + ], + "isUnique": true + }, + "moments_memory_id_idx": { + "name": "moments_memory_id_idx", + "columns": [ + "memory_id" + ], + "isUnique": false + }, + "moments_created_at_id_idx": { + "name": "moments_created_at_id_idx", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "moments_memory_id_memories_id_fk": { + "name": "moments_memory_id_memories_id_fk", + "tableFrom": "moments", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "moments_section_anchor_check": { + "name": "moments_section_anchor_check", + "value": "length(\"moments\".\"section_anchor\") > 0" + }, + "moments_section_title_check": { + "name": "moments_section_title_check", + "value": "length(\"moments\".\"section_title\") > 0" + }, + "moments_section_level_check": { + "name": "moments_section_level_check", + "value": "\"moments\".\"section_level\" >= 1 and \"moments\".\"section_level\" <= 6" + }, + "moments_section_offset_check": { + "name": "moments_section_offset_check", + "value": "(\"moments\".\"section_start_offset\" is null and \"moments\".\"section_end_offset\" is null) or (\"moments\".\"section_start_offset\" is not null and \"moments\".\"section_end_offset\" is not null and \"moments\".\"section_start_offset\" >= 0 and \"moments\".\"section_end_offset\" > \"moments\".\"section_start_offset\")" + } + } + }, + "openai_auth_credentials": { + "name": "openai_auth_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_reference": { + "name": "credential_reference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "openai_auth_credentials_id_check": { + "name": "openai_auth_credentials_id_check", + "value": "\"openai_auth_credentials\".\"id\" = 'default'" + } + } + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_name_unique": { + "name": "tags_name_unique", + "columns": [ + "lower(\"name\")" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "translation_chunks": { + "name": "translation_chunks", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_chunk_hash": { + "name": "source_chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "block_ids_json": { + "name": "block_ids_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "translated_markdown": { + "name": "translated_markdown", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "translated_hash": { + "name": "translated_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projection_spans_json": { + "name": "projection_spans_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_chunks_status_idx": { + "name": "translation_chunks_status_idx", + "columns": [ + "job_id", + "status", + "chunk_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_chunks_job_id_translation_jobs_job_id_fk": { + "name": "translation_chunks_job_id_translation_jobs_job_id_fk", + "tableFrom": "translation_chunks", + "tableTo": "translation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "job_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "translation_chunks_job_id_chunk_index_pk": { + "columns": [ + "job_id", + "chunk_index" + ], + "name": "translation_chunks_job_id_chunk_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_chunks_status_check": { + "name": "translation_chunks_status_check", + "value": "\"translation_chunks\".\"status\" in ('pending', 'running', 'validating', 'retrying', 'complete', 'purged', 'failed')" + }, + "translation_chunks_source_hash_check": { + "name": "translation_chunks_source_hash_check", + "value": "\"translation_chunks\".\"source_chunk_hash\" glob 'sha256:*'" + }, + "translation_chunks_translated_hash_check": { + "name": "translation_chunks_translated_hash_check", + "value": "\"translation_chunks\".\"translated_hash\" is null or \"translation_chunks\".\"translated_hash\" glob 'sha256:*'" + }, + "translation_chunks_retry_count_check": { + "name": "translation_chunks_retry_count_check", + "value": "\"translation_chunks\".\"retry_count\" >= 0" + }, + "translation_chunks_index_check": { + "name": "translation_chunks_index_check", + "value": "\"translation_chunks\".\"chunk_index\" >= 0" + } + } + }, + "translation_jobs": { + "name": "translation_jobs", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_policy_version": { + "name": "prompt_policy_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunker_version": { + "name": "chunker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_hash": { + "name": "output_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_jobs_current_complete_idx": { + "name": "translation_jobs_current_complete_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash" + ], + "isUnique": true, + "where": "\"translation_jobs\".\"status\" = 'complete'" + }, + "translation_jobs_active_idx": { + "name": "translation_jobs_active_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash" + ], + "isUnique": true, + "where": "\"translation_jobs\".\"status\" in ('pending', 'running', 'cancel_requested', 'stitching', 'committing')" + }, + "translation_jobs_memory_lang_idx": { + "name": "translation_jobs_memory_lang_idx", + "columns": [ + "memory_id", + "lang_code", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_jobs_memory_id_memories_id_fk": { + "name": "translation_jobs_memory_id_memories_id_fk", + "tableFrom": "translation_jobs", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_jobs_status_check": { + "name": "translation_jobs_status_check", + "value": "\"translation_jobs\".\"status\" in ('pending', 'running', 'stale', 'cancel_requested', 'canceled', 'unavailable', 'stitching', 'committing', 'complete', 'failed')" + }, + "translation_jobs_source_hash_check": { + "name": "translation_jobs_source_hash_check", + "value": "\"translation_jobs\".\"source_hash\" glob 'sha256:*'" + }, + "translation_jobs_output_hash_check": { + "name": "translation_jobs_output_hash_check", + "value": "\"translation_jobs\".\"output_hash\" is null or \"translation_jobs\".\"output_hash\" glob 'sha256:*'" + }, + "translation_jobs_reasoning_effort_check": { + "name": "translation_jobs_reasoning_effort_check", + "value": "\"translation_jobs\".\"reasoning_effort\" is null or \"translation_jobs\".\"reasoning_effort\" in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh')" + }, + "translation_jobs_chunk_count_check": { + "name": "translation_jobs_chunk_count_check", + "value": "\"translation_jobs\".\"chunk_count\" >= 0" + } + } + }, + "translation_projection_spans": { + "name": "translation_projection_spans", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "span_index": { + "name": "span_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lang_code": { + "name": "lang_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_hash": { + "name": "output_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_markdown_start": { + "name": "source_markdown_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_markdown_end": { + "name": "source_markdown_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_markdown_start": { + "name": "translated_markdown_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_markdown_end": { + "name": "translated_markdown_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_reader_start": { + "name": "source_reader_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_reader_end": { + "name": "source_reader_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_reader_start": { + "name": "translated_reader_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "translated_reader_end": { + "name": "translated_reader_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "translation_projection_current_idx": { + "name": "translation_projection_current_idx", + "columns": [ + "memory_id", + "lang_code", + "source_hash", + "output_hash", + "span_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "translation_projection_spans_job_id_translation_jobs_job_id_fk": { + "name": "translation_projection_spans_job_id_translation_jobs_job_id_fk", + "tableFrom": "translation_projection_spans", + "tableTo": "translation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "job_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "translation_projection_spans_memory_id_memories_id_fk": { + "name": "translation_projection_spans_memory_id_memories_id_fk", + "tableFrom": "translation_projection_spans", + "tableTo": "memories", + "columnsFrom": [ + "memory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "translation_projection_spans_job_id_span_index_pk": { + "columns": [ + "job_id", + "span_index" + ], + "name": "translation_projection_spans_job_id_span_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "translation_projection_source_hash_check": { + "name": "translation_projection_source_hash_check", + "value": "\"translation_projection_spans\".\"source_hash\" glob 'sha256:*'" + }, + "translation_projection_output_hash_check": { + "name": "translation_projection_output_hash_check", + "value": "\"translation_projection_spans\".\"output_hash\" glob 'sha256:*'" + }, + "translation_projection_source_markdown_range_check": { + "name": "translation_projection_source_markdown_range_check", + "value": "\"translation_projection_spans\".\"source_markdown_end\" > \"translation_projection_spans\".\"source_markdown_start\"" + }, + "translation_projection_translated_markdown_range_check": { + "name": "translation_projection_translated_markdown_range_check", + "value": "\"translation_projection_spans\".\"translated_markdown_end\" > \"translation_projection_spans\".\"translated_markdown_start\"" + }, + "translation_projection_source_reader_range_check": { + "name": "translation_projection_source_reader_range_check", + "value": "\"translation_projection_spans\".\"source_reader_end\" > \"translation_projection_spans\".\"source_reader_start\"" + }, + "translation_projection_translated_reader_range_check": { + "name": "translation_projection_translated_reader_range_check", + "value": "\"translation_projection_spans\".\"translated_reader_end\" > \"translation_projection_spans\".\"translated_reader_start\"" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "categories_name_unique": { + "columns": { + "lower(\"name\")": { + "isExpression": true + } + } + }, + "tags_name_unique": { + "columns": { + "lower(\"name\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5ae0fb95..2c4dc553 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1784238332412, "tag": "0020_nasty_kulan_gath", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1784534032874, + "tag": "0021_large_dazzler", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/db/bundled-migrations.ts b/src/server/db/bundled-migrations.ts index 7d82c7ed..9ca49171 100644 --- a/src/server/db/bundled-migrations.ts +++ b/src/server/db/bundled-migrations.ts @@ -21,6 +21,7 @@ import migration0017Sql from "../../../drizzle/0017_skinny_hobgoblin.sql?raw"; import migration0018Sql from "../../../drizzle/0018_scrub_backup_diagnostics.sql?raw"; import migration0019Sql from "../../../drizzle/0019_memory_creation_idempotency.sql?raw"; import migration0020Sql from "../../../drizzle/0020_nasty_kulan_gath.sql?raw"; +import migration0021Sql from "../../../drizzle/0021_large_dazzler.sql?raw"; import type { RuntimeMigration } from "./migrations"; const BUNDLED_MIGRATIONS = [ @@ -129,6 +130,11 @@ const BUNDLED_MIGRATIONS = [ folderMillis: 1784238332412, bps: true, }, + { + sql: migration0021Sql, + folderMillis: 1784534032874, + bps: true, + }, ] as const; export function readBundledMigrations(): RuntimeMigration[] { diff --git a/src/server/db/repositories.ts b/src/server/db/repositories.ts index 45302bc9..208f0301 100644 --- a/src/server/db/repositories.ts +++ b/src/server/db/repositories.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, isNull, lt, or, sql, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; import { @@ -1850,40 +1850,28 @@ function buildMemoryBrowsePageWhere( return and(...filters); } -function buildFlashbackBrowseCursorWhere( +export function buildFlashbackBrowseCursorWhere( cursor: FlashbackBrowseCursor | null, ): SQL | undefined { if (cursor === null) { return undefined; } - return ( - or( - lt(schema.flashbacks.createdAt, cursor.createdAt), - and( - eq(schema.flashbacks.createdAt, cursor.createdAt), - lt(schema.flashbacks.id, cursor.id), - ), - ) ?? sql`0 = 1` - ); + return sql`(${schema.flashbacks.createdAt}, ${schema.flashbacks.id}) < (${ + sql.param(cursor.createdAt, schema.flashbacks.createdAt) + }, ${sql.param(cursor.id, schema.flashbacks.id)})`; } -function buildMomentBrowseCursorWhere( +export function buildMomentBrowseCursorWhere( cursor: MomentBrowseCursor | null, ): SQL | undefined { if (cursor === null) { return undefined; } - return ( - or( - lt(schema.moments.createdAt, cursor.createdAt), - and( - eq(schema.moments.createdAt, cursor.createdAt), - lt(schema.moments.id, cursor.id), - ), - ) ?? sql`0 = 1` - ); + return sql`(${schema.moments.createdAt}, ${schema.moments.id}) < (${ + sql.param(cursor.createdAt, schema.moments.createdAt) + }, ${sql.param(cursor.id, schema.moments.id)})`; } function memoryHasCategoryId(categoryId: string): SQL { diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index c36d9cd0..1bee6afa 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -194,7 +194,7 @@ export const flashbacks = sqliteTable( }, (table) => [ index("flashbacks_memory_id_idx").on(table.memoryId), - index("flashbacks_created_at_idx").on(table.createdAt), + index("flashbacks_created_at_id_idx").on(table.createdAt, table.id), index("flashbacks_memory_variant_idx").on( table.memoryId, table.variantKind, @@ -241,7 +241,7 @@ export const moments = sqliteTable( table.sectionPath, ), index("moments_memory_id_idx").on(table.memoryId), - index("moments_created_at_idx").on(table.createdAt), + index("moments_created_at_id_idx").on(table.createdAt, table.id), check( "moments_section_anchor_check", sql`length(${table.sectionAnchor}) > 0`, diff --git a/tests/server/db/schema.test.ts b/tests/server/db/schema.test.ts index 2f156b1c..9de391ea 100644 --- a/tests/server/db/schema.test.ts +++ b/tests/server/db/schema.test.ts @@ -18,6 +18,7 @@ const MOMENT_PATH_IDENTITY_MIGRATION_FOLDER_MILLIS = 1784223792512; const SCRUB_MEMORY_BACKUP_ERRORS_MIGRATION_FOLDER_MILLIS = 1784232000000; const MEMORY_CREATION_IDEMPOTENCY_MIGRATION_FOLDER_MILLIS = 1784234421333; const CASE_INSENSITIVE_TAXONOMY_MIGRATION_FOLDER_MILLIS = 1784238332412; +const COLLECTION_BROWSE_INDEXES_MIGRATION_FOLDER_MILLIS = 1784534032874; describe("db foundation", () => { it("exports all foundation tables", () => { @@ -630,6 +631,175 @@ describe("db foundation", () => { }); }); + it("uses composite indexes for Flashback and Moment browse ordering", () => { + const root = mkdtempSync(join(tmpdir(), "trauma-db-")); + const output = runBunScript( + ` + import { join } from "node:path"; + import { Database } from "bun:sqlite"; + import { desc, eq } from "drizzle-orm"; + import { drizzle } from "drizzle-orm/bun-sqlite"; + import { readBundledMigrations } from "./src/server/db/bundled-migrations.ts"; + import { applyRuntimeMigrations } from "./src/server/db/migrations.ts"; + import { + buildFlashbackBrowseCursorWhere, + buildMomentBrowseCursorWhere, + } from "./src/server/db/repositories.ts"; + import * as schema from "./src/server/db/schema.ts"; + + const root = process.env.TRAUMA_TEST_DB_ROOT; + if (!root) { + throw new Error("TRAUMA_TEST_DB_ROOT is required"); + } + + const sqlite = new Database(join(root, "trauma.sqlite")); + + try { + sqlite.run("PRAGMA foreign_keys = ON"); + const migrations = readBundledMigrations(); + const previousMigrations = migrations.filter( + (migration) => migration.folderMillis < ${COLLECTION_BROWSE_INDEXES_MIGRATION_FOLDER_MILLIS}, + ); + applyRuntimeMigrations(sqlite, previousMigrations, "previous"); + + const previousIndexes = { + flashbacks: sqlite + .prepare("PRAGMA index_list('flashbacks')") + .all() + .map((row) => row.name), + moments: sqlite + .prepare("PRAGMA index_list('moments')") + .all() + .map((row) => row.name), + }; + + applyRuntimeMigrations(sqlite, migrations, "bundled"); + const db = drizzle({ client: sqlite, schema }); + + const explain = (sql) => sqlite + .prepare(\`EXPLAIN QUERY PLAN \${sql}\`) + .all(51) + .map((row) => row.detail); + const explainQuery = (query) => sqlite + .prepare(\`EXPLAIN QUERY PLAN \${query.sql}\`) + .all(...query.params) + .map((row) => row.detail); + const flashbackCursorQuery = db + .select({ id: schema.flashbacks.id, title: schema.memories.title }) + .from(schema.flashbacks) + .innerJoin(schema.memories, eq(schema.flashbacks.memoryId, schema.memories.id)) + .where(buildFlashbackBrowseCursorWhere({ + createdAt: new Date(42), + id: "flashback-cursor", + })) + .orderBy(desc(schema.flashbacks.createdAt), desc(schema.flashbacks.id)) + .limit(51) + .toSQL(); + const momentCursorQuery = db + .select({ id: schema.moments.id, title: schema.memories.title }) + .from(schema.moments) + .innerJoin(schema.memories, eq(schema.moments.memoryId, schema.memories.id)) + .where(buildMomentBrowseCursorWhere({ + createdAt: new Date(42), + id: "moment-cursor", + })) + .orderBy(desc(schema.moments.createdAt), desc(schema.moments.id)) + .limit(51) + .toSQL(); + + process.stdout.write(JSON.stringify({ + previousIndexes, + flashbacks: explain(\` + select flashbacks.id, memories.title + from flashbacks + inner join memories on flashbacks.memory_id = memories.id + order by flashbacks.created_at desc, flashbacks.id desc + limit ? + \`), + moments: explain(\` + select moments.id, memories.title + from moments + inner join memories on moments.memory_id = memories.id + order by moments.created_at desc, moments.id desc + limit ? + \`), + flashbackCursor: explainQuery(flashbackCursorQuery), + momentCursor: explainQuery(momentCursorQuery), + foreignKeyViolations: sqlite.prepare("PRAGMA foreign_key_check").all(), + migrationRecorded: sqlite + .prepare("select count(*) as count from __drizzle_migrations where created_at = ?") + .get(${COLLECTION_BROWSE_INDEXES_MIGRATION_FOLDER_MILLIS}).count, + })); + } finally { + sqlite.close(); + } + `, + { + cwd: process.cwd(), + env: { + ...process.env, + TRAUMA_TEST_DB_ROOT: root, + }, + }, + ); + + const plans = JSON.parse(output) as { + previousIndexes: { + flashbacks: string[]; + moments: string[]; + }; + flashbacks: string[]; + moments: string[]; + flashbackCursor: string[]; + momentCursor: string[]; + foreignKeyViolations: unknown[]; + migrationRecorded: number; + }; + + expect(plans.previousIndexes.flashbacks).toContain( + "flashbacks_created_at_idx", + ); + expect(plans.previousIndexes.moments).toContain("moments_created_at_idx"); + expect(plans.flashbacks).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "SCAN flashbacks USING INDEX flashbacks_created_at_id_idx", + ), + ]), + ); + expect(plans.moments).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "SCAN moments USING INDEX moments_created_at_id_idx", + ), + ]), + ); + expect(plans.flashbackCursor).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "SEARCH flashbacks USING INDEX flashbacks_created_at_id_idx", + ), + ]), + ); + expect(plans.momentCursor).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "SEARCH moments USING INDEX moments_created_at_id_idx", + ), + ]), + ); + expect([ + ...plans.flashbacks, + ...plans.moments, + ...plans.flashbackCursor, + ...plans.momentCursor, + ]).not.toEqual( + expect.arrayContaining([expect.stringContaining("TEMP B-TREE")]), + ); + expect(plans.foreignKeyViolations).toEqual([]); + expect(plans.migrationRecorded).toBe(1); + }); + it("scrubs legacy backup remote credentials and push diagnostics", () => { const root = mkdtempSync(join(tmpdir(), "trauma-db-")); const output = runBunScript( From bd40d7280cf5e301775b079949c77b82e714a598 Mon Sep 17 00:00:00 2001 From: Haunted Supp Date: Mon, 20 Jul 2026 17:05:21 +0900 Subject: [PATCH 31/54] fix: add reader catalog recovery --- .../design-system/reader-and-content.md | 6 +- e2e/collection-pagination.spec.ts | 4 ++ e2e/reader.spec.ts | 28 ++++++-- src/components/async-action-focus.ts | 16 +++++ src/components/moments/MomentBrowse.tsx | 2 +- src/components/reader/MemoryReader.tsx | 71 ++++++++++++++++--- src/components/settings/SettingsPage.tsx | 14 ++-- src/routes/moments/index.tsx | 2 +- .../components/memory-reader-actions.test.ts | 7 +- tests/components/moment-route.test.ts | 4 ++ 10 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 src/components/async-action-focus.ts diff --git a/docs/references/design-system/reader-and-content.md b/docs/references/design-system/reader-and-content.md index 7202b858..97e3bba5 100644 --- a/docs/references/design-system/reader-and-content.md +++ b/docs/references/design-system/reader-and-content.md @@ -201,8 +201,10 @@ Reader fallback states use the same route frame and should not look like a separate page type. The translation popover treats Codex model-catalog load failure as actionable -async feedback. It remains visible in the popover and uses an assertive -`role="alert"` live region so the failure is announced without relying on color. +async feedback. Its assertive `role="alert"` keeps the error and a Retry action +visible without relying on color. Retry is disabled while its request is pending; +after recovery started from the focused Retry control, focus moves to the +restored Model control unless the user moved focus elsewhere. ## Psychiatrist Dock diff --git a/e2e/collection-pagination.spec.ts b/e2e/collection-pagination.spec.ts index f9cbb24b..97de1954 100644 --- a/e2e/collection-pagination.spec.ts +++ b/e2e/collection-pagination.spec.ts @@ -59,6 +59,10 @@ test("paginates large Flashback and Moment archives through URL history", async await expectCollectionPage(page, PAGE_SIZE, "Flashback selection 37"); await page.goto("/moments"); + await expect(page).toHaveTitle("Moments | TRAUMA"); + await expect( + page.getByRole("heading", { name: "Moments", exact: true }), + ).toBeVisible(); await expectCollectionPage(page, PAGE_SIZE, "Moment Section 37"); await expect(page.getByText("Moment Section 07", { exact: true })).toHaveCount(0); diff --git a/e2e/reader.spec.ts b/e2e/reader.spec.ts index fa185b00..5862e236 100644 --- a/e2e/reader.spec.ts +++ b/e2e/reader.spec.ts @@ -399,6 +399,10 @@ test("coalesces pending reader model catalogs and retries after malformed 2xx", const firstCatalogRequestGate = new Promise((resolve) => { releaseFirstCatalogRequest = resolve; }); + let releaseRetryCatalogRequest: () => void = () => undefined; + const retryCatalogRequestGate = new Promise((resolve) => { + releaseRetryCatalogRequest = resolve; + }); await page.route("**/api/settings/codex-models", async (route) => { catalogRequestCount += 1; if (catalogRequestCount === 1) { @@ -411,6 +415,7 @@ test("coalesces pending reader model catalogs and retries after malformed 2xx", return; } + await retryCatalogRequestGate; await route.fulfill({ contentType: "application/json", status: 200, @@ -447,21 +452,32 @@ test("coalesces pending reader model catalogs and retries after malformed 2xx", await expect.poll(() => catalogRequestCount).toBe(1); releaseFirstCatalogRequest(); - await expect(dialog.getByRole("alert")).toHaveText( + const alert = dialog.getByRole("alert"); + const retry = alert.getByRole("button", { name: /^Retry/ }); + const model = dialog.getByLabel("Model", { exact: true }); + await expect(alert).toContainText( "Codex model catalog response was invalid.", ); + await expect(retry).toBeEnabled(); + await expect(retry).toHaveAccessibleName("Retry model catalog"); - await page.keyboard.press("Escape"); - await expect(dialog).toHaveCount(0); - await trigger.click(); - await expect(dialog).toBeVisible(); + await retry.click(); await expect.poll(() => catalogRequestCount).toBe(2); + await expect(retry).toBeDisabled(); + await expect(retry).toHaveAccessibleName("Retrying model catalog..."); + await retry.evaluate((button: HTMLButtonElement) => button.click()); + expect(catalogRequestCount).toBe(2); + + releaseRetryCatalogRequest(); await expect( - dialog.getByLabel("Model", { exact: true }).locator('option[value="gpt-5.5"]'), + model.locator('option[value="gpt-5.5"]'), ).toHaveCount(1); await expect(dialog.getByRole("alert")).toHaveCount(0); + await expect(model).toBeFocused(); + expect(catalogRequestCount).toBe(2); } finally { releaseFirstCatalogRequest(); + releaseRetryCatalogRequest(); } }); diff --git a/src/components/async-action-focus.ts b/src/components/async-action-focus.ts new file mode 100644 index 00000000..db87103c --- /dev/null +++ b/src/components/async-action-focus.ts @@ -0,0 +1,16 @@ +export function captureAsyncActionFocusIntent( + actionControl: HTMLElement, + readActiveElement: () => Element | null = () => + typeof document === "undefined" ? null : document.activeElement, + readBody: () => HTMLElement | undefined = () => + typeof document === "undefined" ? undefined : document.body, +): () => boolean { + const actionOwnedFocus = readActiveElement() === actionControl; + return () => { + if (!actionOwnedFocus) { + return false; + } + const activeElement = readActiveElement(); + return activeElement === actionControl || activeElement === readBody(); + }; +} diff --git a/src/components/moments/MomentBrowse.tsx b/src/components/moments/MomentBrowse.tsx index 1166efe3..f02af83d 100644 --- a/src/components/moments/MomentBrowse.tsx +++ b/src/components/moments/MomentBrowse.tsx @@ -76,7 +76,7 @@ export function MomentBrowse() { }; return (
- +
([]); const [translationCatalogError, setTranslationCatalogError] = createSignal(""); + const [translationCatalogPending, setTranslationCatalogPending] = + createSignal(false); const { setRightRailContent } = useRightRailContent(); let translationEventSource: EventSource | undefined; let translationCatalogRequest: { generation: number; - promise: Promise; + promise: Promise<"error" | "ignored" | "success">; } | undefined; let translationRequestGeneration = 0; @@ -700,25 +704,28 @@ function ReadyMemoryReader(props: { return translationCatalogModels().find((model) => model.isDefault) ?.supportedReasoningEfforts ?? []; }); - const refreshTranslationCatalog = (): Promise => { + const refreshTranslationCatalog = (): Promise< + "error" | "ignored" | "success" + > => { const readerGeneration = captureReaderGeneration(); if (!isCurrentReaderGeneration(readerGeneration)) { - return Promise.resolve(); + return Promise.resolve("ignored"); } if (translationCatalogRequest?.generation === readerGeneration.generation) { return translationCatalogRequest.promise; } - setTranslationCatalogError(""); + setTranslationCatalogPending(true); const request = (async () => { try { const catalog = await submitReadCodexModels(); if (!isCurrentReaderGeneration(readerGeneration)) { - return; + return "ignored" as const; } setTranslationCatalogModels(catalog.models); setTranslationCatalogError(""); + return "success" as const; } catch (error) { if (isCurrentReaderGeneration(readerGeneration)) { setTranslationCatalogError( @@ -726,7 +733,9 @@ function ReadyMemoryReader(props: { ? error.message : "Codex model catalog is unavailable.", ); + return "error" as const; } + return "ignored" as const; } })(); translationCatalogRequest = { @@ -736,11 +745,37 @@ function ReadyMemoryReader(props: { void request.finally(() => { if (translationCatalogRequest?.promise === request) { translationCatalogRequest = undefined; + if (isCurrentReaderGeneration(readerGeneration)) { + setTranslationCatalogPending(false); + } } }); return request; }; + const retryTranslationCatalog = (retryButton: HTMLButtonElement): void => { + const readerGeneration = captureReaderGeneration(); + const shouldRestoreFocus = captureAsyncActionFocusIntent(retryButton); + void refreshTranslationCatalog().then((outcome) => { + queueMicrotask(() => { + if ( + outcome === "ignored" || + !translationDialogOpen() || + !isCurrentReaderGeneration(readerGeneration) || + !shouldRestoreFocus() + ) { + return; + } + + const focusTarget = outcome === "success" + ? translationModelSelectRef + : retryButton; + if (focusTarget?.isConnected === true && !focusTarget.disabled) { + focusTarget.focus({ preventScroll: true }); + } + }); + }); + }; const handleTranslationPopoverOpenChange = (open: boolean): void => { setTranslationDialogOpen(open); if (!open) { @@ -1316,6 +1351,7 @@ function ReadyMemoryReader(props: {