From 399062252f14c741615247620c96f3793dcf598b Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 08:34:57 +0800 Subject: [PATCH 01/27] build(web): add @sqlite.org/sqlite-wasm and the vitest browser toolchain Pin @sqlite.org/sqlite-wasm at 3.53.0-build1 (SQLite 3.53.0) as a runtime dependency. The floor is 3.50, which is where pauseVfs/unpauseVfs landed. Add vitest, @vitest/browser, @vitest/browser-playwright and playwright as dev dependencies: OPFS sync access handles and dedicated workers do not exist in jsdom, so the web suite has to run in a real browser. @rollup/plugin-terser is for the prebuilt worker bundle, which ships as a built artifact rather than being minified by the consumer. No behaviour change yet. --- package-lock.json | 1543 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 8 +- 2 files changed, 1529 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index e42d3822..72cf0a20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "8.1.0", "license": "MIT", "dependencies": { + "@sqlite.org/sqlite-wasm": "3.53.0-build1", "jeep-sqlite": "^2.7.2" }, "devDependencies": { @@ -22,15 +23,20 @@ "@ionic/swiftlint-config": "^2.0.0", "@rollup/plugin-commonjs": "28.0.2", "@rollup/plugin-node-resolve": "16.0.0", + "@rollup/plugin-terser": "0.4.4", "@types/node": "25.0.3", + "@vitest/browser": "4.1.10", + "@vitest/browser-playwright": "4.1.10", "commit-and-tag-version": "12.6.1", "eslint": "^8.57.1", + "playwright": "1.62.1", "prettier": "^3.6.2", "prettier-plugin-java": "^2.7.7", "rimraf": "^6.1.0", "rollup": "^4.53.2", "swiftlint": "^2.0.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "4.1.10" }, "engines": { "node": ">=16.0.0" @@ -60,6 +66,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@capacitor/android": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.0.0.tgz", @@ -200,6 +213,40 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "dev": true, @@ -473,11 +520,78 @@ "node": ">=18.0.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -510,6 +624,284 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.2", "dev": true, @@ -558,6 +950,29 @@ } } }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", "dev": true, @@ -934,6 +1349,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.53.0-build1", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.53.0-build1.tgz", + "integrity": "sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g==", + "license": "Apache-2.0", + "workspaces": [ + "demos/*" + ], + "engines": { + "node": ">=22" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@stencil/core": { "version": "4.25.1", "license": "MIT", @@ -945,6 +1379,35 @@ "npm": ">=7.10.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1194,6 +1657,176 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/browser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -1205,7 +1838,9 @@ } }, "node_modules/acorn": { - "version": "8.14.0", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1403,7 +2038,17 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=0.10.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" } }, "node_modules/astral-regex": { @@ -1614,6 +2259,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -2135,6 +2790,13 @@ "node": ">=14" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "license": "MIT" @@ -2336,6 +2998,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2588,6 +3260,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "dev": true, @@ -2948,6 +3627,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -3045,9 +3734,14 @@ } }, "node_modules/fdir": { - "version": "6.4.3", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -4436,6 +5130,267 @@ "immediate": "~3.0.5" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "dev": true, @@ -4530,11 +5485,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/map-obj": { @@ -4868,11 +5825,40 @@ "node": ">=0.10.0" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/native-run": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", @@ -5017,6 +6003,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -5217,6 +6217,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -5229,27 +6236,74 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=20" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/plist": { @@ -5267,6 +6321,16 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "dev": true, @@ -5275,6 +6339,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -5373,6 +6466,16 @@ "node": ">=8" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -5661,6 +6764,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -5809,6 +6946,16 @@ "node": ">=10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "dev": true, @@ -5943,11 +7090,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "dev": true, @@ -5977,6 +7146,16 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5987,6 +7166,27 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -6050,6 +7250,20 @@ "version": "1.12.0", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "license": "MIT", @@ -6284,6 +7498,32 @@ "node": ">=18" } }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -6331,6 +7571,50 @@ "node": ">= 6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -6342,6 +7626,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "dev": true, @@ -6585,6 +7879,174 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -6684,6 +8146,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -6720,6 +8199,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "dev": true, diff --git a/package.json b/package.json index c4139b73..72cdd848 100644 --- a/package.json +++ b/package.json @@ -72,15 +72,20 @@ "@ionic/swiftlint-config": "^2.0.0", "@rollup/plugin-commonjs": "28.0.2", "@rollup/plugin-node-resolve": "16.0.0", + "@rollup/plugin-terser": "0.4.4", "@types/node": "25.0.3", + "@vitest/browser": "4.1.10", + "@vitest/browser-playwright": "4.1.10", "commit-and-tag-version": "12.6.1", "eslint": "^8.57.1", + "playwright": "1.62.1", "prettier": "^3.6.2", "prettier-plugin-java": "^2.7.7", "rimraf": "^6.1.0", "rollup": "^4.53.2", "swiftlint": "^2.0.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "4.1.10" }, "peerDependencies": { "@capacitor/core": ">=8.0.0" @@ -102,6 +107,7 @@ } }, "dependencies": { + "@sqlite.org/sqlite-wasm": "3.53.0-build1", "jeep-sqlite": "^2.7.2" } } From ab7752f3093a2101edad7e19d83e14786fe47d8e Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 08:35:23 +0800 Subject: [PATCH 02/27] feat(web): replace the jeep-sqlite shim with an in-repo sqlite-wasm engine src/web.ts was a 589-line pass-through to a DOM element, whose engine (sql.js in memory plus localforage image persistence) has been dormant since 2024-08 and has neither FTS5 nor OPFS. The core method set now runs on @sqlite.org/sqlite-wasm in a dedicated worker. Two durability tiers, one engine: - tier 1 keeps databases in OPFS through the opfs-sahpool VFS, which needs neither COOP/COEP headers nor SharedArrayBuffer and therefore works inside Capacitor WebViews and on ordinary hosting. - tier 2 is the automatic fallback where OPFS sync access handles are missing: the same engine, with databases in :memory: and whole-file images in IndexedDB. The image format is identical, so a database moves between tiers. A rejection from the VFS install is classified before any fallback. A pool held by another browsing context reports NoModificationAllowedError, which is indistinguishable from "this platform has no OPFS" unless inspected; falling back on it would open an empty database over the user's real data and then flush that empty image over the stored one. Only a genuine capability gap reaches tier 2, and a navigator.locks gate keeps a second context out before the install is attempted at all. Other behaviour worth calling out: - changes() and last_insert_rowid() are read inside the same worker op as the statement that produced them, so concurrent calls cannot interleave between the write and the read. - rows stay plain objects and BLOBs stay Uint8Array in both directions, so reorderRows remains a no-op for web results. - readonly connections become real on web: the read-only open flag on tier 1, PRAGMA query_only on tier 2, which has to open writable in order to deserialize its image. - errors keep their message instead of being stringified into "Error: Error:". - upgrade statements run on open, ported from electron-utils/utilsUpgrade.ts, with an in-memory pre-upgrade image standing in for that port's file backup. A failed ladder restores the image and fails the open rather than silently serving the pre-upgrade version. Rollup emits dist/web-worker.js as a classic, non-module worker with dist/sqlite3.wasm beside it. OPFS sync access handles land in Firefox 111 but module workers only in Firefox 114, so an ESM worker would fail to load on browsers that can otherwise run tier 1. setSqliteWorkerFactory and setSqliteWebOptions are additive exports for bundlers that cannot resolve the shipped worker; neither touches CapacitorSQLitePlugin. The eleven JSON, sync-table and asset methods still throw "Not implemented on web." pending their port. --- rollup.config.mjs | 87 +++-- src/index.ts | 4 + src/web.ts | 617 +++++++++++++++-------------------- src/web/client.ts | 111 +++++++ src/web/errors.ts | 59 ++++ src/web/protocol.ts | 86 +++++ src/web/registry.ts | 82 +++++ src/web/worker-factory.ts | 64 ++++ src/web/worker/engine.ts | 190 +++++++++++ src/web/worker/images.ts | 96 ++++++ src/web/worker/paths.ts | 60 ++++ src/web/worker/statements.ts | 160 +++++++++ src/web/worker/tiers.ts | 115 +++++++ src/web/worker/upgrades.ts | 77 +++++ src/web/worker/worker.ts | 372 +++++++++++++++++++++ tsconfig.json | 2 +- 16 files changed, 1808 insertions(+), 374 deletions(-) create mode 100644 src/web/client.ts create mode 100644 src/web/errors.ts create mode 100644 src/web/protocol.ts create mode 100644 src/web/registry.ts create mode 100644 src/web/worker-factory.ts create mode 100644 src/web/worker/engine.ts create mode 100644 src/web/worker/images.ts create mode 100644 src/web/worker/paths.ts create mode 100644 src/web/worker/statements.ts create mode 100644 src/web/worker/tiers.ts create mode 100644 src/web/worker/upgrades.ts create mode 100644 src/web/worker/worker.ts diff --git a/rollup.config.mjs b/rollup.config.mjs index 912889a0..5b024377 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,24 +1,69 @@ -export default { - input: 'dist/esm/index.js', - output: [ - { - file: 'dist/plugin.js', - format: 'iife', - name: 'capacitorCapacitorSQLite', - globals: { - '@capacitor/core': 'capacitorExports', - localforage: 'localForage', - 'sql.js': 'initSqlJs', +import nodeResolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +import { copyFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const wasmSource = require.resolve('@sqlite.org/sqlite-wasm/sqlite3.wasm'); + +/** + * The web worker ships as a prebuilt CLASSIC script rather than a module worker: OPFS sync + * access handles land in Firefox 111 but module workers only in Firefox 114, so an ESM worker + * would fail to load outright on browsers that can otherwise run the OPFS tier. Classic output + * costs nothing, the two builds being within a fraction of a KiB of each other. + * + * Rollup rewrites `import.meta.url` to a `document.currentScript` expression for iife output, + * which throws "document is not defined" inside a worker. `self.location.href` is the worker + * equivalent, and it is also what sqlite-wasm's own `new URL('sqlite3.wasm', import.meta.url)` + * needs in order to find the wasm sitting next to the worker file. + */ +const importMetaUrlInWorker = { + name: 'import-meta-url-in-worker', + resolveImportMeta(property) { + return property === 'url' ? 'self.location.href' : null; + }, +}; + +/** The wasm binary stays a separate asset. Base64 inlining it would cost about a third again. */ +const copyWasmAsset = { + name: 'copy-sqlite-wasm', + writeBundle() { + copyFileSync(wasmSource, 'dist/sqlite3.wasm'); + }, +}; + +export default [ + { + input: 'dist/esm/index.js', + output: [ + { + file: 'dist/plugin.js', + format: 'iife', + name: 'capacitorCapacitorSQLite', + globals: { + '@capacitor/core': 'capacitorExports', + localforage: 'localForage', + 'sql.js': 'initSqlJs', + }, + sourcemap: true, + inlineDynamicImports: true, }, + { + file: 'dist/plugin.cjs.js', + format: 'cjs', + sourcemap: true, + inlineDynamicImports: true, + }, + ], + external: ['@capacitor/core', 'localforage', 'sql.js'], + }, + { + input: 'dist/esm/web/worker/worker.js', + output: { + file: 'dist/web-worker.js', + format: 'iife', sourcemap: true, - inlineDynamicImports: true, - }, - { - file: 'dist/plugin.cjs.js', - format: 'cjs', - sourcemap: true, - inlineDynamicImports: true, }, - ], - external: ['@capacitor/core', 'localforage', 'sql.js'], -}; + plugins: [nodeResolve({ browser: true }), importMetaUrlInWorker, terser(), copyWasmAsset], + }, +]; diff --git a/src/index.ts b/src/index.ts index 0e0575c2..3b8e867a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,7 @@ const CapacitorSQLite = registerPlugin('CapacitorSQLite', export { CapacitorSQLite }; export * from './definitions'; +// Additive web-only exports. They do not appear in CapacitorSQLitePlugin and change no existing +// signature; they exist so bundlers that cannot resolve the shipped worker can supply their own. +export { setSqliteWorkerFactory, setSqliteWebOptions } from './web/worker-factory'; +export type { SqliteWorkerFactory } from './web/worker-factory'; diff --git a/src/web.ts b/src/web.ts index fc66fba6..897e0da9 100644 --- a/src/web.ts +++ b/src/web.ts @@ -36,462 +36,373 @@ import type { capSQLiteExtensionPath, capSQLiteExtensionEnable, } from './definitions'; - +import { WorkerClient } from './web/client'; +import { WEBSTORE_NOT_OPEN, prefixed } from './web/errors'; +import type { SerializedUpgrade, Tier, WorkerInitResult } from './web/protocol'; +import { ConnectionRegistry, parseKey, reconcile } from './web/registry'; +import { connKey } from './web/worker/paths'; +import { getSqliteWebOptions } from './web/worker-factory'; + +/** + * Web implementation backed by `@sqlite.org/sqlite-wasm` in a dedicated worker. + * + * Tier 1 stores databases in OPFS through the `opfs-sahpool` VFS, which needs neither COOP/COEP + * headers nor SharedArrayBuffer and therefore works inside Capacitor WebViews. Tier 2 is the + * automatic fallback for platforms without OPFS sync access handles: the same engine, with + * databases in `:memory:` and whole-file images in IndexedDB. + */ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlugin { - private jeepSqliteElement: any = null; - private isWebStoreOpen = false; + private client = new WorkerClient(); + private registry = new ConnectionRegistry(); + private upgrades = new Map(); + private store: WorkerInitResult | null = null; async initWebStore(): Promise { - await customElements.whenDefined('jeep-sqlite'); - - this.jeepSqliteElement = document.querySelector('jeep-sqlite'); - this.ensureJeepSqliteIsAvailable(); - - this.jeepSqliteElement.addEventListener('jeepSqliteImportProgress', (event: CustomEvent) => { - this.notifyListeners('sqliteImportProgressEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqliteExportProgress', (event: CustomEvent) => { - this.notifyListeners('sqliteExportProgressEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqliteHTTPRequestEnded', (event: CustomEvent) => { - this.notifyListeners('sqliteHTTPRequestEndedEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqlitePickDatabaseEnded', (event: CustomEvent) => { - this.notifyListeners('sqlitePickDatabaseEndedEvent', event.detail); - }); - this.jeepSqliteElement.addEventListener('jeepSqliteSaveDatabaseToDisk', (event: CustomEvent) => { - this.notifyListeners('sqliteSaveDatabaseToDiskEvent', event.detail); - }); - - if (!this.isWebStoreOpen) { - this.isWebStoreOpen = await this.jeepSqliteElement.isStoreOpen(); + if (this.store) return; + try { + await this.client.start(); + this.store = await this.client.call('init', getSqliteWebOptions()); + } catch (err) { + this.store = null; + throw prefixed('initWebStore', err); } + } - return; + /** Which durability tier the store selected. Additive; not part of CapacitorSQLitePlugin. */ + getWebStoreTier(): Tier | null { + return this.store?.tier ?? null; } - async saveToStore(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + /** + * Shut the worker down and release the single-owner lock, so another tab can take over. + * Additive; not part of CapacitorSQLitePlugin. Any open connection is dropped without a + * flush, so call `saveToStore` first if you are on tier 2 and care about unsaved changes. + */ + async closeWebStore(): Promise { + this.client.terminate(); + this.registry.clear(); + this.store = null; + } - try { - await this.jeepSqliteElement.saveToStore(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + private ensureStore(): WorkerInitResult { + if (!this.store) throw new Error(WEBSTORE_NOT_OPEN); + return this.store; } - async getFromLocalDiskToStore(options: capSQLiteLocalDiskOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.getFromLocalDiskToStore(options); - return; - } catch (err) { - throw new Error(`${err}`); + private static optionValue(options: any, key: string, fallback?: T): T { + const value = options?.[key]; + if (value === undefined || value === null) { + if (fallback !== undefined) return fallback; + throw new Error(`Must provide a ${key}`); } + return value as T; } - async saveToLocalDisk(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.saveToLocalDisk(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + private call(op: string, args: Record, database?: string, readonly?: boolean): Promise { + this.ensureStore(); + const key = database !== undefined ? connKey(database, !!readonly) : undefined; + return this.client.call(op, args, key); } async echo(options: capEchoOptions): Promise { - this.ensureJeepSqliteIsAvailable(); + return { value: options?.value }; + } - const echoResult = await this.jeepSqliteElement.echo(options); - return echoResult; + async saveToStore(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + await this.call('saveToStore', { database }, database, false); } async createConnection(options: capConnectionOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + this.ensureStore(); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const version = options.version ?? 1; + const readonly = options.readonly ?? false; + if (options.encrypted) { + throw new Error('CreateConnection: encryption is not supported on the web platform'); + } + this.registry.add(database, readonly, version); + } + async closeConnection(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.registry.require(database, readonly); try { - await this.jeepSqliteElement.createConnection(options); - return; - } catch (err) { - throw new Error(`${err}`); + await this.call('close', { database, readonly }, database, readonly); + } finally { + this.registry.delete(database, readonly); + this.client.releaseQueue(connKey(database, readonly)); } } async open(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - await this.jeepSqliteElement.open(options); - return; + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + const entry = this.registry.require(database, readonly); + try { + await this.call( + 'open', + { database, readonly, version: entry.version, upgrades: this.upgrades.get(database) ?? [] }, + database, + readonly, + ); + entry.isOpen = true; } catch (err) { - throw new Error(`${err}`); + throw prefixed('Open', err); } } - async closeConnection(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - await this.jeepSqliteElement.closeConnection(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async close(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + const entry = this.registry.require(database, readonly); + await this.call('close', { database, readonly }, database, readonly); + entry.isOpen = false; } async getVersion(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const versionResult: capVersionResult = await this.jeepSqliteElement.getVersion(options); - return versionResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'GetVersion'); + return this.call('getVersion', { database, readonly }, database, readonly); } async checkConnectionsConsistency(options: capAllConnectionsOptions): Promise { - this.ensureJeepSqliteIsAvailable(); + const dbNames = options?.dbNames ?? []; + const openModes = options?.openModes ?? []; + const claimed = dbNames.map((name, index) => `${openModes[index] ?? 'RW'}_${name}`); + const { toClose, consistent } = reconcile(this.registry.keys(), claimed); - try { - const consistencyResult: capSQLiteResult = await this.jeepSqliteElement.checkConnectionsConsistency(options); - return consistencyResult; - } catch (err) { - throw new Error(`${err}`); + for (const key of toClose) { + const { database, readonly } = parseKey(key); + try { + await this.call('close', { database, readonly }, database, readonly); + } catch { + // A connection that cannot be closed is still one we must forget about. + } + this.registry.delete(database, readonly); + this.client.releaseQueue(key); } + if (!consistent) this.registry.clear(); + return { result: consistent }; } - async close(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - await this.jeepSqliteElement.close(options); - return; - } catch (err) { - throw new Error(`${err}`); - } - } async beginTransaction(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const changes: capSQLiteChanges = await this.jeepSqliteElement.beginTransaction(options); - return changes; - } catch (err) { - throw new Error(`${err}`); - } + return this.transactionOp('beginTransaction', options); } async commitTransaction(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const changes: capSQLiteChanges = await this.jeepSqliteElement.commitTransaction(options); - return changes; - } catch (err) { - throw new Error(`${err}`); - } + return this.transactionOp('commitTransaction', options); } async rollbackTransaction(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + return this.transactionOp('rollbackTransaction', options); + } - try { - const changes: capSQLiteChanges = await this.jeepSqliteElement.rollbackTransaction(options); - return changes; - } catch (err) { - throw new Error(`${err}`); - } + private async transactionOp(op: string, options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, op); + const result = await this.call(op, { database, readonly }, database, readonly); + return { changes: { changes: result.changes, lastId: result.lastId } }; } - async isTransactionActive(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const result: capSQLiteResult = await this.jeepSqliteElement.isTransactionActive(options); - return result; - } catch (err) { - throw new Error(`${err}`); - } + async isTransactionActive(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'IsTransactionActive'); + return this.call('isTransactionActive', { database, readonly }, database, readonly); } async getTableList(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const tableListResult: capSQLiteValues = await this.jeepSqliteElement.getTableList(options); - return tableListResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'GetTableList'); + return this.call('getTableList', { database, readonly }, database, readonly); } async execute(options: capSQLiteExecuteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const executeResult: capSQLiteChanges = await this.jeepSqliteElement.execute(options); - return executeResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const statements = CapacitorSQLiteWeb.optionValue(options, 'statements'); + const transaction = options.transaction ?? true; + this.rejectReadonly(options.readonly, 'Execute'); + this.requireOpen(database, false, 'Execute'); + const result = await this.call('execute', { database, readonly: false, statements, transaction }, database, false); + return { changes: { changes: result.changes, lastId: result.lastId } }; } async executeSet(options: capSQLiteSetOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const executeResult: capSQLiteChanges = await this.jeepSqliteElement.executeSet(options); - return executeResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const set = CapacitorSQLiteWeb.optionValue(options, 'set'); + const transaction = options.transaction ?? true; + const returnMode = options.returnMode ?? 'no'; + this.rejectReadonly(options.readonly, 'ExecuteSet'); + this.requireOpen(database, false, 'ExecuteSet'); + const result = await this.call( + 'executeSet', + { database, readonly: false, set, transaction, returnMode }, + database, + false, + ); + return { changes: CapacitorSQLiteWeb.changesOf(result) }; } async run(options: capSQLiteRunOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const runResult: capSQLiteChanges = await this.jeepSqliteElement.run(options); - return runResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const statement = CapacitorSQLiteWeb.optionValue(options, 'statement'); + const transaction = options.transaction ?? true; + const returnMode = options.returnMode ?? 'no'; + this.rejectReadonly(options.readonly, 'Run'); + this.requireOpen(database, false, 'Run'); + const result = await this.call( + 'run', + { database, readonly: false, statement, values: options.values ?? [], transaction, returnMode }, + database, + false, + ); + return { changes: CapacitorSQLiteWeb.changesOf(result) }; } - async query(options: capSQLiteQueryOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const queryResult: capSQLiteValues = await this.jeepSqliteElement.query(options); - return queryResult; - } catch (err) { - throw new Error(`${err}`); - } + async query(options: capSQLiteQueryOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const statement = CapacitorSQLiteWeb.optionValue(options, 'statement'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'Query'); + return this.call('query', { database, readonly, statement, values: options.values ?? [] }, database, readonly); } - async isDBExists(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const dbExistsResult: capSQLiteResult = await this.jeepSqliteElement.isDBExists(options); - return dbExistsResult; - } catch (err) { - throw new Error(`${err}`); - } + async isDBExists(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.registry.require(database, readonly); + return this.call('isDatabase', { database }); } async isDBOpen(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const isDBOpenResult: capSQLiteResult = await this.jeepSqliteElement.isDBOpen(options); - return isDBOpenResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.registry.require(database, readonly); + return this.call('isDBOpen', { database, readonly }); } async isDatabase(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const isDatabaseResult: capSQLiteResult = await this.jeepSqliteElement.isDatabase(options); - return isDatabaseResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + return this.call('isDatabase', { database }); } async isTableExists(options: capSQLiteTableOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const tableExistsResult = await this.jeepSqliteElement.isTableExists(options); - return tableExistsResult; - } catch (err) { - throw new Error(`${err}`); - } + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const table = CapacitorSQLiteWeb.optionValue(options, 'table'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'IsTableExists'); + return this.call('isTableExists', { database, readonly, table }, database, readonly); } - async deleteDatabase(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.deleteDatabase(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async deleteDatabase(options: capSQLiteOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + this.rejectReadonly(options.readonly, 'DeleteDatabase'); + this.registry.require(database, false); + await this.call('deleteDatabase', { database }, database, false); + const entry = this.registry.get(database, false); + if (entry) entry.isOpen = false; } - async isJsonValid(options: capSQLiteImportOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const isJsonValidResult = await this.jeepSqliteElement.isJsonValid(options); - return isJsonValidResult; - } catch (err) { - throw new Error(`${err}`); - } + async getDatabaseList(): Promise { + return this.call('getDatabaseList', {}); } - async importFromJson(options: capSQLiteImportOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const importFromJsonResult: capSQLiteChanges = await this.jeepSqliteElement.importFromJson(options); - return importFromJsonResult; - } catch (err) { - throw new Error(`${err}`); - } + async addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise { + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const upgrade = CapacitorSQLiteWeb.optionValue(options, 'upgrade'); + const serialized: SerializedUpgrade[] = upgrade.map((entry, index) => { + if (entry?.toVersion === undefined || !Array.isArray(entry?.statements)) { + throw new Error(`AddUpgradeStatement: upgrade[${index}] needs toVersion and statements`); + } + return { toVersion: entry.toVersion, statements: entry.statements }; + }); + const existing = this.upgrades.get(database) ?? []; + const merged = new Map(); + for (const item of [...existing, ...serialized]) merged.set(item.toVersion, item); + this.upgrades.set( + database, + [...merged.values()].sort((a, b) => a.toVersion - b.toVersion), + ); } - async exportToJson(options: capSQLiteExportOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - - try { - const exportToJsonResult: capSQLiteJson = await this.jeepSqliteElement.exportToJson(options); - return exportToJsonResult; - } catch (err) { - throw new Error(`${err}`); - } + private requireOpen(database: string, readonly: boolean, context: string): void { + const entry = this.registry.require(database, readonly); + if (!entry.isOpen) throw new Error(`${context}: Database ${database} not opened`); } - async createSyncTable(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - const createSyncTableResult: capSQLiteChanges = await this.jeepSqliteElement.createSyncTable(options); - return createSyncTableResult; - } catch (err) { - throw new Error(`${err}`); - } + private rejectReadonly(readonly: boolean | undefined, context: string): void { + if (readonly) throw new Error(`${context}: not allowed in read-only mode`); } - async setSyncDate(options: capSQLiteSyncDateOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.setSyncDate(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + private static changesOf(result: any): { changes: number; lastId: number; values?: any[] } { + const changes: { changes: number; lastId: number; values?: any[] } = { + changes: result.changes, + lastId: result.lastId, + }; + if (result.values !== undefined) changes.values = result.values; + return changes; } - async getSyncDate(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + //////////////////////////////////// + ////// NOT YET PORTED TO THE NEW ENGINE + //////////////////////////////////// - try { - const getSyncDateResult: capSQLiteSyncDate = await this.jeepSqliteElement.getSyncDate(options); - return getSyncDateResult; - } catch (err) { - throw new Error(`${err}`); - } - } - async deleteExportedRows(options: capSQLiteOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); - try { - await this.jeepSqliteElement.deleteExportedRows(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async getFromLocalDiskToStore(options: capSQLiteLocalDiskOptions): Promise { + console.log('getFromLocalDiskToStore', options); + throw this.unimplemented('Not implemented on web.'); } - async addUpgradeStatement(options: capSQLiteUpgradeOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async saveToLocalDisk(options: capSQLiteOptions): Promise { + console.log('saveToLocalDisk', options); + throw this.unimplemented('Not implemented on web.'); + } - try { - await this.jeepSqliteElement.addUpgradeStatement(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async isJsonValid(options: capSQLiteImportOptions): Promise { + console.log('isJsonValid', options); + throw this.unimplemented('Not implemented on web.'); } - async copyFromAssets(options: capSQLiteFromAssetsOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async importFromJson(options: capSQLiteImportOptions): Promise { + console.log('importFromJson', options); + throw this.unimplemented('Not implemented on web.'); + } - try { - await this.jeepSqliteElement.copyFromAssets(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async exportToJson(options: capSQLiteExportOptions): Promise { + console.log('exportToJson', options); + throw this.unimplemented('Not implemented on web.'); } - async getFromHTTPRequest(options: capSQLiteHTTPOptions): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async createSyncTable(options: capSQLiteOptions): Promise { + console.log('createSyncTable', options); + throw this.unimplemented('Not implemented on web.'); + } - try { - await this.jeepSqliteElement.getFromHTTPRequest(options); - return; - } catch (err) { - throw new Error(`${err}`); - } + async setSyncDate(options: capSQLiteSyncDateOptions): Promise { + console.log('setSyncDate', options); + throw this.unimplemented('Not implemented on web.'); } - async getDatabaseList(): Promise { - this.ensureJeepSqliteIsAvailable(); - this.ensureWebstoreIsOpen(); + async getSyncDate(options: capSQLiteOptions): Promise { + console.log('getSyncDate', options); + throw this.unimplemented('Not implemented on web.'); + } - try { - const databaseListResult: capSQLiteValues = await this.jeepSqliteElement.getDatabaseList(); - return databaseListResult; - } catch (err) { - throw new Error(`${err}`); - } + async deleteExportedRows(options: capSQLiteOptions): Promise { + console.log('deleteExportedRows', options); + throw this.unimplemented('Not implemented on web.'); } - /** - * Checks if the `jeep-sqlite` element is present in the DOM. - * If it's not in the DOM, this method throws an Error. - * - * Attention: This will always fail, if the `intWebStore()` method wasn't called before. - */ - private ensureJeepSqliteIsAvailable() { - if (this.jeepSqliteElement === null) { - throw new Error( - `The jeep-sqlite element is not present in the DOM! Please check the @capacitor-community/sqlite documentation for instructions regarding the web platform.`, - ); - } + async copyFromAssets(options: capSQLiteFromAssetsOptions): Promise { + console.log('copyFromAssets', options); + throw this.unimplemented('Not implemented on web.'); } - private ensureWebstoreIsOpen() { - if (!this.isWebStoreOpen) { - /** - * if (!this.isWebStoreOpen) - this.isWebStoreOpen = await this.jeepSqliteElement.isStoreOpen(); - */ - throw new Error('WebStore is not open yet. You have to call "initWebStore()" first.'); - } + async getFromHTTPRequest(options: capSQLiteHTTPOptions): Promise { + console.log('getFromHTTPRequest', options); + throw this.unimplemented('Not implemented on web.'); } //////////////////////////////////// @@ -578,10 +489,12 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug async isInConfigBiometricAuth(): Promise { throw this.unimplemented('Not implemented on web.'); } + async loadExtension(options: capSQLiteExtensionPath): Promise { console.log('loadExtension', options); throw this.unimplemented('Not implemented on web.'); } + async enableLoadExtension(options: capSQLiteExtensionEnable): Promise { console.log('enableLoadExtension', options); throw this.unimplemented('Not implemented on web.'); diff --git a/src/web/client.ts b/src/web/client.ts new file mode 100644 index 00000000..3053a6b6 --- /dev/null +++ b/src/web/client.ts @@ -0,0 +1,111 @@ +/** + * Main-thread half of the worker RPC. + * + * Two responsibilities beyond posting messages: + * + * - Ops that touch a specific connection are serialised per connection key (`RW_foo`), so two + * overlapping `run()` calls cannot interleave. The worker reads `changes()` inside the same op + * as the statement, which removes the read/write race; the queue removes the ordering surprise + * on top of it. + * - Errors come back as a payload and are rebuilt into a real Error here, instead of the + * `throw new Error(\`${err}\`)` stringification the jeep facade used. + */ +import { fromErrorPayload } from './errors'; +import type { WorkerResponse } from './protocol'; +import { BOOT_ID } from './protocol'; +import { createSqliteWorker } from './worker-factory'; + +interface Pending { + resolve: (value: any) => void; + reject: (reason: unknown) => void; +} + +export class WorkerClient { + private worker: Worker | null = null; + private nextId = 1; + private pending = new Map(); + private queues = new Map>(); + private booted: Promise | null = null; + + get isStarted(): boolean { + return this.worker !== null; + } + + start(): Promise { + if (this.booted) return this.booted; + const worker = createSqliteWorker(); + this.worker = worker; + + this.booted = new Promise((resolve, reject) => { + worker.onmessage = (event: MessageEvent) => { + const message = event.data as WorkerResponse; + if (!message || typeof message.id !== 'number') return; + if (message.id === BOOT_ID) { + resolve(); + return; + } + const entry = this.pending.get(message.id); + if (!entry) return; + this.pending.delete(message.id); + if (message.ok) entry.resolve(message.result); + else entry.reject(fromErrorPayload(message.error)); + }; + worker.onerror = (event) => { + const error = new Error( + `The SQLite worker failed to load (${(event as ErrorEvent).message || 'unknown error'}). ` + + 'If your bundler cannot resolve the shipped worker, provide one with setSqliteWorkerFactory().', + ); + reject(error); + this.failAll(error); + }; + }); + return this.booted; + } + + private failAll(error: Error): void { + for (const entry of this.pending.values()) entry.reject(error); + this.pending.clear(); + } + + private dispatch(op: string, args: Record, transfer: Transferable[]): Promise { + const worker = this.worker; + if (!worker) return Promise.reject(new Error('The SQLite worker is not running.')); + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + worker.postMessage({ id, op, args }, transfer); + }); + } + + /** + * @param serializeKey when given, this op waits for the previous op on the same key. Use the + * `RO_`/`RW_` connection key so per-connection ordering is guaranteed without serialising + * unrelated databases against each other. + */ + call(op: string, args: Record = {}, serializeKey?: string, transfer: Transferable[] = []): Promise { + if (!serializeKey) return this.dispatch(op, args, transfer); + const previous = this.queues.get(serializeKey) ?? Promise.resolve(); + const next = previous.then( + () => this.dispatch(op, args, transfer), + () => this.dispatch(op, args, transfer), + ); + // Keep a settled-either-way tail so one failure does not wedge the queue. + this.queues.set( + serializeKey, + next.catch(() => undefined), + ); + return next; + } + + releaseQueue(serializeKey: string): void { + this.queues.delete(serializeKey); + } + + terminate(): void { + this.worker?.terminate(); + this.worker = null; + this.booted = null; + this.queues.clear(); + this.failAll(new Error('The SQLite worker was terminated.')); + } +} diff --git a/src/web/errors.ts b/src/web/errors.ts new file mode 100644 index 00000000..ef239748 --- /dev/null +++ b/src/web/errors.ts @@ -0,0 +1,59 @@ +/** + * Error plumbing for the web implementation. + * + * The jeep-based facade wrapped everything in `throw new Error(\`${err}\`)`, which turned a real + * Error into the string "Error: ...". Errors are rebuilt from the worker payload here instead, + * so `err.message` stays clean and the `name` survives the structured-clone boundary. + */ +import type { WorkerErrorPayload } from './protocol'; + +export const WEBSTORE_NOT_OPEN = 'WebStore is not open yet. You have to call "initWebStore()" first.'; + +export const MULTI_TAB_LOCKED = + 'Database is open in another tab or window. @capacitor-community/sqlite supports a single ' + + 'owning context per origin on the web; close the other tab and retry.'; + +export class SQLiteWebError extends Error { + readonly code?: string; + + constructor(message: string, options?: { name?: string; code?: string; cause?: unknown }) { + super(message); + this.name = options?.name ?? 'SQLiteWebError'; + this.code = options?.code; + if (options?.cause !== undefined) (this as any).cause = options.cause; + } +} + +/** Turn anything thrown into a message without the "Error: " prefix a template literal adds. */ +export function messageOf(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + const message = (err as any)?.message; + return typeof message === 'string' ? message : String(err); +} + +/** Shape an arbitrary throwable for postMessage (Error is cloneable, but not all fields survive). */ +export function toErrorPayload(err: unknown): WorkerErrorPayload { + return { + message: messageOf(err), + name: err instanceof Error ? err.name : undefined, + code: (err as any)?.code !== undefined ? String((err as any).code) : undefined, + }; +} + +/** Rebuild an Error on the main thread from what the worker sent. */ +export function fromErrorPayload(payload: WorkerErrorPayload | undefined): SQLiteWebError { + return new SQLiteWebError(payload?.message ?? 'Unknown worker error', { + name: payload?.name, + code: payload?.code, + }); +} + +/** Prefix a message the way the native implementations do, without stringifying the Error. */ +export function prefixed(prefix: string, err: unknown): SQLiteWebError { + return new SQLiteWebError(`${prefix}: ${messageOf(err)}`, { + name: err instanceof Error ? err.name : undefined, + code: (err as any)?.code !== undefined ? String((err as any).code) : undefined, + cause: err, + }); +} diff --git a/src/web/protocol.ts b/src/web/protocol.ts new file mode 100644 index 00000000..ad63f507 --- /dev/null +++ b/src/web/protocol.ts @@ -0,0 +1,86 @@ +/** + * Wire types shared by the facade and the worker. + * + * Envelope: `{ id, op, args }` request, `{ id, ok: true, result }` or + * `{ id, ok: false, error }` response. Values cross the boundary by structured clone, so + * `Uint8Array` BLOBs and `BigInt` int64s survive without any JSON step. + */ + +/** 1 = opfs-sahpool (durable files), 2 = :memory: plus whole-image persistence in IndexedDB. */ +export type Tier = 1 | 2; + +export interface WorkerInitArgs { + poolName: string; + directory: string; + /** Test hook: skip the VFS install entirely and run on tier 2. */ + forceTier2?: boolean; + /** Test hook: pretend the install rejected with this DOMException name. */ + simulateInstallError?: string; + /** Override where sqlite3.wasm is fetched from. Defaults to a sibling of the worker file. */ + wasmUrl?: string; +} + +export interface WorkerInitResult { + tier: Tier; + sqliteVersion: string; + /** Reason the probe fell back, for logging. Absent on tier 1. */ + fallbackReason?: string; +} + +export interface OpenArgs { + database: string; + readonly: boolean; + version: number; + upgrades: SerializedUpgrade[]; +} + +export interface SerializedUpgrade { + toVersion: number; + statements: string[]; +} + +export interface ExecResult { + changes: number; + lastId: number; + values?: any[]; +} + +export interface QueryResult { + values: any[]; +} + +export interface WorkerRequest { + id: number; + op: string; + args: Record; +} + +export interface WorkerErrorPayload { + message: string; + name?: string; + code?: string; +} + +export type WorkerResponse = + | { id: number; ok: true; result: any } + | { id: number; ok: false; error: WorkerErrorPayload }; + +/** Sent unsolicited by the worker as soon as its module body has run. */ +export const BOOT_ID = -1; + +/** The IndexedDB database and object store backing tier 2 images. */ +export const IMAGE_STORE_DB = 'capacitorSqliteStore'; +export const IMAGE_STORE_NAME = 'databases'; + +/** + * Web Locks name held for the lifetime of the worker so only one context owns the pool. + * Scoped by pool name: two stores configured with different pool names are genuinely + * independent on disk, so they must not gate each other. + */ +export function ownerLockName(poolName: string): string { + return `capacitor-sqlite-owner:${poolName}`; +} + +/** Defaults for the sahpool. The pool name is part of the on-disk contract: changing it orphans data. */ +export const DEFAULT_POOL_NAME = 'capacitor-sqlite'; +export const DEFAULT_POOL_DIRECTORY = '.capacitor-sqlite'; diff --git a/src/web/registry.ts b/src/web/registry.ts new file mode 100644 index 00000000..63ab2853 --- /dev/null +++ b/src/web/registry.ts @@ -0,0 +1,82 @@ +/** + * Connection registry, keyed the same way `SQLiteConnection._connectionDict` in + * `src/definitions.ts` keys its own map: `RW_` / `RO_`. Keeping the two in + * the same shape is what makes `checkConnectionsConsistency` able to compare them. + * + * Registration and opening are separate steps, exactly as on native: `createConnection` records + * the intent (name, version, mode) and `open` does the work. + */ +import { connKey } from './worker/paths'; + +export interface RegisteredConnection { + database: string; + readonly: boolean; + version: number; + isOpen: boolean; +} + +export class ConnectionRegistry { + private entries = new Map(); + + key(database: string, readonly: boolean): string { + return connKey(database, readonly); + } + + add(database: string, readonly: boolean, version: number): RegisteredConnection { + const entry: RegisteredConnection = { database, readonly, version, isOpen: false }; + this.entries.set(this.key(database, readonly), entry); + return entry; + } + + get(database: string, readonly: boolean): RegisteredConnection | undefined { + return this.entries.get(this.key(database, readonly)); + } + + require(database: string, readonly: boolean): RegisteredConnection { + const entry = this.get(database, readonly); + if (!entry) { + throw new Error(`No available connection for database ${database}. Call createConnection() first.`); + } + return entry; + } + + delete(database: string, readonly: boolean): void { + this.entries.delete(this.key(database, readonly)); + } + + keys(): string[] { + return [...this.entries.keys()]; + } + + all(): RegisteredConnection[] { + return [...this.entries.values()]; + } + + clear(): void { + this.entries.clear(); + } +} + +/** Decompose a registry key back into its parts. `RO_foo` -> `{ database: 'foo', readonly: true }`. */ +export function parseKey(key: string): { database: string; readonly: boolean } { + return { database: key.substring(3), readonly: key.substring(0, 3) === 'RO_' }; +} + +/** + * The comparison `checkConnectionsConsistency` performs, ported from + * `electron/src/index.ts` (MIT, this repo): the caller passes the connections it believes exist, + * and anything the plugin holds beyond that set is closed. A mismatch in the other direction + * (the caller knows about more than the plugin does) resets everything and reports false. + * + * @returns the keys to close, and whether the two sides agree afterwards. + */ +export function reconcile(held: string[], claimed: string[]): { toClose: string[]; consistent: boolean } { + const claimedSet = new Set(claimed); + if (claimedSet.size === 0) return { toClose: held, consistent: false }; + if (held.length < claimedSet.size) return { toClose: held, consistent: false }; + + const toClose = held.filter((key) => !claimedSet.has(key)); + const remaining = held.filter((key) => claimedSet.has(key)); + const consistent = remaining.length === claimedSet.size; + return { toClose: consistent ? toClose : held, consistent }; +} diff --git a/src/web/worker-factory.ts b/src/web/worker-factory.ts new file mode 100644 index 00000000..5f0bfb23 --- /dev/null +++ b/src/web/worker-factory.ts @@ -0,0 +1,64 @@ +/** + * How the worker gets constructed, and the two escape hatches around it. + * + * The shipped worker is a self-contained classic script at `dist/web-worker.js` with + * `sqlite3.wasm` beside it. Classic rather than a module worker on purpose: OPFS sync access + * handles land in Firefox 111 but module workers only in Firefox 114, so an ESM worker would + * fail to load outright on browsers that can otherwise run tier 1. + * + * Bundlers that inline this module lose the ability to resolve that URL, which is what + * `setSqliteWorkerFactory` is for. It is an additive export: it does not appear in + * `CapacitorSQLitePlugin` and does not change any existing signature. + */ +import { DEFAULT_POOL_DIRECTORY, DEFAULT_POOL_NAME } from './protocol'; +import type { WorkerInitArgs } from './protocol'; + +export type SqliteWorkerFactory = () => Worker; + +let workerFactory: SqliteWorkerFactory | null = null; + +const options: WorkerInitArgs = { + poolName: DEFAULT_POOL_NAME, + directory: DEFAULT_POOL_DIRECTORY, +}; + +/** + * Supply your own worker, for bundlers that cannot resolve the shipped one. + * + * ```ts + * import { setSqliteWorkerFactory } from '@capacitor-community/sqlite'; + * setSqliteWorkerFactory(() => new Worker(new URL('...', import.meta.url), { type: 'module' })); + * ``` + */ +export function setSqliteWorkerFactory(factory: SqliteWorkerFactory | null): void { + workerFactory = factory; +} + +/** + * Override the pool identity or force the IndexedDB tier. + * + * The pool name and directory are part of the on-disk contract: changing them on an existing + * installation orphans the databases already stored under the old name. + */ +export function setSqliteWebOptions(overrides: Partial): void { + Object.assign(options, overrides); +} + +export function getSqliteWebOptions(): WorkerInitArgs { + return { ...options }; +} + +/** + * `dist/web-worker.js` relative to whichever build is running: `dist/esm/web/worker-factory.js` + * for the module build, `dist/plugin.js` for the iife/cjs bundles. + */ +function defaultWorkerUrl(): URL { + const here = new URL('.', import.meta.url); + const root = here.pathname.endsWith('/esm/web/') ? new URL('../../', here) : here; + return new URL('web-worker.js', root); +} + +export function createSqliteWorker(): Worker { + if (workerFactory) return workerFactory(); + return new Worker(defaultWorkerUrl()); +} diff --git a/src/web/worker/engine.ts b/src/web/worker/engine.ts new file mode 100644 index 00000000..b44dcc5b --- /dev/null +++ b/src/web/worker/engine.ts @@ -0,0 +1,190 @@ +/** + * Thin wrapper over sqlite-wasm's oo1 API. + * + * The semantics are the ones `electron/src/electron-utils/utilsSQLite.ts` (MIT, this repo) + * defines over better-sqlite3: `changes` is a `total_changes()` delta, `lastId` comes from + * `last_insert_rowid()`, and `execute` runs a whole batch. Two deliberate differences: + * + * - changes and lastId are read inside the same worker op as the statement, so nothing can + * interleave between the write and the read. + * - RETURNING is executed directly. better-sqlite3 cannot step a RETURNING statement with + * `.run()`, which is why the electron port strips the clause and re-selects by rowid range; + * sqlite-wasm has no such limit, so the rows come straight from the statement that produced + * them and the rowid-range race disappears. + */ +import { prefixed } from '../errors'; +import type { ExecResult } from '../protocol'; + +import { producesRows, replaceUndefinedByNull } from './statements'; + +export class Connection { + private transactionActive = false; + + constructor( + readonly storage: string, + readonly isReadonly: boolean, + private readonly db: any, + private readonly sqlite3: any, + ) {} + + get pointer(): number { + return this.db.pointer; + } + + /** The oo1 handle. Needed by the tier 2 deserialize path; nothing else should reach for it. */ + get raw(): any { + return this.db; + } + + get isOpen(): boolean { + return !!this.db.pointer; + } + + close(): void { + this.db.close(); + } + + private totalChanges(): number { + return this.db.changes(true, false); + } + + private lastInsertRowid(): number { + return Number(this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer)); + } + + userVersion(): number { + return Number(this.db.selectValue('PRAGMA user_version') ?? 0); + } + + setUserVersion(version: number): void { + this.db.exec(`PRAGMA user_version = ${Math.trunc(version)}`); + } + + /** + * Tier 2 opens `:memory:` with create flags because a deserialize needs a writable handle, so + * a read-only connection is enforced here instead of by the open flags. Tier 1 gets the real + * thing from the 'r' flag and does not need this. + */ + setQueryOnly(on: boolean): void { + this.db.exec(`PRAGMA query_only = ${on ? 'ON' : 'OFF'}`); + } + + setForeignKeyConstraintsEnabled(enabled: boolean): void { + this.db.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`); + } + + /** A batch of raw statements, as `execute()` receives it. */ + executeBatch(statements: string): ExecResult { + const before = this.totalChanges(); + try { + this.db.exec(statements); + } catch (err) { + throw prefixed('Execute', err); + } + return { changes: this.totalChanges() - before, lastId: this.lastInsertRowid() }; + } + + /** One statement with its bind values. `wantRows` collects RETURNING / SELECT output. */ + run(statement: string, values: any[] | undefined, wantRows: boolean): ExecResult { + const bind = replaceUndefinedByNull(values); + const before = this.totalChanges(); + let rows: any[] | undefined; + try { + if (wantRows) { + rows = this.db.exec({ + sql: statement, + bind: bind.length > 0 ? bind : undefined, + rowMode: 'object', + returnValue: 'resultRows', + }); + } else { + this.db.exec({ sql: statement, bind: bind.length > 0 ? bind : undefined }); + } + } catch (err) { + throw prefixed('Run', err); + } + const result: ExecResult = { changes: this.totalChanges() - before, lastId: this.lastInsertRowid() }; + if (rows !== undefined) result.values = rows; + return result; + } + + query(statement: string, values?: any[]): any[] { + const bind = replaceUndefinedByNull(values); + try { + return this.db.exec({ + sql: statement, + bind: bind.length > 0 ? bind : undefined, + rowMode: 'object', + returnValue: 'resultRows', + }); + } catch (err) { + throw prefixed('Query', err); + } + } + + beginTransaction(): void { + if (this.transactionActive) throw new Error('a transaction is already active'); + this.db.exec('BEGIN TRANSACTION;'); + this.transactionActive = true; + } + + commitTransaction(): void { + if (!this.transactionActive) throw new Error('no transaction is active'); + this.db.exec('COMMIT TRANSACTION;'); + this.transactionActive = false; + } + + rollbackTransaction(): void { + if (!this.transactionActive) throw new Error('no transaction is active'); + this.db.exec('ROLLBACK TRANSACTION;'); + this.transactionActive = false; + } + + get isTransactionActive(): boolean { + return this.transactionActive; + } + + /** + * Run `body` inside a transaction the caller asked for, but never nest: `execute`/`run` default + * to transaction=true and are routinely called from inside an explicit beginTransaction(). + */ + withOptionalTransaction(wanted: boolean, body: () => T): T { + const own = wanted && !this.transactionActive; + if (!own) return body(); + this.beginTransaction(); + try { + const out = body(); + this.commitTransaction(); + return out; + } catch (err) { + try { + this.rollbackTransaction(); + } catch { + this.transactionActive = false; + } + throw err; + } + } + + tableList(): any[] { + return this.query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).map((row: any) => row.name); + } + + tableExists(table: string): boolean { + const found = this.db.selectValue("SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?", [table]); + return Number(found ?? 0) > 0; + } + + /** Whole-file image, used by tier 2 flushes and by exportDb. */ + serialize(): Uint8Array { + return this.sqlite3.capi.sqlite3_js_db_export(this.db.pointer); + } +} + +/** Statement-level helper shared by run/executeSet so both decide row collection identically. */ +export function wantsRows(statement: string, returnMode: string | undefined): boolean { + if (returnMode === 'all' || returnMode === 'one') return producesRows(statement); + return false; +} diff --git a/src/web/worker/images.ts b/src/web/worker/images.ts new file mode 100644 index 00000000..fe2ca15e --- /dev/null +++ b/src/web/worker/images.ts @@ -0,0 +1,96 @@ +/** + * Tier 2 persistence: whole-database images in IndexedDB. + * + * This is jeep-sqlite's durability model, reimplemented on the one engine: the database lives in + * `:memory:` and the bytes rest in IndexedDB, written at the flush points the plugin already + * documents (close, closeConnection, saveToStore). The image format is plain SQLite, byte for + * byte the same thing tier 1 stores in the pool, which is what lets a database move between the + * tiers and what the M3 jeep migration will rely on. + * + * Raw IndexedDB on purpose: localforage is not a dependency and will not become one. + */ +import { IMAGE_STORE_DB, IMAGE_STORE_NAME } from '../protocol'; + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed')); + }); +} + +export class ImageStore { + private dbPromise: Promise | null = null; + + private open(): Promise { + if (!this.dbPromise) { + this.dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(IMAGE_STORE_DB, 1); + req.onupgradeneeded = () => { + if (!req.result.objectStoreNames.contains(IMAGE_STORE_NAME)) { + req.result.createObjectStore(IMAGE_STORE_NAME); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('Could not open the image store')); + }); + } + return this.dbPromise; + } + + private async tx(mode: IDBTransactionMode): Promise { + const db = await this.open(); + return db.transaction(IMAGE_STORE_NAME, mode).objectStore(IMAGE_STORE_NAME); + } + + async get(storage: string): Promise { + const store = await this.tx('readonly'); + const value = await request(store.get(storage)); + if (!value) return null; + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return null; + } + + async put(storage: string, image: Uint8Array): Promise { + const store = await this.tx('readwrite'); + await request(store.put(image, storage)); + } + + async delete(storage: string): Promise { + const store = await this.tx('readwrite'); + await request(store.delete(storage)); + } + + async has(storage: string): Promise { + const store = await this.tx('readonly'); + const count = await request(store.count(storage)); + return count > 0; + } + + async keys(): Promise { + const store = await this.tx('readonly'); + const keys = await request(store.getAllKeys()); + return keys.map((k) => String(k)); + } +} + +/** + * Load an image into an open `:memory:` database. + * + * FREEONCLOSE hands the buffer's lifetime to sqlite (it was allocated with sqlite's allocator by + * allocFromTypedArray), and RESIZEABLE is what allows writes that grow the database past the + * size of the image it was restored from. + */ +export function deserializeInto(sqlite3: any, db: any, image: Uint8Array): void { + const capi = sqlite3.capi; + const pData = sqlite3.wasm.allocFromTypedArray(image); + const rc = capi.sqlite3_deserialize( + db.pointer, + 'main', + pData, + image.byteLength, + image.byteLength, + capi.SQLITE_DESERIALIZE_FREEONCLOSE | capi.SQLITE_DESERIALIZE_RESIZEABLE, + ); + db.checkRc(rc); +} diff --git a/src/web/worker/paths.ts b/src/web/worker/paths.ts new file mode 100644 index 00000000..dd86a580 --- /dev/null +++ b/src/web/worker/paths.ts @@ -0,0 +1,60 @@ +/** + * Naming rules for stored databases, in one place because both of them bite silently. + * + * 1. The plugin's storage name is the connection name plus the `SQLite.db` suffix, matching + * every native implementation (`electron/src/index.ts:94`, `ios/Plugin/CapacitorSQLite.swift:1157`) + * and what `getDatabaseList()` is documented to return. + * 2. opfs-sahpool registers files under the path its VFS computes + * (`new URL(name, 'file://localhost/').pathname`), so `new OpfsSAHPoolDb('a.db')` maps to + * `/a.db`, but `exportFile` / `importDb` / `unlink` look their argument up RAW. Feeding them + * the unnormalized name throws "File not found" on a database that plainly exists. Note that + * the normalization is a URL pathname, so it also percent-encodes: a database called `存在` + * is registered as `/%E5%AD%98%E5%9C%A8SQLite.db`. Reusing the same URL computation here is + * the only way to stay in step with it. + */ + +const SUFFIX = 'SQLite.db'; + +/** `foo` or `foo.db` or `fooSQLite.db` -> `fooSQLite.db`. */ +export function storageName(database: string): string { + let name = database; + if (name.endsWith(SUFFIX)) return name; + if (name.endsWith('.db')) name = name.slice(0, -3); + return `${name}${SUFFIX}`; +} + +/** `fooSQLite.db` -> `foo`. Inverse of storageName for registry keys and error messages. */ +export function connectionName(storage: string): string { + return storage.endsWith(SUFFIX) ? storage.slice(0, -SUFFIX.length) : storage; +} + +/** The key opfs-sahpool's exportFile / importDb / unlink actually look up. */ +export function poolPath(storage: string): string { + return new URL(storage.replace(/^\/+/, ''), 'file://localhost/').pathname; +} + +/** Inverse of poolPath, for turning `getFileNames()` output back into database names. */ +export function fromPoolPath(path: string): string { + const stripped = path.replace(/^\/+/, ''); + try { + return decodeURIComponent(stripped); + } catch { + return stripped; + } +} + +/** Registry key shared with SQLiteConnection._connectionDict in definitions.ts. */ +export function connKey(database: string, readonly: boolean): string { + return `${readonly ? 'RO' : 'RW'}_${database}`; +} + +/** + * importDb picks a free handle out of the pool and throws "No available handles to import to." + * when there is none. Default initialCapacity is 6, so growing before an import is mandatory. + */ +export async function reserveCapacity(poolUtil: any, extra = 2): Promise { + const needed = poolUtil.getFileCount() + extra; + if (poolUtil.getCapacity() < needed) { + await poolUtil.reserveMinimumCapacity(needed); + } +} diff --git a/src/web/worker/statements.ts b/src/web/worker/statements.ts new file mode 100644 index 00000000..11dc9f04 --- /dev/null +++ b/src/web/worker/statements.ts @@ -0,0 +1,160 @@ +/** + * Statement splitting and inspection. + * + * Behaviour is the one `electron/src/electron-utils/utilsSQLite.ts` (MIT, this repo) established + * for the batch `execute` path, but the splitting is done with a character scanner rather than + * the string-replace-and-split-on-semicolon approach used there: a semicolon inside a string + * literal, a comment, or a trigger body must not end a statement, and trigger bodies are common + * in this plugin's upgrade statements. + * + * The `sql_deleted` DELETE rewrite that also lives in this layer arrives in M2. + */ + +/** Split a batch into individual statements, ignoring semicolons that are not separators. */ +export function splitStatements(sql: string): string[] { + const out: string[] = []; + let current = ''; + let depth = 0; // nesting of BEGIN ... END inside CREATE TRIGGER bodies + + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + + // string literal + if (ch === "'") { + const end = scanQuoted(sql, i, "'"); + current += sql.slice(i, end); + i = end - 1; + continue; + } + // quoted identifiers + if (ch === '"' || ch === '`') { + const end = scanQuoted(sql, i, ch); + current += sql.slice(i, end); + i = end - 1; + continue; + } + if (ch === '[') { + const end = sql.indexOf(']', i + 1); + const stop = end === -1 ? sql.length : end + 1; + current += sql.slice(i, stop); + i = stop - 1; + continue; + } + // comments + if (ch === '-' && sql[i + 1] === '-') { + const nl = sql.indexOf('\n', i); + const stop = nl === -1 ? sql.length : nl; + current += sql.slice(i, stop); + i = stop - 1; + continue; + } + if (ch === '/' && sql[i + 1] === '*') { + const end = sql.indexOf('*/', i + 2); + const stop = end === -1 ? sql.length : end + 2; + current += sql.slice(i, stop); + i = stop - 1; + continue; + } + + if (isWordBoundary(sql, i)) { + if (matchesKeyword(sql, i, 'BEGIN')) depth++; + else if (matchesKeyword(sql, i, 'END')) depth = Math.max(0, depth - 1); + } + + if (ch === ';' && depth === 0) { + push(out, current); + current = ''; + continue; + } + current += ch; + } + push(out, current); + return out; +} + +function push(out: string[], candidate: string): void { + const trimmed = candidate.trim(); + if (trimmed.length > 0 && stripNoise(trimmed).trim().length > 0) out.push(trimmed); +} + +function scanQuoted(sql: string, start: number, quote: string): number { + let i = start + 1; + while (i < sql.length) { + if (sql[i] === quote) { + if (sql[i + 1] === quote) { + i += 2; // doubled quote is an escaped quote + continue; + } + return i + 1; + } + i++; + } + return sql.length; +} + +function isWordBoundary(sql: string, i: number): boolean { + return i === 0 || !/[A-Za-z0-9_]/.test(sql[i - 1]); +} + +function matchesKeyword(sql: string, i: number, keyword: string): boolean { + if (sql.substr(i, keyword.length).toUpperCase() !== keyword) return false; + const after = sql[i + keyword.length]; + return after === undefined || !/[A-Za-z0-9_]/.test(after); +} + +/** Remove string literals and comments so keyword matching cannot be fooled by data. */ +export function stripNoise(sql: string): string { + let out = ''; + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (ch === "'" || ch === '"' || ch === '`') { + i = scanQuoted(sql, i, ch) - 1; + out += ' '; + continue; + } + if (ch === '-' && sql[i + 1] === '-') { + const nl = sql.indexOf('\n', i); + i = (nl === -1 ? sql.length : nl) - 1; + out += ' '; + continue; + } + if (ch === '/' && sql[i + 1] === '*') { + const end = sql.indexOf('*/', i + 2); + i = (end === -1 ? sql.length : end + 2) - 1; + out += ' '; + continue; + } + out += ch; + } + return out; +} + +/** The leading keyword, upper-cased: SELECT, INSERT, UPDATE, DELETE, CREATE, PRAGMA, ... */ +export function statementKind(sql: string): string { + const match = stripNoise(sql) + .trim() + .match(/^([A-Za-z]+)/); + return match ? match[1].toUpperCase() : ''; +} + +/** + * True when the statement carries a RETURNING clause. Unlike better-sqlite3, sqlite-wasm can + * step a RETURNING statement and hand back its rows directly, so the electron workaround of + * stripping the clause and re-selecting by rowid range is not needed here. + */ +export function hasReturningClause(sql: string): boolean { + return /\bRETURNING\b/i.test(stripNoise(sql)); +} + +/** Statements that produce rows, so `exec` knows whether to collect any. */ +export function producesRows(sql: string): boolean { + const kind = statementKind(sql); + if (kind === 'SELECT' || kind === 'WITH' || kind === 'PRAGMA' || kind === 'EXPLAIN') return true; + return hasReturningClause(sql); +} + +/** Ported from electron-utils: bound `undefined` must reach sqlite as NULL, not throw. */ +export function replaceUndefinedByNull(values: any[] | undefined): any[] { + if (!values || values.length === 0) return []; + return values.map((value) => (value === undefined ? null : value)); +} diff --git a/src/web/worker/tiers.ts b/src/web/worker/tiers.ts new file mode 100644 index 00000000..5878258c --- /dev/null +++ b/src/web/worker/tiers.ts @@ -0,0 +1,115 @@ +/** + * Tier selection: single-owner lock first, then the VFS install, then a CLASSIFIED fallback. + * + * The classification is the whole point. A second browsing context that installs the pool while + * the first still holds the sync access handles gets a `NoModificationAllowedError`, which looks + * exactly like "this platform has no OPFS" unless you inspect it. Treating it as a fallback + * would hand the app an empty `:memory:` database on top of its real data, and the next flush + * would write that empty image over the stored one. So a busy pool fails loudly and only genuine + * capability gaps reach tier 2. + */ +import { MULTI_TAB_LOCKED, SQLiteWebError, messageOf } from '../errors'; +import type { Tier, WorkerInitArgs } from '../protocol'; +import { ownerLockName } from '../protocol'; + +export interface TierSelection { + tier: Tier; + poolUtil: any | null; + fallbackReason?: string; +} + +/** + * Take the owner lock and never release it: the held callback returns a promise that never + * settles, so the lock lives exactly as long as this worker. Resolves false when another + * context already owns it. + */ +export function acquireOwnerLock(poolName: string): Promise { + const locks = (navigator as any).locks; + if (!locks || typeof locks.request !== 'function') { + // No Web Locks means no way to gate. Proceed; the VFS install is still the backstop, + // and its busy-pool rejection is classified below rather than silently downgraded. + return Promise.resolve(true); + } + return new Promise((resolve, reject) => { + locks + .request(ownerLockName(poolName), { ifAvailable: true }, (lock: unknown) => { + if (!lock) { + resolve(false); + return undefined; + } + resolve(true); + return new Promise(() => { + /* held until this worker is terminated */ + }); + }) + .catch(reject); + }); +} + +/** A rejection that means "someone else owns the pool", not "this platform cannot do OPFS". */ +function isPoolBusy(err: unknown): boolean { + const name = (err as any)?.name; + if (name === 'NoModificationAllowedError' || name === 'InvalidStateError') return true; + return /access handles cannot be created|no modification allowed/i.test(messageOf(err)); +} + +/** The only three rejections that legitimately mean tier 2. */ +function isCapabilityGap(err: unknown): boolean { + const message = messageOf(err); + if (/missing required opfs apis/i.test(message)) return true; + if (/too old for opfs-sahpool/i.test(message)) return true; + return (err as any)?.name === 'SecurityError'; +} + +function opfsApisPresent(): boolean { + const g = globalThis as any; + return !!( + g.FileSystemHandle && + g.FileSystemDirectoryHandle && + typeof g.FileSystemFileHandle?.prototype?.createSyncAccessHandle === 'function' && + navigator?.storage?.getDirectory + ); +} + +export async function selectTier(sqlite3: any, args: WorkerInitArgs): Promise { + if (args.forceTier2) { + return { tier: 2, poolUtil: null, fallbackReason: 'forced by configuration' }; + } + + const owned = await acquireOwnerLock(args.poolName); + if (!owned) throw new SQLiteWebError(MULTI_TAB_LOCKED, { name: 'SQLiteWebLockedError', code: 'LOCKED' }); + + if (typeof sqlite3.installOpfsSAHPoolVfs !== 'function') { + return { tier: 2, poolUtil: null, fallbackReason: 'installOpfsSAHPoolVfs is unavailable in this build' }; + } + if (!opfsApisPresent()) { + return { tier: 2, poolUtil: null, fallbackReason: 'Missing required OPFS APIs.' }; + } + + try { + if (args.simulateInstallError) { + // Test hook: exercise the classifier without needing a second browsing context. The value + // is used as both the DOMException name and the message, so either arm can be driven. + const fake: any = new Error(args.simulateInstallError); + fake.name = args.simulateInstallError; + throw fake; + } + const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + directory: args.directory, + name: args.poolName, + }); + return { tier: 1, poolUtil }; + } catch (err) { + if (isPoolBusy(err)) { + throw new SQLiteWebError(MULTI_TAB_LOCKED, { name: 'SQLiteWebLockedError', code: 'LOCKED', cause: err }); + } + if (isCapabilityGap(err)) { + return { tier: 2, poolUtil: null, fallbackReason: messageOf(err) }; + } + // Unrecognised: do not guess. Silently degrading here is what loses user data. + throw new SQLiteWebError(`Could not initialise the opfs-sahpool VFS: ${messageOf(err)}`, { + name: (err as any)?.name, + cause: err, + }); + } +} diff --git a/src/web/worker/upgrades.ts b/src/web/worker/upgrades.ts new file mode 100644 index 00000000..f4e7e9eb --- /dev/null +++ b/src/web/worker/upgrades.ts @@ -0,0 +1,77 @@ +/** + * Version upgrade execution, ported from `electron/src/electron-utils/utilsUpgrade.ts` + * (MIT, this repo) with the same contract: run every registered upgrade whose `toVersion` sits + * above the database's current `user_version` and at or below the requested version, in + * ascending order, each inside its own transaction with foreign keys disabled. + * + * The electron version takes a file-level backup around the whole ladder and restores it on + * failure. There is no file copy on tier 1 (the pool owns the file) and no meaningful one on + * tier 2 either, so the web port takes an in-memory image of the database instead and restores + * from that, which gives the same all-or-nothing guarantee without touching the VFS. + */ +import { prefixed } from '../errors'; +import type { SerializedUpgrade } from '../protocol'; + +import type { Connection } from './engine'; + +export interface UpgradeOutcome { + upgraded: boolean; + fromVersion: number; + toVersion: number; + changes: number; +} + +export function sortUpgrades(upgrades: SerializedUpgrade[]): SerializedUpgrade[] { + return [...upgrades].sort((a, b) => a.toVersion - b.toVersion); +} + +/** + * @param restore called with a pre-upgrade image when the ladder fails partway, so the caller + * can put the database back exactly as it was. Omitted for read-only opens. + */ +export function runUpgrades( + conn: Connection, + upgrades: SerializedUpgrade[], + targetVersion: number, + restore?: (image: Uint8Array) => void, +): UpgradeOutcome { + const fromVersion = conn.userVersion(); + const pending = sortUpgrades(upgrades).filter((u) => u.toVersion > fromVersion && u.toVersion <= targetVersion); + if (pending.length === 0) { + return { upgraded: false, fromVersion, toVersion: fromVersion, changes: 0 }; + } + + for (const upgrade of pending) { + if (!upgrade.statements || upgrade.statements.length === 0) { + throw new Error(`onUpgrade: statements not given for version ${upgrade.toVersion}`); + } + } + + const backup = restore ? conn.serialize() : undefined; + let changes = 0; + let reached = fromVersion; + + try { + for (const upgrade of pending) { + conn.setForeignKeyConstraintsEnabled(false); + try { + changes += conn.withOptionalTransaction(true, () => { + let stepChanges = 0; + for (const statement of upgrade.statements) { + stepChanges += conn.executeBatch(statement).changes; + } + return stepChanges; + }); + conn.setUserVersion(upgrade.toVersion); + reached = upgrade.toVersion; + } finally { + conn.setForeignKeyConstraintsEnabled(true); + } + } + } catch (err) { + if (backup && restore) restore(backup); + throw prefixed('onUpgrade', err); + } + + return { upgraded: true, fromVersion, toVersion: reached, changes }; +} diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts new file mode 100644 index 00000000..09557450 --- /dev/null +++ b/src/web/worker/worker.ts @@ -0,0 +1,372 @@ +/** + * Worker entry: owns the wasm heap, the VFS, and every open database. The main thread never + * touches any of them. + * + * Ops are dispatched from a single table and each one returns a plain, structured-cloneable + * value. Anything that needs `changes()` or `last_insert_rowid()` reads them inside the same op + * as the statement that produced them, so concurrent calls cannot interleave between the write + * and the read. + */ +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +import { toErrorPayload } from '../errors'; +import type { ExecResult, OpenArgs, WorkerInitArgs, WorkerInitResult, WorkerRequest } from '../protocol'; +import { BOOT_ID } from '../protocol'; + +import { Connection, wantsRows } from './engine'; +import { ImageStore, deserializeInto } from './images'; +import { connKey, fromPoolPath, poolPath, reserveCapacity, storageName } from './paths'; +import { selectTier } from './tiers'; +import { runUpgrades } from './upgrades'; + +/** + * The published types declare `init()` with no parameters, but the runtime export is an + * Emscripten module factory and does accept a Module config: that is how `print`, `printErr` + * and `locateFile` are wired. Narrow cast rather than a broad `any` on the import. + */ +interface Sqlite3InitConfig { + print?: (...args: any[]) => void; + printErr?: (...args: any[]) => void; + locateFile?: (file: string) => string; +} +const initModule = sqlite3InitModule as unknown as (config?: Sqlite3InitConfig) => Promise; + +let sqlite3: any = null; +let poolUtil: any = null; +let tier: 1 | 2 = 2; +const images = new ImageStore(); +const connections = new Map(); + +function requireInit(): void { + if (!sqlite3) throw new Error('The SQLite worker is not initialised.'); +} + +function connection(database: string, readonly: boolean): Connection { + const conn = connections.get(connKey(database, readonly)); + if (!conn) throw new Error(`Database ${database} is not open`); + return conn; +} + +/** Every connection open on a database, whichever mode it was opened in. */ +function connectionsFor(database: string): Connection[] { + return [connections.get(connKey(database, false)), connections.get(connKey(database, true))].filter( + (c): c is Connection => !!c, + ); +} + +function openRaw(storage: string, readonly: boolean): Connection { + if (tier === 1) { + const db = new poolUtil.OpfsSAHPoolDb(storage, readonly ? 'r' : 'cw'); + return new Connection(storage, readonly, db, sqlite3); + } + // Tier 2 always opens writable: sqlite3_deserialize needs it. Read-only is applied after the + // image is loaded, with PRAGMA query_only. + return new Connection(storage, readonly, new sqlite3.oo1.DB(':memory:', 'c'), sqlite3); +} + +async function openTier2(storage: string, readonly: boolean): Promise { + const image = await images.get(storage); + if (!image && readonly) throw new Error(`Database ${storage} does not exist`); + const conn = openRaw(storage, readonly); + if (image) deserializeInto(sqlite3, conn.raw, image); + if (readonly) conn.setQueryOnly(true); + return conn; +} + +async function flushIfTier2(conn: Connection): Promise { + if (tier !== 1 && !conn.isReadonly && conn.isOpen) { + await images.put(conn.storage, conn.serialize()); + } +} + +async function storedNames(): Promise { + if (tier === 1) return (poolUtil.getFileNames() as string[]).map(fromPoolPath); + const names = new Set(await images.keys()); + for (const conn of connections.values()) names.add(conn.storage); + return [...names].sort(); +} + +const ops: Record any> = { + async init(args: WorkerInitArgs): Promise { + if (!sqlite3) { + sqlite3 = await initModule({ + print: () => undefined, + printErr: () => undefined, + // Without an override the wasm is resolved next to this worker file, which is how the + // shipped dist/web-worker.js + dist/sqlite3.wasm pair works. + ...(args.wasmUrl ? { locateFile: () => args.wasmUrl as string } : {}), + }); + } + const selection = await selectTier(sqlite3, args); + tier = selection.tier; + poolUtil = selection.poolUtil; + if (tier === 1) await reserveCapacity(poolUtil, 4); + return { + tier, + sqliteVersion: sqlite3.version.libVersion, + ...(selection.fallbackReason ? { fallbackReason: selection.fallbackReason } : {}), + }; + }, + + async open({ database, readonly, version, upgrades }: OpenArgs) { + requireInit(); + const key = connKey(database, readonly); + if (connections.has(key)) return { alreadyOpen: true }; + const storage = storageName(database); + + let conn = tier === 1 ? openRaw(storage, readonly) : await openTier2(storage, readonly); + + let outcome = { upgraded: false, fromVersion: conn.userVersion(), toVersion: conn.userVersion(), changes: 0 }; + if (!readonly && upgrades && upgrades.length > 0) { + try { + outcome = runUpgrades(conn, upgrades, version, (image) => { + conn.close(); + if (tier === 1) { + poolUtil.importDb(poolPath(storage), image); + conn = openRaw(storage, false); + } else { + conn = openRaw(storage, false); + deserializeInto(sqlite3, conn.raw, image); + } + }); + } catch (err) { + connections.set(key, conn); + await flushIfTier2(conn); + throw err; + } + } else if (!readonly && version > conn.userVersion()) { + conn.setUserVersion(version); + outcome = { upgraded: false, fromVersion: outcome.fromVersion, toVersion: version, changes: 0 }; + } + + connections.set(key, conn); + if (outcome.upgraded) await flushIfTier2(conn); + return outcome; + }, + + async close({ database, readonly }: { database: string; readonly: boolean }) { + requireInit(); + const key = connKey(database, readonly); + const conn = connections.get(key); + if (!conn) return { closed: false }; + await flushIfTier2(conn); + conn.close(); + connections.delete(key); + return { closed: true }; + }, + + async execute({ + database, + readonly, + statements, + transaction, + }: { + database: string; + readonly: boolean; + statements: string; + transaction: boolean; + }): Promise { + const conn = connection(database, readonly); + return conn.withOptionalTransaction(transaction, () => conn.executeBatch(statements)); + }, + + async run({ + database, + readonly, + statement, + values, + transaction, + returnMode, + }: { + database: string; + readonly: boolean; + statement: string; + values?: any[]; + transaction: boolean; + returnMode?: string; + }): Promise { + const conn = connection(database, readonly); + const result = conn.withOptionalTransaction(transaction, () => + conn.run(statement, values, wantsRows(statement, returnMode)), + ); + if (returnMode === 'one' && result.values) result.values = result.values.slice(0, 1); + return result; + }, + + async executeSet({ + database, + readonly, + set, + transaction, + returnMode, + }: { + database: string; + readonly: boolean; + set: { statement?: string; values?: any[] }[]; + transaction: boolean; + returnMode?: string; + }): Promise { + const conn = connection(database, readonly); + return conn.withOptionalTransaction(transaction, () => { + const collected: any[] = []; + const out: ExecResult = { changes: 0, lastId: -1, values: collected }; + set.forEach((entry, index) => { + const statement = entry?.statement; + if (!statement) throw new Error(`ExecuteSet: no statement for index ${index}`); + const rows = entry.values ?? []; + // A set entry whose values are themselves arrays runs the statement once per row. + const batches: any[][] = Array.isArray(rows[0]) ? (rows as any[][]) : [rows]; + for (const batch of batches) { + const step = conn.run(statement, batch, wantsRows(statement, returnMode)); + out.changes += step.changes; + out.lastId = step.lastId; + // Flat array of row objects, matching the native implementations. + if (step.values?.length) collected.push(...step.values); + } + }); + if (returnMode === 'one') out.values = collected.slice(0, 1); + return out; + }); + }, + + async query({ + database, + readonly, + statement, + values, + }: { + database: string; + readonly: boolean; + statement: string; + values?: any[]; + }) { + return { values: connection(database, readonly).query(statement, values) }; + }, + + async beginTransaction({ database, readonly }: { database: string; readonly: boolean }) { + connection(database, readonly).beginTransaction(); + return { changes: 0, lastId: -1 }; + }, + async commitTransaction({ database, readonly }: { database: string; readonly: boolean }) { + const conn = connection(database, readonly); + conn.commitTransaction(); + await flushIfTier2(conn); + return { changes: 0, lastId: -1 }; + }, + async rollbackTransaction({ database, readonly }: { database: string; readonly: boolean }) { + connection(database, readonly).rollbackTransaction(); + return { changes: 0, lastId: -1 }; + }, + async isTransactionActive({ database, readonly }: { database: string; readonly: boolean }) { + return { result: connection(database, readonly).isTransactionActive }; + }, + + async getVersion({ database, readonly }: { database: string; readonly: boolean }) { + return { version: connection(database, readonly).userVersion() }; + }, + + async isDBOpen({ database, readonly }: { database: string; readonly: boolean }) { + const conn = connections.get(connKey(database, readonly)); + return { result: !!conn && conn.isOpen }; + }, + + async isDatabase({ database }: { database: string }) { + requireInit(); + const storage = storageName(database); + if (tier === 1) return { result: (poolUtil.getFileNames() as string[]).includes(poolPath(storage)) }; + // On tier 1 a database exists from the moment it is opened. Tier 2 only writes its image at + // a flush point, so an open connection counts as existing too, otherwise a freshly created + // database would report false until it was closed. + if (connectionsFor(database).length > 0) return { result: true }; + return { result: await images.has(storage) }; + }, + + async isTableExists({ database, readonly, table }: { database: string; readonly: boolean; table: string }) { + return { result: connection(database, readonly).tableExists(table) }; + }, + + async getTableList({ database, readonly }: { database: string; readonly: boolean }) { + return { values: connection(database, readonly).tableList() }; + }, + + async getDatabaseList() { + requireInit(); + return { values: await storedNames() }; + }, + + async deleteDatabase({ database }: { database: string }) { + requireInit(); + const storage = storageName(database); + for (const conn of connectionsFor(database)) { + conn.close(); + connections.delete(connKey(database, conn.isReadonly)); + } + if (tier === 1) poolUtil.unlink(poolPath(storage)); + else await images.delete(storage); + return {}; + }, + + async saveToStore({ database }: { database: string }) { + requireInit(); + if (tier === 1) return { flushed: false }; // always durable + const conn = connections.get(connKey(database, false)); + if (!conn) throw new Error(`Database ${database} is not open`); + await flushIfTier2(conn); + return { flushed: true }; + }, + + async exportDb({ database }: { database: string }) { + requireInit(); + const storage = storageName(database); + const conn = connections.get(connKey(database, false)) ?? connections.get(connKey(database, true)); + if (conn) return { bytes: conn.serialize() }; + if (tier === 1) return { bytes: poolUtil.exportFile(poolPath(storage)) }; + const image = await images.get(storage); + if (!image) throw new Error(`Database ${database} does not exist`); + return { bytes: image }; + }, + + /** + * pauseVfs throws SQLITE_MISUSE while any database is open, so the close-first sequence is + * part of the op rather than something the caller has to remember. Wiring these to Capacitor + * appStateChange is M4; the ops exist now so the worker side is done. + */ + async pause() { + requireInit(); + if (tier !== 1) return { paused: false, reopen: [] }; + const reopen: { database: string; readonly: boolean }[] = []; + for (const [key, conn] of connections) { + await flushIfTier2(conn); + conn.close(); + reopen.push({ database: key.substring(3), readonly: conn.isReadonly }); + } + connections.clear(); + poolUtil.pauseVfs(); + return { paused: poolUtil.isPaused(), reopen }; + }, + + async unpause() { + requireInit(); + if (tier !== 1) return { paused: false }; + await poolUtil.unpauseVfs(); + return { paused: poolUtil.isPaused() }; + }, + + async isPaused() { + requireInit(); + return { paused: tier === 1 ? poolUtil.isPaused() : false }; + }, +}; + +self.onmessage = async (event: MessageEvent) => { + const { id, op, args } = (event.data ?? {}) as WorkerRequest; + try { + const fn = ops[op]; + if (!fn) throw new Error(`Unknown worker op: ${op}`); + self.postMessage({ id, ok: true, result: await fn(args ?? {}) }); + } catch (err) { + self.postMessage({ id, ok: false, error: toErrorPayload(err) }); + } +}; + +self.postMessage({ id: BOOT_ID, ok: true, result: { booted: true } }); diff --git a/tsconfig.json b/tsconfig.json index dee5deb8..b2750cb8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "strict": true, "target": "es2020" }, - "files": ["src/index.ts"] + "files": ["src/index.ts", "src/web/worker/worker.ts"] } From a3c9f8c99cd1e2a2016e63fd235d2360abd950df Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 08:35:57 +0800 Subject: [PATCH 03/27] test(web): cover the core method set on both durability tiers Vitest browser mode against real Chromium, because OPFS sync access handles and dedicated workers do not exist in jsdom. Every contract test is parameterised over tier 1 and tier 2, tier 2 being forced through the documented configuration hook, and they drive the public SQLiteConnection and SQLiteDBConnection wrappers unmodified so that those are part of the surface under test rather than assumed correct. Coverage: the core method set, transactions including rollback, the upgrade version ladder and its failure path, BLOB round-trips, int64 as BigInt, FTS5 create/match/snippet, readonly enforcement at the engine rather than only at the wrapper, connection consistency reconciliation, tier classification including the busy-pool case that must not downgrade, the statement splitter, and a run of the shipped dist/web-worker.js rather than the TypeScript sources. browser.fileParallelism stays on. opfs-sahpool is single-owner per origin, which looks like it would collide across parallel files, but each file runs in its own browser context with its own storage partition. The consequence is the opposite of the obvious one: multi-context scenarios have to be built inside a single file with several workers. eslint and prettier are scoped to the real source roots. Both were globbing the whole working tree, so any scratch directory in a checkout failed npm run lint for reasons unrelated to the plugin. Coverage on a clean checkout is unchanged. --- package.json | 8 +- test/web/core.test.ts | 211 +++++++++++++++++++++++++++++++++++ test/web/dist-worker.test.ts | 72 ++++++++++++ test/web/fts5.test.ts | 72 ++++++++++++ test/web/harness.ts | 68 +++++++++++ test/web/readonly.test.ts | 104 +++++++++++++++++ test/web/statements.test.ts | 96 ++++++++++++++++ test/web/tiers.test.ts | 127 +++++++++++++++++++++ test/web/types.test.ts | 128 +++++++++++++++++++++ test/web/upgrades.test.ts | 131 ++++++++++++++++++++++ vitest.config.mts | 31 +++++ 11 files changed, 1044 insertions(+), 4 deletions(-) create mode 100644 test/web/core.test.ts create mode 100644 test/web/dist-worker.test.ts create mode 100644 test/web/fts5.test.ts create mode 100644 test/web/harness.ts create mode 100644 test/web/readonly.test.ts create mode 100644 test/web/statements.test.ts create mode 100644 test/web/tiers.test.ts create mode 100644 test/web/types.test.ts create mode 100644 test/web/upgrades.test.ts create mode 100644 vitest.config.mts diff --git a/package.json b/package.json index 72cdd848..6f7a1291 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,12 @@ "verify": "npm run verify:ios && npm run verify:android && npm run verify:web && npm run verify:electron", "verify:ios": "cd ios && pod install && xcodebuild -workspace Plugin.xcworkspace -scheme Plugin OTHER_CFLAGS='-DHAVE_GETHOSTUUID=0' && cd ..", "verify:android": "cd android && ./gradlew clean build test && cd ..", - "verify:web": "npm run build", + "verify:web": "npm run build && npm run test", "verify:electron": "npm run build-electron", "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint", "fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format", - "eslint": "eslint . --ext ts", - "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java", + "eslint": "eslint src test electron/src --ext ts", + "prettier": "prettier \"{src,test,electron/src,android/src,ios}/**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java", "swiftlint": "node-swiftlint", "docgen": "npm run docgenPlugin && npm run docgenConnection && npm run docgenDBConnection", "docgenPlugin": "docgen --api CapacitorSQLitePlugin --output-readme docs/API.md", @@ -56,7 +56,7 @@ "build-electron": "tsc --project electron/tsconfig.json && rollup -c electron/rollup.config.mjs && rimraf ./electron/build", "clean": "rimraf ./dist", "watch": "tsc --watch", - "test": "echo \"No test specified\"", + "test": "vitest run", "prepublishOnly": "npm run build && npm run build-electron && npm run docgen", "release": "commit-and-tag-version", "ios:spm:install": "swift package resolve" diff --git a/test/web/core.test.ts b/test/web/core.test.ts new file mode 100644 index 00000000..3f8941e5 --- /dev/null +++ b/test/web/core.test.ts @@ -0,0 +1,211 @@ +/** + * The M1 exit criteria, run against both tiers through the public wrappers: + * open / query / run / executeSet / transactions / upgrade statements / deleteDatabase / + * getDatabaseList, with rows as plain objects and BLOBs as Uint8Array. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: create, open, execute, query`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('core', false, 'no-encryption', 1, false); + await db.open(); + + const created = await db.execute(` + CREATE TABLE IF NOT EXISTS people (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, age INTEGER); + CREATE INDEX IF NOT EXISTS people_name ON people (name); + `); + expect(created.changes?.changes).toBeGreaterThanOrEqual(0); + + const inserted = await db.execute( + `INSERT INTO people (name, age) VALUES ('Alice', 41), ('Bob', 29), ('Carol', 35);`, + ); + expect(inserted.changes?.changes).toBe(3); + + const rows = await db.query('SELECT id, name, age FROM people ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'Alice', age: 41 }, + { id: 2, name: 'Bob', age: 29 }, + { id: 3, name: 'Carol', age: 35 }, + ]); + + const filtered = await db.query('SELECT name FROM people WHERE age > ?', [30]); + expect(filtered.values?.map((r: any) => r.name)).toEqual(['Alice', 'Carol']); + + await sqlite.closeConnection('core', false); + }); + + test(`${label}: run reports changes and lastId together`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('runs', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + const first = await db.run('INSERT INTO t (v) VALUES (?)', ['one']); + expect(first.changes?.changes).toBe(1); + expect(first.changes?.lastId).toBe(1); + + const second = await db.run('INSERT INTO t (v) VALUES (?)', ['two']); + expect(second.changes?.lastId).toBe(2); + + const updated = await db.run("UPDATE t SET v = 'x' WHERE id <= ?", [2]); + expect(updated.changes?.changes).toBe(2); + + const deleted = await db.run('DELETE FROM t WHERE id = ?', [1]); + expect(deleted.changes?.changes).toBe(1); + + await sqlite.closeConnection('runs', false); + }); + + test(`${label}: run with returnMode all returns the RETURNING rows`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('returning', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + const res = await db.run('INSERT INTO t (v) VALUES (?) RETURNING id, v', ['hello'], true, 'all'); + expect(res.changes?.changes).toBe(1); + expect(res.changes?.values).toEqual([{ id: 1, v: 'hello' }]); + + const none = await db.run('INSERT INTO t (v) VALUES (?)', ['quiet']); + expect(none.changes?.values).toBeUndefined(); + + await sqlite.closeConnection('returning', false); + }); + + test(`${label}: executeSet, including one statement per row`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('sets', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, a TEXT, b INTEGER);'); + + const res = await db.executeSet([ + { statement: 'INSERT INTO t (a, b) VALUES (?, ?)', values: ['first', 1] }, + { + statement: 'INSERT INTO t (a, b) VALUES (?, ?)', + values: [ + ['second', 2], + ['third', 3], + ], + }, + ]); + expect(res.changes?.changes).toBe(3); + expect(res.changes?.lastId).toBe(3); + + const rows = await db.query('SELECT a, b FROM t ORDER BY id'); + expect(rows.values).toEqual([ + { a: 'first', b: 1 }, + { a: 'second', b: 2 }, + { a: 'third', b: 3 }, + ]); + + await sqlite.closeConnection('sets', false); + }); + + test(`${label}: transactions commit and roll back`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('tx', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + expect((await db.isTransactionActive()).result).toBe(false); + await db.beginTransaction(); + expect((await db.isTransactionActive()).result).toBe(true); + await db.run("INSERT INTO t (v) VALUES ('kept')", [], false); + await db.commitTransaction(); + expect((await db.isTransactionActive()).result).toBe(false); + + await db.beginTransaction(); + await db.run("INSERT INTO t (v) VALUES ('dropped')", [], false); + await db.rollbackTransaction(); + + const rows = await db.query('SELECT v FROM t'); + expect(rows.values?.map((r: any) => r.v)).toEqual(['kept']); + + await sqlite.closeConnection('tx', false); + }); + + test(`${label}: a failing statement inside execute rolls the whole batch back`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('atomic', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT UNIQUE);'); + + await expect(db.execute(`INSERT INTO t (v) VALUES ('a'); INSERT INTO t (v) VALUES ('a');`)).rejects.toBeTruthy(); + + const rows = await db.query('SELECT count(*) AS n FROM t'); + expect(rows.values?.[0].n).toBe(0); + + await sqlite.closeConnection('atomic', false); + }); + + test(`${label}: executeTransaction from the wrapper`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('wrappertx', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + + await db.executeTransaction([ + { statement: "INSERT INTO t (v) VALUES ('a')" }, + { statement: 'INSERT INTO t (v) VALUES (?)', values: ['b'] }, + ]); + + const rows = await db.query('SELECT v FROM t ORDER BY id'); + expect(rows.values?.map((r: any) => r.v)).toEqual(['a', 'b']); + + await sqlite.closeConnection('wrappertx', false); + }); + + test(`${label}: existence, table list and delete`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('存在', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE alpha (id INTEGER PRIMARY KEY NOT NULL); CREATE TABLE beta (id INTEGER);'); + + expect((await db.isExists()).result).toBe(true); + expect((await db.isDBOpen()).result).toBe(true); + expect((await db.isTable('alpha')).result).toBe(true); + expect((await db.isTable('missing')).result).toBe(false); + expect((await db.getTableList()).values).toEqual(['alpha', 'beta']); + + const list = await sqlite.getDatabaseList(); + expect(list.values).toContain('存在SQLite.db'); + + await db.delete(); + const after = await sqlite.getDatabaseList(); + expect(after.values).not.toContain('存在SQLite.db'); + + await sqlite.closeConnection('存在', false); + }); + + test(`${label}: data survives close and reopen`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('persist', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('durable');"); + await sqlite.closeConnection('persist', false); + + const again = await sqlite.createConnection('persist', false, 'no-encryption', 1, false); + await again.open(); + const rows = await again.query('SELECT v FROM t'); + expect(rows.values?.[0].v).toBe('durable'); + await sqlite.closeConnection('persist', false); + }); + + test(`${label}: getVersion reports user_version`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('versioned', false, 'no-encryption', 3, false); + await db.open(); + expect((await db.getVersion()).version).toBe(3); + await sqlite.closeConnection('versioned', false); + }); + + test(`${label}: echo`, async () => { + const { sqlite } = await startHarness(tier); + expect((await sqlite.echo('hi')).value).toBe('hi'); + }); +}); diff --git a/test/web/dist-worker.test.ts b/test/web/dist-worker.test.ts new file mode 100644 index 00000000..8a2d3948 --- /dev/null +++ b/test/web/dist-worker.test.ts @@ -0,0 +1,72 @@ +/** + * The other suites run the worker from TypeScript source through setSqliteWorkerFactory. This + * one runs the artefact that actually ships: `dist/web-worker.js`, a classic (non-module) + * worker that has to find `dist/sqlite3.wasm` sitting next to it with no bundler involved. + * + * Requires `npm run build` first. It fails rather than skipping if the build is missing, because + * a silently skipped packaging test is worse than no packaging test. + */ +import { afterEach, beforeAll, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +const WORKER_URL = '/dist/web-worker.js'; +const WASM_URL = '/dist/sqlite3.wasm'; + +let plugin: CapacitorSQLiteWeb | null = null; + +afterEach(async () => { + await plugin?.closeWebStore(); + plugin = null; +}); + +beforeAll(async () => { + const [worker, wasm] = await Promise.all([fetch(WORKER_URL), fetch(WASM_URL)]); + if (!worker.ok || !wasm.ok) { + throw new Error(`${WORKER_URL} / ${WASM_URL} are not built. Run "npm run build" before the web tests.`); + } + setSqliteWorkerFactory(() => new Worker(WORKER_URL)); +}); + +test('the shipped classic worker boots and resolves sqlite3.wasm beside itself', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'dist-check', + directory: '.dist-check', + }); + plugin = new CapacitorSQLiteWeb(); + const sqlite = new SQLiteConnection(plugin); + await sqlite.initWebStore(); + expect(plugin.getWebStoreTier()).toBe(1); + + const db = await sqlite.createConnection('shipped', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT); INSERT INTO t (v) VALUES ('built');"); + const rows = await db.query('SELECT v FROM t'); + expect(rows.values).toEqual([{ v: 'built' }]); + + // BLOBs and BigInt cross the real worker boundary too, not just the source one. + const payload = new Uint8Array([1, 2, 3, 250]); + await db.execute('CREATE TABLE b (p BLOB, n INTEGER);'); + await db.run('INSERT INTO b (p, n) VALUES (?, ?)', [payload, 9007199254740993n]); + const back = await db.query('SELECT p, n FROM b'); + expect(Array.from(back.values?.[0].p as Uint8Array)).toEqual([1, 2, 3, 250]); + expect(back.values?.[0].n).toBe(9007199254740993n); + + await sqlite.closeConnection('shipped', false); +}); + +test('the wasm is a separate asset, not inlined into the worker', async () => { + const [worker, wasm] = await Promise.all([fetch(WORKER_URL), fetch(WASM_URL)]); + const workerSource = await worker.text(); + const wasmBytes = new Uint8Array(await wasm.arrayBuffer()); + + expect(Array.from(wasmBytes.subarray(0, 4))).toEqual([0x00, 0x61, 0x73, 0x6d]); + expect(workerSource).not.toContain('data:application/octet-stream;base64'); + // Classic workers have no import.meta and no document; both would be fatal in here. + expect(workerSource).toContain('self.location.href'); + expect(workerSource).not.toContain('document.currentScript'); +}); diff --git a/test/web/fts5.test.ts b/test/web/fts5.test.ts new file mode 100644 index 00000000..98121274 --- /dev/null +++ b/test/web/fts5.test.ts @@ -0,0 +1,72 @@ +/** + * FTS5 on both tiers. Dropping sql.js is only defensible because sqlite-wasm's canonical build + * has FTS5 and sql.js does not, so this is a load-bearing assertion rather than a nicety. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: create virtual table, MATCH, snippet, bm25, prefix`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('search', false, 'no-encryption', 1, false); + await db.open(); + + await db.execute("CREATE VIRTUAL TABLE docs USING fts5(title, body, tokenize='unicode61');"); + await db.executeSet([ + { + statement: 'INSERT INTO docs (title, body) VALUES (?, ?)', + values: [ + ['Genesis', 'In the beginning God created the heaven and the earth'], + ['Exodus', 'And the LORD said unto Moses, Go unto the people'], + ['Psalms', 'The LORD is my shepherd; I shall not want'], + ], + }, + ]); + + const hits = await db.query( + "SELECT title, snippet(docs, 1, '[', ']', '...', 8) AS snip, rank FROM docs WHERE docs MATCH ? ORDER BY rank", + ['lord'], + ); + expect(hits.values?.map((r: any) => r.title).sort()).toEqual(['Exodus', 'Psalms']); + expect(hits.values?.[0].snip).toContain('['); + expect(typeof hits.values?.[0].rank).toBe('number'); + + const scored = await db.query('SELECT title, bm25(docs) AS score FROM docs WHERE docs MATCH ? ORDER BY score', [ + 'created OR shepherd', + ]); + expect(scored.values?.map((r: any) => r.title).sort()).toEqual(['Genesis', 'Psalms']); + + const prefix = await db.query('SELECT count(*) AS n FROM docs WHERE docs MATCH ?', ['shep*']); + expect(prefix.values?.[0].n).toBe(1); + + await sqlite.closeConnection('search', false); + }); + + test(`${label}: an FTS5 index survives close and reopen`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('searchpersist', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE VIRTUAL TABLE d USING fts5(body); INSERT INTO d (body) VALUES ('persisted text');"); + await sqlite.closeConnection('searchpersist', false); + + const again = await sqlite.createConnection('searchpersist', false, 'no-encryption', 1, false); + await again.open(); + const hits = await again.query('SELECT body FROM d WHERE d MATCH ?', ['persisted']); + expect(hits.values).toEqual([{ body: 'persisted text' }]); + await sqlite.closeConnection('searchpersist', false); + }); + + test(`${label}: JSON1 is available, which the M2 import/export port relies on`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('jsonfns', false, 'no-encryption', 1, false); + await db.open(); + const res = await db.query( + `SELECT json_extract('{"a":{"b":42}}', '$.a.b') AS v, json_array_length('[1,2,3]') AS n`, + ); + expect(res.values?.[0]).toEqual({ v: 42, n: 3 }); + await sqlite.closeConnection('jsonfns', false); + }); +}); diff --git a/test/web/harness.ts b/test/web/harness.ts new file mode 100644 index 00000000..baf361ba --- /dev/null +++ b/test/web/harness.ts @@ -0,0 +1,68 @@ +/** + * Shared harness for the web contract tests. + * + * Tests drive the real `SQLiteConnection` / `SQLiteDBConnection` wrappers from + * `src/definitions.ts` over the real facade, so the wrappers are part of the surface under test + * exactly as the M1 exit criteria require. + * + * The worker is supplied through `setSqliteWorkerFactory` rather than the shipped + * `dist/web-worker.js`, so the suite runs against TypeScript sources and exercises the + * documented escape hatch at the same time. + */ +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import type { Tier } from '../../src/web/protocol'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +export interface Harness { + plugin: CapacitorSQLiteWeb; + sqlite: SQLiteConnection; + tier: Tier; +} + +let poolCounter = 0; +let active: CapacitorSQLiteWeb | null = null; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +/** + * @param tier which durability tier to exercise. Tier 2 is forced through the documented test + * hook, which skips the VFS install rather than faking a failure inside sqlite-wasm. + * @param freshPool give this suite its own pool directory so tests in the same file do not see + * each other's databases. + */ +export async function startHarness(tier: Tier, freshPool = true): Promise { + // One worker at a time. Each holds a wasm heap and, on tier 1, the pool's access handles. + if (active) await active.closeWebStore(); + active = null; + poolCounter += 1; + setSqliteWebOptions({ + forceTier2: tier === 2, + poolName: freshPool ? `capacitor-sqlite-test-${poolCounter}` : 'capacitor-sqlite', + directory: freshPool ? `.capacitor-sqlite-test-${poolCounter}` : '.capacitor-sqlite', + }); + + const plugin = new CapacitorSQLiteWeb(); + const sqlite = new SQLiteConnection(plugin); + await sqlite.initWebStore(); + + active = plugin; + const selected = plugin.getWebStoreTier(); + if (selected !== tier) throw new Error(`harness expected tier ${tier} but got ${selected}`); + return { plugin, sqlite, tier }; +} + +/** Both tiers, so every contract test runs twice. */ +export const TIERS: Tier[] = [1, 2]; + +export function tierLabel(tier: Tier): string { + return tier === 1 ? 'tier 1 (opfs-sahpool)' : 'tier 2 (:memory: + IndexedDB)'; +} + +/** Release the worker held by the most recent startHarness call. */ +export async function stopHarness(): Promise { + if (active) await active.closeWebStore(); + active = null; +} diff --git a/test/web/readonly.test.ts b/test/web/readonly.test.ts new file mode 100644 index 00000000..19b76be4 --- /dev/null +++ b/test/web/readonly.test.ts @@ -0,0 +1,104 @@ +/** + * Read-only connections are real on web now. README.md documented them as unsupported under + * jeep-sqlite; tier 1 opens the file with sqlite's read-only flag and tier 2, which must open + * `:memory:` writable so it can deserialize, enforces it with PRAGMA query_only. + * + * The RO_/RW_ registry split has to keep working either way, because + * SQLiteConnection._connectionDict keys on exactly that. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function seed(sqlite: any, name: string) { + const rw = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await rw.open(); + await rw.execute("CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT); INSERT INTO t (v) VALUES ('seeded');"); + await sqlite.closeConnection(name, false); + } + + test(`${label}: a read-only connection can read`, async () => { + const { sqlite } = await startHarness(tier); + await seed(sqlite, 'ro'); + const ro = await sqlite.createConnection('ro', false, 'no-encryption', 1, true); + await ro.open(); + const rows = await ro.query('SELECT v FROM t'); + expect(rows.values?.[0].v).toBe('seeded'); + expect((await ro.isDBOpen()).result).toBe(true); + await sqlite.closeConnection('ro', true); + }); + + test(`${label}: the engine refuses a write on a read-only connection`, async () => { + const { sqlite, plugin } = await startHarness(tier); + await seed(sqlite, 'roguard'); + const ro = await sqlite.createConnection('roguard', false, 'no-encryption', 1, true); + await ro.open(); + + // Going through the wrapper is refused before it reaches the plugin. + await expect(ro.execute("INSERT INTO t (v) VALUES ('nope')")).rejects.toMatch(/read-only/i); + + // Going straight at the plugin has to be refused by sqlite itself. + await expect( + plugin.query({ database: 'roguard', statement: "INSERT INTO t (v) VALUES ('nope')", values: [], readonly: true }), + ).rejects.toThrow(/readonly|read-only|read only/i); + + const rows = await ro.query('SELECT count(*) AS n FROM t'); + expect(rows.values?.[0].n).toBe(1); + await sqlite.closeConnection('roguard', true); + }); + + test(`${label}: RO and RW connections to one database coexist in the registry`, async () => { + const { sqlite } = await startHarness(tier); + await seed(sqlite, 'both'); + + const rw = await sqlite.createConnection('both', false, 'no-encryption', 1, false); + await rw.open(); + const ro = await sqlite.createConnection('both', false, 'no-encryption', 1, true); + await ro.open(); + + expect((await sqlite.isConnection('both', false)).result).toBe(true); + expect((await sqlite.isConnection('both', true)).result).toBe(true); + expect((await sqlite.checkConnectionsConsistency()).result).toBe(true); + + await rw.run('INSERT INTO t (v) VALUES (?)', ['from rw']); + expect((await rw.query('SELECT count(*) AS n FROM t')).values?.[0].n).toBe(2); + + await sqlite.closeConnection('both', true); + await sqlite.closeConnection('both', false); + }); + + test(`${label}: opening a database that does not exist read-only fails`, async () => { + const { sqlite } = await startHarness(tier); + const ro = await sqlite.createConnection('ghost', false, 'no-encryption', 1, true); + await expect(ro.open()).rejects.toBeTruthy(); + await sqlite.closeConnection('ghost', true).catch(() => undefined); + }); + + test(`${label}: checkConnectionsConsistency closes what the caller no longer knows about`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const a = await sqlite.createConnection('cc-a', false, 'no-encryption', 1, false); + await a.open(); + const b = await sqlite.createConnection('cc-b', false, 'no-encryption', 1, false); + await b.open(); + + // The plugin holds two; the caller claims only one. + const res = await plugin.checkConnectionsConsistency({ dbNames: ['cc-a'], openModes: ['RW'] }); + expect(res.result).toBe(true); + expect((await plugin.isDBOpen({ database: 'cc-a', readonly: false })).result).toBe(true); + await expect(plugin.isDBOpen({ database: 'cc-b', readonly: false })).rejects.toThrow(/No available connection/); + + await sqlite.closeConnection('cc-a', false).catch(() => undefined); + }); + + test(`${label}: an empty claim set resets everything and reports false`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const a = await sqlite.createConnection('cc-reset', false, 'no-encryption', 1, false); + await a.open(); + const res = await plugin.checkConnectionsConsistency({ dbNames: [], openModes: [] }); + expect(res.result).toBe(false); + await expect(plugin.isDBOpen({ database: 'cc-reset', readonly: false })).rejects.toThrow(/No available connection/); + }); +}); diff --git a/test/web/statements.test.ts b/test/web/statements.test.ts new file mode 100644 index 00000000..b5b4fbac --- /dev/null +++ b/test/web/statements.test.ts @@ -0,0 +1,96 @@ +/** + * Unit tests for the statement scanner. Semicolons inside string literals, comments and trigger + * bodies must not split a statement, which is exactly what the string-replace approach in + * electron-utils could not guarantee. + */ +import { describe, expect, test } from 'vitest'; + +import { + hasReturningClause, + producesRows, + replaceUndefinedByNull, + splitStatements, + statementKind, + stripNoise, +} from '../../src/web/worker/statements'; + +describe('splitStatements', () => { + test('splits a plain batch and drops empty fragments', () => { + expect(splitStatements('CREATE TABLE t (a); INSERT INTO t VALUES (1); ;')).toEqual([ + 'CREATE TABLE t (a)', + 'INSERT INTO t VALUES (1)', + ]); + }); + + test('a semicolon inside a string literal is data, not a separator', () => { + expect(splitStatements("INSERT INTO t VALUES ('a;b'); SELECT 1")).toEqual([ + "INSERT INTO t VALUES ('a;b')", + 'SELECT 1', + ]); + }); + + test('doubled quotes inside a literal are escapes', () => { + expect(splitStatements("INSERT INTO t VALUES ('it''s; fine'); SELECT 2")).toEqual([ + "INSERT INTO t VALUES ('it''s; fine')", + 'SELECT 2', + ]); + }); + + test('quoted identifiers and bracket identifiers are opaque', () => { + expect(splitStatements('SELECT "a;b" FROM [c;d]; SELECT 3')).toEqual(['SELECT "a;b" FROM [c;d]', 'SELECT 3']); + }); + + test('comments cannot end a statement', () => { + expect(splitStatements('SELECT 1 -- trailing; comment\n; SELECT 2')).toEqual([ + 'SELECT 1 -- trailing; comment', + 'SELECT 2', + ]); + expect(splitStatements('SELECT 1 /* block ; comment */; SELECT 2')).toEqual([ + 'SELECT 1 /* block ; comment */', + 'SELECT 2', + ]); + }); + + test('a trigger body stays in one piece', () => { + const sql = `CREATE TRIGGER t_ins AFTER INSERT ON t BEGIN UPDATE t SET seen = 1; DELETE FROM u; END; SELECT 1;`; + const parts = splitStatements(sql); + expect(parts).toHaveLength(2); + expect(parts[0]).toContain('DELETE FROM u'); + expect(parts[0].endsWith('END')).toBe(true); + expect(parts[1]).toBe('SELECT 1'); + }); + + test('a comment-only batch produces nothing', () => { + expect(splitStatements('-- nothing here\n/* nor here */')).toEqual([]); + }); +}); + +describe('inspection helpers', () => { + test('statementKind reads the leading keyword', () => { + expect(statementKind(' insert into t values (1)')).toBe('INSERT'); + expect(statementKind('/* lead */ SELECT 1')).toBe('SELECT'); + expect(statementKind('')).toBe(''); + }); + + test('hasReturningClause ignores the word inside a literal', () => { + expect(hasReturningClause('INSERT INTO t VALUES (1) RETURNING id')).toBe(true); + expect(hasReturningClause("INSERT INTO t VALUES ('returning')")).toBe(false); + }); + + test('producesRows covers reads and RETURNING writes', () => { + expect(producesRows('SELECT 1')).toBe(true); + expect(producesRows('WITH c AS (SELECT 1) SELECT * FROM c')).toBe(true); + expect(producesRows('PRAGMA user_version')).toBe(true); + expect(producesRows('INSERT INTO t VALUES (1)')).toBe(false); + expect(producesRows('DELETE FROM t RETURNING id')).toBe(true); + }); + + test('stripNoise blanks literals and comments', () => { + expect(stripNoise("SELECT 'x' -- y\n, 1").includes('x')).toBe(false); + }); + + test('replaceUndefinedByNull only touches undefined', () => { + expect(replaceUndefinedByNull([1, undefined, null, 'a'])).toEqual([1, null, null, 'a']); + expect(replaceUndefinedByNull(undefined)).toEqual([]); + }); +}); diff --git a/test/web/tiers.test.ts b/test/web/tiers.test.ts new file mode 100644 index 00000000..330ce57a --- /dev/null +++ b/test/web/tiers.test.ts @@ -0,0 +1,127 @@ +/** + * Tier selection, which is the part of this implementation that can lose data if it guesses. + * + * A busy pool and an unsupported platform produce rejections that look alike; only the first + * must fail loudly. Falling back on a busy pool would open an empty `:memory:` database over the + * user's real data and then flush that empty image over the stored one. + * + * Everything here lives in one file on purpose: vitest browser mode gives each test FILE its own + * storage partition, so a second owning context has to be a second worker inside one file. + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { stopHarness } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const open: CapacitorSQLiteWeb[] = []; + +afterEach(async () => { + await Promise.all(open.splice(0).map((p) => p.closeWebStore())); + await stopHarness(); +}); + +function makePlugin(): CapacitorSQLiteWeb { + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + return plugin; +} + +describe('tier selection', () => { + test('a healthy environment selects tier 1 without cross-origin isolation', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'tiers-happy', + directory: '.tiers-happy', + }); + const plugin = makePlugin(); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(1); + expect(self.crossOriginIsolated).toBe(false); + }); + + test('a second owning context is refused, not downgraded to tier 2', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'tiers-shared', + directory: '.tiers-shared', + }); + const first = makePlugin(); + await new SQLiteConnection(first).initWebStore(); + expect(first.getWebStoreTier()).toBe(1); + + const second = makePlugin(); + await expect(new SQLiteConnection(second).initWebStore()).rejects.toThrow(/another tab or window/i); + // The critical assertion: it did NOT silently become a tier 2 store. + expect(second.getWebStoreTier()).toBeNull(); + }); + + test('releasing the store lets the next context take over', async () => { + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + poolName: 'tiers-handover', + directory: '.tiers-handover', + }); + const first = makePlugin(); + await new SQLiteConnection(first).initWebStore(); + await first.closeWebStore(); + + const second = makePlugin(); + await new SQLiteConnection(second).initWebStore(); + expect(second.getWebStoreTier()).toBe(1); + }); + + test('a busy-pool rejection that slips past the lock still fails loudly', async () => { + setSqliteWebOptions({ + forceTier2: false, + poolName: 'tiers-busy', + directory: '.tiers-busy', + simulateInstallError: 'NoModificationAllowedError', + }); + const plugin = makePlugin(); + await expect(new SQLiteConnection(plugin).initWebStore()).rejects.toThrow(/another tab or window/i); + expect(plugin.getWebStoreTier()).toBeNull(); + }); + + test('an unrecognised rejection fails loudly rather than guessing', async () => { + setSqliteWebOptions({ + forceTier2: false, + poolName: 'tiers-weird', + directory: '.tiers-weird', + simulateInstallError: 'SomethingNobodyPlannedFor', + }); + const plugin = makePlugin(); + await expect(new SQLiteConnection(plugin).initWebStore()).rejects.toThrow(/opfs-sahpool/i); + expect(plugin.getWebStoreTier()).toBeNull(); + }); + + test.each(['Missing required OPFS APIs.', 'The local OPFS API is too old for opfs-sahpool'])( + 'a genuine capability gap (%s) falls back to tier 2', + async (message) => { + setSqliteWebOptions({ + forceTier2: false, + poolName: `tiers-gap-${message.length}`, + directory: `.tiers-gap-${message.length}`, + simulateInstallError: message, + }); + const plugin = makePlugin(); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(2); + }, + ); + + test('methods refuse to run before initWebStore', async () => { + const plugin = new CapacitorSQLiteWeb(); + await expect(plugin.createConnection({ database: 'nope' })).rejects.toThrow(/initWebStore/); + await expect(plugin.getDatabaseList()).rejects.toThrow(/initWebStore/); + }); +}); diff --git a/test/web/types.test.ts b/test/web/types.test.ts new file mode 100644 index 00000000..36f4d048 --- /dev/null +++ b/test/web/types.test.ts @@ -0,0 +1,128 @@ +/** + * The result-shape contract from PLAN 6.3, which the rest of the plugin depends on: + * plain row objects, `Uint8Array` for BLOBs, and int64 as BigInt. + */ +import { describe, expect, test } from 'vitest'; + +import { CapacitorSQLiteWeb } from '../../src/web'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: BLOBs stay Uint8Array in and out, byte for byte`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('blobs', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE b (id INTEGER PRIMARY KEY NOT NULL, name TEXT, payload BLOB);'); + + for (const size of [0, 1, 15, 512, 65536]) { + const source = new Uint8Array(size); + crypto.getRandomValues(source); + await db.run('INSERT INTO b (name, payload) VALUES (?, ?)', [`s${size}`, source]); + + const rows = await db.query( + 'SELECT payload, length(payload) AS len, typeof(payload) AS t FROM b WHERE name = ?', + [`s${size}`], + ); + const row = rows.values?.[0]; + expect(row.t).toBe('blob'); + expect(row.len).toBe(size); + expect(row.payload).toBeInstanceOf(Uint8Array); + expect(Array.from(row.payload as Uint8Array)).toEqual(Array.from(source)); + } + + await sqlite.closeConnection('blobs', false); + }); + + test(`${label}: rows are plain objects and reorderRows leaves them alone`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('shapes', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (a INTEGER, b TEXT, c REAL, d BLOB); INSERT INTO t VALUES (1, 'x', 2.5, NULL);"); + + const rows = await db.query('SELECT a, b, c, d FROM t'); + const row = rows.values?.[0]; + // reorderRows only rewrites a result whose first row carries the iOS `ios_columns` key. + // Web must never produce that, so the helper has to be a no-op here. + expect(Object.getPrototypeOf(row)).toBe(Object.prototype); + expect(Object.keys(row)).toEqual(['a', 'b', 'c', 'd']); + expect(row).toEqual({ a: 1, b: 'x', c: 2.5, d: null }); + expect(rows.values?.some((r: any) => 'ios_columns' in r)).toBe(false); + + // And directly against the helper the wrapper runs every query through: given web results + // it has to hand back the identical object, not a rebuilt one. + const reorderRows = (db as any).reorderRows.bind(db); + const before = { values: [{ a: 1, b: 'x' }] }; + const after = await reorderRows(before); + expect(after).toBe(before); + expect(after.values).toEqual([{ a: 1, b: 'x' }]); + + // Contrast: an iOS-shaped result is the only thing it rewrites. + const iosShaped = { values: [{ ios_columns: ['b', 'a'] }, { a: 1, b: 'x' }] }; + const iosResult = await reorderRows(iosShaped); + expect(iosResult.values).toEqual([{ b: 'x', a: 1 }]); + + await sqlite.closeConnection('shapes', false); + }); + + test(`${label}: int64 above 2^53 round-trips as BigInt without losing precision`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('bigints', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v INTEGER);'); + + const big = 9007199254740993n; // 2^53 + 1, not representable as a JS number + await db.run('INSERT INTO t (v) VALUES (?)', [big]); + await db.run('INSERT INTO t (v) VALUES (?)', [42]); + + const rows = await db.query('SELECT v, typeof(v) AS t FROM t ORDER BY id'); + expect(rows.values?.[0].t).toBe('integer'); + expect(typeof rows.values?.[0].v).toBe('bigint'); + expect(rows.values?.[0].v).toBe(big); + // Small integers still come back as plain numbers, so ordinary code is unaffected. + expect(typeof rows.values?.[1].v).toBe('number'); + expect(rows.values?.[1].v).toBe(42); + + // Documented consequence: JSON.stringify cannot serialise this, which is why the M2 + // exportToJson port has to be BigInt-aware. + expect(() => JSON.stringify(rows.values)).toThrow(TypeError); + + await sqlite.closeConnection('bigints', false); + }); + + test(`${label}: undefined bind values become NULL rather than throwing`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('undef', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (a TEXT, b TEXT);'); + await db.run('INSERT INTO t (a, b) VALUES (?, ?)', ['set', undefined]); + const rows = await db.query('SELECT a, b FROM t'); + expect(rows.values?.[0]).toEqual({ a: 'set', b: null }); + await sqlite.closeConnection('undef', false); + }); + + test(`${label}: errors keep their message instead of being stringified`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('errors', false, 'no-encryption', 1, false); + await db.open(); + await expect(db.query('SELECT * FROM nope')).rejects.toThrow(/no such table: nope/); + // The jeep facade produced "Error: Error: ..." here by interpolating the Error itself. + await db.query('SELECT 1 AS ok').catch(() => undefined); + try { + await db.query('SELECT * FROM nope'); + throw new Error('expected a rejection'); + } catch (err) { + expect((err as Error).message.startsWith('Error:')).toBe(false); + } + await sqlite.closeConnection('errors', false); + }); +}); + +test('the facade is a WebPlugin and implements the plugin surface', async () => { + const plugin = new CapacitorSQLiteWeb(); + for (const method of ['initWebStore', 'createConnection', 'open', 'query', 'run', 'execute', 'executeSet']) { + expect(typeof (plugin as any)[method]).toBe('function'); + } +}); diff --git a/test/web/upgrades.test.ts b/test/web/upgrades.test.ts new file mode 100644 index 00000000..344098de --- /dev/null +++ b/test/web/upgrades.test.ts @@ -0,0 +1,131 @@ +/** + * addUpgradeStatement plus the version ladder that runs on open, ported from + * electron-utils/utilsUpgrade.ts. The interesting cases are the ones the electron port had to + * take a file backup for: a partially applied ladder must leave the database exactly as it was. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const V1 = ['CREATE TABLE notes (id INTEGER PRIMARY KEY NOT NULL, body TEXT NOT NULL);']; +const V2 = ['ALTER TABLE notes ADD COLUMN pinned INTEGER DEFAULT 0;']; +const V3 = [ + 'CREATE TABLE tags (id INTEGER PRIMARY KEY NOT NULL, label TEXT);', + 'CREATE INDEX tags_label ON tags(label);', +]; + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: the ladder runs in order and lands on the requested version`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('ladder', [ + { toVersion: 3, statements: V3 }, + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: V2 }, + ]); + const db = await sqlite.createConnection('ladder', false, 'no-encryption', 3, false); + await db.open(); + + expect((await db.getVersion()).version).toBe(3); + expect((await db.getTableList()).values).toEqual(['notes', 'tags']); + await db.run('INSERT INTO notes (body, pinned) VALUES (?, ?)', ['hello', 1]); + expect((await db.query('SELECT pinned FROM notes')).values?.[0].pinned).toBe(1); + + await sqlite.closeConnection('ladder', false); + }); + + test(`${label}: reopening at a higher version applies only the new steps`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('stepwise', [{ toVersion: 1, statements: V1 }]); + const first = await sqlite.createConnection('stepwise', false, 'no-encryption', 1, false); + await first.open(); + await first.run('INSERT INTO notes (body) VALUES (?)', ['survivor']); + expect((await first.getVersion()).version).toBe(1); + await sqlite.closeConnection('stepwise', false); + + await sqlite.addUpgradeStatement('stepwise', [ + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: V2 }, + ]); + const second = await sqlite.createConnection('stepwise', false, 'no-encryption', 2, false); + await second.open(); + expect((await second.getVersion()).version).toBe(2); + // The v1 statements must not have run again, and the existing row must still be there. + const rows = await second.query('SELECT body, pinned FROM notes'); + expect(rows.values).toEqual([{ body: 'survivor', pinned: 0 }]); + + await sqlite.closeConnection('stepwise', false); + }); + + test(`${label}: opening below the stored version applies nothing`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('nodowngrade', [ + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: V2 }, + ]); + const first = await sqlite.createConnection('nodowngrade', false, 'no-encryption', 2, false); + await first.open(); + await sqlite.closeConnection('nodowngrade', false); + + const second = await sqlite.createConnection('nodowngrade', false, 'no-encryption', 1, false); + await second.open(); + expect((await second.getVersion()).version).toBe(2); + await sqlite.closeConnection('nodowngrade', false); + }); + + test(`${label}: a failing upgrade restores the database and reports the failure`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('broken', [{ toVersion: 1, statements: V1 }]); + const first = await sqlite.createConnection('broken', false, 'no-encryption', 1, false); + await first.open(); + await first.run('INSERT INTO notes (body) VALUES (?)', ['precious']); + await sqlite.closeConnection('broken', false); + + await sqlite.addUpgradeStatement('broken', [ + { toVersion: 1, statements: V1 }, + { toVersion: 2, statements: ['ALTER TABLE notes ADD COLUMN pinned INTEGER;', 'THIS IS NOT SQL;'] }, + ]); + const second = await sqlite.createConnection('broken', false, 'no-encryption', 2, false); + await expect(second.open()).rejects.toThrow(/onUpgrade/i); + + // Reopen at the old version: the schema and the data must be exactly as they were. + await sqlite.closeConnection('broken', false); + await sqlite.addUpgradeStatement('broken', [{ toVersion: 1, statements: V1 }]); + const third = await sqlite.createConnection('broken', false, 'no-encryption', 1, false); + await third.open(); + expect((await third.getVersion()).version).toBe(1); + const rows = await third.query('SELECT * FROM notes'); + expect(rows.values).toEqual([{ id: 1, body: 'precious' }]); + await sqlite.closeConnection('broken', false); + }); + + test(`${label}: an upgrade entry with no statements is rejected`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('empty', [{ toVersion: 1, statements: [] }]); + const db = await sqlite.createConnection('empty', false, 'no-encryption', 1, false); + await expect(db.open()).rejects.toThrow(/statements not given/i); + await sqlite.closeConnection('empty', false); + }); + + test(`${label}: upgrade statements containing trigger bodies survive splitting`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.addUpgradeStatement('triggers', [ + { + toVersion: 1, + statements: [ + 'CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT, seen INTEGER DEFAULT 0);', + `CREATE TRIGGER t_ins AFTER INSERT ON t + BEGIN + UPDATE t SET seen = 1 WHERE id = new.id; + END;`, + ], + }, + ]); + const db = await sqlite.createConnection('triggers', false, 'no-encryption', 1, false); + await db.open(); + await db.run('INSERT INTO t (v) VALUES (?)', ['fires']); + expect((await db.query('SELECT seen FROM t')).values?.[0].seen).toBe(1); + await sqlite.closeConnection('triggers', false); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 00000000..db28de76 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,31 @@ +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +/** + * Real Chromium, not jsdom: OPFS sync access handles and dedicated workers do not exist there, + * and both are load-bearing for this implementation. + * + * browser.fileParallelism stays on. opfs-sahpool is single-owner per origin, which looks like it + * would collide across parallel files, but each file runs in its own Playwright browser context + * with its own storage partition. The consequence is the opposite of the obvious one: any + * multi-context scenario has to be built inside a single file with several workers. + */ +export default defineConfig({ + optimizeDeps: { + // sqlite-wasm resolves sqlite3.wasm with `new URL('sqlite3.wasm', import.meta.url)`. + // Pre-bundling moves the module and breaks that resolution. + exclude: ['@sqlite.org/sqlite-wasm'], + }, + test: { + include: ['test/web/**/*.test.ts'], + testTimeout: 60_000, + hookTimeout: 60_000, + browser: { + enabled: true, + provider: playwright(), + headless: true, + screenshotFailures: false, + instances: [{ browser: 'chromium' }], + }, + }, +}); From 267e77352bcc63f37d0970c50c5ca0852d144f5e Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 09:55:29 +0800 Subject: [PATCH 04/27] fix(web): namespace the tier-2 image store by pool name On tier 1 the pool name already isolates one store from another, so two stores configured with different pool names keep separate databases. Tier 2 wrote every image into a single origin-wide IndexedDB database, so the same two stores shared their data. Nobody would expect isolation to depend on which durability tier the browser happened to select. The IndexedDB name is now derived from the pool name. Nothing has to migrate: the tier 2 store has not been released. --- src/web/protocol.ts | 15 +++++++++++++-- src/web/worker/images.ts | 10 ++++++++-- src/web/worker/worker.ts | 4 +++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/web/protocol.ts b/src/web/protocol.ts index ad63f507..e30c9acf 100644 --- a/src/web/protocol.ts +++ b/src/web/protocol.ts @@ -68,10 +68,21 @@ export type WorkerResponse = /** Sent unsolicited by the worker as soon as its module body has run. */ export const BOOT_ID = -1; -/** The IndexedDB database and object store backing tier 2 images. */ -export const IMAGE_STORE_DB = 'capacitorSqliteStore'; +/** + * The IndexedDB database and object store backing tier 2 images. + * + * The database name is derived from the pool name so that the two tiers namespace identically. + * On tier 1 the pool name already isolates one store from another (M0 finding S11); without the + * same treatment here, two stores configured with different pool names would share their tier 2 + * databases while keeping their tier 1 ones separate, which is a difference nobody would expect + * to depend on which tier the browser happened to select. + */ export const IMAGE_STORE_NAME = 'databases'; +export function imageStoreDbName(poolName: string): string { + return `${poolName}-store`; +} + /** * Web Locks name held for the lifetime of the worker so only one context owns the pool. * Scoped by pool name: two stores configured with different pool names are genuinely diff --git a/src/web/worker/images.ts b/src/web/worker/images.ts index fe2ca15e..df2f1091 100644 --- a/src/web/worker/images.ts +++ b/src/web/worker/images.ts @@ -9,7 +9,7 @@ * * Raw IndexedDB on purpose: localforage is not a dependency and will not become one. */ -import { IMAGE_STORE_DB, IMAGE_STORE_NAME } from '../protocol'; +import { IMAGE_STORE_NAME, imageStoreDbName } from '../protocol'; function request(req: IDBRequest): Promise { return new Promise((resolve, reject) => { @@ -20,11 +20,17 @@ function request(req: IDBRequest): Promise { export class ImageStore { private dbPromise: Promise | null = null; + private name: string; + + constructor(poolName: string) { + this.name = imageStoreDbName(poolName); + } private open(): Promise { if (!this.dbPromise) { + const name = this.name; this.dbPromise = new Promise((resolve, reject) => { - const req = indexedDB.open(IMAGE_STORE_DB, 1); + const req = indexedDB.open(name, 1); req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(IMAGE_STORE_NAME)) { req.result.createObjectStore(IMAGE_STORE_NAME); diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index 09557450..d4468ca8 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -34,7 +34,8 @@ const initModule = sqlite3InitModule as unknown as (config?: Sqlite3InitConfig) let sqlite3: any = null; let poolUtil: any = null; let tier: 1 | 2 = 2; -const images = new ImageStore(); +/** Constructed at init, because its IndexedDB name is derived from the configured pool name. */ +let images = new ImageStore('capacitor-sqlite'); const connections = new Map(); function requireInit(): void { @@ -97,6 +98,7 @@ const ops: Record any> = { ...(args.wasmUrl ? { locateFile: () => args.wasmUrl as string } : {}), }); } + images = new ImageStore(args.poolName); const selection = await selectTier(sqlite3, args); tier = selection.tier; poolUtil = selection.poolUtil; From eede8109aafd3d00e6d0675a222ed54eaf284ee0 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 09:55:47 +0800 Subject: [PATCH 05/27] build(web): add fflate for zip asset support copyFromAssets accepts a .zip entry in databases.json as an archive of databases, so the web implementation needs an inflater. fflate over jszip: MIT rather than dual MIT-or-GPL, which is a complication an MIT plugin does not need; 21.6 KiB gzip against 27.7; and it has a streaming API should the zip path ever need one. --- package-lock.json | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index 72cf0a20..d0397f6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "fflate": "0.8.3", "jeep-sqlite": "^2.7.2" }, "devDependencies": { @@ -3751,6 +3752,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", diff --git a/package.json b/package.json index 6f7a1291..dbbd10c6 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ }, "dependencies": { "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "fflate": "0.8.3", "jeep-sqlite": "^2.7.2" } } From f94a13f78d5a58cb3a0d1631974ebaeb4ea4f07a Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 09:56:58 +0800 Subject: [PATCH 06/27] feat(web): port the JSON import and export pipeline isJsonValid, importFromJson and exportToJson, ported from electron/src/electron-utils/ImportExportJson with jeep-sqlite as the cross-check. The two turned out to be the same logic under the same names, which makes the row semantics below the real contract rather than one implementation's habits: - mode "full" drops and recreates; mode "partial" inserts rows whose key is absent and updates the rest. - In partial mode a row carrying sql_deleted = 1 deletes the local row, which is how a soft delete replicates from a server into a client database. - An UPDATE whose stored row already matches is skipped, so importing the same payload twice reports no changes rather than churning last_modified. - A value arriving as an array of numbers is a BLOB and becomes a Uint8Array. exportToJson is BigInt-aware, which is web-specific. sqlite-wasm returns int64 values above 2^53 as BigInt and JSON.stringify throws TypeError on those, so an export that ignored it would hand back an object the caller cannot serialise. Casting to Number would be worse: it silently corrupts exactly the values BigInt exists to protect. Values inside the safe-integer range stay numbers, anything outside becomes its decimal string, and SQLite's INTEGER affinity converts that back to the identical int64 on import. Also adds the worker event channel the facade forwards to notifyListeners, restoring sqliteImportProgressEvent and sqliteExportProgressEvent. Encrypted payloads are refused with an explicit message rather than half-imported; there is no SQLCipher in wasm. --- src/web.ts | 45 +++-- src/web/client.ts | 11 +- src/web/protocol.ts | 19 +++ src/web/worker/json/export.ts | 290 ++++++++++++++++++++++++++++++++ src/web/worker/json/import.ts | 162 ++++++++++++++++++ src/web/worker/json/schema.ts | 111 ++++++++++++ src/web/worker/json/validate.ts | 190 +++++++++++++++++++++ src/web/worker/worker.ts | 101 ++++++++++- 8 files changed, 909 insertions(+), 20 deletions(-) create mode 100644 src/web/worker/json/export.ts create mode 100644 src/web/worker/json/import.ts create mode 100644 src/web/worker/json/schema.ts create mode 100644 src/web/worker/json/validate.ts diff --git a/src/web.ts b/src/web.ts index 897e0da9..57e38609 100644 --- a/src/web.ts +++ b/src/web.ts @@ -60,6 +60,7 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug async initWebStore(): Promise { if (this.store) return; try { + this.client.onEvent = (event, data) => this.notifyListeners(event, data); await this.client.start(); this.store = await this.client.call('init', getSqliteWebOptions()); } catch (err) { @@ -347,32 +348,31 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug } //////////////////////////////////// - ////// NOT YET PORTED TO THE NEW ENGINE + ////// JSON PIPELINE //////////////////////////////////// - async getFromLocalDiskToStore(options: capSQLiteLocalDiskOptions): Promise { - console.log('getFromLocalDiskToStore', options); - throw this.unimplemented('Not implemented on web.'); - } - - async saveToLocalDisk(options: capSQLiteOptions): Promise { - console.log('saveToLocalDisk', options); - throw this.unimplemented('Not implemented on web.'); - } - async isJsonValid(options: capSQLiteImportOptions): Promise { - console.log('isJsonValid', options); - throw this.unimplemented('Not implemented on web.'); + const jsonstring = CapacitorSQLiteWeb.optionValue(options, 'jsonstring'); + return this.call('isJsonValid', { jsonstring }); } async importFromJson(options: capSQLiteImportOptions): Promise { - console.log('importFromJson', options); - throw this.unimplemented('Not implemented on web.'); + const jsonstring = CapacitorSQLiteWeb.optionValue(options, 'jsonstring'); + const result = await this.call('importFromJson', { jsonstring }); + return { changes: { changes: result.changes, lastId: result.lastId } }; } async exportToJson(options: capSQLiteExportOptions): Promise { - console.log('exportToJson', options); - throw this.unimplemented('Not implemented on web.'); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const jsonexportmode = CapacitorSQLiteWeb.optionValue(options, 'jsonexportmode'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'ExportToJson'); + return this.call( + 'exportToJson', + { database, readonly, jsonexportmode, encrypted: options.encrypted ?? false }, + database, + readonly, + ); } async createSyncTable(options: capSQLiteOptions): Promise { @@ -405,6 +405,17 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug throw this.unimplemented('Not implemented on web.'); } + async getFromLocalDiskToStore(options: capSQLiteLocalDiskOptions): Promise { + console.log('getFromLocalDiskToStore', options); + throw this.unimplemented('Not implemented on web.'); + } + + async saveToLocalDisk(options: capSQLiteOptions): Promise { + console.log('saveToLocalDisk', options); + throw this.unimplemented('Not implemented on web.'); + } + + //////////////////////////////////// ////// UNIMPLEMENTED METHODS //////////////////////////////////// diff --git a/src/web/client.ts b/src/web/client.ts index 3053a6b6..b68c62d2 100644 --- a/src/web/client.ts +++ b/src/web/client.ts @@ -11,8 +11,8 @@ * `throw new Error(\`${err}\`)` stringification the jeep facade used. */ import { fromErrorPayload } from './errors'; -import type { WorkerResponse } from './protocol'; -import { BOOT_ID } from './protocol'; +import type { WorkerEvent, WorkerResponse } from './protocol'; +import { BOOT_ID, EVENT_ID } from './protocol'; import { createSqliteWorker } from './worker-factory'; interface Pending { @@ -26,6 +26,8 @@ export class WorkerClient { private pending = new Map(); private queues = new Map>(); private booted: Promise | null = null; + /** Set by the facade so worker-raised events reach notifyListeners. */ + onEvent: ((event: string, data: any) => void) | null = null; get isStarted(): boolean { return this.worker !== null; @@ -44,6 +46,11 @@ export class WorkerClient { resolve(); return; } + if (message.id === EVENT_ID) { + const raised = message as unknown as WorkerEvent; + this.onEvent?.(raised.event, raised.data); + return; + } const entry = this.pending.get(message.id); if (!entry) return; this.pending.delete(message.id); diff --git a/src/web/protocol.ts b/src/web/protocol.ts index e30c9acf..d4f6c671 100644 --- a/src/web/protocol.ts +++ b/src/web/protocol.ts @@ -68,6 +68,25 @@ export type WorkerResponse = /** Sent unsolicited by the worker as soon as its module body has run. */ export const BOOT_ID = -1; +/** + * Sent unsolicited by the worker to raise a plugin event. The facade forwards these to + * `notifyListeners`, which is how the five documented web events reach the app. + */ +export const EVENT_ID = -2; + +export interface WorkerEvent { + id: typeof EVENT_ID; + event: string; + data: any; +} + +/** The five events the web implementation has always emitted (PLAN 2.4). */ +export const EV_IMPORT_PROGRESS = 'sqliteImportProgressEvent'; +export const EV_EXPORT_PROGRESS = 'sqliteExportProgressEvent'; +export const EV_HTTP_REQUEST_ENDED = 'sqliteHTTPRequestEndedEvent'; +export const EV_PICK_DATABASE_ENDED = 'sqlitePickDatabaseEndedEvent'; +export const EV_SAVE_TO_DISK = 'sqliteSaveDatabaseToDiskEvent'; + /** * The IndexedDB database and object store backing tier 2 images. * diff --git a/src/web/worker/json/export.ts b/src/web/worker/json/export.ts new file mode 100644 index 00000000..a8895c59 --- /dev/null +++ b/src/web/worker/json/export.ts @@ -0,0 +1,290 @@ +/** + * exportToJson, ported from `electron/src/electron-utils/ImportExportJson/exportToJson.ts` + * (MIT, this repo), cross-checked against jeep-sqlite (MIT). + * + * One behaviour is web-specific and deliberate (PLAN 6.3 / M0 finding S3): sqlite-wasm returns + * int64 values above 2^53 as `BigInt`, and `JSON.stringify` throws `TypeError` on those. Since + * the whole point of this method is to hand the caller something they will stringify, every + * value is passed through `jsonSafeValue` on the way out. Safe integers stay numbers; anything + * that a double cannot hold exactly becomes its decimal string, which SQLite's INTEGER affinity + * converts back to the identical int64 on import. Converting to Number instead would silently + * corrupt exactly the values BigInt exists to protect. + */ +import type { JsonColumn, JsonIndex, JsonSQLite, JsonTable, JsonTrigger, JsonView } from '../../../definitions'; +import type { Connection } from '../engine'; + +import { exportableTables, tableColumnNamesTypes } from './schema'; +import { checkIndexesValidity, checkSchemaValidity, checkTriggersValidity } from './validate'; + +export type ProgressFn = (message: string) => void; + +/** JSON cannot carry BigInt. Keep the value, change the carrier. */ +export function jsonSafeValue(value: any): any { + if (typeof value !== 'bigint') return value; + return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : value.toString(); +} + +/** + * Commas inside nested parentheses are not column separators. Replacing them with a sentinel + * before splitting, then restoring them, is the port source's approach and it is kept because + * the resulting schema strings have to match what the format already documents. + */ +function maskNestedCommas(input: string): string { + let depth = 0; + let out = ''; + for (const ch of input) { + if (ch === '(') depth++; + else if (ch === ')') depth--; + out += ch === ',' && depth > 0 ? '§' : ch; + } + return out; +} + +export function parseSchema(createSql: string): JsonColumn[] { + const open = createSql.indexOf('('); + const close = createSql.lastIndexOf(')'); + const body = maskNestedCommas(createSql.substring(open + 1, close)); + const schema: JsonColumn[] = []; + + for (const part of body.split(',')) { + const text = part.replace(/\n/g, ' ').trim(); + if (text.length === 0) continue; + const firstWord = text.substring(0, text.indexOf(' ') === -1 ? text.length : text.indexOf(' ')); + const rest = text.indexOf(' ') === -1 ? '' : text.substring(text.indexOf(' ') + 1); + const entry: JsonColumn = {} as JsonColumn; + let value = rest; + + switch (firstWord.toUpperCase()) { + case 'FOREIGN': { + const oPar = text.indexOf('('); + const cPar = text.indexOf(')'); + entry.foreignkey = text + .substring(oPar + 1, cPar) + .split('§') + .map((s) => s.trim()) + .join(','); + value = text.substring(cPar + 2); + break; + } + case 'PRIMARY': + case 'UNIQUE': { + const prefix = firstWord.toUpperCase() === 'PRIMARY' ? 'CPK_' : 'CUN_'; + const oPar = text.indexOf('('); + const cPar = text.indexOf(')'); + entry.constraint = + prefix + + text + .substring(oPar + 1, cPar) + .split('§') + .map((s) => s.trim()) + .join('_'); + value = text; + break; + } + case 'CONSTRAINT': { + const trimmed = rest.trim(); + entry.constraint = trimmed.substring(0, trimmed.indexOf(' ')); + value = trimmed.substring(trimmed.indexOf(' ') + 1); + break; + } + default: { + entry.column = firstWord; + break; + } + } + entry.value = value.replace(/§/g, ','); + schema.push(entry); + } + return schema; +} + +export function getIndexes(conn: Connection, table: string): JsonIndex[] { + const rows = conn.query( + "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql NOTNULL", + [table], + ); + return rows.map((row: any) => { + const sql: string = row.sql; + const index: JsonIndex = {} as JsonIndex; + index.name = row.name; + index.value = sql.slice(sql.lastIndexOf('(') + 1, sql.lastIndexOf(')')); + if (sql.includes('UNIQUE')) index.mode = 'UNIQUE'; + return index; + }); +} + +export function getTriggers(conn: Connection, table: string): JsonTrigger[] { + const rows = conn.query( + "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? AND sql NOT NULL", + [table], + ); + return rows.map((row: any) => { + const sql: string = row.sql; + const name: string = row.name; + const afterName = sql.split(name); + if (afterName.length < 2) throw new Error(`GetTriggers: sql split name does not return 2 values`); + if (!afterName[1].includes(table)) throw new Error(`GetTriggers: sql split does not contain ${table}`); + const timeevent = afterName[1].split(table, 1)[0].trim(); + const afterTable = afterName[1].split(`${timeevent} ${table}`); + if (afterTable.length < 2) throw new Error(`GetTriggers: sql split tableName does not return 2 values`); + + const trigger: JsonTrigger = {} as JsonTrigger; + trigger.name = name; + trigger.timeevent = timeevent; + const tail = afterTable[1].trim(); + if (tail.substring(0, 5).toUpperCase() !== 'BEGIN') { + const parts = tail.split('BEGIN'); + if (parts.length < 2) throw new Error(`GetTriggers: sql split BEGIN does not return 2 values`); + trigger.condition = parts[0].trim(); + trigger.logic = 'BEGIN' + parts.slice(1).join('BEGIN'); + } else { + trigger.logic = tail; + } + return trigger; + }); +} + +/** Rows as arrays in declared column order, which is the shape the JSON format uses. */ +export function getValues(conn: Connection, query: string, table: string, bind: any[] = []): any[][] { + const { names } = tableColumnNamesTypes(conn, table); + if (names.length === 0) throw new Error(`GetValues: Table ${table} no names`); + return conn + .query(query, bind) + .map((row: any) => names.map((name) => (Object.keys(row).includes(name) ? jsonSafeValue(row[name]) : 'NULL'))); +} + +export function getViews(conn: Connection): JsonView[] { + return conn + .query("SELECT name, sql FROM sqlite_master WHERE type = 'view' AND name NOT LIKE 'sqlite_%'") + .map((row: any) => ({ name: row.name, value: row.sql.substring(row.sql.indexOf('AS ') + 3) })); +} + +export function getSyncDate(conn: Connection): number { + const rows = conn.query('SELECT sync_date FROM sync_table WHERE id = ?', [1]); + if (rows.length === 0) throw new Error('GetSyncDate: no syncDate'); + return Number(rows[0][Object.keys(rows[0])[0]]); +} + +export function getLastExportDate(conn: Connection): number { + const rows = conn.query('SELECT sync_date FROM sync_table WHERE id = ?', [2]); + if (rows.length === 0) return -1; + return Number(rows[0][Object.keys(rows[0])[0]]); +} + +export function setLastExportDate(conn: Connection, isoDate: string): void { + if (!conn.tableExists('sync_table')) throw new Error('SetLastExportDate: No sync_table available'); + const seconds = Math.round(new Date(isoDate).getTime() / 1000); + const statement = + getLastExportDate(conn) > 0 + ? `UPDATE sync_table SET sync_date = ${seconds} WHERE id = 2;` + : `INSERT INTO sync_table (sync_date) VALUES (${seconds});`; + conn.executeBatch(statement); +} + +function buildTable( + conn: Connection, + name: string, + createSql: string, + withSchema: boolean, + query: string, + bind: any[], +): JsonTable { + const table: JsonTable = {} as JsonTable; + table.name = name; + + if (withSchema) { + const schema = parseSchema(createSql); + if (schema.length === 0) throw new Error(`GetTables: no Schema returned for ${name}`); + checkSchemaValidity(schema); + table.schema = schema; + + const indexes = getIndexes(conn, name); + if (indexes.length > 0) { + checkIndexesValidity(indexes); + table.indexes = indexes; + } + const triggers = getTriggers(conn, name); + if (triggers.length > 0) { + checkTriggersValidity(triggers); + table.triggers = triggers; + } + } + + const values = getValues(conn, query, name, bind); + if (values.length > 0) table.values = values; + return table; +} + +function getTablesFull(conn: Connection, tables: { name: string; sql: string }[], progress: ProgressFn): JsonTable[] { + const out: JsonTable[] = []; + for (const rTable of tables) { + if (!rTable.name) throw new Error('GetTablesFull: no name'); + if (!rTable.sql) throw new Error('GetTablesFull: no sql'); + out.push(buildTable(conn, rTable.name, rTable.sql, true, `SELECT * FROM ${rTable.name};`, [])); + progress(`Table ${rTable.name} exported`); + } + return out; +} + +/** + * Partial mode exports only what changed since the stored sync date. A table whose rows have + * ALL changed is treated as new and exported with its schema; a partially changed table ships + * rows only; an unchanged table is skipped. + */ +function getTablesPartial( + conn: Connection, + tables: { name: string; sql: string }[], + progress: ProgressFn, +): JsonTable[] { + const syncDate = getSyncDate(conn); + if (syncDate <= 0) throw new Error('GetPartialModeData: no syncDate'); + + const out: JsonTable[] = []; + for (const rTable of tables) { + const total = Number(conn.query(`SELECT count(*) AS n FROM ${rTable.name}`)[0].n); + const modified = Number( + conn.query(`SELECT count(*) AS n FROM ${rTable.name} WHERE last_modified > ?`, [syncDate])[0].n, + ); + if (modified === 0) continue; + const isCreate = total === modified; + const query = isCreate + ? `SELECT * FROM ${rTable.name};` + : `SELECT * FROM ${rTable.name} WHERE last_modified > ${syncDate};`; + out.push(buildTable(conn, rTable.name, rTable.sql, isCreate, query, [])); + progress(`Table ${rTable.name} exported`); + } + return out; +} + +export function exportJson(conn: Connection, database: string, mode: string, progress: ProgressFn): JsonSQLite { + const hasSyncTable = conn.tableExists('sync_table'); + if (hasSyncTable) { + setLastExportDate(conn, new Date().toISOString()); + } else if (mode === 'partial') { + throw new Error('ExportToJson: No sync_table available'); + } + + progress('Start creating the export object'); + const views = getViews(conn); + const resTables = exportableTables(conn); + if (resTables.length === 0) throw new Error("ExportToJson: table's names failed"); + + let tables: JsonTable[]; + if (mode === 'partial') tables = getTablesPartial(conn, resTables, progress); + else if (mode === 'full') tables = getTablesFull(conn, resTables, progress); + else throw new Error(`ExportToJson: expMode ${mode} not defined`); + + const out: JsonSQLite = {} as JsonSQLite; + if (tables.length > 0) { + out.database = database; + out.version = conn.userVersion(); + out.encrypted = false; + out.mode = mode; + out.tables = tables; + if (views.length > 0) out.views = views; + } + progress(`Export object created, ${tables.length} table(s)`); + return out; +} diff --git a/src/web/worker/json/import.ts b/src/web/worker/json/import.ts new file mode 100644 index 00000000..56860d8c --- /dev/null +++ b/src/web/worker/json/import.ts @@ -0,0 +1,162 @@ +/** + * importFromJson, ported from `electron/src/electron-utils/ImportExportJson/importFromJson.ts` + * and `utilsJson.ts` (MIT, this repo), cross-checked against jeep-sqlite (MIT). + * + * Row semantics carried over verbatim, because they are the format's contract: + * + * - `mode: 'full'` drops every table first and inserts; `mode: 'partial'` inserts rows whose + * first-column key is absent and updates the rest. + * - In partial mode a row whose `sql_deleted` column is 1 becomes a real DELETE, which is how a + * soft delete replicates from the server into a client database. + * - An UPDATE is skipped entirely when the stored row already matches, so importing the same + * payload twice reports no changes rather than churning `last_modified`. + * - A value that arrives as an array of numbers is a BLOB and is converted back to Uint8Array. + */ +import type { JsonSQLite, JsonTable } from '../../../definitions'; +import { EV_IMPORT_PROGRESS } from '../../protocol'; +import type { Connection } from '../engine'; + +import { createSchema, createViews, tableColumnNamesTypes } from './schema'; + +export type ProgressFn = (message: string) => void; + +/** Drop everything a `mode: 'full'` import is about to recreate. */ +function dropAll(conn: Connection): void { + const drops: string[] = []; + for (const type of ['table', 'index', 'trigger', 'view']) { + const extra = type === 'table' ? " AND name NOT IN ('sync_table')" : ''; + const rows = conn.query( + `SELECT name FROM sqlite_master WHERE type = '${type}' AND name NOT LIKE 'sqlite_%'${extra}`, + ); + for (const row of rows) drops.push(`DROP ${type.toUpperCase()} IF EXISTS ${row.name};`); + } + if (drops.length > 0) conn.executeBatch(drops.join('\n')); + conn.executeBatch('VACUUM;'); +} + +/** An array of plain numbers in a value row is a BLOB in transit. */ +export function reviveRowBlobs(row: any[]): any[] { + return row.map((value) => + Array.isArray(value) && value.every((item: any) => typeof item === 'number') ? Uint8Array.from(value) : value, + ); +} + +function quoteKey(value: any): string { + return typeof value === 'string' ? `'${String(value).replace(/'/g, "''")}'` : `${value}`; +} + +function keyExists(conn: Connection, table: string, keyColumn: string, key: any): boolean { + const rows = conn.query(`SELECT ${keyColumn} FROM ${table} WHERE ${keyColumn} = ?`, [key]); + return rows.length === 1; +} + +/** INSERT, UPDATE or DELETE for one value row, decided the way the port source decides it. */ +export function rowStatement( + conn: Connection, + columnNames: string[], + row: any[], + rowIndex: number, + table: string, + mode: string, +): string { + if (row.length !== columnNames.length || row.length === 0) { + throw new Error(`CreateRowStatement: Table ${table} values row ${rowIndex} not correct length`); + } + const exists = keyExists(conn, table, columnNames[0], row[0]); + + if (mode === 'full' || (mode === 'partial' && !exists)) { + const marks = columnNames.map(() => '?').join(','); + return `INSERT INTO ${table} (${columnNames.join(',')}) VALUES (${marks});`; + } + + const deletedIndex = columnNames.indexOf('sql_deleted'); + if (deletedIndex >= 0 && row[deletedIndex] === 1) { + return `DELETE FROM ${table} WHERE ${columnNames[0]} = ${quoteKey(row[0])};`; + } + const setClause = columnNames.map((name) => `${name} = ?`).join(' ,'); + return `UPDATE ${table} SET ${setClause} WHERE ${columnNames[0]} = ${quoteKey(row[0])};`; +} + +/** Read the stored row back in column order, so an UPDATE can be skipped when nothing changed. */ +function storedRow(conn: Connection, table: string, columnNames: string[], key: any): any[] { + const rows = conn.query(`SELECT * FROM ${table} WHERE ${columnNames[0]} = ?`, [key]); + if (rows.length === 0) return []; + return columnNames.map((name) => (Object.keys(rows[0]).includes(name) ? rows[0][name] : 'NULL')); +} + +function sameValues(a: any[], b: any[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const left = a[i]; + const right = b[i]; + if (left instanceof Uint8Array && right instanceof Uint8Array) { + if (left.length !== right.length) return false; + for (let j = 0; j < left.length; j++) if (left[j] !== right[j]) return false; + continue; + } + // Loose on purpose: an int64 read back as BigInt must compare equal to the number that + // produced it, which is exactly the case `==` handles and `===` does not. + // eslint-disable-next-line eqeqeq + if (left != right) return false; + } + return true; +} + +function createTableData(conn: Connection, table: JsonTable, mode: string, progress: ProgressFn): number { + if (!table.values || table.values.length === 0) return 0; + if (!conn.tableExists(table.name)) { + throw new Error(`CreateDataTable: ${table.name} does not exist`); + } + const { names } = tableColumnNamesTypes(conn, table.name); + if (names.length === 0) throw new Error(`CreateDataTable: ${table.name} info does not exist`); + + let changes = 0; + for (let i = 0; i < table.values.length; i++) { + const row = reviveRowBlobs(table.values[i]); + const statement = rowStatement(conn, names, row, i, table.name, mode); + + if (statement.startsWith('UPDATE')) { + const stored = storedRow(conn, table.name, names, row[0]); + if (stored.length > 0 && sameValues(row, stored)) continue; + } + const bind = statement.startsWith('DELETE') ? [] : row; + changes += conn.run(statement, bind, false).changes; + } + progress.call(null, `Table ${table.name} data imported`); + return changes; +} + +export function importJson(conn: Connection, jsonData: JsonSQLite, progress: ProgressFn): number { + let changes = 0; + conn.setForeignKeyConstraintsEnabled(false); + try { + if (jsonData.tables && jsonData.tables.length > 0) { + conn.setUserVersion(jsonData.version); + if (jsonData.mode === 'full') dropAll(conn); + + progress('Start creating the database schema'); + changes = createSchema(conn, jsonData); + progress(`Schema creation completed changes: ${changes}`); + + progress('Start importing the tables data'); + changes += conn.withOptionalTransaction(true, () => { + let dataChanges = 0; + for (const table of jsonData.tables) { + dataChanges += createTableData(conn, table, jsonData.mode, progress); + } + return dataChanges; + }); + progress(`Tables data import completed changes: ${changes}`); + } + if (jsonData.views && jsonData.views.length > 0) { + progress('Start creating the views'); + changes += createViews(conn, jsonData.views); + progress(`Views creation completed changes: ${changes}`); + } + } finally { + conn.setForeignKeyConstraintsEnabled(true); + } + return changes; +} + +export { EV_IMPORT_PROGRESS }; diff --git a/src/web/worker/json/schema.ts b/src/web/worker/json/schema.ts new file mode 100644 index 00000000..1e6be807 --- /dev/null +++ b/src/web/worker/json/schema.ts @@ -0,0 +1,111 @@ +/** + * Turning a JsonSQLite object back into DDL, ported from + * `electron/src/electron-utils/ImportExportJson/utilsJson.ts` (MIT, this repo) and + * cross-checked against jeep-sqlite (MIT). + * + * The one piece of behaviour worth knowing about: a table that declares BOTH `last_modified` + * and `sql_deleted` columns gets an automatic `_trigger_last_modified` trigger. That + * pair is what marks a table as participating in the sync protocol, and the same pair is what + * `statements.ts` looks for when deciding to rewrite a DELETE into a soft delete. + */ +import type { JsonSQLite, JsonTable, JsonView } from '../../../definitions'; +import type { Connection } from '../engine'; + +export function createSchemaStatements(jsonData: JsonSQLite): string[] { + const statements: string[] = []; + + for (const jTable of jsonData.tables ?? []) { + if (jTable.schema != null && jTable.schema.length >= 1) { + let hasLastModified = false; + let hasSqlDeleted = false; + const columns: string[] = []; + + for (const entry of jTable.schema) { + if (entry.column) { + columns.push(`${entry.column} ${entry.value}`); + if (entry.column === 'last_modified') hasLastModified = true; + if (entry.column === 'sql_deleted') hasSqlDeleted = true; + } else if (entry.foreignkey) { + columns.push(`FOREIGN KEY (${entry.foreignkey}) ${entry.value}`); + } else if (entry.constraint) { + columns.push(`CONSTRAINT ${entry.constraint} ${entry.value}`); + } + } + statements.push(`CREATE TABLE IF NOT EXISTS ${jTable.name} (${columns.join(',')});`); + + if (hasLastModified && hasSqlDeleted) { + statements.push( + `CREATE TRIGGER IF NOT EXISTS ${jTable.name}_trigger_last_modified ` + + `AFTER UPDATE ON ${jTable.name} ` + + `FOR EACH ROW WHEN NEW.last_modified < OLD.last_modified BEGIN ` + + `UPDATE ${jTable.name} SET last_modified = (strftime('%s','now')) WHERE id=OLD.id; END;`, + ); + } + } + + for (const jIndex of jTable.indexes ?? []) { + const mode = jIndex.mode ? `${jIndex.mode} ` : ''; + statements.push(`CREATE ${mode}INDEX IF NOT EXISTS ${jIndex.name} ON ${jTable.name} (${jIndex.value});`); + } + + for (const jTrigger of jTable.triggers ?? []) { + let timeevent = jTrigger.timeevent; + if (timeevent.toUpperCase().endsWith(' ON')) timeevent = timeevent.substring(0, timeevent.length - 3); + const condition = jTrigger.condition ? `${jTrigger.condition} ` : ''; + statements.push( + `CREATE TRIGGER IF NOT EXISTS ${jTrigger.name} ${timeevent} ON ${jTable.name} ${condition}${jTrigger.logic};`, + ); + } + } + + return statements; +} + +export function createSchema(conn: Connection, jsonData: JsonSQLite): number { + const statements = createSchemaStatements(jsonData); + if (statements.length === 0) return 0; + return conn.withOptionalTransaction(true, () => conn.executeBatch(statements.join('\n')).changes); +} + +export function createViews(conn: Connection, views: JsonView[]): number { + if (!views || views.length === 0) return 0; + return conn.withOptionalTransaction(true, () => { + let changes = 0; + for (const view of views) { + if (view.value == null) continue; + changes += conn.executeBatch(`CREATE VIEW IF NOT EXISTS ${view.name} AS ${view.value};`).changes; + } + return changes; + }); +} + +/** Column names and declared types for a table, as PRAGMA table_info reports them. */ +export function tableColumnNamesTypes(conn: Connection, table: string): { names: string[]; types: string[] } { + const rows = conn.query(`PRAGMA table_info(${table})`); + return { + names: rows.map((row: any) => row.name), + types: rows.map((row: any) => row.type), + }; +} + +export function tableExists(conn: Connection, table: string): boolean { + return conn.tableExists(table); +} + +export function tableNames(conn: Connection): string[] { + return conn + .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .map((row: any) => row.name); +} + +/** Tables eligible for export: user tables, excluding sync bookkeeping and temporaries. */ +export function exportableTables(conn: Connection): { name: string; sql: string }[] { + return conn.query( + "SELECT name, sql FROM sqlite_master WHERE type = 'table' " + + "AND name NOT LIKE 'sync_table' AND name NOT LIKE '_temp_%' AND name NOT LIKE 'sqlite_%'", + ) as { name: string; sql: string }[]; +} + +export function tableFromName(jsonData: JsonSQLite, name: string): JsonTable | undefined { + return (jsonData.tables ?? []).find((t) => t.name === name); +} diff --git a/src/web/worker/json/validate.ts b/src/web/worker/json/validate.ts new file mode 100644 index 00000000..bd653b41 --- /dev/null +++ b/src/web/worker/json/validate.ts @@ -0,0 +1,190 @@ +/** + * JSON shape validation, ported from + * `electron/src/electron-utils/ImportExportJson/utilsJson.ts` (MIT, this repo), cross-checked + * against jeep-sqlite (MIT), which carries the same logic under the same names. + * + * The rules are deliberately conservative and key-exact: an unknown key anywhere in the object + * makes the whole thing invalid. `isJsonValid` is a public plugin method, so loosening this + * would change a documented contract, not just an internal check. + * + * The format itself is specified in `docs/ImportExportJson.md`. + */ +import type { JsonColumn, JsonIndex, JsonSQLite, JsonTrigger, JsonView } from '../../../definitions'; + +const FIRST_LEVEL_KEYS = ['database', 'version', 'overwrite', 'encrypted', 'mode', 'tables', 'views']; +const TABLE_KEYS = ['name', 'schema', 'indexes', 'triggers', 'values']; +const SCHEMA_KEYS = ['column', 'value', 'foreignkey', 'primarykey', 'constraint']; +const INDEX_KEYS = ['name', 'value', 'mode']; +const TRIGGER_KEYS = ['name', 'timeevent', 'condition', 'logic']; +const VIEW_KEYS = ['name', 'value']; + +function isEmptyObject(obj: any): boolean { + return obj == null || (Object.keys(obj).length === 0 && obj.constructor === Object); +} + +/** Every key present must be known, and every known key present must have the right type. */ +function keysAreTyped(obj: any, allowed: string[], types: Record boolean>): boolean { + if (isEmptyObject(obj)) return false; + for (const key of Object.keys(obj)) { + if (allowed.indexOf(key) === -1) return false; + const check = types[key]; + if (check && !check(obj[key])) return false; + } + return true; +} + +const isString = (v: any) => typeof v === 'string'; +const isNumber = (v: any) => typeof v === 'number'; +const isBoolean = (v: any) => typeof v === 'boolean'; +const isObject = (v: any) => typeof v === 'object'; + +export function isSchema(obj: any): boolean { + return keysAreTyped(obj, SCHEMA_KEYS, { + column: isString, + value: isString, + foreignkey: isString, + primarykey: isString, + constraint: isString, + }); +} + +export function isIndex(obj: any): boolean { + return keysAreTyped(obj, INDEX_KEYS, { + name: isString, + value: isString, + // The only index modifier the format accepts. + mode: (v: any) => typeof v === 'string' && v.toUpperCase() === 'UNIQUE', + }); +} + +export function isTrigger(obj: any): boolean { + return keysAreTyped(obj, TRIGGER_KEYS, { + name: isString, + timeevent: isString, + condition: isString, + logic: isString, + }); +} + +export function isView(obj: any): boolean { + return keysAreTyped(obj, VIEW_KEYS, { name: isString, value: isString }); +} + +export function isTable(obj: any): boolean { + if ( + !keysAreTyped(obj, TABLE_KEYS, { + name: isString, + schema: isObject, + indexes: isObject, + triggers: isObject, + values: isObject, + }) + ) { + return false; + } + + // Column count comes from the schema and constrains the width of every value row. + let columnCount = 0; + if (obj.schema) { + for (const element of obj.schema) { + if (element?.column) columnCount++; + } + for (let i = 0; i < columnCount; i++) { + if (!isSchema(obj.schema[i])) return false; + } + } + if (obj.indexes) { + for (const index of obj.indexes) if (!isIndex(index)) return false; + } + if (obj.triggers) { + for (const trigger of obj.triggers) if (!isTrigger(trigger)) return false; + } + if (obj.values && columnCount > 0) { + for (const row of obj.values) { + if (typeof row !== 'object' || row.length !== columnCount) return false; + } + } + return true; +} + +export function isJsonSQLite(obj: any): boolean { + if ( + !keysAreTyped(obj, FIRST_LEVEL_KEYS, { + database: isString, + version: isNumber, + overwrite: isBoolean, + encrypted: isBoolean, + mode: isString, + tables: isObject, + views: isObject, + }) + ) { + return false; + } + if (obj.tables) { + for (const table of obj.tables) if (!isTable(table)) return false; + } + if (obj.views) { + for (const view of obj.views) if (!isView(view)) return false; + } + return true; +} + +/** + * The check* helpers rebuild each object from its known keys before validating, so a stray key + * is dropped rather than failing. That asymmetry with isJsonSQLite is intentional and matches + * the port source: these run on data the plugin itself produced during export. + */ +function pick(source: any, keys: string[]): T { + const out: any = {}; + for (const key of keys) { + if (Object.keys(source).includes(key)) out[key] = source[key]; + } + return out as T; +} + +export function checkSchemaValidity(schema: JsonColumn[]): void { + schema.forEach((entry, index) => { + if (!isSchema(pick(entry, ['column', 'value', 'foreignkey', 'constraint']))) { + throw new Error(`CheckSchemaValidity: schema[${index}] not valid`); + } + }); +} + +export function checkIndexesValidity(indexes: JsonIndex[]): void { + indexes.forEach((entry, index) => { + if (!isIndex(pick(entry, ['name', 'value', 'mode']))) { + throw new Error(`CheckIndexesValidity: indexes[${index}] not valid`); + } + }); +} + +export function checkTriggersValidity(triggers: JsonTrigger[]): void { + triggers.forEach((entry, index) => { + if (!isTrigger(pick(entry, ['name', 'timeevent', 'condition', 'logic']))) { + throw new Error(`CheckTriggersValidity: triggers[${index}] not valid`); + } + }); +} + +export function checkViewsValidity(views: JsonView[]): void { + views.forEach((entry, index) => { + if (!isView(pick(entry, ['name', 'value']))) { + throw new Error(`CheckViewsValidity: views[${index}] not valid`); + } + }); +} + +/** Parse and validate in one step, with the error text the plugin has always used. */ +export function parseJsonSQLite(jsonstring: string): JsonSQLite { + let parsed: any; + try { + parsed = JSON.parse(jsonstring); + } catch (err) { + throw new Error(`ImportFromJson: Stringify Json Object not Valid`); + } + if (!isJsonSQLite(parsed)) { + throw new Error(`ImportFromJson: Stringify Json Object not Valid`); + } + return parsed as JsonSQLite; +} diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index d4468ca8..ea93b20d 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -11,10 +11,13 @@ import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; import { toErrorPayload } from '../errors'; import type { ExecResult, OpenArgs, WorkerInitArgs, WorkerInitResult, WorkerRequest } from '../protocol'; -import { BOOT_ID } from '../protocol'; +import { BOOT_ID, EVENT_ID, EV_EXPORT_PROGRESS, EV_IMPORT_PROGRESS } from '../protocol'; import { Connection, wantsRows } from './engine'; import { ImageStore, deserializeInto } from './images'; +import { exportJson } from './json/export'; +import { importJson } from './json/import'; +import { isJsonSQLite, parseJsonSQLite } from './json/validate'; import { connKey, fromPoolPath, poolPath, reserveCapacity, storageName } from './paths'; import { selectTier } from './tiers'; import { runUpgrades } from './upgrades'; @@ -42,6 +45,31 @@ function requireInit(): void { if (!sqlite3) throw new Error('The SQLite worker is not initialised.'); } +/** Raise a plugin event. The facade forwards these to notifyListeners. */ +function raise(event: string, data: any): void { + self.postMessage({ id: EVENT_ID, event, data }); +} + +/** + * importFromJson names its own target database, which may or may not already be open. Reuse an + * open read-write connection when there is one so the caller keeps seeing its own data, and + * otherwise open a scratch connection and close it again. + */ +async function withWritableConnection(database: string, body: (conn: Connection) => Promise | T): Promise { + const existing = connections.get(connKey(database, false)); + if (existing) return body(existing); + + const storage = storageName(database); + const conn = tier === 1 ? openRaw(storage, false) : await openTier2(storage, false); + try { + const result = await body(conn); + if (tier !== 1) await images.put(storage, conn.serialize()); + return result; + } finally { + if (conn.isOpen) conn.close(); + } +} + function connection(database: string, readonly: boolean): Connection { const conn = connections.get(connKey(database, readonly)); if (!conn) throw new Error(`Database ${database} is not open`); @@ -328,6 +356,77 @@ const ops: Record any> = { return { bytes: image }; }, + // ---------------------------------------------------------------- JSON pipeline + + async isJsonValid({ jsonstring }: { jsonstring: string }) { + try { + return { result: isJsonSQLite(JSON.parse(jsonstring)) }; + } catch { + return { result: false }; + } + }, + + async importFromJson({ jsonstring }: { jsonstring: string }) { + requireInit(); + const jsonData = parseJsonSQLite(jsonstring); + if (jsonData.encrypted) { + throw new Error('ImportFromJson: encrypted databases are not supported on the web platform'); + } + const mode = jsonData.mode ?? 'full'; + const database = jsonData.database; + const storage = storageName(database); + + // `overwrite` with a full import means start from an empty database, not merge into one. + if (jsonData.overwrite && mode === 'full') { + for (const conn of connectionsFor(database)) { + conn.close(); + connections.delete(connKey(database, conn.isReadonly)); + } + if (tier === 1) poolUtil.unlink(poolPath(storage)); + else await images.delete(storage); + } + + const progress = (message: string) => raise(EV_IMPORT_PROGRESS, { progress: message }); + progress(`Start importing the database ${database}`); + + const changes = await withWritableConnection(database, (conn) => { + // A full import into a database that already sits at the target version is a no-op, which + // is what makes repeated imports of the same payload cheap. + if (mode === 'full' && conn.tableList().length > 0) { + const current = conn.userVersion(); + if (jsonData.version < current) { + throw new Error(`ImportFromJson: Cannot import a version lower than ${current}`); + } + if (jsonData.version === current) return 0; + } + return importJson(conn, jsonData, progress); + }); + + progress(`Import completed, changes: ${changes}`); + return { changes, lastId: -1 }; + }, + + async exportToJson({ + database, + readonly, + jsonexportmode, + encrypted, + }: { + database: string; + readonly: boolean; + jsonexportmode: string; + encrypted?: boolean; + }) { + if (encrypted) { + throw new Error('ExportToJson: encrypted export is not supported on the web platform'); + } + const conn = connection(database, readonly); + const progress = (message: string) => raise(EV_EXPORT_PROGRESS, { progress: message }); + const exported = exportJson(conn, database, jsonexportmode, progress); + if (tier !== 1 && !conn.isReadonly) await images.put(conn.storage, conn.serialize()); + return { export: exported }; + }, + /** * pauseVfs throws SQLITE_MISUSE while any database is open, so the close-first sequence is * part of the op rather than something the caller has to remember. Wiring these to Capacitor From a47917715bf1e578cda51080a987fd1ec5a6510e Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 09:58:09 +0800 Subject: [PATCH 07/27] feat(web): port sync tables and the sql_deleted soft delete createSyncTable, setSyncDate, getSyncDate and deleteExportedRows, plus the DELETE rewrite in the statement layer, ported from electron-utils with jeep-sqlite as the cross-check. A database opts into the sync protocol by giving its tables both a last_modified and a sql_deleted column. From then on a DELETE marks the row instead of removing it, so the next export can tell the server about it, and deleteExportedRows is what finally reclaims rows a completed export already carried away. The rewritten statement carries AND sql_deleted = 0, which keeps it idempotent: deleting an already-marked row reports no changes rather than touching last_modified again. The gate keys off the columns rather than off sync_table, matching the port source. Keying off the table would mean a DELETE behaved differently either side of a createSyncTable call that is documented as bookkeeping. The answer is cached per connection and invalidated on any DDL, since computing it costs a PRAGMA per table. Connection.run gains rewriteDeletes, the equivalent of the port source's fromJson flag, because the plugin's own deletes must not be rewritten. deleteExportedRows exists precisely to reclaim marked rows, and a JSON import replicating an upstream deletion has already been told the row is gone; rewriting either would turn it into a silent no-op. --- src/web.ts | 28 +++++++---- src/web/worker/engine.ts | 55 +++++++++++++++++++-- src/web/worker/json/import.ts | 4 +- src/web/worker/statements.ts | 39 +++++++++++++++ src/web/worker/sync.ts | 92 +++++++++++++++++++++++++++++++++++ src/web/worker/worker.ts | 29 +++++++++++ 6 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 src/web/worker/sync.ts diff --git a/src/web.ts b/src/web.ts index 57e38609..3ed26706 100644 --- a/src/web.ts +++ b/src/web.ts @@ -348,7 +348,7 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug } //////////////////////////////////// - ////// JSON PIPELINE + ////// JSON PIPELINE AND SYNC TABLES //////////////////////////////////// async isJsonValid(options: capSQLiteImportOptions): Promise { @@ -376,23 +376,33 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug } async createSyncTable(options: capSQLiteOptions): Promise { - console.log('createSyncTable', options); - throw this.unimplemented('Not implemented on web.'); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + this.rejectReadonly(options.readonly, 'CreateSyncTable'); + this.requireOpen(database, false, 'CreateSyncTable'); + const result = await this.call('createSyncTable', { database }, database, false); + return { changes: { changes: result.changes, lastId: result.lastId } }; } async setSyncDate(options: capSQLiteSyncDateOptions): Promise { - console.log('setSyncDate', options); - throw this.unimplemented('Not implemented on web.'); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const syncdate = CapacitorSQLiteWeb.optionValue(options, 'syncdate'); + this.rejectReadonly(options.readonly, 'SetSyncDate'); + this.requireOpen(database, false, 'SetSyncDate'); + await this.call('setSyncDate', { database, syncdate }, database, false); } async getSyncDate(options: capSQLiteOptions): Promise { - console.log('getSyncDate', options); - throw this.unimplemented('Not implemented on web.'); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + const readonly = options.readonly ?? false; + this.requireOpen(database, readonly, 'GetSyncDate'); + return this.call('getSyncDate', { database, readonly }, database, readonly); } async deleteExportedRows(options: capSQLiteOptions): Promise { - console.log('deleteExportedRows', options); - throw this.unimplemented('Not implemented on web.'); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + this.rejectReadonly(options.readonly, 'DeleteExportedRows'); + this.requireOpen(database, false, 'DeleteExportedRows'); + await this.call('deleteExportedRows', { database }, database, false); } async copyFromAssets(options: capSQLiteFromAssetsOptions): Promise { diff --git a/src/web/worker/engine.ts b/src/web/worker/engine.ts index b44dcc5b..903cea60 100644 --- a/src/web/worker/engine.ts +++ b/src/web/worker/engine.ts @@ -15,10 +15,16 @@ import { prefixed } from '../errors'; import type { ExecResult } from '../protocol'; -import { producesRows, replaceUndefinedByNull } from './statements'; +import { producesRows, replaceUndefinedByNull, softDeleteRewrite } from './statements'; export class Connection { private transactionActive = false; + /** + * Whether this database participates in the soft-delete protocol, cached because answering it + * means a PRAGMA per table. Invalidated whenever DDL runs, since that is the only thing that + * can add or remove the `last_modified` / `sql_deleted` pair. + */ + private syncEnabledCache: boolean | null = null; constructor( readonly storage: string, @@ -73,8 +79,25 @@ export class Connection { this.db.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`); } + /** + * Gate for the DELETE rewrite. The port source keys off the columns rather than the presence + * of `sync_table`, so a database gains soft-delete behaviour as soon as its schema declares + * the pair, which is also when `createSyncTable` will accept it. + */ + get syncEnabled(): boolean { + if (this.syncEnabledCache === null) { + this.syncEnabledCache = hasSyncColumns(this); + } + return this.syncEnabledCache; + } + + invalidateSyncCache(): void { + this.syncEnabledCache = null; + } + /** A batch of raw statements, as `execute()` receives it. */ executeBatch(statements: string): ExecResult { + this.invalidateSyncCache(); const before = this.totalChanges(); try { this.db.exec(statements); @@ -84,8 +107,16 @@ export class Connection { return { changes: this.totalChanges() - before, lastId: this.lastInsertRowid() }; } - /** One statement with its bind values. `wantRows` collects RETURNING / SELECT output. */ - run(statement: string, values: any[] | undefined, wantRows: boolean): ExecResult { + /** + * One statement with its bind values. `wantRows` collects RETURNING / SELECT output. + * + * `rewriteDeletes` is the equivalent of the port source's `fromJson` flag. A DELETE the + * plugin generates itself must run as a real delete: `deleteExportedRows` exists precisely to + * reclaim soft-deleted rows, and a JSON import replicating a server-side deletion has already + * been told the row is gone. Rewriting those would make both operations no-ops. + */ + run(rawStatement: string, values: any[] | undefined, wantRows: boolean, rewriteDeletes = true): ExecResult { + const statement = rewriteDeletes ? softDeleteRewrite(rawStatement, this.syncEnabled) : rawStatement; const bind = replaceUndefinedByNull(values); const before = this.totalChanges(); let rows: any[] | undefined; @@ -188,3 +219,21 @@ export function wantsRows(statement: string, returnMode: string | undefined): bo if (returnMode === 'all' || returnMode === 'one') return producesRows(statement); return false; } + +/** + * Local copy of the sync-column probe. It lives here rather than in `sync.ts` to avoid a + * circular import: `sync.ts` needs Connection, and Connection needs this answer. + */ +function hasSyncColumns(conn: Connection): boolean { + let lastModified = false; + let sqlDeleted = false; + for (const table of conn.tableList()) { + if (table === 'sync_table') continue; + for (const row of conn.query(`PRAGMA table_info(${table})`)) { + if (row.name === 'last_modified') lastModified = true; + if (row.name === 'sql_deleted') sqlDeleted = true; + } + if (lastModified && sqlDeleted) return true; + } + return false; +} diff --git a/src/web/worker/json/import.ts b/src/web/worker/json/import.ts index 56860d8c..d5f32f45 100644 --- a/src/web/worker/json/import.ts +++ b/src/web/worker/json/import.ts @@ -120,7 +120,9 @@ function createTableData(conn: Connection, table: JsonTable, mode: string, progr if (stored.length > 0 && sameValues(row, stored)) continue; } const bind = statement.startsWith('DELETE') ? [] : row; - changes += conn.run(statement, bind, false).changes; + // rewriteDeletes = false: a row arriving with sql_deleted = 1 is replicating a deletion that + // already happened upstream, so it must remove the local row rather than re-mark it. + changes += conn.run(statement, bind, false, false).changes; } progress.call(null, `Table ${table.name} data imported`); return changes; diff --git a/src/web/worker/statements.ts b/src/web/worker/statements.ts index 11dc9f04..4d6338b0 100644 --- a/src/web/worker/statements.ts +++ b/src/web/worker/statements.ts @@ -158,3 +158,42 @@ export function replaceUndefinedByNull(values: any[] | undefined): any[] { if (!values || values.length === 0) return []; return values.map((value) => (value === undefined ? null : value)); } + +/** + * The soft-delete rewrite (PLAN 2.4), ported from `utilsSQLite.deleteSQL` in + * `electron/src/electron-utils/` (MIT, this repo) and cross-checked against jeep-sqlite (MIT), + * which carries the identical logic. + * + * When a database participates in sync (its tables carry both `last_modified` and + * `sql_deleted`), a DELETE is not a delete: the row is marked instead, so the next export can + * tell the server about it. `deleteExportedRows` is what eventually removes it for real. + * + * `AND sql_deleted = 0` on the rewritten statement keeps the operation idempotent: deleting an + * already-soft-deleted row reports zero changes rather than touching `last_modified` again. + */ +export function extractTableName(statement: string): string | null { + const match = stripNoise(statement).match(/(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([^\s;(]+)/i); + return match?.[1] ?? null; +} + +export function extractWhereClause(statement: string): string | null { + const match = stripNoise(statement).match(/WHERE\s(.+?)(?:ORDER\s+BY|LIMIT|$)/is); + return match?.[1] ? match[1].trim() : null; +} + +/** + * Rewrite a DELETE into a soft delete. Returns the statement unchanged when the database does + * not participate in sync, so this is safe to run over every DELETE. + */ +export function softDeleteRewrite(statement: string, syncEnabled: boolean): string { + if (!syncEnabled) return statement; + if (statementKind(statement) !== 'DELETE') return statement; + + const tableName = extractTableName(statement); + if (!tableName) throw new Error('deleteSQL: cannot find a table name'); + const whereClause = extractWhereClause(statement); + if (!whereClause) throw new Error('deleteSQL: cannot find a WHERE clause'); + + const where = whereClause.endsWith(';') ? whereClause.slice(0, -1) : whereClause; + return `UPDATE ${tableName} SET sql_deleted = 1 WHERE ${where} AND sql_deleted = 0;`; +} diff --git a/src/web/worker/sync.ts b/src/web/worker/sync.ts new file mode 100644 index 00000000..7aafcd01 --- /dev/null +++ b/src/web/worker/sync.ts @@ -0,0 +1,92 @@ +/** + * Sync-table support, ported from `electron/src/electron-utils/Database.ts` and + * `ImportExportJson/exportToJson.ts` (MIT, this repo), cross-checked against jeep-sqlite (MIT), + * whose implementation is the same logic under the same names. + * + * The convention (documented in `docs/info_releases.md:36-56`): a database opts into sync by + * giving its tables both a `last_modified` and a `sql_deleted` column. Once `sync_table` exists, + * a DELETE becomes a soft delete (`statements.ts`), and `deleteExportedRows` is what finally + * removes rows that a completed export has already carried away. + * + * Two rows in `sync_table` matter: id 1 is the synchronisation date set by `setSyncDate`, id 2 + * is the last export date written by `exportToJson`. + */ +import type { Connection } from './engine'; + +export const SYNC_TABLE = 'sync_table'; + +/** Does any user table carry the named column? That is what marks sync participation. */ +function anyTableHasColumn(conn: Connection, column: string): boolean { + for (const table of conn.tableList()) { + if (table === SYNC_TABLE) continue; + const rows = conn.query(`PRAGMA table_info(${table})`); + if (rows.some((row: any) => row.name === column)) return true; + } + return false; +} + +export function hasLastModified(conn: Connection): boolean { + return anyTableHasColumn(conn, 'last_modified'); +} + +export function hasSqlDeleted(conn: Connection): boolean { + return anyTableHasColumn(conn, 'sql_deleted'); +} + +/** True when this database participates in the soft-delete protocol. */ +export function isSyncEnabled(conn: Connection): boolean { + return hasLastModified(conn) && hasSqlDeleted(conn); +} + +export function createSyncTable(conn: Connection): number { + if (conn.tableExists(SYNC_TABLE)) return 0; + if (!isSyncEnabled(conn)) { + throw new Error('CreateSyncTable: No last_modified/sql_deleted columns in tables'); + } + const seconds = Math.round(new Date().getTime() / 1000); + const statements = + `CREATE TABLE IF NOT EXISTS ${SYNC_TABLE} (id INTEGER PRIMARY KEY NOT NULL, sync_date INTEGER);` + + `INSERT INTO ${SYNC_TABLE} (sync_date) VALUES (${seconds});`; + return conn.executeBatch(statements).changes; +} + +export function setSyncDate(conn: Connection, syncDate: string): void { + if (!conn.tableExists(SYNC_TABLE)) throw new Error('SetSyncDate: No sync_table available'); + const seconds = Math.round(new Date(syncDate).getTime() / 1000); + if (Number.isNaN(seconds)) throw new Error(`SetSyncDate: ${syncDate} is not a valid date`); + conn.executeBatch(`UPDATE ${SYNC_TABLE} SET sync_date = ${seconds} WHERE id = 1;`); +} + +export function getSyncDate(conn: Connection): number { + if (!conn.tableExists(SYNC_TABLE)) throw new Error('GetSyncDate: No sync_table available'); + const rows = conn.query(`SELECT sync_date FROM ${SYNC_TABLE} WHERE id = ?`, [1]); + if (rows.length === 0) throw new Error('GetSyncDate: no syncDate available'); + const value = Number(rows[0][Object.keys(rows[0])[0]]); + if (!(value > 0)) throw new Error('GetSyncDate: no syncDate available'); + return value; +} + +/** + * Physically remove the rows a previous export already carried away: soft-deleted, and older + * than the last export date written to `sync_table` id 2 by `exportToJson`. + */ +export function deleteExportedRows(conn: Connection): void { + if (!conn.tableExists(SYNC_TABLE)) throw new Error('DeleteExportedRows: No sync_table available'); + const rows = conn.query(`SELECT sync_date FROM ${SYNC_TABLE} WHERE id = ?`, [2]); + const lastExportDate = rows.length > 0 ? Number(rows[0][Object.keys(rows[0])[0]]) : -1; + if (!(lastExportDate > 0)) { + throw new Error('DeleteExportedRows: no last exported date available'); + } + const tables = conn.tableList().filter((name: string) => name !== SYNC_TABLE); + if (tables.length === 0) throw new Error("DeleteExportedRows: No table's names returned"); + + conn.withOptionalTransaction(true, () => { + for (const table of tables) { + const columns = conn.query(`PRAGMA table_info(${table})`).map((row: any) => row.name); + if (!columns.includes('sql_deleted') || !columns.includes('last_modified')) continue; + // Bypass the rewrite: reclaiming soft-deleted rows is the one place a real DELETE is meant. + conn.run(`DELETE FROM ${table} WHERE sql_deleted = 1 AND last_modified < ?`, [lastExportDate], false, false); + } + return 0; + }); +} diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index ea93b20d..326f312f 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -19,6 +19,7 @@ import { exportJson } from './json/export'; import { importJson } from './json/import'; import { isJsonSQLite, parseJsonSQLite } from './json/validate'; import { connKey, fromPoolPath, poolPath, reserveCapacity, storageName } from './paths'; +import * as sync from './sync'; import { selectTier } from './tiers'; import { runUpgrades } from './upgrades'; @@ -427,6 +428,34 @@ const ops: Record any> = { return { export: exported }; }, + // ---------------------------------------------------------------- sync tables + + async createSyncTable({ database }: { database: string }) { + const conn = connection(database, false); + const changes = sync.createSyncTable(conn); + conn.invalidateSyncCache(); + await flushIfTier2(conn); + return { changes, lastId: -1 }; + }, + + async setSyncDate({ database, syncdate }: { database: string; syncdate: string }) { + const conn = connection(database, false); + sync.setSyncDate(conn, syncdate); + await flushIfTier2(conn); + return {}; + }, + + async getSyncDate({ database, readonly }: { database: string; readonly: boolean }) { + return { syncDate: sync.getSyncDate(connection(database, readonly)) }; + }, + + async deleteExportedRows({ database }: { database: string }) { + const conn = connection(database, false); + sync.deleteExportedRows(conn); + await flushIfTier2(conn); + return {}; + }, + /** * pauseVfs throws SQLITE_MISUSE while any database is open, so the close-first sequence is * part of the op rather than something the caller has to remember. Wiring these to Capacitor From 828ea93724d3c2a07bbfd35b32a951c08efec6f6 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 09:58:43 +0800 Subject: [PATCH 08/27] feat(web): port copyFromAssets, getFromHTTPRequest and the local disk flows The last four M2 methods, restoring the three remaining web events: sqliteHTTPRequestEndedEvent, sqlitePickDatabaseEndedEvent and sqliteSaveDatabaseToDiskEvent. Downloads stream. A plain database is fed to importDb's async-callback form one network chunk at a time, so a multi-hundred-megabyte bundle costs one chunk of memory rather than its own size. The fetch runs inside the worker, so no MessagePort is involved: the pull protocol with backpressure exists for the case where the main thread owns the source, and here producer and consumer are the same thread. Zip archives are the exception and are materialised, because inflating requires the archive in memory; that is a property of the container, not of this path, and it is why zip assets should stay modest. copyFromAssets accepts both manifest shapes, a bare array and { databaseList: [...] }, rather than guessing which one an existing app ships. getFromLocalDiskToStore and saveToLocalDisk live on the main thread because they are DOM flows: one opens a file picker, the other triggers a download. The boundary between the plugin and that chrome is injectable through the additive setSqliteLocalDiskAdapter export, so the logic either side of it is testable and an app with its own file UI can supply one. setSqliteWebOptions gains assetsPath for apps that do not host databases under assets/databases/. --- src/index.ts | 2 + src/web.ts | 58 +++++++++--- src/web/localdisk.ts | 110 +++++++++++++++++++++++ src/web/protocol.ts | 2 + src/web/worker/assets.ts | 184 +++++++++++++++++++++++++++++++++++++++ src/web/worker/worker.ts | 51 +++++++++++ 6 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 src/web/localdisk.ts create mode 100644 src/web/worker/assets.ts diff --git a/src/index.ts b/src/index.ts index 3b8e867a..65576651 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,5 @@ export * from './definitions'; // signature; they exist so bundlers that cannot resolve the shipped worker can supply their own. export { setSqliteWorkerFactory, setSqliteWebOptions } from './web/worker-factory'; export type { SqliteWorkerFactory } from './web/worker-factory'; +export { setSqliteLocalDiskAdapter } from './web/localdisk'; +export type { LocalDiskAdapter, PickedFile } from './web/localdisk'; diff --git a/src/web.ts b/src/web.ts index 3ed26706..9456906e 100644 --- a/src/web.ts +++ b/src/web.ts @@ -37,10 +37,12 @@ import type { capSQLiteExtensionEnable, } from './definitions'; import { WorkerClient } from './web/client'; -import { WEBSTORE_NOT_OPEN, prefixed } from './web/errors'; +import { WEBSTORE_NOT_OPEN, messageOf, prefixed } from './web/errors'; +import { connectionNameFromFile, getLocalDiskAdapter } from './web/localdisk'; import type { SerializedUpgrade, Tier, WorkerInitResult } from './web/protocol'; +import { EV_PICK_DATABASE_ENDED, EV_SAVE_TO_DISK, EV_HTTP_REQUEST_ENDED } from './web/protocol'; import { ConnectionRegistry, parseKey, reconcile } from './web/registry'; -import { connKey } from './web/worker/paths'; +import { connKey, storageName } from './web/worker/paths'; import { getSqliteWebOptions } from './web/worker-factory'; /** @@ -348,7 +350,7 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug } //////////////////////////////////// - ////// JSON PIPELINE AND SYNC TABLES + ////// JSON, SYNC, ASSETS AND LOCAL DISK //////////////////////////////////// async isJsonValid(options: capSQLiteImportOptions): Promise { @@ -406,25 +408,59 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug } async copyFromAssets(options: capSQLiteFromAssetsOptions): Promise { - console.log('copyFromAssets', options); - throw this.unimplemented('Not implemented on web.'); + const overwrite = options?.overwrite ?? true; + await this.call('copyFromAssets', { base: this.assetsBase(), overwrite }); } async getFromHTTPRequest(options: capSQLiteHTTPOptions): Promise { - console.log('getFromHTTPRequest', options); - throw this.unimplemented('Not implemented on web.'); + const url = CapacitorSQLiteWeb.optionValue(options, 'url'); + const overwrite = options.overwrite ?? true; + try { + await this.call('getFromHTTPRequest', { url, overwrite }); + this.notifyListeners(EV_HTTP_REQUEST_ENDED, { message: 'ended' }); + } catch (err) { + this.notifyListeners(EV_HTTP_REQUEST_ENDED, { message: `Error: ${messageOf(err)}` }); + throw prefixed('GetFromHTTPRequest', err); + } } async getFromLocalDiskToStore(options: capSQLiteLocalDiskOptions): Promise { - console.log('getFromLocalDiskToStore', options); - throw this.unimplemented('Not implemented on web.'); + this.ensureStore(); + const overwrite = options?.overwrite ?? true; + try { + const picked = await getLocalDiskAdapter().pickDatabase(); + if (!picked) { + this.notifyListeners(EV_PICK_DATABASE_ENDED, { message: 'User cancelled' }); + return; + } + const database = connectionNameFromFile(picked.name); + const result = await this.call('adoptImage', { database, bytes: picked.bytes, overwrite }); + this.notifyListeners(EV_PICK_DATABASE_ENDED, { db_name: result.storage, message: 'ended' }); + } catch (err) { + this.notifyListeners(EV_PICK_DATABASE_ENDED, { message: `Error: ${messageOf(err)}` }); + throw prefixed('GetFromLocalDiskToStore', err); + } } async saveToLocalDisk(options: capSQLiteOptions): Promise { - console.log('saveToLocalDisk', options); - throw this.unimplemented('Not implemented on web.'); + const database = CapacitorSQLiteWeb.optionValue(options, 'database'); + try { + const { bytes } = await this.call('exportDb', { database }, database, false); + const fileName = storageName(database); + await getLocalDiskAdapter().saveDatabase(fileName, bytes); + this.notifyListeners(EV_SAVE_TO_DISK, { db_name: fileName, message: 'ended' }); + } catch (err) { + this.notifyListeners(EV_SAVE_TO_DISK, { message: `Error: ${messageOf(err)}` }); + throw prefixed('SaveToLocalDisk', err); + } } + /** Where copyFromAssets looks for `databases.json`, overridable through setSqliteWebOptions. */ + private assetsBase(): string { + const configured = getSqliteWebOptions().assetsPath; + const base = typeof document !== 'undefined' ? document.baseURI : self.location.href; + return new URL(configured ?? 'assets/databases/', base).href; + } //////////////////////////////////// ////// UNIMPLEMENTED METHODS diff --git a/src/web/localdisk.ts b/src/web/localdisk.ts new file mode 100644 index 00000000..34392d1d --- /dev/null +++ b/src/web/localdisk.ts @@ -0,0 +1,110 @@ +/** + * getFromLocalDiskToStore and saveToLocalDisk. + * + * These two live on the main thread rather than in the worker because they are DOM flows: one + * opens a file picker, the other triggers a download. Behaviour ported from jeep-sqlite (MIT), + * which does the same two things with the same events. + * + * The picker boundary is injectable so the logic either side of it can be tested. Real pickers + * cannot be driven from an automated browser without a user gesture, so a test supplies the + * file and the download sink directly and everything else runs unchanged. What remains + * manual-only is the picker chrome itself; see section 13. + */ + +export interface PickedFile { + name: string; + bytes: Uint8Array; +} + +export interface LocalDiskAdapter { + /** Ask the user for a database file. Resolves null when the user cancels. */ + pickDatabase: () => Promise; + /** Hand bytes to the user as a download. */ + saveDatabase: (fileName: string, bytes: Uint8Array) => Promise; +} + +/** `` is the only picker that works without a secure-context gesture policy. */ +function defaultPickDatabase(): Promise { + return new Promise((resolve, reject) => { + if (typeof document === 'undefined') { + reject(new Error('GetFromLocalDiskToStore: no document available to open a file picker')); + return; + } + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.db,.sqlite,.sqlite3,.zip,application/octet-stream'; + input.style.display = 'none'; + let settled = false; + + const finish = (value: PickedFile | null) => { + if (settled) return; + settled = true; + input.remove(); + resolve(value); + }; + + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return finish(null); + finish({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) }); + }; + // A cancelled picker fires no change event in most browsers; window focus is the signal. + window.addEventListener('focus', () => setTimeout(() => finish(input.files?.[0] ? null : null), 500), { + once: true, + }); + document.body.appendChild(input); + input.click(); + }); +} + +function defaultSaveDatabase(fileName: string, bytes: Uint8Array): Promise { + if (typeof document === 'undefined') { + return Promise.reject(new Error('SaveToLocalDisk: no document available to start a download')); + } + // Copy into a fresh buffer: a Uint8Array that came over the worker boundary may be a view + // onto a larger buffer, and Blob would otherwise capture the whole thing. + const blob = new Blob([bytes.slice()], { type: 'application/vnd.sqlite3' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + // Revoking immediately can cancel the download in some browsers. + setTimeout(() => URL.revokeObjectURL(url), 10_000); + return Promise.resolve(); +} + +export const defaultLocalDiskAdapter: LocalDiskAdapter = { + pickDatabase: defaultPickDatabase, + saveDatabase: defaultSaveDatabase, +}; + +let adapter: LocalDiskAdapter = defaultLocalDiskAdapter; + +/** + * Replace the picker and download boundary. Additive, not part of CapacitorSQLitePlugin; it + * exists so the surrounding logic is testable and so an app with its own file UI can use it. + */ +export function setSqliteLocalDiskAdapter(next: LocalDiskAdapter | null): void { + adapter = next ?? defaultLocalDiskAdapter; +} + +export function getLocalDiskAdapter(): LocalDiskAdapter { + return adapter; +} + +/** `mydbSQLite.db` or `mydb.db` picked from disk -> the connection name `mydb`. */ +export function connectionNameFromFile(fileName: string): string { + let base = fileName.split(/[\\/]/).pop() ?? fileName; + for (const suffix of ['.sqlite3', '.sqlite', '.db']) { + if (base.toLowerCase().endsWith(suffix)) { + base = base.slice(0, -suffix.length); + break; + } + } + if (base.endsWith('SQLite')) base = base.slice(0, -'SQLite'.length); + return base; +} diff --git a/src/web/protocol.ts b/src/web/protocol.ts index d4f6c671..d01cd924 100644 --- a/src/web/protocol.ts +++ b/src/web/protocol.ts @@ -18,6 +18,8 @@ export interface WorkerInitArgs { simulateInstallError?: string; /** Override where sqlite3.wasm is fetched from. Defaults to a sibling of the worker file. */ wasmUrl?: string; + /** Where copyFromAssets looks for databases.json. Defaults to `assets/databases/`. */ + assetsPath?: string; } export interface WorkerInitResult { diff --git a/src/web/worker/assets.ts b/src/web/worker/assets.ts new file mode 100644 index 00000000..3678237e --- /dev/null +++ b/src/web/worker/assets.ts @@ -0,0 +1,184 @@ +/** + * copyFromAssets and getFromHTTPRequest. + * + * Behaviour ported from jeep-sqlite (MIT) and `electron/src/electron-utils/utilsFile.ts` + * (MIT, this repo): the asset copy reads `assets/databases/databases.json` as a manifest and + * pulls each listed file, where a `.zip` entry is an archive of databases rather than a + * database. + * + * Streaming. A plain database download is fed to `importDb`'s async-callback form one network + * chunk at a time, so a multi-hundred-megabyte bundle costs one chunk of memory rather than its + * own size (M0 item 2 measured 1.10 MiB against 65.02 MiB for the whole-buffer path). No + * MessagePort is involved because producer and consumer are both inside this worker; the pull + * protocol in PLAN 3.1/A5 exists for the additive API in section 8.1, where the main thread owns + * the source. + * + * Zip is the exception and is materialised: the archive has to be in memory to be inflated. That + * is a property of the container, not of our path, and it is the reason `.zip` assets should be + * kept modest. Recorded in section 13. + */ +import { unzipSync } from 'fflate'; + +import { prefixed } from '../errors'; + +import { poolPath, reserveCapacity, storageName } from './paths'; + +export interface AssetTarget { + /** tier 1 pool, or null on tier 2 where images land in IndexedDB instead. */ + poolUtil: any | null; + /** Adopt a whole image (tier 2, and the zip path on either tier). */ + adopt: (storage: string, bytes: Uint8Array) => Promise; + exists: (storage: string) => Promise; +} + +const DB_SUFFIXES = ['.db', '.sqlite', '.sqlite3']; + +function isDatabaseName(name: string): boolean { + const lower = name.toLowerCase(); + return DB_SUFFIXES.some((suffix) => lower.endsWith(suffix)); +} + +/** `foo.db` / `fooSQLite.db` -> the plugin's storage name `fooSQLite.db`. */ +export function assetStorageName(fileName: string): string { + let base = fileName; + for (const suffix of DB_SUFFIXES) { + if (base.toLowerCase().endsWith(suffix)) { + base = base.slice(0, -suffix.length); + break; + } + } + return storageName(base); +} + +/** + * Stream a response body straight into the pool. Falls back to buffering only when the response + * has no readable stream, which is the case for some polyfilled fetch implementations. + */ +async function streamIntoPool(target: AssetTarget, storage: string, response: Response): Promise { + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + await target.adopt(storage, bytes); + return bytes.byteLength; + } + await reserveCapacity(target.poolUtil, 2); + const reader = response.body.getReader(); + let written = 0; + await target.poolUtil.importDb(poolPath(storage), async () => { + const { done, value } = await reader.read(); + if (done || !value) return undefined; + written += value.byteLength; + return value; + }); + return written; +} + +async function fetchOk(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) throw new Error(`request for ${url} failed with status ${response.status}`); + return response; +} + +/** One database file from a URL into storage, streamed when the tier allows it. */ +export async function importFromUrl( + target: AssetTarget, + storage: string, + url: string, + overwrite: boolean, +): Promise { + if (!overwrite && (await target.exists(storage))) return false; + const response = await fetchOk(url); + if (target.poolUtil) { + await streamIntoPool(target, storage, response); + } else { + // Tier 2 stores a whole image anyway, so streaming would buy nothing here. + await target.adopt(storage, new Uint8Array(await response.arrayBuffer())); + } + return true; +} + +function looksLikeSqlite(bytes: Uint8Array): boolean { + const header = 'SQLite format 3'; + if (bytes.byteLength < header.length) return false; + for (let i = 0; i < header.length; i++) if (bytes[i] !== header.charCodeAt(i)) return false; + return true; +} + +/** Every database inside a zip archive, keyed by its storage name. */ +export function databasesFromZip(archive: Uint8Array): { storage: string; bytes: Uint8Array }[] { + const entries = unzipSync(archive); + const out: { storage: string; bytes: Uint8Array }[] = []; + for (const [name, bytes] of Object.entries(entries)) { + const leaf = name.split('/').pop() ?? name; + if (leaf.length === 0 || leaf.startsWith('.')) continue; + if (!isDatabaseName(leaf) && !looksLikeSqlite(bytes)) continue; + out.push({ storage: assetStorageName(leaf), bytes }); + } + return out; +} + +export interface CopyFromAssetsResult { + copied: string[]; + skipped: string[]; +} + +/** + * @param base the directory holding `databases.json`, normally `assets/databases/` relative to + * the app's base URL. + */ +export async function copyFromAssets( + target: AssetTarget, + base: string, + overwrite: boolean, +): Promise { + const manifestUrl = new URL('databases.json', base).href; + let manifest: any; + try { + manifest = await (await fetchOk(manifestUrl)).json(); + } catch (err) { + throw prefixed('CopyFromAssets', err); + } + const files: string[] = Array.isArray(manifest) ? manifest : (manifest?.databaseList ?? []); + if (!Array.isArray(files) || files.length === 0) { + throw new Error('CopyFromAssets: databases.json does not list any database'); + } + + const copied: string[] = []; + const skipped: string[] = []; + for (const file of files) { + const url = new URL(file, base).href; + if (file.toLowerCase().endsWith('.zip')) { + const archive = new Uint8Array(await (await fetchOk(url)).arrayBuffer()); + for (const entry of databasesFromZip(archive)) { + if (!overwrite && (await target.exists(entry.storage))) { + skipped.push(entry.storage); + continue; + } + await target.adopt(entry.storage, entry.bytes); + copied.push(entry.storage); + } + continue; + } + const storage = assetStorageName(file); + if (await importFromUrl(target, storage, url, overwrite)) copied.push(storage); + else skipped.push(storage); + } + return { copied, skipped }; +} + +/** getFromHTTPRequest: one database at a URL, streamed into storage. */ +export async function getFromHTTPRequest(target: AssetTarget, url: string, overwrite: boolean): Promise { + const leaf = new URL(url, 'http://localhost/').pathname.split('/').pop() ?? 'downloaded.db'; + if (leaf.toLowerCase().endsWith('.zip')) { + const archive = new Uint8Array(await (await fetchOk(url)).arrayBuffer()); + const entries = databasesFromZip(archive); + if (entries.length === 0) throw new Error('GetFromHTTPRequest: the archive contains no database'); + for (const entry of entries) { + if (!overwrite && (await target.exists(entry.storage))) continue; + await target.adopt(entry.storage, entry.bytes); + } + return entries[0].storage; + } + const storage = assetStorageName(leaf); + await importFromUrl(target, storage, url, overwrite); + return storage; +} diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index 326f312f..f47a0335 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -13,6 +13,8 @@ import { toErrorPayload } from '../errors'; import type { ExecResult, OpenArgs, WorkerInitArgs, WorkerInitResult, WorkerRequest } from '../protocol'; import { BOOT_ID, EVENT_ID, EV_EXPORT_PROGRESS, EV_IMPORT_PROGRESS } from '../protocol'; +import type { AssetTarget } from './assets'; +import { copyFromAssets, getFromHTTPRequest } from './assets'; import { Connection, wantsRows } from './engine'; import { ImageStore, deserializeInto } from './images'; import { exportJson } from './json/export'; @@ -51,6 +53,28 @@ function raise(event: string, data: any): void { self.postMessage({ id: EVENT_ID, event, data }); } +/** + * Where copyFromAssets and getFromHTTPRequest put what they fetch, abstracted so the same code + * serves the pool on tier 1 and the image store on tier 2. + */ +function assetTarget(): AssetTarget { + return { + poolUtil: tier === 1 ? poolUtil : null, + exists: async (storage: string) => { + if (tier === 1) return (poolUtil.getFileNames() as string[]).includes(poolPath(storage)); + return images.has(storage); + }, + adopt: async (storage: string, bytes: Uint8Array) => { + if (tier === 1) { + await reserveCapacity(poolUtil, 2); + poolUtil.importDb(poolPath(storage), bytes); + } else { + await images.put(storage, bytes); + } + }, + }; +} + /** * importFromJson names its own target database, which may or may not already be open. Reuse an * open read-write connection when there is one so the caller keeps seeing its own data, and @@ -456,6 +480,33 @@ const ops: Record any> = { return {}; }, + // ---------------------------------------------------------------- assets and downloads + + async copyFromAssets({ base, overwrite }: { base: string; overwrite: boolean }) { + requireInit(); + return copyFromAssets(assetTarget(), base, overwrite); + }, + + async getFromHTTPRequest({ url, overwrite }: { url: string; overwrite: boolean }) { + requireInit(); + const storage = await getFromHTTPRequest(assetTarget(), url, overwrite); + return { storage }; + }, + + /** Adopt a whole image the main thread produced, used by getFromLocalDiskToStore. */ + async adoptImage({ database, bytes, overwrite }: { database: string; bytes: Uint8Array; overwrite: boolean }) { + requireInit(); + const storage = storageName(database); + const target = assetTarget(); + if (!overwrite && (await target.exists(storage))) return { adopted: false, storage }; + for (const conn of connectionsFor(database)) { + conn.close(); + connections.delete(connKey(database, conn.isReadonly)); + } + await target.adopt(storage, bytes); + return { adopted: true, storage }; + }, + /** * pauseVfs throws SQLITE_MISUSE while any database is open, so the close-first sequence is * part of the op rather than something the caller has to remember. Wiring these to Capacitor From 9604f4034f80c42cc607d153e5a4a0fec1a3294e Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 09:59:16 +0800 Subject: [PATCH 09/27] test(web): cover the JSON, sync, asset and local disk ports Every ported method on both tiers, 76 further tests. copyFromAssets and getFromHTTPRequest fetch from inside the worker, which has its own global scope, so stubbing fetch on the main thread reaches nothing. The suite runs a small in-memory fixture endpoint on the vitest dev server instead: a test builds a real database through the plugin, PUTs its bytes to /__fixture/..., and the worker fetches them back over real HTTP with a real streaming body. Nothing leaves localhost and no product hook exists purely for tests. The file picker and the download sink are replaced through the injectable adapter, which covers both events, cancellation, overwrite = false and a save-then-pick round trip. The browser's own picker dialog stays manual-only: it needs a user gesture no automated browser will supply. Two tests earn their keep by having already caught bugs: an int64 above 2^53 exported and re-imported without precision loss, and deleteExportedRows physically removing rows, which the soft-delete rewrite had been silently turning into a no-op. --- test/web/assets.test.ts | 181 ++++++++++++++++++++++++++ test/web/json.test.ts | 252 +++++++++++++++++++++++++++++++++++++ test/web/localdisk.test.ts | 166 ++++++++++++++++++++++++ test/web/sync.test.ts | 206 ++++++++++++++++++++++++++++++ vitest.config.mts | 47 +++++++ 5 files changed, 852 insertions(+) create mode 100644 test/web/assets.test.ts create mode 100644 test/web/json.test.ts create mode 100644 test/web/localdisk.test.ts create mode 100644 test/web/sync.test.ts diff --git a/test/web/assets.test.ts b/test/web/assets.test.ts new file mode 100644 index 00000000..337eac3c --- /dev/null +++ b/test/web/assets.test.ts @@ -0,0 +1,181 @@ +/** + * copyFromAssets and getFromHTTPRequest. + * + * Fixtures are real SQLite files, built in the browser through the plugin itself, uploaded to + * the dev server's `/__fixture/` endpoint (see vitest.config.mts), and then fetched back by the + * worker over real HTTP. Nothing leaves localhost, and the streaming path is exercised for real + * rather than through a stubbed fetch, which could not reach the worker's scope anyway. + */ +import { beforeEach, describe, expect, test } from 'vitest'; + +import { setSqliteWebOptions } from '../../src/web/worker-factory'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const BASE = '/__fixture/dbs/'; + +async function putFixture(path: string, body: Uint8Array | string): Promise { + const res = await fetch(path, { method: 'PUT', body: typeof body === 'string' ? body : (body.slice() as any) }); + if (!res.ok) throw new Error(`fixture upload failed for ${path}: ${res.status}`); +} + +beforeEach(async () => { + await fetch('/__fixture/reset', { method: 'DELETE' }); +}); + +/** Build a real SQLite file through the plugin and return its bytes. */ +async function makeDatabaseBytes(sqlite: any, plugin: any, name: string, rows: string[]): Promise { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + for (const row of rows) await db.run('INSERT INTO t (v) VALUES (?)', [row]); + await sqlite.closeConnection(name, false); + const { bytes } = await (plugin as any).client.call('exportDb', { database: name }); + return bytes; +} + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function harness() { + const h = await startHarness(tier); + // Point copyFromAssets at the fixture directory instead of assets/databases/. + setSqliteWebOptions({ assetsPath: BASE }); + return h; + } + + test(`${label}: copyFromAssets pulls every database in databases.json`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}alpha.db`, await makeDatabaseBytes(sqlite, plugin, 'srcAlpha', ['a1', 'a2'])); + await putFixture(`${BASE}beta.db`, await makeDatabaseBytes(sqlite, plugin, 'srcBeta', ['b1'])); + await putFixture(`${BASE}databases.json`, JSON.stringify(['alpha.db', 'beta.db'])); + + await sqlite.copyFromAssets(true); + + const list = await sqlite.getDatabaseList(); + expect(list.values).toContain('alphaSQLite.db'); + expect(list.values).toContain('betaSQLite.db'); + + const db = await sqlite.createConnection('alpha', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t ORDER BY id')).values?.map((r: any) => r.v)).toEqual(['a1', 'a2']); + await sqlite.closeConnection('alpha', false); + }); + + test(`${label}: copyFromAssets honours overwrite = false`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}keep.db`, await makeDatabaseBytes(sqlite, plugin, 'srcOne', ['original'])); + await putFixture(`${BASE}databases.json`, JSON.stringify(['keep.db'])); + await sqlite.copyFromAssets(true); + + await putFixture(`${BASE}keep.db`, await makeDatabaseBytes(sqlite, plugin, 'srcTwo', ['replacement'])); + await sqlite.copyFromAssets(false); + + const db = await sqlite.createConnection('keep', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('original'); + await sqlite.closeConnection('keep', false); + }); + + test(`${label}: overwrite = true really replaces the stored database`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}swap.db`, await makeDatabaseBytes(sqlite, plugin, 'srcA', ['first'])); + await putFixture(`${BASE}databases.json`, JSON.stringify(['swap.db'])); + await sqlite.copyFromAssets(true); + + await putFixture(`${BASE}swap.db`, await makeDatabaseBytes(sqlite, plugin, 'srcB', ['second'])); + await sqlite.copyFromAssets(true); + + const db = await sqlite.createConnection('swap', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('second'); + await sqlite.closeConnection('swap', false); + }); + + test(`${label}: copyFromAssets accepts the object form of the manifest`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture(`${BASE}obj.db`, await makeDatabaseBytes(sqlite, plugin, 'srcObj', ['objform'])); + await putFixture(`${BASE}databases.json`, JSON.stringify({ databaseList: ['obj.db'] })); + await sqlite.copyFromAssets(true); + + const db = await sqlite.createConnection('obj', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('objform'); + await sqlite.closeConnection('obj', false); + }); + + test(`${label}: a missing manifest fails loudly`, async () => { + const { sqlite } = await harness(); + await expect(sqlite.copyFromAssets(true)).rejects.toThrow(/CopyFromAssets/i); + }); + + test(`${label}: getFromHTTPRequest streams a database in and raises its ended event`, async () => { + const { sqlite, plugin } = await harness(); + await putFixture('/__fixture/remote.db', await makeDatabaseBytes(sqlite, plugin, 'srcHttp', ['downloaded'])); + const events: any[] = []; + await plugin.addListener('sqliteHTTPRequestEndedEvent', (e: any) => events.push(e)); + + await sqlite.getFromHTTPRequest(new URL('/__fixture/remote.db', location.href).href, true); + + expect(events.length).toBe(1); + expect(events[0].message).toBe('ended'); + + const db = await sqlite.createConnection('remote', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('downloaded'); + await sqlite.closeConnection('remote', false); + }); + + test(`${label}: a failed download raises the ended event with the error and rejects`, async () => { + const { sqlite, plugin } = await harness(); + const events: any[] = []; + await plugin.addListener('sqliteHTTPRequestEndedEvent', (e: any) => events.push(e)); + await expect( + sqlite.getFromHTTPRequest(new URL('/__fixture/never-uploaded.db', location.href).href, true), + ).rejects.toThrow(/GetFromHTTPRequest/i); + expect(events.length).toBe(1); + expect(events[0].message).toMatch(/^Error:/); + }); + + test(`${label}: a zip asset is unpacked into its databases`, async () => { + const { sqlite, plugin } = await harness(); + const bytes = await makeDatabaseBytes(sqlite, plugin, 'srcZip', ['zipped']); + const { zipSync } = await import('fflate'); + await putFixture(`${BASE}bundle.zip`, zipSync({ 'inner.db': bytes }, { level: 0 })); + await putFixture(`${BASE}databases.json`, JSON.stringify(['bundle.zip'])); + + await sqlite.copyFromAssets(true); + + const db = await sqlite.createConnection('inner', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('zipped'); + await sqlite.closeConnection('inner', false); + }); + + test(`${label}: a large download costs a chunk, not the file`, async () => { + const { sqlite, plugin } = await harness(); + // Big enough that a whole-buffer implementation would be obvious in the wasm heap. + const db = await sqlite.createConnection('srcBig', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE big (id INTEGER PRIMARY KEY NOT NULL, blob BLOB);'); + await db.execute( + `WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n < 128) + INSERT INTO big (blob) SELECT randomblob(65536) FROM c;`, + ); + await sqlite.closeConnection('srcBig', false); + const { bytes } = await (plugin as any).client.call('exportDb', { database: 'srcBig' }); + expect(bytes.byteLength).toBeGreaterThan(8 * 1024 * 1024); + await putFixture('/__fixture/big.db', bytes); + + await sqlite.getFromHTTPRequest(new URL('/__fixture/big.db', location.href).href, true); + + const copy = await sqlite.createConnection('big', false, 'no-encryption', 1, false); + await copy.open(); + const count = await copy.query('SELECT count(*) AS n, sum(length(blob)) AS total FROM big'); + expect(count.values?.[0].n).toBe(128); + expect(Number(count.values?.[0].total)).toBe(128 * 65536); + const check = await copy.query('PRAGMA integrity_check'); + expect(check.values?.[0].integrity_check).toBe('ok'); + await sqlite.closeConnection('big', false); + }); +}); diff --git a/test/web/json.test.ts b/test/web/json.test.ts new file mode 100644 index 00000000..e0825aac --- /dev/null +++ b/test/web/json.test.ts @@ -0,0 +1,252 @@ +/** + * The JSON pipeline: isJsonValid, importFromJson, exportToJson, and the two progress events. + * + * The int64 round trip is the one that matters most for web specifically. sqlite-wasm returns + * values above 2^53 as BigInt and `JSON.stringify` throws on those, so an export that did not + * handle it would produce an object the caller cannot serialise, and a naive Number() cast would + * silently corrupt exactly the values BigInt exists to protect. + */ +import { describe, expect, test } from 'vitest'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const SCHEMA = [ + { column: 'id', value: 'INTEGER PRIMARY KEY NOT NULL' }, + { column: 'name', value: 'TEXT NOT NULL' }, + { column: 'size', value: 'INTEGER' }, +]; + +function payload(overrides: any = {}) { + return { + database: 'jsondb', + version: 1, + encrypted: false, + mode: 'full', + tables: [ + { + name: 'items', + schema: SCHEMA, + indexes: [{ name: 'items_name', value: 'name' }], + values: [ + [1, 'alpha', 10], + [2, 'beta', 20], + ], + }, + ], + ...overrides, + }; +} + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: isJsonValid accepts a good object and rejects bad ones`, async () => { + const { sqlite } = await startHarness(tier); + expect((await sqlite.isJsonValid(JSON.stringify(payload()))).result).toBe(true); + // An unknown key anywhere invalidates the whole object; that is the documented contract. + expect((await sqlite.isJsonValid(JSON.stringify(payload({ nonsense: 1 })))).result).toBe(false); + expect((await sqlite.isJsonValid(JSON.stringify({ database: 'x' }))).result).toBe(true); + expect((await sqlite.isJsonValid(JSON.stringify({ database: 42 }))).result).toBe(false); + expect((await sqlite.isJsonValid('not json at all')).result).toBe(false); + // A value row narrower than the schema is rejected. + const short = payload(); + short.tables[0].values = [[1, 'alpha']] as any; + expect((await sqlite.isJsonValid(JSON.stringify(short))).result).toBe(false); + }); + + test(`${label}: importFromJson creates schema, indexes and data`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const progress: string[] = []; + await plugin.addListener('sqliteImportProgressEvent', (e: any) => progress.push(e.progress)); + + const result = await sqlite.importFromJson(JSON.stringify(payload())); + expect(result.changes?.changes).toBeGreaterThan(0); + expect(progress.length).toBeGreaterThan(0); + expect(progress.some((p) => /Import completed/i.test(p))).toBe(true); + + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT id, name, size FROM items ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'alpha', size: 10 }, + { id: 2, name: 'beta', size: 20 }, + ]); + expect((await db.getVersion()).version).toBe(1); + const indexes = await db.query("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'items_name'"); + expect(indexes.values?.length).toBe(1); + await sqlite.closeConnection('jsondb', false); + }); + + test(`${label}: partial mode inserts new rows and updates existing ones`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload())); + + const partial = payload({ + mode: 'partial', + tables: [ + { + name: 'items', + schema: SCHEMA, + values: [ + [2, 'beta renamed', 22], + [3, 'gamma', 30], + ], + }, + ], + }); + await sqlite.importFromJson(JSON.stringify(partial)); + + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT id, name, size FROM items ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'alpha', size: 10 }, + { id: 2, name: 'beta renamed', size: 22 }, + { id: 3, name: 'gamma', size: 30 }, + ]); + await sqlite.closeConnection('jsondb', false); + }); + + test(`${label}: re-importing an unchanged partial payload reports no changes`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload())); + const same = payload({ mode: 'partial' }); + const result = await sqlite.importFromJson(JSON.stringify(same)); + expect(result.changes?.changes).toBe(0); + }); + + test(`${label}: exportToJson full round trip through a fresh database`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const progress: string[] = []; + await plugin.addListener('sqliteExportProgressEvent', (e: any) => progress.push(e.progress)); + + await sqlite.importFromJson(JSON.stringify(payload())); + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const exported = await db.exportToJson('full'); + expect(progress.length).toBeGreaterThan(0); + + const json = exported.export as any; + expect(json.database).toBe('jsondb'); + expect(json.mode).toBe('full'); + expect(json.version).toBe(1); + const table = json.tables.find((t: any) => t.name === 'items'); + expect(table.values).toEqual([ + [1, 'alpha', 10], + [2, 'beta', 20], + ]); + expect(table.schema.map((c: any) => c.column)).toEqual(['id', 'name', 'size']); + expect(table.indexes.map((i: any) => i.name)).toEqual(['items_name']); + // The whole point of the exercise: the caller can serialise what they were handed. + expect(() => JSON.stringify(exported.export)).not.toThrow(); + await sqlite.closeConnection('jsondb', false); + + // And it re-imports into an empty database as the same data. + const reimport = { ...json, database: 'jsonrt', overwrite: true }; + await sqlite.importFromJson(JSON.stringify(reimport)); + const copy = await sqlite.createConnection('jsonrt', false, 'no-encryption', 1, false); + await copy.open(); + const rows = await copy.query('SELECT id, name, size FROM items ORDER BY id'); + expect(rows.values).toEqual([ + { id: 1, name: 'alpha', size: 10 }, + { id: 2, name: 'beta', size: 20 }, + ]); + await sqlite.closeConnection('jsonrt', false); + }); + + test(`${label}: int64 above 2^53 survives export and re-import without precision loss`, async () => { + const { sqlite } = await startHarness(tier); + const big = 9007199254740993n; // 2^53 + 1 + const db = await sqlite.createConnection('bigjson', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE nums (id INTEGER PRIMARY KEY NOT NULL, v INTEGER);'); + await db.run('INSERT INTO nums (id, v) VALUES (?, ?)', [1, big]); + await db.run('INSERT INTO nums (id, v) VALUES (?, ?)', [2, 42]); + expect((await db.query('SELECT v FROM nums WHERE id = 1')).values?.[0].v).toBe(big); + + const exported = await db.exportToJson('full'); + const json = exported.export as any; + // BigInt cannot be serialised, so the exporter hands back the decimal string. A Number cast + // would have produced 9007199254740992 and lost the value silently. + const table = json.tables.find((t: any) => t.name === 'nums'); + expect(table.values).toEqual([ + [1, '9007199254740993'], + [2, 42], + ]); + const serialised = JSON.stringify(exported.export); + expect(serialised).toContain('9007199254740993'); + await sqlite.closeConnection('bigjson', false); + + // SQLite's INTEGER affinity turns that string back into the identical int64. + await sqlite.importFromJson(JSON.stringify({ ...json, database: 'bigjson2', overwrite: true })); + const copy = await sqlite.createConnection('bigjson2', false, 'no-encryption', 1, false); + await copy.open(); + const back = await copy.query('SELECT v, typeof(v) AS t FROM nums ORDER BY id'); + expect(back.values?.[0].t).toBe('integer'); + expect(back.values?.[0].v).toBe(big); + expect(back.values?.[1].v).toBe(42); + await sqlite.closeConnection('bigjson2', false); + }); + + test(`${label}: BLOBs round trip through JSON as number arrays`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('blobjson', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE b (id INTEGER PRIMARY KEY NOT NULL, payload BLOB);'); + await db.run('INSERT INTO b (id, payload) VALUES (?, ?)', [1, new Uint8Array([1, 2, 250])]); + const exported = await db.exportToJson('full'); + await sqlite.closeConnection('blobjson', false); + + const json = exported.export as any; + const table = json.tables.find((t: any) => t.name === 'b'); + // Uint8Array serialises to an object with numeric keys, which is what the importer revives. + const asArray = Array.from(Object.values(table.values[0][1] as any)); + expect(asArray).toEqual([1, 2, 250]); + + table.values[0][1] = asArray; + await sqlite.importFromJson(JSON.stringify({ ...json, database: 'blobjson2', overwrite: true })); + const copy = await sqlite.createConnection('blobjson2', false, 'no-encryption', 1, false); + await copy.open(); + const back = await copy.query('SELECT payload FROM b'); + expect(back.values?.[0].payload).toBeInstanceOf(Uint8Array); + expect(Array.from(back.values?.[0].payload as Uint8Array)).toEqual([1, 2, 250]); + await sqlite.closeConnection('blobjson2', false); + }); + + test(`${label}: views survive the round trip`, async () => { + const { sqlite } = await startHarness(tier); + const withView = payload({ views: [{ name: 'big_items', value: 'SELECT * FROM items WHERE size > 15' }] }); + await sqlite.importFromJson(JSON.stringify(withView)); + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT name FROM big_items'); + expect(rows.values).toEqual([{ name: 'beta' }]); + const exported = (await db.exportToJson('full')).export as any; + expect(exported.views.map((v: any) => v.name)).toEqual(['big_items']); + await sqlite.closeConnection('jsondb', false); + }); + + test(`${label}: an encrypted payload is refused rather than half-imported`, async () => { + const { sqlite } = await startHarness(tier); + await expect(sqlite.importFromJson(JSON.stringify(payload({ encrypted: true })))).rejects.toThrow( + /not supported on the web platform/i, + ); + }); + + test(`${label}: importing a lower version than the database holds is refused`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload({ version: 3 }))); + await expect(sqlite.importFromJson(JSON.stringify(payload({ version: 2 })))).rejects.toThrow( + /Cannot import a version lower than 3/i, + ); + }); + + test(`${label}: exportToJson refuses partial mode without a sync table`, async () => { + const { sqlite } = await startHarness(tier); + await sqlite.importFromJson(JSON.stringify(payload())); + const db = await sqlite.createConnection('jsondb', false, 'no-encryption', 1, false); + await db.open(); + await expect(db.exportToJson('partial')).rejects.toThrow(/No sync_table available/i); + await sqlite.closeConnection('jsondb', false); + }); +}); diff --git a/test/web/localdisk.test.ts b/test/web/localdisk.test.ts new file mode 100644 index 00000000..fe5d4dc7 --- /dev/null +++ b/test/web/localdisk.test.ts @@ -0,0 +1,166 @@ +/** + * getFromLocalDiskToStore and saveToLocalDisk. + * + * Both are DOM flows: one opens a file picker, the other triggers a download. Neither can be + * driven from an automated browser, because a picker needs a real user gesture and a download + * lands outside the page. The picker/download boundary is therefore injectable, and these tests + * replace it so that everything on either side of it, including the events, runs for real. What + * stays manual-only is the picker chrome itself; recorded in PLAN section 13. + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import { connectionNameFromFile, setSqliteLocalDiskAdapter } from '../../src/web/localdisk'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +afterEach(() => setSqliteLocalDiskAdapter(null)); + +describe('file name mapping (unit)', () => { + test('strips the plugin suffix and the extension', () => { + expect(connectionNameFromFile('mydbSQLite.db')).toBe('mydb'); + expect(connectionNameFromFile('mydb.db')).toBe('mydb'); + expect(connectionNameFromFile('mydb.sqlite')).toBe('mydb'); + expect(connectionNameFromFile('/downloads/mydb.sqlite3')).toBe('mydb'); + expect(connectionNameFromFile('plain')).toBe('plain'); + }); +}); + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function exportBytes(sqlite: any, plugin: any, name: string, value: string): Promise { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + await db.run('INSERT INTO t (v) VALUES (?)', [value]); + await sqlite.closeConnection(name, false); + const { bytes } = await (plugin as any).client.call('exportDb', { database: name }); + return bytes; + } + + test(`${label}: getFromLocalDiskToStore adopts the picked file and raises its event`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const bytes = await exportBytes(sqlite, plugin, 'pickSource', 'from disk'); + const events: any[] = []; + await plugin.addListener('sqlitePickDatabaseEndedEvent', (e: any) => events.push(e)); + + setSqliteLocalDiskAdapter({ + pickDatabase: async () => ({ name: 'restored.db', bytes }), + saveDatabase: async () => undefined, + }); + + await sqlite.getFromLocalDiskToStore(true); + expect(events.length).toBe(1); + expect(events[0].db_name).toBe('restoredSQLite.db'); + expect(events[0].message).toBe('ended'); + + const db = await sqlite.createConnection('restored', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('from disk'); + await sqlite.closeConnection('restored', false); + }); + + test(`${label}: a cancelled picker reports it and changes nothing`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const events: any[] = []; + await plugin.addListener('sqlitePickDatabaseEndedEvent', (e: any) => events.push(e)); + setSqliteLocalDiskAdapter({ + pickDatabase: async () => null, + saveDatabase: async () => undefined, + }); + + await sqlite.getFromLocalDiskToStore(true); + expect(events.length).toBe(1); + expect(events[0].message).toMatch(/cancel/i); + expect(events[0].db_name).toBeUndefined(); + }); + + test(`${label}: overwrite = false leaves an existing database alone`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const original = await exportBytes(sqlite, plugin, 'keepMine', 'mine'); + const incoming = await exportBytes(sqlite, plugin, 'theirs', 'theirs'); + + setSqliteLocalDiskAdapter({ + pickDatabase: async () => ({ name: 'keepMine.db', bytes: incoming }), + saveDatabase: async () => undefined, + }); + expect(original.byteLength).toBeGreaterThan(0); + + await sqlite.getFromLocalDiskToStore(false); + const db = await sqlite.createConnection('keepMine', false, 'no-encryption', 1, false); + await db.open(); + expect((await db.query('SELECT v FROM t')).values?.[0].v).toBe('mine'); + await sqlite.closeConnection('keepMine', false); + }); + + test(`${label}: saveToLocalDisk hands over a valid database file and raises its event`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const db = await sqlite.createConnection('saveme', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('saved');"); + + const saved: { name: string; bytes: Uint8Array }[] = []; + const events: any[] = []; + await plugin.addListener('sqliteSaveDatabaseToDiskEvent', (e: any) => events.push(e)); + setSqliteLocalDiskAdapter({ + pickDatabase: async () => null, + saveDatabase: async (name, bytes) => { + saved.push({ name, bytes }); + }, + }); + + await sqlite.saveToLocalDisk('saveme'); + + expect(saved.length).toBe(1); + expect(saved[0].name).toBe('savemeSQLite.db'); + expect(new TextDecoder().decode(saved[0].bytes.subarray(0, 15))).toBe('SQLite format 3'); + expect(events.length).toBe(1); + expect(events[0].message).toBe('ended'); + await sqlite.closeConnection('saveme', false); + }); + + test(`${label}: a failing save reports the error on the event and rejects`, async () => { + const { sqlite, plugin } = await startHarness(tier); + const db = await sqlite.createConnection('savefail', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (v TEXT);'); + + const events: any[] = []; + await plugin.addListener('sqliteSaveDatabaseToDiskEvent', (e: any) => events.push(e)); + setSqliteLocalDiskAdapter({ + pickDatabase: async () => null, + saveDatabase: async () => { + throw new Error('disk full'); + }, + }); + + await expect(sqlite.saveToLocalDisk('savefail')).rejects.toThrow(/SaveToLocalDisk: disk full/); + expect(events[0].message).toMatch(/^Error:.*disk full/); + await sqlite.closeConnection('savefail', false); + }); + + test(`${label}: a saved database can be picked straight back in`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('roundtrip', false, 'no-encryption', 1, false); + await db.open(); + await db.execute("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('there and back');"); + + let captured: Uint8Array | null = null; + setSqliteLocalDiskAdapter({ + pickDatabase: async () => (captured ? { name: 'copy.db', bytes: captured } : null), + saveDatabase: async (_name, bytes) => { + captured = bytes; + }, + }); + + await sqlite.saveToLocalDisk('roundtrip'); + await sqlite.closeConnection('roundtrip', false); + expect(captured).not.toBeNull(); + + await sqlite.getFromLocalDiskToStore(true); + const copy = await sqlite.createConnection('copy', false, 'no-encryption', 1, false); + await copy.open(); + expect((await copy.query('SELECT v FROM t')).values?.[0].v).toBe('there and back'); + await sqlite.closeConnection('copy', false); + }); +}); diff --git a/test/web/sync.test.ts b/test/web/sync.test.ts new file mode 100644 index 00000000..152e0cc5 --- /dev/null +++ b/test/web/sync.test.ts @@ -0,0 +1,206 @@ +/** + * Sync tables and the soft-delete convention (PLAN 2.4). + * + * A database opts in by giving its tables both `last_modified` and `sql_deleted`. From then on + * a DELETE marks the row instead of removing it, so the next export can tell the server about + * it, and `deleteExportedRows` is what finally removes rows a completed export already carried. + */ +import { describe, expect, test } from 'vitest'; + +import { extractTableName, extractWhereClause, softDeleteRewrite } from '../../src/web/worker/statements'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +const SYNC_SCHEMA = ` + CREATE TABLE items ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + last_modified INTEGER DEFAULT (strftime('%s','now')), + sql_deleted BOOLEAN DEFAULT 0 + );`; + +describe('soft-delete rewrite (unit)', () => { + test('leaves statements alone when the database does not participate', () => { + expect(softDeleteRewrite('DELETE FROM t WHERE id = 1', false)).toBe('DELETE FROM t WHERE id = 1'); + }); + + test('rewrites a DELETE into a guarded UPDATE', () => { + expect(softDeleteRewrite('DELETE FROM items WHERE id = 3;', true)).toBe( + 'UPDATE items SET sql_deleted = 1 WHERE id = 3 AND sql_deleted = 0;', + ); + }); + + test('only touches DELETE', () => { + for (const sql of ['SELECT * FROM items', 'UPDATE items SET name = ?', 'INSERT INTO items VALUES (1)']) { + expect(softDeleteRewrite(sql, true)).toBe(sql); + } + }); + + test('a DELETE with no WHERE clause is an error rather than a silent full-table update', () => { + expect(() => softDeleteRewrite('DELETE FROM items', true)).toThrow(/WHERE/i); + }); + + test('table and where extraction ignore string literals', () => { + expect(extractTableName("DELETE FROM items WHERE name = 'DELETE FROM other'")).toBe('items'); + // The literal is blanked before matching, so ORDER BY still terminates the clause. + expect(extractWhereClause('DELETE FROM items WHERE id = 4 ORDER BY id')).toBe('id = 4'); + }); +}); + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + async function seeded(sqlite: any, name: string) { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute(SYNC_SCHEMA); + await db.execute("INSERT INTO items (id, name) VALUES (1, 'one'), (2, 'two'), (3, 'three');"); + return db; + } + + test(`${label}: createSyncTable requires the sync columns`, async () => { + const { sqlite } = await startHarness(tier); + const plain = await sqlite.createConnection('plain', false, 'no-encryption', 1, false); + await plain.open(); + await plain.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + await expect(plain.createSyncTable()).rejects.toThrow(/last_modified\/sql_deleted/i); + await sqlite.closeConnection('plain', false); + }); + + test(`${label}: createSyncTable is idempotent and sets a sync date`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'syncdb'); + const first = await db.createSyncTable(); + expect(first.changes?.changes).toBeGreaterThan(0); + const second = await db.createSyncTable(); + expect(second.changes?.changes).toBe(0); + + // The wrapper hands back an ISO string, not the raw seconds the plugin returns. + const date = await db.getSyncDate(); + expect(typeof date).toBe('string'); + expect(Date.parse(date)).toBeGreaterThan(0); + await sqlite.closeConnection('syncdb', false); + }); + + test(`${label}: setSyncDate and getSyncDate round trip`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'syncdate'); + await db.createSyncTable(); + await db.setSyncDate('2026-01-02T03:04:05.000Z'); + const date = await db.getSyncDate(); + expect(date).toBe('2026-01-02T03:04:05.000Z'); + await sqlite.closeConnection('syncdate', false); + }); + + test(`${label}: DELETE soft-deletes once the sync columns exist`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'softdel'); + await db.createSyncTable(); + + const deleted = await db.run('DELETE FROM items WHERE id = ?', [2]); + expect(deleted.changes?.changes).toBe(1); + + // The row is still there, marked. + const all = await db.query('SELECT id, sql_deleted FROM items ORDER BY id'); + expect(all.values).toEqual([ + { id: 1, sql_deleted: 0 }, + { id: 2, sql_deleted: 1 }, + { id: 3, sql_deleted: 0 }, + ]); + + // And deleting it again is a no-op rather than churning last_modified. + const again = await db.run('DELETE FROM items WHERE id = ?', [2]); + expect(again.changes?.changes).toBe(0); + await sqlite.closeConnection('softdel', false); + }); + + test(`${label}: a database without the sync columns still hard-deletes`, async () => { + const { sqlite } = await startHarness(tier); + const db = await sqlite.createConnection('harddel', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY NOT NULL, v TEXT);'); + await db.execute("INSERT INTO t (id, v) VALUES (1, 'a'), (2, 'b');"); + await db.run('DELETE FROM t WHERE id = ?', [1]); + const rows = await db.query('SELECT id FROM t'); + expect(rows.values).toEqual([{ id: 2 }]); + await sqlite.closeConnection('harddel', false); + }); + + test(`${label}: deleteExportedRows physically removes what an export carried away`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'exported'); + await db.createSyncTable(); + await db.run('DELETE FROM items WHERE id = ?', [2]); + expect((await db.query('SELECT count(*) AS n FROM items')).values?.[0].n).toBe(3); + + // Without an export there is no last-export date, so there is nothing to reclaim. + await expect(db.deleteExportedRows()).rejects.toThrow(/no last exported date/i); + + // Exporting stamps sync_table id 2, which is what makes the rows reclaimable. The stamp has + // to be strictly newer than the rows' last_modified, hence the explicit backdate. + await db.execute('UPDATE items SET last_modified = 1 WHERE id = 2;'); + await db.exportToJson('full'); + + await db.deleteExportedRows(); + const rows = await db.query('SELECT id FROM items ORDER BY id'); + expect(rows.values).toEqual([{ id: 1 }, { id: 3 }]); + await sqlite.closeConnection('exported', false); + }); + + test(`${label}: partial export carries only rows modified since the sync date`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'partialexp'); + await db.createSyncTable(); + // Backdate everything, then move the sync date between the old rows and a new one. + await db.execute('UPDATE items SET last_modified = 1000;'); + await db.setSyncDate(new Date(2000 * 1000).toISOString()); + await db.run('INSERT INTO items (id, name, last_modified) VALUES (?, ?, ?)', [4, 'four', 3000]); + + const exported = (await db.exportToJson('partial')).export as any; + expect(exported.mode).toBe('partial'); + const table = exported.tables.find((t: any) => t.name === 'items'); + expect(table.values.map((row: any[]) => row[0])).toEqual([4]); + await sqlite.closeConnection('partialexp', false); + }); + + test(`${label}: sync_table is not itself exported`, async () => { + const { sqlite } = await startHarness(tier); + const db = await seeded(sqlite, 'nosynctable'); + await db.createSyncTable(); + const exported = (await db.exportToJson('full')).export as any; + expect(exported.tables.map((t: any) => t.name)).not.toContain('sync_table'); + await sqlite.closeConnection('nosynctable', false); + }); + + test(`${label}: an importFromJson row marked sql_deleted deletes the local row`, async () => { + const { sqlite } = await startHarness(tier); + await seeded(sqlite, 'importdel'); + await sqlite.closeConnection('importdel', false); + + const payload = { + database: 'importdel', + version: 1, + encrypted: false, + mode: 'partial', + tables: [ + { + name: 'items', + schema: [ + { column: 'id', value: 'INTEGER PRIMARY KEY NOT NULL' }, + { column: 'name', value: 'TEXT NOT NULL' }, + { column: 'last_modified', value: "INTEGER DEFAULT (strftime('%s','now'))" }, + { column: 'sql_deleted', value: 'BOOLEAN DEFAULT 0' }, + ], + values: [[3, 'three', 1234, 1]], + }, + ], + }; + await sqlite.importFromJson(JSON.stringify(payload)); + + const again = await sqlite.createConnection('importdel', false, 'no-encryption', 1, false); + await again.open(); + const rows = await again.query('SELECT id FROM items ORDER BY id'); + expect(rows.values).toEqual([{ id: 1 }, { id: 2 }]); + await sqlite.closeConnection('importdel', false); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index db28de76..adfea473 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,6 +1,52 @@ import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; +/** + * An in-memory fixture endpoint on the dev server. + * + * copyFromAssets and getFromHTTPRequest fetch from inside the worker, which has its own global + * scope, so stubbing `fetch` on the main thread would not reach them. Tests PUT real database + * bytes here and then point the plugin at the resulting URLs, which exercises the actual + * network path (real fetch, real ReadableStream body) without ever leaving localhost. + */ +function fixtureServer() { + const store = new Map(); + return { + name: 'sqlite-web-test-fixtures', + configureServer(server: any) { + server.middlewares.use((req: any, res: any, next: any) => { + const path = (req.url || '').split('?')[0]; + if (!path.startsWith('/__fixture/')) return next(); + if (req.method === 'PUT') { + const chunks: any[] = []; + req.on('data', (chunk: any) => chunks.push(chunk)); + req.on('end', () => { + store.set(path, Buffer.concat(chunks)); + res.statusCode = 204; + res.end(); + }); + return; + } + if (req.method === 'DELETE') { + store.clear(); + res.statusCode = 204; + res.end(); + return; + } + const body = store.get(path); + if (!body) { + res.statusCode = 404; + res.end('no fixture'); + return; + } + res.setHeader('content-type', 'application/octet-stream'); + res.setHeader('content-length', String(body.length)); + res.end(body); + }); + }, + }; +} + /** * Real Chromium, not jsdom: OPFS sync access handles and dedicated workers do not exist there, * and both are load-bearing for this implementation. @@ -11,6 +57,7 @@ import { defineConfig } from 'vitest/config'; * multi-context scenario has to be built inside a single file with several workers. */ export default defineConfig({ + plugins: [fixtureServer()], optimizeDeps: { // sqlite-wasm resolves sqlite3.wasm with `new URL('sqlite3.wasm', import.meta.url)`. // Pre-bundling moves the module and breaks that resolution. From 4008d3eb8067bba48f8234103de82bab8ec8f087 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 11:47:36 +0800 Subject: [PATCH 10/27] ci: install Playwright Chromium before the web verify step verify:web runs the browser test suite now, and vitest browser mode needs a Playwright browser that npm ci does not install. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f75736f6..4416416f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: run: npm run verify:ios - name: Build Android run: npm run verify:android + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium - name: Build Web run: npm run verify:web - name: Build Electron From 08c9128e3f5b23797fddf24d5dd5e6956e217631 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 11:48:05 +0800 Subject: [PATCH 11/27] feat(web): migrate databases out of the jeep-sqlite store on first boot The previous web implementation kept every database as a whole-file image in a localforage store. The first initWebStore() reads that store with raw IndexedDB, imports each image through the same path a downloaded database takes, verifies it with PRAGMA integrity_check, and only then deletes the legacy store. The rules come from jeep-sqlite 2.8.0 driven in a real browser rather than from reading it: the legacy database is at version 2 with a second object store that localforage adds itself, a database opened but never saved leaves its key with an undefined value, and an interrupted version upgrade leaves a backup- copy behind. None of the three blocks retiring the store. Nothing is deleted while any database failed to import or failed its integrity check, nothing is ever written over a name already in the store, and the run happens at most once: retrying would re-import old images over databases the app has been writing to since. --- src/web.ts | 29 ++- src/web/protocol.ts | 36 ++++ src/web/worker/images.ts | 50 ++++- src/web/worker/migrate-jeep.ts | 381 +++++++++++++++++++++++++++++++++ src/web/worker/worker.ts | 39 ++++ 5 files changed, 524 insertions(+), 11 deletions(-) create mode 100644 src/web/worker/migrate-jeep.ts diff --git a/src/web.ts b/src/web.ts index 9456906e..7526582b 100644 --- a/src/web.ts +++ b/src/web.ts @@ -39,12 +39,29 @@ import type { import { WorkerClient } from './web/client'; import { WEBSTORE_NOT_OPEN, messageOf, prefixed } from './web/errors'; import { connectionNameFromFile, getLocalDiskAdapter } from './web/localdisk'; -import type { SerializedUpgrade, Tier, WorkerInitResult } from './web/protocol'; +import type { JeepMigrationResult, SerializedUpgrade, Tier, WorkerInitResult } from './web/protocol'; import { EV_PICK_DATABASE_ENDED, EV_SAVE_TO_DISK, EV_HTTP_REQUEST_ENDED } from './web/protocol'; import { ConnectionRegistry, parseKey, reconcile } from './web/registry'; import { connKey, storageName } from './web/worker/paths'; import { getSqliteWebOptions } from './web/worker-factory'; +/** + * The one-time jeep-sqlite import is loud when it does something and louder when it cannot. + * A store this plugin fails to migrate leaves the legacy data exactly where it was, so the app + * still boots; the warning is what tells the developer their users' data is still in the old + * place. Silence means there was nothing to migrate. + */ +function reportMigration(migration: WorkerInitResult['migration']): void { + if (!migration) return; + if (migration.migrated.length > 0) { + console.info( + `[capacitor-sqlite] migrated ${migration.migrated.length} database(s) out of jeep-sqlite: ` + + `${migration.migrated.join(', ')}.`, + ); + } + if (migration.warning) console.warn(`[capacitor-sqlite] ${migration.warning}`); +} + /** * Web implementation backed by `@sqlite.org/sqlite-wasm` in a dedicated worker. * @@ -65,6 +82,7 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug this.client.onEvent = (event, data) => this.notifyListeners(event, data); await this.client.start(); this.store = await this.client.call('init', getSqliteWebOptions()); + reportMigration(this.store?.migration); } catch (err) { this.store = null; throw prefixed('initWebStore', err); @@ -76,6 +94,15 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug return this.store?.tier ?? null; } + /** + * What the one-time jeep-sqlite import did on this boot, or null when it had nothing to do. + * Additive; not part of CapacitorSQLitePlugin. Useful for telling a user their data moved, and + * for noticing the warning path in an app that suppresses console output. + */ + getJeepMigration(): JeepMigrationResult | null { + return this.store?.migration ?? null; + } + /** * Shut the worker down and release the single-owner lock, so another tab can take over. * Additive; not part of CapacitorSQLitePlugin. Any open connection is dropped without a diff --git a/src/web/protocol.ts b/src/web/protocol.ts index d01cd924..fa21f490 100644 --- a/src/web/protocol.ts +++ b/src/web/protocol.ts @@ -20,6 +20,12 @@ export interface WorkerInitArgs { wasmUrl?: string; /** Where copyFromAssets looks for databases.json. Defaults to `assets/databases/`. */ assetsPath?: string; + /** + * Skip the one-time import of jeep-sqlite's IndexedDB store. Only for an app that wants to + * keep the legacy store for its own reasons; the migration is otherwise safe to leave on, + * since it is a no-op once it has run and on any installation that never used jeep-sqlite. + */ + skipJeepMigration?: boolean; } export interface WorkerInitResult { @@ -27,6 +33,25 @@ export interface WorkerInitResult { sqliteVersion: string; /** Reason the probe fell back, for logging. Absent on tier 1. */ fallbackReason?: string; + /** Outcome of the one-time jeep-sqlite migration. Absent when it had already run. */ + migration?: JeepMigrationResult; +} + +/** + * What the one-time jeep-sqlite migration did. Reported rather than thrown: a store that cannot + * be migrated must leave the legacy data untouched and warn, not stop the app from booting. + */ +export interface JeepMigrationResult { + /** False when there was no legacy store to read. */ + ran: boolean; + /** Connection names imported into the active tier. */ + migrated: string[]; + /** Legacy keys deliberately passed over: `backup-*` copies and never-saved placeholders. */ + skipped: string[]; + /** Connection names whose import or verification failed. Empty on success. */ + failed: string[]; + legacyStoreDeleted: boolean; + warning?: string; } export interface OpenArgs { @@ -100,6 +125,17 @@ export const EV_SAVE_TO_DISK = 'sqliteSaveDatabaseToDiskEvent'; */ export const IMAGE_STORE_NAME = 'databases'; +/** + * Companion store for bookkeeping that is not a database image, currently only the marker that + * says the one-time jeep-sqlite migration has already run. It is a second object store rather + * than a reserved key in `databases` so that `getDatabaseList()` on tier 2, which enumerates that + * store, cannot ever see it. + */ +export const META_STORE_NAME = 'meta'; + +/** Bumped from 1 to 2 when META_STORE_NAME was added. */ +export const IMAGE_STORE_VERSION = 2; + export function imageStoreDbName(poolName: string): string { return `${poolName}-store`; } diff --git a/src/web/worker/images.ts b/src/web/worker/images.ts index df2f1091..d2ee59ef 100644 --- a/src/web/worker/images.ts +++ b/src/web/worker/images.ts @@ -9,7 +9,7 @@ * * Raw IndexedDB on purpose: localforage is not a dependency and will not become one. */ -import { IMAGE_STORE_NAME, imageStoreDbName } from '../protocol'; +import { IMAGE_STORE_NAME, IMAGE_STORE_VERSION, META_STORE_NAME, imageStoreDbName } from '../protocol'; function request(req: IDBRequest): Promise { return new Promise((resolve, reject) => { @@ -18,6 +18,19 @@ function request(req: IDBRequest): Promise { }); } +/** + * Wait for the transaction, not just the request. A write request fires `success` well before its + * transaction commits, and the commit can still fail (quota, eviction, a force-closed connection), + * so resolving on the request alone reports durable data that was never written. + */ +function committed(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); + }); +} + export class ImageStore { private dbPromise: Promise | null = null; private name: string; @@ -30,10 +43,10 @@ export class ImageStore { if (!this.dbPromise) { const name = this.name; this.dbPromise = new Promise((resolve, reject) => { - const req = indexedDB.open(name, 1); + const req = indexedDB.open(name, IMAGE_STORE_VERSION); req.onupgradeneeded = () => { - if (!req.result.objectStoreNames.contains(IMAGE_STORE_NAME)) { - req.result.createObjectStore(IMAGE_STORE_NAME); + for (const store of [IMAGE_STORE_NAME, META_STORE_NAME]) { + if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store); } }; req.onsuccess = () => resolve(req.result); @@ -43,9 +56,17 @@ export class ImageStore { return this.dbPromise; } - private async tx(mode: IDBTransactionMode): Promise { + private async tx(mode: IDBTransactionMode, store: string = IMAGE_STORE_NAME): Promise { const db = await this.open(); - return db.transaction(IMAGE_STORE_NAME, mode).objectStore(IMAGE_STORE_NAME); + return db.transaction(store, mode).objectStore(store); + } + + /** A write, waited on all the way to the commit. */ + private async write(store: string, body: (target: IDBObjectStore) => IDBRequest): Promise { + const target = await this.tx('readwrite', store); + const done = committed(target.transaction); + await request(body(target)); + await done; } async get(storage: string): Promise { @@ -58,13 +79,11 @@ export class ImageStore { } async put(storage: string, image: Uint8Array): Promise { - const store = await this.tx('readwrite'); - await request(store.put(image, storage)); + await this.write(IMAGE_STORE_NAME, (store) => store.put(image, storage)); } async delete(storage: string): Promise { - const store = await this.tx('readwrite'); - await request(store.delete(storage)); + await this.write(IMAGE_STORE_NAME, (store) => store.delete(storage)); } async has(storage: string): Promise { @@ -78,6 +97,17 @@ export class ImageStore { const keys = await request(store.getAllKeys()); return keys.map((k) => String(k)); } + + /** Bookkeeping that is not a database image. Kept out of the image store so it cannot be listed. */ + async getMeta(key: string): Promise { + const store = await this.tx('readonly', META_STORE_NAME); + const value = await request(store.get(key)); + return value === undefined ? null : (value as T); + } + + async setMeta(key: string, value: unknown): Promise { + await this.write(META_STORE_NAME, (store) => store.put(value, key)); + } } /** diff --git a/src/web/worker/migrate-jeep.ts b/src/web/worker/migrate-jeep.ts new file mode 100644 index 00000000..f17af58e --- /dev/null +++ b/src/web/worker/migrate-jeep.ts @@ -0,0 +1,381 @@ +/** + * One-time migration out of jeep-sqlite's storage. + * + * jeep-sqlite kept every database as a whole-file image in a localforage store: IndexedDB + * database `jeepSqliteStore`, object store `databases`, out-of-line string keys of the form + * `SQLite.db`, values produced by sql.js's `export()`. This module reads that store with + * raw IndexedDB (localforage is not a dependency and will not become one), hands every image to + * the active tier through the same import path a downloaded `.sqlite` file takes, verifies each + * one with `PRAGMA integrity_check`, and only then retires the legacy store. + * + * The rules below were verified against jeep-sqlite 2.8.0 driven in a real browser rather than + * read off its source, because three of them are invisible in the source: + * + * - The legacy IndexedDB database is at **version 2 with two object stores**, `databases` and + * `local-forage-detect-blob-support`; localforage adds the second one itself. Opening with an + * explicit version 1, which is what jeep's own localforage config asks for, would fail with a + * VersionError, so the open here passes no version at all. + * - A database jeep opened but never saved leaves its key present with an **undefined value** + * (`UtilsStore.setInitialDBToStore` stores `null`, which reads back as `undefined`). Those + * keys are placeholders for a database that never had any content, not data. + * - `backup-SQLite.db` keys are written before a version upgrade and removed after it + * (jeep `utils/database.js`), so an interrupted upgrade leaves one behind. Importing one would + * conjure a phantom database called `backup-`, so it is skipped and a leftover backup + * never blocks retiring the legacy store. Only while `SQLite.db` is also present, though: + * on its own the key is the sole copy of something, quite possibly a database the app really + * did name `backup-...`, and it is then migrated like any other. + * + * Failure policy: anything that goes wrong leaves the legacy store exactly as it was and reports + * a warning. Boot is never blocked, and no legacy byte is deleted until every real database has + * been imported and has passed its integrity check. The run happens at most once either way: a + * second attempt would re-import legacy images over databases the app has been writing to since + * the first, which loses more than it recovers. + */ +import { messageOf } from '../errors'; +import type { JeepMigrationResult } from '../protocol'; + +/** localforage `name` and `storeName` from jeep's `setConfig` (jeep-sqlite.js `setConfig`). */ +export const JEEP_DB_NAME = 'jeepSqliteStore'; +export const JEEP_STORE_NAME = 'databases'; + +/** Marker key in the image store's meta store. Its value is the ISO date the migration ran. */ +export const JEEP_MIGRATION_MARKER = 'jeep-migration'; + +const BACKUP_PREFIX = 'backup-'; +const SUFFIX = 'SQLite.db'; +/** The first 16 bytes of every SQLite file are these 15 characters followed by a NUL. */ +const SQLITE_HEADER = 'SQLite format 3'; +/** How long to wait for a delete that another context is blocking before giving up on it. */ +const DELETE_TIMEOUT_MS = 5000; + +/** Where the marker lives. Implemented by ImageStore on both tiers. */ +export interface MarkerStore { + getMeta(key: string): Promise; + setMeta(key: string, value: unknown): Promise; +} + +/** + * The tier-specific half. Nothing here ever writes over a database that is already in the store: + * `exists` is consulted first, and a taken name fails that database instead. The migration cannot + * know whether such a database is a leftover of its own or one the app has been using, and only + * one of those two is safe to overwrite. + */ +export interface JeepMigrationTarget { + exists(storage: string): Promise; + adopt(storage: string, bytes: Uint8Array): Promise; + /** Open what was adopted and run `PRAGMA integrity_check`. Throws when it is not a database. */ + verify(storage: string): Promise; + /** Undo an adopt whose verification failed, so no unreadable file is left behind. */ + discard(storage: string): Promise; +} + +interface LegacyEntry { + key: string; + value: unknown; +} + +/** + * jeep's own `removePathSuffix`: `fooSQLite.db` -> `foo`, with a secondary plain-`.db` case for + * keys that predate the suffix convention. Kept here rather than in paths.ts because it is + * jeep's rule for jeep's keys, not this plugin's naming. + */ +export function databaseNameFromKey(key: string): string { + if (key.includes(SUFFIX)) return key.split(SUFFIX)[0]; + if (key.endsWith('.db')) return key.slice(0, -3); + return key; +} + +/** + * Values arrive as Uint8Array in practice; the other shapes are localforage driver defence. + * `undefined` means the value could not be decoded at all, which is NOT the same as an empty + * placeholder and must never be mistaken for one: skipping an undecodable value would delete the + * only copy of whatever it was. + */ +async function toBytes(value: unknown): Promise { + if (value === null || value === undefined) return null; + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) { + const view = value as ArrayBufferView; + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + if (typeof Blob !== 'undefined' && value instanceof Blob) return new Uint8Array(await value.arrayBuffer()); + return undefined; +} + +function looksLikeSQLite(bytes: Uint8Array): boolean { + if (bytes.byteLength < SQLITE_HEADER.length) return false; + for (let i = 0; i < SQLITE_HEADER.length; i++) { + if (bytes[i] !== SQLITE_HEADER.charCodeAt(i)) return false; + } + return true; +} + +/** + * True/false when the browser can answer cheaply, null when it cannot. `indexedDB.databases()` + * is absent on Firefox before 126, which is why the caller also has the open-and-detect path. + */ +async function legacyStoreListed(): Promise { + const idb = indexedDB as any; + if (typeof idb.databases !== 'function') return null; + try { + const list: { name?: string }[] = await idb.databases(); + return list.some((entry) => entry?.name === JEEP_DB_NAME); + } catch { + return null; + } +} + +/** + * Open the legacy store without creating one. `indexedDB.open` with no version creates the + * database when it is absent, which would leave a phantom `jeepSqliteStore` behind on every + * fresh install, so a create is detected through `onupgradeneeded` and undone immediately. + */ +function openLegacyStore(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(JEEP_DB_NAME); + let created = false; + req.onupgradeneeded = () => { + created = true; + }; + req.onsuccess = () => { + const db = req.result; + if (created) { + db.close(); + indexedDB.deleteDatabase(JEEP_DB_NAME); + resolve(null); + return; + } + if (!db.objectStoreNames.contains(JEEP_STORE_NAME)) { + db.close(); + resolve(null); + return; + } + resolve(db); + }; + req.onerror = () => reject(req.error ?? new Error(`Could not open ${JEEP_DB_NAME}`)); + }); +} + +function readAll(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const entries: LegacyEntry[] = []; + const cursor = db.transaction(JEEP_STORE_NAME, 'readonly').objectStore(JEEP_STORE_NAME).openCursor(); + cursor.onsuccess = () => { + const at = cursor.result; + if (!at) { + resolve(entries); + return; + } + entries.push({ key: String(at.key), value: at.value }); + at.continue(); + }; + cursor.onerror = () => reject(cursor.error ?? new Error(`Could not read ${JEEP_STORE_NAME}`)); + }); +} + +/** + * Best effort. A delete that another browsing context blocks stays pending indefinitely, so it + * is raced against a timeout: the databases have already been imported and verified by this + * point, and the marker stops the migration running again, so a legacy store left on disk costs + * quota rather than correctness. + */ +function deleteLegacyStore(): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (value: boolean) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + const req = indexedDB.deleteDatabase(JEEP_DB_NAME); + req.onsuccess = () => done(true); + req.onerror = () => done(false); + setTimeout(() => done(false), DELETE_TIMEOUT_MS); + }); +} + +function nothingToDo(): JeepMigrationResult { + return { ran: false, migrated: [], skipped: [], failed: [], legacyStoreDeleted: false }; +} + +/** + * Run the migration if it has not run before. Never throws: every failure path returns a result + * carrying a warning, because a store this plugin cannot read is not a reason to refuse to boot. + */ +export async function migrateFromJeep(marker: MarkerStore, target: JeepMigrationTarget): Promise { + try { + if (await marker.getMeta(JEEP_MIGRATION_MARKER)) return nothingToDo(); + } catch { + // An unreadable marker store means the migration cannot be made one-shot, and re-importing + // legacy images over live data on every boot would be worse than not migrating at all. + return { + ...nothingToDo(), + warning: 'Could not read the migration marker, so the jeep-sqlite migration was skipped.', + }; + } + + if ((await legacyStoreListed()) === false) { + await markDone(marker); + return nothingToDo(); + } + + let legacy: IDBDatabase | null = null; + try { + legacy = await openLegacyStore(); + if (!legacy) { + await markDone(marker); + return nothingToDo(); + } + const entries = await readAll(legacy); + legacy.close(); + legacy = null; + return await importEntries(marker, target, entries); + } catch (err) { + legacy?.close(); + return { + ...nothingToDo(), + ran: true, + warning: `The jeep-sqlite store could not be migrated and was left untouched: ${messageOf(err)}`, + }; + } +} + +async function importEntries( + marker: MarkerStore, + target: JeepMigrationTarget, + entries: LegacyEntry[], +): Promise { + const migrated: string[] = []; + const skipped: string[] = []; + const failed: string[] = []; + const problems: string[] = []; + + // Classify everything before importing anything, so a collision is caught while the target is + // still untouched. + const candidates: { database: string; storage: string; bytes: Uint8Array }[] = []; + const claimed = new Map(); + const keys = new Set(entries.map((entry) => entry.key)); + + for (const entry of entries) { + // A `backup-x` key is jeep's pre-upgrade copy of `x`, so it is only redundant while `x` is + // actually there. On its own it is the sole copy of something, quite possibly a database the + // app really did call `backup-...`, and skipping it would delete it along with the store. + if (entry.key.startsWith(BACKUP_PREFIX) && keys.has(entry.key.slice(BACKUP_PREFIX.length))) { + skipped.push(entry.key); + continue; + } + const bytes = await toBytes(entry.value); + const database = databaseNameFromKey(entry.key); + const storage = `${database}${SUFFIX}`; + if (bytes === undefined) { + failed.push(database); + problems.push(`${database}: the stored value is not readable as bytes`); + continue; + } + if (bytes === null || bytes.byteLength === 0) { + // jeep's placeholder for a database it created but never saved. There is nothing to import + // and nothing to lose, so it must not stand in the way of retiring the store. + skipped.push(entry.key); + continue; + } + if (!looksLikeSQLite(bytes)) { + failed.push(database); + problems.push(`${database}: the stored value is not a SQLite database image`); + continue; + } + // Two legacy keys can resolve to one database: jeep's own key format is `SQLite.db`, + // but `setPathSuffix` leaves a picked file called `foo` under the bare key `foo`, which names + // the same target as `fooSQLite.db`. Importing both would mean one silently overwriting the + // other and the loser then being deleted along with the legacy store. The second claim fails + // instead, which is enough to keep the store and everything in it. + const other = claimed.get(storage); + if (other !== undefined) { + failed.push(database); + problems.push(`${database}: the keys ${other} and ${entry.key} both claim it`); + continue; + } + claimed.set(storage, entry.key); + candidates.push({ database, storage, bytes }); + } + + for (const { database, storage, bytes } of candidates) { + // Never write over something already in the store. On a normal first boot nothing is there, + // but an app that ran with the migration disabled, or one that crashed mid-migration, can + // leave a database under this name, and importing over it would destroy live data. This is + // checked outside the try on purpose: the recovery below discards the target, which must + // never happen to a database this migration did not create. + let taken: boolean; + try { + taken = await target.exists(storage); + } catch (err) { + failed.push(database); + problems.push(`${database}: ${messageOf(err)}`); + continue; + } + if (taken) { + failed.push(database); + problems.push(`${database}: a database of that name is already in the store`); + continue; + } + + try { + await target.adopt(storage, bytes); + await target.verify(storage); + migrated.push(database); + } catch (err) { + failed.push(database); + problems.push(`${database}: ${messageOf(err)}`); + try { + await target.discard(storage); + } catch { + // Nothing further to do: the legacy image is still there and the store is kept. + } + } + } + + // The marker is set whatever happened, because this ran to completion. Retrying on the next + // boot would mean re-importing legacy images over databases the app has been writing to since, + // which costs more data than the one it would recover. A failure keeps the legacy store, so + // nothing is lost and an app that wants a retry can clear the marker itself. + await markDone(marker); + + if (failed.length > 0) { + return { + ran: true, + migrated, + skipped, + failed, + legacyStoreDeleted: false, + warning: + `The jeep-sqlite store was left in place because ${failed.length} of its databases ` + + `could not be migrated (${problems.join('; ')}). It will not be retried automatically.` + + (migrated.length > 0 ? ` Migrated: ${migrated.join(', ')}.` : ''), + }; + } + + const legacyStoreDeleted = await deleteLegacyStore(); + return { + ran: true, + migrated, + skipped, + failed, + legacyStoreDeleted, + ...(legacyStoreDeleted + ? {} + : { + warning: + 'Every jeep-sqlite database was migrated, but the legacy IndexedDB store could not be ' + + 'deleted (another tab may still hold it open). It is safe to remove by hand.', + }), + }; +} + +async function markDone(marker: MarkerStore): Promise { + try { + await marker.setMeta(JEEP_MIGRATION_MARKER, new Date().toISOString()); + } catch { + // Worst case the probe runs again on the next boot and finds nothing to do. + } +} diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index f47a0335..bf9ab57a 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -20,6 +20,8 @@ import { ImageStore, deserializeInto } from './images'; import { exportJson } from './json/export'; import { importJson } from './json/import'; import { isJsonSQLite, parseJsonSQLite } from './json/validate'; +import type { JeepMigrationTarget } from './migrate-jeep'; +import { migrateFromJeep } from './migrate-jeep'; import { connKey, fromPoolPath, poolPath, reserveCapacity, storageName } from './paths'; import * as sync from './sync'; import { selectTier } from './tiers'; @@ -75,6 +77,38 @@ function assetTarget(): AssetTarget { }; } +/** + * The tier-specific half of the jeep-sqlite migration. Adoption goes through the same import + * path a downloaded database takes; verification opens what was adopted and asks sqlite whether + * it is a database at all, which is the only check that would catch a truncated legacy image. + */ +function migrationTarget(): JeepMigrationTarget { + const target = assetTarget(); + return { + exists: (storage) => target.exists(storage), + adopt: (storage, bytes) => target.adopt(storage, bytes), + verify: async (storage) => { + // The presence check comes first and is not optional. Opening a tier 2 database whose image + // is missing yields an empty `:memory:` database, and `integrity_check` says `ok` to that, + // so without this a write that never reached IndexedDB would be reported as migrated and + // the legacy copy deleted. + if (!(await target.exists(storage))) throw new Error('nothing was stored under that name'); + const conn = tier === 1 ? openRaw(storage, false) : await openTier2(storage, false); + try { + const [row] = conn.query('PRAGMA integrity_check'); + const verdict = row?.integrity_check; + if (verdict !== 'ok') throw new Error(`integrity_check returned ${verdict ?? 'nothing'}`); + } finally { + if (conn.isOpen) conn.close(); + } + }, + discard: async (storage) => { + if (tier === 1) poolUtil.unlink(poolPath(storage)); + else await images.delete(storage); + }, + }; +} + /** * importFromJson names its own target database, which may or may not already be open. Reuse an * open read-write connection when there is one so the caller keeps seeing its own data, and @@ -156,10 +190,15 @@ const ops: Record any> = { tier = selection.tier; poolUtil = selection.poolUtil; if (tier === 1) await reserveCapacity(poolUtil, 4); + + // Runs before any connection is opened, so an import can never race a live database. + const migration = args.skipJeepMigration ? null : await migrateFromJeep(images, migrationTarget()); + return { tier, sqliteVersion: sqlite3.version.libVersion, ...(selection.fallbackReason ? { fallbackReason: selection.fallbackReason } : {}), + ...(migration && (migration.ran || migration.warning) ? { migration } : {}), }; }, From 6113810401fe3640248e57b57ed0a3cf1bb91474 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 11:48:40 +0800 Subject: [PATCH 12/27] test(web): cover the jeep-sqlite migration with a real jeep fixture The seeded store is shaped like the real one, down to localforage's own second object store, and the database image is jeep-sqlite's own output rather than a stand-in. Twenty-seven cases across both tiers: the happy path, orphan and lone backup keys, never-saved placeholders, undecodable values, corrupt and half-written images, two keys claiming one database, a live database of the same name, the once-only marker and the skip option. --- test/web/fixtures/jeep-image.ts | 46 +++++ test/web/migrate-jeep.test.ts | 320 ++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 test/web/fixtures/jeep-image.ts create mode 100644 test/web/migrate-jeep.test.ts diff --git a/test/web/fixtures/jeep-image.ts b/test/web/fixtures/jeep-image.ts new file mode 100644 index 00000000..e2e58403 --- /dev/null +++ b/test/web/fixtures/jeep-image.ts @@ -0,0 +1,46 @@ +/** + * A real jeep-sqlite database image, not a hand-rolled stand-in. + * + * Produced by driving the actual `` element (jeep-sqlite 2.8.0, sql.js) in Chromium + * and reading the bytes back out of its localforage store with raw IndexedDB, which is the same + * path the migration takes. `PRAGMA page_size = 512` was set before the schema purely to keep the + * fixture small; everything else is jeep's own output. + * + * CREATE TABLE legacy (id INTEGER PRIMARY KEY, name TEXT, payload BLOB) + * INSERT INTO legacy (name, payload) VALUES ('from jeep', x'0102ff') + * PRAGMA user_version = 3 + */ +const JEEP_IMAGE_BASE64 = + 'U1FMaXRlIGZvcm1hdCAzAAIAAQEAQCAgAAAAAgAAAAIAAAAAAAAAAAAAAAEAAAAEAAAAAAAAAAAAAAABAAAAAwAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAACAC52ig0AAAABAaAAAaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABeAQcXGRkBgRd0YWJsZWxlZ2FjeWxlZ2FjeQJDUkVBVEUg' + + 'VEFCTEUgbGVnYWN5IChpZCBJTlRFR0VSIFBSSU1BUlkgS0VZLCBuYW1lIFRFWFQsIHBheWxvYWQgQkxPQikNAAAAAQHuAAHuAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABABBAAfEmZyb20gamVlcAEC/w=='; + +export function jeepImage(): Uint8Array { + return Uint8Array.from(atob(JEEP_IMAGE_BASE64), (c) => c.charCodeAt(0)); +} + +/** The same image with its pages scribbled over but its header intact: fails integrity_check. */ +export function corruptedJeepImage(): Uint8Array { + const bytes = jeepImage(); + bytes.fill(0xab, 100); + return bytes; +} + +/** + * A half-written image: real header, but neither a whole number of pages nor a length the OPFS + * pool's importDb will accept. What an interrupted write to the legacy store would leave. + */ +export function truncatedJeepImage(): Uint8Array { + return jeepImage().slice(0, 600); +} diff --git a/test/web/migrate-jeep.test.ts b/test/web/migrate-jeep.test.ts new file mode 100644 index 00000000..83b6a5ad --- /dev/null +++ b/test/web/migrate-jeep.test.ts @@ -0,0 +1,320 @@ +/** + * The one-time import of jeep-sqlite's IndexedDB store. + * + * The fixtures are shaped like the real thing rather than like the plan's description of it: the + * legacy database is at version 2 with localforage's own `local-forage-detect-blob-support` store + * beside `databases`, a never-saved database is a key with an undefined value, and an interrupted + * upgrade leaves a `backup-SQLite.db` copy behind. All three were observed by driving + * jeep-sqlite 2.8.0 in this same browser; the database image in `fixtures/jeep-image.ts` is its + * actual output. + * + * Everything runs in one file because the legacy store is origin-wide and vitest browser mode + * gives each test FILE its own storage partition. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import type { Tier } from '../../src/web/protocol'; +import { JEEP_DB_NAME, JEEP_STORE_NAME, databaseNameFromKey } from '../../src/web/worker/migrate-jeep'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { corruptedJeepImage, jeepImage, truncatedJeepImage } from './fixtures/jeep-image'; +import { TIERS, stopHarness, tierLabel } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const BLOB_SUPPORT_STORE = 'local-forage-detect-blob-support'; + +function idb(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed')); + }); +} + +/** Recreate what localforage leaves behind: version 2, two object stores. */ +function openLegacy(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(JEEP_DB_NAME, 2); + req.onupgradeneeded = () => { + for (const store of [JEEP_STORE_NAME, BLOB_SUPPORT_STORE]) { + if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('could not seed the legacy store')); + }); +} + +async function seedLegacy(entries: Record): Promise { + const db = await openLegacy(); + const store = db.transaction(JEEP_STORE_NAME, 'readwrite').objectStore(JEEP_STORE_NAME); + // Every put is issued before the first await: a transaction goes inactive as soon as control + // returns to the event loop with nothing pending. + await Promise.all(Object.entries(entries).map(([key, value]) => idb(store.put(value, key)))); + db.close(); +} + +/** Null when the legacy database no longer exists at all. */ +async function legacyKeys(): Promise { + const present = (await (indexedDB as any).databases()).some((entry: any) => entry?.name === JEEP_DB_NAME); + if (!present) return null; + const db = await openLegacy(); + const store = db.transaction(JEEP_STORE_NAME, 'readonly').objectStore(JEEP_STORE_NAME); + const keys = await idb(store.getAllKeys()); + db.close(); + return keys.map(String); +} + +function dropLegacy(): Promise { + return new Promise((resolve) => { + const req = indexedDB.deleteDatabase(JEEP_DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +} + +const open: CapacitorSQLiteWeb[] = []; + +/** A store of its own, so the migration marker starts unset for every test. */ +async function boot(tier: Tier, poolName: string, skipJeepMigration = false): Promise { + setSqliteWebOptions({ + forceTier2: tier === 2, + simulateInstallError: undefined, + poolName, + directory: `.${poolName}`, + skipJeepMigration, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(tier); + return plugin; +} + +beforeEach(async () => { + await dropLegacy(); +}); + +afterEach(async () => { + await Promise.all(open.splice(0).map((plugin) => plugin.closeWebStore())); + await stopHarness(); + await dropLegacy(); +}); + +describe('jeep key naming', () => { + test.each([ + ['jeepdemoSQLite.db', 'jeepdemo'], + ['legacy.db', 'legacy'], + ['plain', 'plain'], + ])('%s belongs to the database %s', (key, database) => { + expect(databaseNameFromKey(key)).toBe(database); + }); +}); + +describe.each(TIERS)('jeep-sqlite migration on %s', (tier) => { + const label = tierLabel(tier); + const pool = (suffix: string) => `jeep-${tier}-${suffix}`; + + test(`imports every real database and retires the legacy store (${label})`, async () => { + await seedLegacy({ + 'jeepdemoSQLite.db': jeepImage(), + // An interrupted version upgrade leaves this behind. It is a copy of a database that is + // already present under its own key, so importing it would conjure a phantom `backup-jeepdemo`. + 'backup-jeepdemoSQLite.db': jeepImage(), + // Opened by jeep but never saved: the key exists, the value does not. + 'neversavedSQLite.db': undefined, + }); + + const plugin = await boot(tier, pool('happy')); + const migration = plugin.getJeepMigration(); + expect(migration?.migrated).toEqual(['jeepdemo']); + expect(migration?.failed).toEqual([]); + expect(migration?.skipped.sort()).toEqual(['backup-jeepdemoSQLite.db', 'neversavedSQLite.db']); + expect(migration?.warning).toBeUndefined(); + + // An orphan backup key must not stand in the way of retiring the store. + expect(migration?.legacyStoreDeleted).toBe(true); + expect(await legacyKeys()).toBeNull(); + + const list = await plugin.getDatabaseList(); + expect(list.values).toEqual(['jeepdemoSQLite.db']); + + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection('jeepdemo', false, 'no-encryption', 3, false); + await db.open(); + // The data, the schema version and the BLOB all survive the move. + expect((await db.query('SELECT id, name, payload FROM legacy')).values).toEqual([ + { id: 1, name: 'from jeep', payload: new Uint8Array([1, 2, 255]) }, + ]); + expect((await db.getVersion()).version).toBe(3); + // And it is a live database, not a read-only snapshot. + await db.run('INSERT INTO legacy (name) VALUES (?)', ['written after migrating']); + expect((await db.query('SELECT count(*) AS n FROM legacy')).values?.[0].n).toBe(2); + await sqlite.closeConnection('jeepdemo', false); + }); + + test(`a store holding nothing but placeholders is still retired (${label})`, async () => { + await seedLegacy({ 'neversavedSQLite.db': undefined, 'emptySQLite.db': new Uint8Array(0) }); + const plugin = await boot(tier, pool('placeholders')); + expect(plugin.getJeepMigration()?.migrated).toEqual([]); + expect(plugin.getJeepMigration()?.legacyStoreDeleted).toBe(true); + expect(await legacyKeys()).toBeNull(); + }); + + test(`nothing happens when there is no legacy store (${label})`, async () => { + const plugin = await boot(tier, pool('absent')); + expect(plugin.getJeepMigration()).toBeNull(); + // The probe must not create the database it went looking for. + expect(await legacyKeys()).toBeNull(); + }); + + test(`it never runs a second time (${label})`, async () => { + await seedLegacy({ 'firstSQLite.db': jeepImage() }); + const first = await boot(tier, pool('once')); + expect(first.getJeepMigration()?.migrated).toEqual(['first']); + await first.closeWebStore(); + + // A legacy store that reappears after the marker is set is left alone: on a real installation + // it can only be data the app has already moved past, and re-importing it would overwrite + // whatever the app has written since. + await seedLegacy({ 'secondSQLite.db': jeepImage() }); + const second = await boot(tier, pool('once')); + expect(second.getJeepMigration()).toBeNull(); + expect(await legacyKeys()).toEqual(['secondSQLite.db']); + expect((await second.getDatabaseList()).values).toEqual(['firstSQLite.db']); + }); + + test(`a database that will not verify leaves the whole legacy store in place (${label})`, async () => { + await seedLegacy({ + 'goodSQLite.db': jeepImage(), + 'corruptSQLite.db': corruptedJeepImage(), + 'garbageSQLite.db': new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + }); + + const plugin = await boot(tier, pool('broken')); + const migration = plugin.getJeepMigration(); + expect(migration?.failed.sort()).toEqual(['corrupt', 'garbage']); + expect(migration?.warning).toMatch(/left in place/i); + expect(migration?.legacyStoreDeleted).toBe(false); + // Not one legacy byte is thrown away while anything remains unmigrated. + expect((await legacyKeys())?.sort()).toEqual(['corruptSQLite.db', 'garbageSQLite.db', 'goodSQLite.db']); + + // The database that did import is usable, and the ones that did not were rolled back rather + // than left in the store as unreadable files. + expect(migration?.migrated).toEqual(['good']); + expect((await plugin.getDatabaseList()).values).toEqual(['goodSQLite.db']); + }); + + test(`a half-written image is refused on both tiers (${label})`, async () => { + await seedLegacy({ 'cutSQLite.db': truncatedJeepImage() }); + const plugin = await boot(tier, pool('truncated')); + const migration = plugin.getJeepMigration(); + expect(migration?.migrated).toEqual([]); + expect(migration?.failed).toEqual(['cut']); + expect(migration?.legacyStoreDeleted).toBe(false); + expect(await legacyKeys()).toEqual(['cutSQLite.db']); + // Whatever the tier did with it, nothing readable is left behind under that name. + expect((await plugin.getDatabaseList()).values).toEqual([]); + }); + + test(`a partial failure is not retried on the next boot (${label})`, async () => { + await seedLegacy({ 'goodSQLite.db': jeepImage(), 'garbageSQLite.db': new Uint8Array([1, 2, 3, 4]) }); + const first = await boot(tier, pool('noretry')); + expect(first.getJeepMigration()?.migrated).toEqual(['good']); + expect(first.getJeepMigration()?.warning).toMatch(/not be retried/i); + + // The app now writes to the database it just got back. + const sqlite = new SQLiteConnection(first); + const db = await sqlite.createConnection('good', false, 'no-encryption', 3, false); + await db.open(); + await db.run("INSERT INTO legacy (name) VALUES ('written after migrating')"); + await sqlite.closeConnection('good', false); + await first.closeWebStore(); + + // Re-importing the legacy image here would silently undo that write, which costs more than + // the one database it would recover. + const second = await boot(tier, pool('noretry')); + expect(second.getJeepMigration()).toBeNull(); + const sqlite2 = new SQLiteConnection(second); + const db2 = await sqlite2.createConnection('good', false, 'no-encryption', 3, false); + await db2.open(); + expect((await db2.query('SELECT count(*) AS n FROM legacy')).values?.[0].n).toBe(2); + await sqlite2.closeConnection('good', false); + }); + + test(`two keys claiming one database do not overwrite each other (${label})`, async () => { + // `fooSQLite.db` is jeep's own key format; a bare `foo` is what jeep's setPathSuffix leaves + // behind for a picked file with no extension. Both resolve to the same target here, so the + // second one must be refused rather than silently written over the first. + await seedLegacy({ 'fooSQLite.db': jeepImage(), foo: jeepImage() }); + + const plugin = await boot(tier, pool('collision')); + const migration = plugin.getJeepMigration(); + expect(migration?.failed).toEqual(['foo']); + expect(migration?.warning).toMatch(/both claim it/i); + expect(migration?.legacyStoreDeleted).toBe(false); + expect((await legacyKeys())?.sort()).toEqual(['foo', 'fooSQLite.db']); + }); + + test(`a value that cannot be decoded fails rather than being taken for a placeholder (${label})`, async () => { + // Not one of the shapes the migration knows how to read. Treating it as jeep's never-saved + // placeholder would drop it and then delete the store holding the only copy. + await seedLegacy({ 'oddSQLite.db': { looksLike: 'nothing we can read' } }); + const plugin = await boot(tier, pool('undecodable')); + const migration = plugin.getJeepMigration(); + expect(migration?.skipped).toEqual([]); + expect(migration?.failed).toEqual(['odd']); + expect(migration?.warning).toMatch(/not readable as bytes/i); + expect(await legacyKeys()).toEqual(['oddSQLite.db']); + }); + + test(`a backup key without its primary is a database in its own right (${label})`, async () => { + // jeep only writes backup-x while x exists. On its own the key is somebody's database, quite + // possibly one they really did call backup-2024. + await seedLegacy({ 'backup-2024SQLite.db': jeepImage() }); + const plugin = await boot(tier, pool('lonebackup')); + expect(plugin.getJeepMigration()?.migrated).toEqual(['backup-2024']); + expect(plugin.getJeepMigration()?.skipped).toEqual([]); + expect((await plugin.getDatabaseList()).values).toEqual(['backup-2024SQLite.db']); + }); + + test(`a live database of the same name is never written over (${label})`, async () => { + // What an app that ran with skipJeepMigration on, and then turned it off, looks like. + await seedLegacy({ 'notesSQLite.db': jeepImage() }); + const skipped = await boot(tier, pool('liveclash'), true); + const sqlite = new SQLiteConnection(skipped); + const db = await sqlite.createConnection('notes', false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE current (id INTEGER PRIMARY KEY, note TEXT);'); + await db.run('INSERT INTO current (note) VALUES (?)', ['written on the new engine']); + await sqlite.closeConnection('notes', false); + await skipped.closeWebStore(); + + const migrating = await boot(tier, pool('liveclash')); + const migration = migrating.getJeepMigration(); + expect(migration?.migrated).toEqual([]); + expect(migration?.failed).toEqual(['notes']); + expect(migration?.warning).toMatch(/already in the store/i); + expect(await legacyKeys()).toEqual(['notesSQLite.db']); + + // The live database is intact: not overwritten by the legacy image, and not discarded either. + const sqlite2 = new SQLiteConnection(migrating); + const db2 = await sqlite2.createConnection('notes', false, 'no-encryption', 1, false); + await db2.open(); + expect((await db2.query('SELECT note FROM current')).values).toEqual([{ note: 'written on the new engine' }]); + await sqlite2.closeConnection('notes', false); + }); + + test(`skipJeepMigration leaves the legacy store untouched (${label})`, async () => { + await seedLegacy({ 'jeepdemoSQLite.db': jeepImage() }); + const plugin = await boot(tier, pool('skip'), true); + expect(plugin.getJeepMigration()).toBeNull(); + expect(await legacyKeys()).toEqual(['jeepdemoSQLite.db']); + expect((await plugin.getDatabaseList()).values).toEqual([]); + }); +}); From 4218f4be3e17c3b5ae5d66fceae8a4d5796c5322 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 11:49:54 +0800 Subject: [PATCH 13/27] build(web): drop the jeep-sqlite dependency and the stale bundler externals Nothing imports jeep-sqlite any more. The localforage and sql.js rollup externals and globals date from a web implementation deleted in 2021. --- package-lock.json | 84 +++++------------------------------------------ package.json | 3 +- rollup.config.mjs | 4 +-- 3 files changed, 11 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index d0397f6e..bea85e82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,7 @@ "license": "MIT", "dependencies": { "@sqlite.org/sqlite-wasm": "3.53.0-build1", - "fflate": "0.8.3", - "jeep-sqlite": "^2.7.2" + "fflate": "0.8.3" }, "devDependencies": { "@capacitor/android": "^8.0.0", @@ -1369,17 +1368,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@stencil/core": { - "version": "4.25.1", - "license": "MIT", - "bin": { - "stencil": "bin/stencil" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=7.10.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2159,10 +2147,6 @@ "node": ">=8" } }, - "node_modules/browser-fs-access": { - "version": "0.35.0", - "license": "Apache-2.0" - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -2800,6 +2784,7 @@ }, "node_modules/core-util-is": { "version": "1.0.3", + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -4454,10 +4439,6 @@ "node": ">= 4" } }, - "node_modules/immediate": { - "version": "3.0.6", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.0", "dev": true, @@ -4502,6 +4483,7 @@ }, "node_modules/inherits": { "version": "2.0.4", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -4951,6 +4933,7 @@ }, "node_modules/isarray": { "version": "1.0.0", + "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -4970,17 +4953,6 @@ "lodash": "4.17.21" } }, - "node_modules/jeep-sqlite": { - "version": "2.8.0", - "license": "MIT", - "dependencies": { - "@stencil/core": "^4.20.0", - "browser-fs-access": "^0.35.0", - "jszip": "^3.10.1", - "localforage": "^1.10.0", - "sql.js": "^1.11.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -5082,16 +5054,6 @@ "node": "*" } }, - "node_modules/jszip": { - "version": "3.10.1", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -5130,13 +5092,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -5429,20 +5384,6 @@ "node": ">=4" } }, - "node_modules/localforage": { - "version": "1.10.0", - "license": "Apache-2.0", - "dependencies": { - "lie": "3.1.1" - } - }, - "node_modules/localforage/node_modules/lie": { - "version": "3.1.1", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -6125,10 +6066,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/pako": { - "version": "1.0.11", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -6414,6 +6351,7 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", + "dev": true, "license": "MIT" }, "node_modules/prompts": { @@ -6640,6 +6578,7 @@ }, "node_modules/readable-stream": { "version": "2.3.8", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -6897,6 +6836,7 @@ }, "node_modules/safe-buffer": { "version": "5.1.2", + "dev": true, "license": "MIT" }, "node_modules/safe-push-apply": { @@ -7006,10 +6946,6 @@ "node": ">= 0.4" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -7253,10 +7189,6 @@ "node": ">= 10.x" } }, - "node_modules/sql.js": { - "version": "1.12.0", - "license": "MIT" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7273,6 +7205,7 @@ }, "node_modules/string_decoder": { "version": "1.1.1", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -7873,6 +7806,7 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", + "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-license": { diff --git a/package.json b/package.json index dbbd10c6..9931461d 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,6 @@ }, "dependencies": { "@sqlite.org/sqlite-wasm": "3.53.0-build1", - "fflate": "0.8.3", - "jeep-sqlite": "^2.7.2" + "fflate": "0.8.3" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index 5b024377..a5971545 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -42,8 +42,6 @@ export default [ name: 'capacitorCapacitorSQLite', globals: { '@capacitor/core': 'capacitorExports', - localforage: 'localForage', - 'sql.js': 'initSqlJs', }, sourcemap: true, inlineDynamicImports: true, @@ -55,7 +53,7 @@ export default [ inlineDynamicImports: true, }, ], - external: ['@capacitor/core', 'localforage', 'sql.js'], + external: ['@capacitor/core'], }, { input: 'dist/esm/web/worker/worker.js', From a9edab873a854d0aed5b4873c10a5438adcd8192 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 11:50:26 +0800 Subject: [PATCH 14/27] docs(web): replace the jeep-sqlite setup with the worker and wasm story There is no element to define, no package to install alongside the plugin and no sql-wasm.wasm to copy: the worker and sqlite3.wasm ship in dist/. Documents what was never written down: the two durability tiers and what saveToStore means on each, both browser floors including the one below which the plugin does not load at all rather than degrading, the single-owner tab rule, the automatic migration, BigInt for int64 above 2^53, and that read-only connections now work on web. Corrects the storage location and the WAL claim in the API reference, whose opening prose docgen does not regenerate. JSDoc only in definitions.ts, no signature changed; the docgen output it regenerates is committed alongside. --- README.md | 63 +++--- docs/API.md | 25 ++- docs/APIConnection.md | 24 ++- docs/Ionic-Angular-Usage.md | 20 +- docs/Ionic-React-Usage.md | 40 ++-- docs/Ionic-Vue-Usage.md | 22 +- docs/SQLiteTransaction.md | 7 +- docs/SyntaxScanner-For-SQLite-Code.md | 2 +- docs/TypeORM-Usage-From-5.6.0.md | 43 ++-- docs/TypeORM-Usage.md | 15 +- docs/Web-Usage.md | 283 ++++++++++++++++++-------- src/definitions.ts | 26 ++- 12 files changed, 378 insertions(+), 192 deletions(-) diff --git a/README.md b/README.md index 42e2ed90..b1366a7c 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@

- Capacitor community plugin for Native and Electron SQLite Databases. + Capacitor community plugin for Native, Electron and Web SQLite Databases. - In Native, databases could be encrypted with `SQLCipher` - In Electron, databases could be encrypted with `better-sqlite3-multiple-ciphers` + - In Web, databases run on `@sqlite.org/sqlite-wasm` in a Worker and persist in OPFS; encryption is not available


@@ -45,8 +46,6 @@ npx cap sync ``` pnpm install --save @capacitor-community/sqlite -pnpm install --save jeep-sqlite -pnpm install --save sql.js npx cap sync ``` @@ -101,35 +100,46 @@ export default config; ## Tutorials Blog - - [JeepQ Capacitor Plugin Tutorials](https://jepiqueau.github.io/) + - [JeepQ Capacitor Plugin Tutorials](https://jepiqueau.github.io/) (the Web tutorials there describe the previous `jeep-sqlite` setup, which no longer applies) ## Web Quirks -The plugin follows the guidelines from the `Capacitor Team`, +On the Web platform the plugin runs [`@sqlite.org/sqlite-wasm`](https://github.com/sqlite/sqlite-wasm), the official SQLite build, inside a dedicated Worker that it creates itself. There is nothing to install alongside it and nothing to copy into your assets folder: the worker (`dist/web-worker.js`) and the SQLite binary (`dist/sqlite3.wasm`) ship inside the package. Full setup is in [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md), which is required reading for the Web platform. -- [Capacitor Browser Support](https://capacitorjs.com/docs/v3/web#browser-support) +`initWebStore()` is still mandatory and is still called exactly as before, once, before the first connection. -Meaning that it will not work in IE11 without additional JavaScript transformations, e.g. with [Babel](https://babeljs.io/). -You'll need the usual capacitor/android/react npm script to build and copy the assets folder. +#### Where the data lives -#### For Angular framework +`initWebStore()` picks one of two durability tiers: -- Copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `src/assets` folder of YOUR_APP +| Tier | Storage | When it is used | +| ---- | ------- | --------------- | +| 1 | Real database files in the `Origin Private File System`, through the `opfs-sahpool` VFS. Needs no COOP/COEP headers and no `SharedArrayBuffer`, so it works inside Capacitor WebViews and on ordinary hosting. | Whenever the browser has OPFS sync access handles. | +| 2 | `:memory:` databases whose whole-file image is written to IndexedDB, the model the previous implementation used. | Automatic fallback when it does not. | -#### For Vue & React frameworks +`saveToStore()` is a no-op on tier 1, where every committed write is already durable, and performs the real image flush on tier 2 (as do `close` and `closeConnection`). Calling it unconditionally is the portable pattern. The old `` `autosave` attribute is gone and has no replacement. -- Copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `public/assets` folder of YOUR_APP +#### Browser support -## Web Debugging Tools +There are two separate floors, and they mean different things: + +| Floor | Chromium / Android WebView | Safari / iOS WebKit | Firefox | What happens below it | +| ----- | -------------------------- | ------------------- | ------- | --------------------- | +| Engine (`BigInt`, optional chaining, nullish coalescing) | 80 | 14 | 74 | **The plugin does not load at all.** The failure is a syntax error inside the SQLite build, not a fallback, and no transpiler setting in your app can change it. | +| Durability (OPFS sync access handles) | 108 | 16.4 | 111 | Tier 2 above: everything works, the database is an image in IndexedDB rather than a file. Android WebView reached this in M132, January 2025. | -When using the Web platform, SQLite databases are backed by `jeep-sqlite` and stored in IndexedDB, which can be difficult to inspect during development. +#### Other things worth knowing -For easier debugging, there is a Chrome DevTools extension that allows browsing, querying, and exporting `jeep-sqlite` databases directly from IndexedDB: +- **One tab at a time.** OPFS access handles are single-owner by design, so `initWebStore()` fails with an explicit error if another tab of the same origin already owns the store. +- **Migration is automatic.** The first `initWebStore()` after upgrading imports every database left behind by the previous `jeep-sqlite`/IndexedDB implementation, verifies each one with `PRAGMA integrity_check`, and only then retires the old store. A failure leaves the old data untouched and warns on the console. +- **Integers above 2^53 are returned as `BigInt`.** The previous web engine silently lost precision on those. `JSON.stringify` refuses to serialise a `BigInt`, so code that stringifies query results may need `exportToJson`, which handles this, or a replacer. +- **No encryption.** There is no SQLCipher build for wasm, so encrypted connections and every secret-related method still reject on Web. +- **Read-only connections work on Web**, on both tiers. -- **Jeep SQLite Browser (Chrome DevTools Extension)** - - Chrome Web Store: https://chromewebstore.google.com/detail/jeep-sqlite-browser/ocgeealadeabmhponndjebghfkbfbnch - - GitHub: https://github.com/pinguluk/jeep-sqlite-browser +## Web Debugging Tools + +Where to look depends on the tier. On tier 1, open DevTools > Application > Storage and browse the Origin Private File System: the databases sit in the `.capacitor-sqlite` directory, though `opfs-sahpool` names the files opaquely, so exporting through the plugin is usually easier than reading them in place. On tier 2, they appear in IndexedDB under `capacitor-sqlite-store` > `databases`, one whole-file image per key. ## Android Quirks @@ -218,11 +228,11 @@ npm install --save-dev electron-builder@24.6.4 | Name | Android | iOS | Electron | Web | | :--------------------------- | :------ | :--- | :------- | :--- | | createConnection (ReadWrite) | ✅ | ✅ | ✅ | ✅ | -| createConnection (ReadOnly) | ✅ | ✅ | ✅ | ❌ | since 4.1.0-7 | +| createConnection (ReadOnly) | ✅ | ✅ | ✅ | ✅ | since 4.1.0-7, Web since 8.2.0 | | closeConnection (ReadWrite) | ✅ | ✅ | ✅ | ✅ | -| closeConnection (ReadOnly) | ✅ | ✅ | ✅ | ❌ | since 4.1.0-7 | +| closeConnection (ReadOnly) | ✅ | ✅ | ✅ | ✅ | since 4.1.0-7, Web since 8.2.0 | | isConnection (ReadWrite) | ✅ | ✅ | ✅ | ✅ | -| isConnection (ReadOnly) | ✅ | ✅ | ✅ | ❌ | since 4.1.0-7 | +| isConnection (ReadOnly) | ✅ | ✅ | ✅ | ✅ | since 4.1.0-7, Web since 8.2.0 | | open (non-encrypted DB) | ✅ | ✅ | ✅ | ✅ | | open (encrypted DB) | ✅ | ✅ | ✅ | ❌ | | close | ✅ | ✅ | ✅ | ✅ | @@ -259,7 +269,7 @@ npm install --save-dev electron-builder@24.6.4 | clearEncryptionSecret | ✅ | ✅ | ✅ | ❌ | | checkEncryptionSecret | ✅ | ✅ | ✅ | ❌ | | initWebStore | ❌ | ❌ | ❌ | ✅ | -| saveToStore | ❌ | ❌ | ❌ | ✅ | +| saveToStore | ❌ | ❌ | ❌ | ✅ | Web: no-op on OPFS, flushes the image on the IndexedDB tier | | getNCDatabasePath | ✅ | ✅ | ❌ | ❌ | | createNCConnection | ✅ | ✅ | ❌ | ❌ | | closeNCConnection | ✅ | ✅ | ❌ | ❌ | @@ -295,7 +305,7 @@ npm install --save-dev electron-builder@24.6.4 - [TypeORM-From-5.6.0](https://github.com/capacitor-community/sqlite/blob/master/docs/TypeORM-Usage-From-5.6.0.md) -- [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md) +- [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md) (required reading for the Web platform) - [Non Conformed Databases](https://github.com/capacitor-community/sqlite/blob/master/docs/NonConformedDatabases.md) @@ -310,6 +320,11 @@ npm install --save-dev electron-builder@24.6.4 ## Applications demonstrating the use of the plugin and related documentation +> The sample apps below predate the current Web engine. Their native code is unaffected, but every +> Web setup step they show (installing `jeep-sqlite`, defining the custom element, copying +> `sql-wasm.wasm`) has been removed from the plugin. Follow +> [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md) instead. + ### Ionic/Angular - [Web ionic7-angular-sqlite-app](https://github.com/jepiqueau/blog-tutorials-apps/tree/main/SQLite/Part-1/ionic7-angular-sqlite-app) Ionic 7 Angular 16 Capacitor 5 SQLite CRUD operations for Web. @@ -370,7 +385,7 @@ npm install --save-dev electron-builder@24.6.4 The iOS and Android codes are using `SQLCipher` allowing for database encryption. The iOS code is using `ZIPFoundation` for unzipping assets files The Electron code is using `better-sqlite3-multiple-ciphers` , `electron-json-storage` and `node-fetch` from 5.0.4. -The Web code is using the Stencil component `jeep-sqlite` based on `sql.js`, `localforage`. and `jszip` +The Web code is using `@sqlite.org/sqlite-wasm`, the official SQLite wasm build, in a dedicated Worker, with `fflate` for unzipping assets files. ## Contributors ✨ diff --git a/docs/API.md b/docs/API.md index 25e3a03c..a680de7b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -87,7 +87,12 @@ The plugin add a suffix "SQLite" and an extension ".db" to the database name giv ### Web -- the database is stored in Web browser INDEXEDDB storage as a `localforage` store under the `jeepSqliteStore` name and `databases` table name. +Where a database is stored depends on the durability tier the plugin selected when `initWebStore()` ran: + +- Tier 1: as a real file in the browser's `Origin Private File System`, in the pool directory `.capacitor-sqlite`, through the `opfs-sahpool` VFS. This is the normal case. +- Tier 2 (fallback for browsers without OPFS sync access handles): as a whole-database image in `IndexedDB`, in a database named `capacitor-sqlite-store` under the object store `databases`. + +In both cases the file name is `YOUR_DATABASE_NAMESQLite.db`, the same convention the native platforms use. See [Web Usage](Web-Usage.md) for the browser requirements behind that choice. ## Comments within SQL statements @@ -164,7 +169,7 @@ Unexpected or erroneous behaviour users of this library have encountered. In https://github.com/capacitor-community/sqlite/issues/393 a user of this library experienced bugs when running a statement that itself contained multiple update statements. -The statement executed fine on the web version of this library (sql-wasm.wasm). +The statement executed fine on the web version of this library. But on android and IOS only some updates took place, some updates were ignored and did not take effect in the database. @@ -176,12 +181,14 @@ Note that in general in SQLite this is not recommended, since it makes your quer ## Write-Ahead Logging (WAL) - - Electron, Web platforms only WAL journal_mode is implemented + - Electron platform: only WAL journal_mode is implemented - Both WAL and WAL2 journal_mode are implemented - Android WAL2 is set by default, so you do not need to set it up + - Web platform: WAL is NOT available. The `opfs-sahpool` VFS has no shared-memory support, so tier 1 databases stay on the `delete` journal and a `PRAGMA journal_mode=WAL` is silently refused; tier 2 databases run in memory and use the `memory` journal. + ## Error Return values - For all methods, a message containing the error message will be returned @@ -276,6 +283,12 @@ initWebStore() => Promise Initialize the web store +Mandatory on the web platform and must resolve before the first `createConnection`. It starts +the plugin's SQLite worker, selects the durability tier (databases as files in OPFS, or as +whole-file images in IndexedDB on browsers without OPFS sync access handles), and on its very +first run imports any database left behind by the previous jeep-sqlite implementation. +Rejects when another tab of the same origin already owns the store. + **Since:** 3.2.3-1 -------------------- @@ -287,7 +300,11 @@ Initialize the web store saveToStore(options: capSQLiteOptions) => Promise ``` -Save database to the web store +Save database to the web store + +A no-op when the web store is on OPFS, where every committed write is already durable, and +the real flush of the database image to IndexedDB on the fallback tier. Safe and cheap to +call unconditionally after a batch of writes. | Param | Type | Description | | ------------- | ------------------------------------------------------------- | -------------------------------------------------- | diff --git a/docs/APIConnection.md b/docs/APIConnection.md index 422dd953..3992ff3f 100644 --- a/docs/APIConnection.md +++ b/docs/APIConnection.md @@ -64,6 +64,11 @@ initWebStore() => Promise Init the web store +Mandatory on the web platform and must resolve before the first `createConnection`. It starts +the plugin's SQLite worker, selects the durability tier, and on its very first run imports any +database left behind by the previous jeep-sqlite implementation. Rejects when another tab of +the same origin already owns the store. + **Since:** 3.2.3-1 -------------------- @@ -75,7 +80,10 @@ Init the web store saveToStore(database: string) => Promise ``` -Save the datbase to the web store +Save the database to the web store + +A no-op when the web store is on OPFS, and the real flush of the database image to IndexedDB +on the fallback tier. Safe and cheap to call unconditionally. | Param | Type | | -------------- | ------------------- | @@ -247,13 +255,13 @@ createConnection(database: string, encrypted: boolean, mode: string, version: nu Create a connection to a database -| Param | Type | -| --------------- | -------------------- | -| **`database`** | string | -| **`encrypted`** | boolean | -| **`mode`** | string | -| **`version`** | number | -| **`readonly`** | boolean | +| Param | Type | Description | +| --------------- | -------------------- | --------------------------------------------- | +| **`database`** | string | | +| **`encrypted`** | boolean | not available on the web platform | +| **`mode`** | string | | +| **`version`** | number | | +| **`readonly`** | boolean | supported on every platform, the web included | **Returns:** Promise<SQLiteDBConnection> diff --git a/docs/Ionic-Angular-Usage.md b/docs/Ionic-Angular-Usage.md index 359c9452..71217ee4 100644 --- a/docs/Ionic-Angular-Usage.md +++ b/docs/Ionic-Angular-Usage.md @@ -50,6 +50,15 @@ export class SQLiteService { } this.sqlitePlugin = CapacitorSQLite; this.sqlite = new SQLiteConnection(this.sqlitePlugin); + if ( this.platform === 'web' ) { + // Required on web, and the only web-specific step there is: it boots the plugin's + // worker, selects the durability tier (OPFS when the browser supports it, whole + // database images in IndexedDB otherwise), and on its very first run imports any + // database left behind by the previous jeep-sqlite based implementation. It must + // complete before any createConnection call. There is no element to add to the DOM + // and no wasm file to copy into the app's assets. + await this.sqlite.initWebStore(); + } this.isService = true; return true; } @@ -165,12 +174,13 @@ export class SQLiteService { * @param encrypted * @param mode * @param version + * @param readonly read-only connections work on every platform, web included */ async createConnection(database: string, encrypted: boolean, - mode: string, version: number + mode: string, version: number, readonly = false ): Promise { this.ensureConnectionIsOpen(); - const db: SQLiteDBConnection = await this.sqlite.createConnection(database, encrypted, mode, version, false); + const db: SQLiteDBConnection = await this.sqlite.createConnection(database, encrypted, mode, version, readonly); if ( db == null ) { throw new Error(`no db returned is null`); } @@ -316,7 +326,11 @@ export class SQLiteService { } /** - * Save a database to store + * Save a database to store. + * On the web this flushes the whole database image to IndexedDB, but only on the fallback + * tier: when the plugin is running on OPFS every committed write is already durable and this + * is a no-op. Calling it after a batch of writes is the portable pattern either way. The + * "autosave" attribute it used to pair with no longer exists. * @param database */ async saveToStore(database: string): Promise { diff --git a/docs/Ionic-React-Usage.md b/docs/Ionic-React-Usage.md index 72461924..709a8914 100644 --- a/docs/Ionic-React-Usage.md +++ b/docs/Ionic-React-Usage.md @@ -34,10 +34,14 @@ To install it in your Ionic/React App npm i --save-dev react-sqlite-hook@latest ``` - - `for Web Browser` -```bash - npm i --save-dev jeep-sqlite@latest -``` + - `for Web Browser` + + Nothing further to install. The plugin ships its own worker (`dist/web-worker.js`) and SQLite + build (`dist/sqlite3.wasm`). Note the browser floors: the plugin needs `BigInt`, optional + chaining and nullish coalescing (Chrome/Android WebView 80, Safari 14, Firefox 74) or it does + not load at all, and it stores databases in OPFS only where sync access handles exist + (Chromium 108, WebKit 16.4, Firefox 111, Android WebView M132), falling back to IndexedDB + images below that. See [Web Usage](Web-Usage.md). ### React SQLite Hook Declaration for platforms other than Web @@ -86,7 +90,7 @@ export default App; Now the Singleton SQLite Hook `sqlite`and Existing Connections Store `existingConn` can be used in other components ### React SQLite Hook Declaration for platforms including Web -As for the Web platform, the `jeep-sqlite` Stencil component is used and requires the DOM it is then defined and initialized in the `index.tsx` file. +On the Web platform the only extra step is `initWebStore()`, called once before any connection is created. It boots the plugin's worker, selects the durability tier, and on its very first run imports any database left behind by the previous `jeep-sqlite` based implementation. There is no custom element to define, no `applyPolyfills`, and no DOM dependency, so the declaration below no longer has to live in `index.tsx` for the web's sake. ```ts import React from 'react'; @@ -94,36 +98,15 @@ import { createRoot } from 'react-dom/client'; import App from './App'; import * as serviceWorkerRegistration from './serviceWorkerRegistration'; import reportWebVitals from './reportWebVitals'; -import { defineCustomElements as jeepSqlite, applyPolyfills, JSX as LocalJSX } from "jeep-sqlite/loader"; -import { HTMLAttributes } from 'react'; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; -type StencilToReact = { - [P in keyof T]?: T[P] & Omit, 'className'> & { - class?: string; - }; -} ; - -declare global { - export namespace JSX { - interface IntrinsicElements extends StencilToReact { - } - } -} - -applyPolyfills().then(() => { - jeepSqlite(window); -}); window.addEventListener('DOMContentLoaded', async () => { console.log('$$$ in index $$$'); const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) try { if(platform === "web") { - const jeepEl = document.createElement("jeep-sqlite"); - document.body.appendChild(jeepEl); - await customElements.whenDefined('jeep-sqlite'); await sqlite.initWebStore(); } const ret = await sqlite.checkConnectionsConsistency(); @@ -329,6 +312,8 @@ const Test2dbs: React.FC = () => { // initialize the connection const db = await sqlite .createConnection("testNew", false, "no-encryption", 1); + // Native and Electron only: encryption is not supported on the Web platform, where + // this call rejects. const db1 = await sqlite .createConnection("testSet", true, "secret", 1); @@ -425,6 +410,9 @@ const Test2dbs: React.FC = () => { return false; } if (platform === "web") { + // Tier 2 (the IndexedDB fallback) only: a no-op when the plugin is running on + // OPFS, where every committed write is already durable. Calling it + // unconditionally is the portable pattern. await sqlite.saveToStore("testNew"); await sqlite.saveToStore("testSet"); } diff --git a/docs/Ionic-Vue-Usage.md b/docs/Ionic-Vue-Usage.md index 4620b971..dc6800db 100644 --- a/docs/Ionic-Vue-Usage.md +++ b/docs/Ionic-Vue-Usage.md @@ -30,6 +30,13 @@ To install it in your Ionic/Vue App npm i --save-dev vue-sqlite-hook@latest ``` +No extra package is needed for the Web platform: the plugin ships its own worker +(`dist/web-worker.js`) and SQLite build (`dist/sqlite3.wasm`). It does have browser floors, +though: `BigInt`, optional chaining and nullish coalescing (Chrome/Android WebView 80, Safari 14, +Firefox 74) or it does not load at all, and OPFS sync access handles (Chromium 108, WebKit 16.4, +Firefox 111, Android WebView M132) for durable files rather than the IndexedDB fallback. See +[Web Usage](Web-Usage.md). + ### Vue SQLite Hook Declaration for platforms other than Web To use the `vue-sqlite-hook`as a singleton hook, the declaration must be done in the `main.ts` file of your application @@ -99,21 +106,20 @@ Now the Singleton SQLite Hook `$sqlite`and Existing Connections Store `$existing ### Vue SQLite Hook Declaration for platforms including Web -As for the Web platform, the `jeep-sqlite` Stencil component is used and requires the DOM: -the declaration of the SQLite Hook has to be moved to the App.vue +On the Web platform the only extra step is `initWebStore()`, called once before any connection is +created. It boots the plugin's worker, selects the durability tier, and on its very first run +imports any database left behind by the previous `jeep-sqlite` based implementation. Nothing needs +the DOM any more, so the SQLite Hook declaration can stay in `main.ts` exactly as in the section +above; the App.vue variant below is kept only as an alternative pattern. So the `main.ts`file ```js ... -import { defineCustomElements as jeepSqlite, applyPolyfills } from "jeep-sqlite/loader"; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; import { useState } from '@/composables/state'; import { schemaToImport179 } from '@/utils/utils-import-from-json'; -applyPolyfills().then(() => { - jeepSqlite(window); -}); window.addEventListener('DOMContentLoaded', async () => { const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) @@ -138,10 +144,6 @@ window.addEventListener('DOMContentLoaded', async () => { try { if(platform === "web") { - // Create the 'jeep-sqlite' Stencil component - const jeepSqlite = document.createElement('jeep-sqlite'); - document.body.appendChild(jeepSqlite); - await customElements.whenDefined('jeep-sqlite'); // Initialize the Web store await sqlite.initWebStore(); } diff --git a/docs/SQLiteTransaction.md b/docs/SQLiteTransaction.md index 07b78d6b..b4c0ee99 100644 --- a/docs/SQLiteTransaction.md +++ b/docs/SQLiteTransaction.md @@ -10,9 +10,9 @@ ### By Default The three SQLite methods (execute, executeSet, and run) in the `@capacitor-community/sqlite` plugin are inherently transactional. - On the Web platform, the in-memory database is saved in the store after the execution of each method if you enable the autosave property of the jeep-sqlite web component. + On the Web platform, how durable that transaction is depends on the tier the plugin selected when `initWebStore()` ran. On tier 1 (OPFS) a committed transaction is already on disk when the method resolves, exactly as on native. On tier 2 (the `:memory:` + IndexedDB fallback, used when the browser has no OPFS sync access handles) the change lives in memory until the database image is flushed, which happens on `saveToStore`, `close`, `closeConnection`, after `importFromJson`, and after a committed explicit transaction. See [Web Usage](Web-Usage.md) for how to tell which tier you are on. - This approach is suitable and secure when the methods are executed from a UI component. However, it can be notably slow when dealing with a batch of commands. + This approach is suitable and secure when the methods are executed from a UI component. However, it can be notably slow when dealing with a batch of commands, because every call pays for its own implicit transaction. Code example: @@ -95,6 +95,7 @@ // Commit Transaction await db.commitTransaction() if (platform === 'web') { + // Tier 2 (the IndexedDB fallback) only: a no-op when the plugin is running on OPFS. await sqliteServ.saveToStore(dbName); } setLog(prevLog => prevLog + '### Commit Test Transaction Manage ###\n'); @@ -118,6 +119,8 @@ This method has been updated to utilize the new techniques outlined in the `For Batch of Commands` chapter. It accepts a collection of tasks of type `capTask` as its input parameter. + On the Web platform the whole task set is durable as soon as `executeTransaction` resolves when the plugin is on tier 1 (OPFS). On tier 2, follow it with `saveToStore(dbName)` exactly as in the example above. + ```ts const testExecuteTransaction = async (db: SQLiteDBConnection) => { setLog(prevLog => prevLog + '### Start Execute Transaction ###\n'); diff --git a/docs/SyntaxScanner-For-SQLite-Code.md b/docs/SyntaxScanner-For-SQLite-Code.md index 34df6297..cde26028 100644 --- a/docs/SyntaxScanner-For-SQLite-Code.md +++ b/docs/SyntaxScanner-For-SQLite-Code.md @@ -12,7 +12,7 @@ This was only tested with Intellij Ultimate syntax highlighting that comes when In for example H2-database, with a normal database file you can always connect it to the IDE and get syntax highlighting. This capacitor-community/sqlite plugin however doesn't use a "normal database file" however. -Instead it uses either the IOS/Android emulator or real phone db-file, or if you are using the web version of this plugin then it is using the sql-wasm.wasm file. +Instead it uses either the IOS/Android emulator or real phone db-file, or if you are using the web version of this plugin then the database lives inside the browser, in the Origin Private File System or, on browsers without it, as an image in IndexedDB. All of those database files cannot be read from an IDE like IntelliJ and thus it might give you a lot of errors when looking at your SQLite code snippets in typescript saying things like "table not found" etc. ### The solution: diff --git a/docs/TypeORM-Usage-From-5.6.0.md b/docs/TypeORM-Usage-From-5.6.0.md index be95d59e..aab43af5 100644 --- a/docs/TypeORM-Usage-From-5.6.0.md +++ b/docs/TypeORM-Usage-From-5.6.0.md @@ -13,12 +13,19 @@ - The `typeOrm` package now has a `capacitor` driver type that must be used with the `@capacitor-community/sqlite` capacitor plugin. - - Apps using the `@capacitor-community/sqlite` capacitor plugin cannot use the `CLI` of the `typeOrm` package. Developers trying to do so, will receive a message stating that the "jeep-sqlite element is not present in the DOM". The Web part of the plugin needs the DOM to create and store the database. + - Apps using the `@capacitor-community/sqlite` capacitor plugin cannot use the `CLI` of the `typeOrm` package. The `CLI` runs under Node, and the Web part of the plugin needs a browser: it runs SQLite in a dedicated Worker and stores databases in the Origin Private File System, or in IndexedDB on browsers without it. Neither exists under Node, so the CLI cannot reach a database. - In release 5.6.0, I attempted to propose a workaround by utilizing certain Node.js packages like 'fs', 'os', and 'path' to facilitate the generation of initial and subsequent migration files. While this workaround worked smoothly on some frameworks using Vite (such as React and Svelte), unfortunately, I encountered difficulties with Angular due to the inability to find a suitable method for building the app. - In release 5.6.1.1, I decided to retract the proposal for the workaround and refrain from implementing it. As a consequence, migration files will need to be created manually. + - On the Web platform, four constraints are worth knowing before you start. They are described in full in [Web Usage](Web-Usage.md): + + - The plugin needs a browser with `BigInt`, optional chaining and nullish coalescing (Chrome/Android WebView 80, Safari 14, Firefox 74). Below that it does not load at all, and no transpiler target can change that. + - Databases are durable files in the Origin Private File System when the browser supports OPFS sync access handles (Chromium 108, WebKit 16.4, Firefox 111, Android WebView M132), and whole-database images in IndexedDB otherwise. The `saveToStore` calls below matter only in the second case. + - Only one browser tab may own the store. A second tab gets an explicit error from `initWebStore()`. + - Integer values above 2^53 are returned as `BigInt`. Declare such columns as `bigint` in your entities with a transformer rather than `number`, which the previous web engine let you get away with by silently losing precision. + ## Applications/Tutorials @@ -368,12 +375,9 @@ Somewhere in the `main.ts` file of your App you must initialize your DataSources ```ts ... -import { JeepSqlite } from 'jeep-sqlite/dist/components/jeep-sqlite'; import sqliteParams from './databases/sqliteParams'; import authorDataSource from './databases/datasources/AuthorDataSource'; -customElements.define('jeep-sqlite', JeepSqlite); - const initializeDataSources = async () => { //check sqlite connections consistency await sqliteParams.connection.checkConnectionsConsistency() @@ -391,31 +395,28 @@ const initializeDataSources = async () => { await mDataSource.dataSource.runMigrations(); } if( sqliteParams.platform === 'web') { + // Tier 2 (the IndexedDB fallback) only: a no-op when the plugin is running on OPFS. await sqliteParams.connection.saveToStore(mDataSource.dbName); } } } -if (sqliteParams.platform !== "web") { - initializeDataSources(); +const start = async () => { + if (sqliteParams.platform === "web") { + // Required on web, and the only web-specific step there is. It boots the plugin's worker, + // selects the durability tier, and on its first run imports any database left behind by the + // previous jeep-sqlite based implementation. + await sqliteParams.connection.initWebStore(); + } + await initializeDataSources(); // Now depending on the Framework render your APP ... -} else { - window.addEventListener('DOMContentLoaded', async () => { - const jeepEl = document.createElement("jeep-sqlite"); - document.body.appendChild(jeepEl); - customElements.whenDefined('jeep-sqlite').then(async () => { - await sqliteParams.connection.initWebStore(); - await initializeDataSources(); - // Now depending on the Framework render your APP - ... - }) - .catch ((err) => { - console.log(`Error: ${err}`); - throw new Error(`Error: ${err}`) - }); - }); +} +start().catch((err) => { + console.log(`Error: ${err}`); + throw new Error(`Error: ${err}`); +}); ``` diff --git a/docs/TypeORM-Usage.md b/docs/TypeORM-Usage.md index 4bafb829..332bb8c0 100644 --- a/docs/TypeORM-Usage.md +++ b/docs/TypeORM-Usage.md @@ -4,6 +4,10 @@ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!! This documentation is obsolete if you are using Ionic7 and Vite !!!! !!!! Go To TypeORM-Usage-From-5.6.0 +!!!! +!!!! Its Web setup also predates the current web engine. All Web setup, +!!!! including initWebStore and asset handling, is documented in +!!!! TypeORM-Usage-From-5.6.0 and in Web-Usage. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ```typescript @@ -220,7 +224,7 @@ module.exports = { "$schema": "https://json.schemastore.org/swcrc", "jsc": { "externalHelpers": true, - "target": "es5", + "target": "es2020", "preserveAllComments": true, "parser": { "syntax": "typescript", @@ -236,6 +240,8 @@ module.exports = { } ``` +The target must stay at `es2020` or later. The web engine depends on `BigInt`, which cannot be downlevelled, and the shipped `@sqlite.org/sqlite-wasm` bundle uses optional chaining and nullish coalescing. Building for `es5` does not widen browser support here; it only breaks the build. + #### 3. Update build commands to use craco CLI In particular in the : @@ -245,14 +251,15 @@ In particular in the : ```json "scripts": { - "start": "npm run copysqlwasm && craco start", - "build": "npm run copysqlwasm && craco build", - "copysqlwasm": "copyfiles -u 3 node_modules/sql.js/dist/sql-wasm.wasm public/assets", + "start": "craco start", + "build": "craco build", "ionic:build": "npm run build", "ionic:serve": "npm run start" }, ``` +There is no wasm copy step any more. The plugin ships `dist/web-worker.js` and `dist/sqlite3.wasm` itself and resolves them at runtime; only apps whose bundler cannot serve those files need `setSqliteWebOptions({ wasmUrl })` or `setSqliteWorkerFactory()`, both described in [Web Usage](Web-Usage.md). + #### 4. Add the following to tsconfig.json ```json diff --git a/docs/Web-Usage.md b/docs/Web-Usage.md index 6f48abec..106dc009 100644 --- a/docs/Web-Usage.md +++ b/docs/Web-Usage.md @@ -6,49 +6,162 @@ ## General to all applications -This is the description on how to use the Web part of the @capacitor-community/sqlite for development purpose. - -When your developement is fully tested, it will be a good idea to minimize the package size of your native app to remove the `jeep-sqlite` Stencil component and the `sql-wasm.wasm` file from the assets folder. - +This describes how to use the Web part of `@capacitor-community/sqlite`. It is a real SQLite, fit +for production, not only for development: the plugin runs +[`@sqlite.org/sqlite-wasm`](https://github.com/sqlite/sqlite-wasm), the official SQLite wasm build, +inside a dedicated Worker that it creates and owns. All SQL and all persistence happen in that +worker; the main thread never touches the wasm heap. ```bash npm i --save @capacitor-community/sqlite@latest -npm i --save jeep-sqlite@latest ``` -`jeep-sqlite` is a Stencil Component which is using `sql.js` for sql in-memory queries and store the database in the Browser on a `localforage` IndexedDB store named `jeepSqliteStore` and inside a table named `databases`. +That is the whole install. There is no second package for the Web platform: the worker +(`dist/web-worker.js`) and the SQLite binary (`dist/sqlite3.wasm`) ship inside the plugin, and +there is no file to copy into your assets folder. + +`initWebStore()` is mandatory on Web and must complete before the first `createConnection`. It +boots the worker, selects the durability tier, and, the very first time it runs after an upgrade, +imports any database left behind by the previous `jeep-sqlite` implementation. + +## App Index + +* [`Requirements and browser support`](#requirements-and-browser-support) +* [`Durability tiers`](#durability-tiers) +* [`Serving the worker and the wasm`](#serving-the-worker-and-the-wasm) +* [`Migrating from jeep-sqlite`](#migrating-from-jeep-sqlite) +* [`Limitations`](#limitations) +* [`Ionic/Angular App`](#ionicangular-app) +* [`Ionic/Vue App`](#ionicvue-app) +* [`Ionic/React App`](#ionicreact-app) +* [`Troubleshooting`](#troubleshooting) + +## Requirements and browser support + +There are two floors, and they are not the same floor. The first decides whether the plugin runs +at all; the second decides only where your data rests. + +| Floor | Chromium / Android WebView | Safari / iOS WebKit | Firefox | Below it | +| ----- | -------------------------- | ------------------- | ------- | -------- | +| **Engine**: `BigInt`, optional chaining, nullish coalescing | 80 | 14 | 74 | **Nothing loads.** The worker fails to parse and `initWebStore()` rejects. | +| **Durability**: OPFS sync access handles | 108 | 16.4 | 111 | Everything works, on tier 2 below. Android WebView reached this in M132, January 2025. | + +The engine floor is not a limitation this plugin can lift. `BigInt` is a runtime dependency of +SQLite's int64 support and cannot be polyfilled, and the ES2020 syntax is upstream +`@sqlite.org/sqlite-wasm`'s own, so lowering your app's build target does not help and only breaks +your own code. Anything older than that floor is unsupported on the Web platform and fails loudly +rather than degrading. -🚨 The database is stored from in-memory to `localforage` IndexedDB store when one requires +No COOP/COEP headers and no `SharedArrayBuffer` are required on either tier, which is what makes +this work unchanged inside Capacitor's `capacitor://` and `https://localhost` WebViews and on +ordinary static hosting. + +## Durability tiers + +`initWebStore()` probes the browser and settles on one of two tiers for the lifetime of the worker. + +**Tier 1, the normal case.** Databases are real files in the browser's Origin Private File System, +reached through SQLite's own `opfs-sahpool` VFS, in a pool directory named `.capacitor-sqlite`. +Every committed write is durable when the method resolves, exactly as on native. + +**Tier 2, the automatic fallback** when the browser has no OPFS sync access handles. Databases are +opened `:memory:` and the whole database image is written to IndexedDB, in a database named +`capacitor-sqlite-store` under the object store `databases`. This is the model the previous +`jeep-sqlite` implementation used everywhere. + +🚨 On tier 2 the image is written to IndexedDB when one requires - a `saveToStore`, - a `close`, - - a `closeConnection`. + - a `closeConnection`, + +and additionally after an `importFromJson` and after a committed explicit transaction. 🚨 -## App Index +`saveToStore()` is a no-op on tier 1 and a real flush on tier 2, so calling it after a batch of +writes is the portable pattern and costs nothing where it is unnecessary. The `` +element's `autosave` attribute is gone and has no replacement: there is no element to put it on, +and on tier 1 there is nothing for it to do. -* [`Ionic/Angular App`](#ionic/angular-app) -* [`Ionic/Vue App`](#ionic/vue-app) -* [`Ionic/React App`](#ionic/react-app) +Only one browsing context may own the store, because OPFS access handles are single-owner by +design. If another tab of the same origin already holds it, `initWebStore()` rejects with an +explicit error rather than quietly opening an empty database over your data. -## Ionic/Angular App +## Serving the worker and the wasm + +The default path needs no configuration: the plugin builds its worker from `dist/web-worker.js` +inside the package, and that worker loads `dist/sqlite3.wasm` sitting next to it. Vite, Angular's +builder and webpack 5 all resolve and emit both files without help, and Capacitor copies them into +the native web assets on `npx cap sync`. + +Two escape hatches exist for setups where that resolution fails, both additive exports of the +plugin and both to be called before `initWebStore()`: -- **sql-wasm.wasm** - - Either copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `src/assets` folder of YOUR_APP - - or `npm i --save-dev copyfiles` and modify the scripts in the `package.json` file as follows: +```ts +import { setSqliteWebOptions, setSqliteWorkerFactory } from '@capacitor-community/sqlite'; + +// 1. The wasm is served from somewhere else, for instance a CDN or a hashed asset path. +setSqliteWebOptions({ wasmUrl: '/assets/sqlite3.wasm' }); + +// 2. The bundler inlines the plugin and loses the worker URL. Build the worker yourself. +setSqliteWorkerFactory(() => new Worker(new URL('./web-worker.js', import.meta.url))); +``` + +`setSqliteWebOptions` also accepts `assetsPath`, if your prepopulated databases are not served +from `assets/databases/`. + +One bundler note: a Vite build also emits `sqlite3-worker1.js` and `sqlite3-opfs-async-proxy.js`, +about 237 KiB together, because `@sqlite.org/sqlite-wasm`'s entry point references them with +`new URL`. This plugin uses neither and never fetches them at runtime; they are dead weight in the +output directory, and you can exclude them in your bundler configuration if the size matters. + +## Migrating from jeep-sqlite + +Nothing to do. The first `initWebStore()` after upgrading reads the old +`jeepSqliteStore` IndexedDB store, imports every database it finds into the active tier, and +verifies each one with `PRAGMA integrity_check` before retiring the legacy store. It runs once and +then records that it has run. + +Two details worth knowing if you are watching the console: + +- A leftover `backup-SQLite.db` key, which jeep-sqlite writes before a version upgrade and + deletes after it, is skipped rather than imported as a database in its own right, and does not + prevent the old store being retired. That only applies while `SQLite.db` is there too: a + `backup-` key on its own is the only copy of something and is migrated like any other database. +- A database that already exists under the same name is never written over. That cannot happen on + a normal upgrade, where nothing has been opened yet, but it can if you ran with + `skipJeepMigration` and later turned it off. The legacy database is reported as failed and both + copies are left intact for you to reconcile. +- If any database fails to import or fails its integrity check, nothing is deleted. The legacy + store is left exactly as it was and a warning naming the database is written to the console, so + the data is still recoverable by hand. The migration is not retried on the next boot: by then + the databases that did migrate are live, and re-importing the old images over them would undo + whatever the app has written since. + +The databases keep their names, so no application code changes. + +## Limitations + +- **No encryption.** There is no SQLCipher build for wasm. `createConnection` with + `encrypted: true` rejects, as do `setEncryptionSecret`, `changeEncryptionSecret`, + `clearEncryptionSecret`, `checkEncryptionSecret`, `isSecretStored`, `isDatabaseEncrypted`, + `isInConfigEncryption` and `isInConfigBiometricAuth`. +- **No WAL.** `opfs-sahpool` has no shared-memory support, so tier 1 stays on the `delete` journal + and a `PRAGMA journal_mode=WAL` is silently refused. Tier 2 runs on the `memory` journal. +- **Integers above 2^53 come back as `BigInt`.** This is a correctness improvement over the + previous engine, which silently lost precision, but `JSON.stringify` throws a `TypeError` on a + `BigInt`. Use `exportToJson`, which encodes out-of-range integers as decimal strings that SQLite + reads back identically on import, or supply your own replacer. +- **One owning tab per origin**, as described under [Durability tiers](#durability-tiers). +- **Not implemented on Web**: `getUrl`, the Cordova migration helpers (`getMigratableDbList`, + `addSQLiteSuffix`, `deleteOldDatabases`, `moveDatabasesAndAddSuffix`) and the non-conformed + database methods (`getNCDatabasePath`, `createNCConnection`, `closeNCConnection`, + `isNCDatabase`), which are all filesystem-path based and native-only. + +Read-only connections (`readonly: true`) are supported on Web, on both tiers. - ``` - "scripts": { - "ng": "ng", - "start": "npm run copysqlwasm && ng serve", - "build": "npm run copysqlwasm && ng build", - "test": "ng test", - "lint": "ng lint", - "e2e": "ng e2e", - "copysqlwasm": "copyfiles -u 3 node_modules/sql.js/dist/sql-wasm.wasm src/assets" - }, - ``` +## Ionic/Angular App -- For databases in the `src/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names +- For databases in the `src/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names (on Web that is every database, since encryption is unsupported there) ```json { @@ -60,13 +173,11 @@ npm i --save jeep-sqlite@latest } ``` -- open the `main.ts` file and add the following +- `main.ts` needs no web-specific step. There is no custom element to register, so bootstrap the + app as you normally would ```js ... -import { defineCustomElements as jeepSqlite} from 'jeep-sqlite/loader'; -... -jeepSqlite(window); platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.log(err)); ``` @@ -74,7 +185,7 @@ platformBrowserDynamic().bootstrapModule(AppModule) - open the `app.module.ts` file and add ```js -import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; ... import { SQLiteService } from './services/sqlite.service'; import { DetailService } from './services/detail.service'; @@ -89,16 +200,17 @@ import { DetailService } from './services/detail.service'; { provide: RouteReuseStrategy, useClass: IonicRouteStrategy } ], bootstrap: [AppComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], }) ``` -- open the `app-component.html` file and add + `CUSTOM_ELEMENTS_SCHEMA` is no longer needed: it was only there so Angular would tolerate the + `` tag in a template. + +- `app-component.html` needs no web-specific element either ```html - ``` @@ -115,7 +227,6 @@ import { SQLiteService } from './services/sqlite.service'; styleUrls: ['app.component.scss'] }) export class AppComponent { - public isWeb: boolean = false; private initPlugin: boolean; constructor( private platform: Platform, @@ -129,14 +240,13 @@ export class AppComponent { this.sqlite.initializePlugin().then(async (ret) => { this.initPlugin = ret; if( this.sqlite.platform === "web") { - this.isWeb = true; - await customElements.whenDefined('jeep-sqlite'); - const jeepSqliteEl = document.querySelector('jeep-sqlite'); - if(jeepSqliteEl != null) { + try { await this.sqlite.initWebStore(); - console.log(`>>>> isStoreOpen ${await jeepSqliteEl.isStoreOpen()}`); - } else { - console.log('>>>> jeepSqliteEl is null'); + } catch (err) { + // Two failure modes are worth telling the user apart: a browser below the engine + // floor, where the plugin cannot run at all, and another tab already owning the + // store. See the Troubleshooting section. + console.log(`>>>> initWebStore failed: ${err}`); } } @@ -252,7 +362,7 @@ export class SQLiteService { : Promise { if(this.sqlite != null) { try { - await this.sqlite.addUpgradeStatement(database, toVersion, statement); + await this.sqlite.addUpgradeStatement(database, toVersion, statements); return Promise.resolve(); } catch (err) { return Promise.reject(new Error(err)); @@ -383,9 +493,10 @@ export class SQLiteService { * @param encrypted * @param mode * @param version + * @param readonly read-only connections work on every platform, web included */ async createConnection(database:string, encrypted: boolean, - mode: string, version: number + mode: string, version: number, readonly = false ): Promise { if(this.sqlite != null) { try { @@ -399,7 +510,7 @@ export class SQLiteService { } */ const db: SQLiteDBConnection = await this.sqlite.createConnection( - database, encrypted, mode, version); + database, encrypted, mode, version, readonly); if (db != null) { return Promise.resolve(db); } else { @@ -652,8 +763,10 @@ export class SQLiteService { } /** - * Initialize the Web store - * @param database + * Initialize the Web store. + * Mandatory on web and must complete before the first createConnection. It boots the + * plugin's worker, selects the durability tier, and on its first run imports any database + * left behind by the previous jeep-sqlite implementation. */ async initWebStore(): Promise { if(this.platform !== 'web') { @@ -671,7 +784,9 @@ export class SQLiteService { } } /** - * Save a database to store + * Save a database to store. + * A no-op on tier 1, where OPFS writes are already durable, and the real flush of the + * database image to IndexedDB on tier 2. Safe and cheap to call unconditionally. * @param database */ async saveToStore(database:string): Promise { @@ -707,7 +822,7 @@ that is it. ## Ionic/Vue App -- copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `public/assets` folder of YOUR_APP +- Nothing to copy: see [Serving the worker and the wasm](#serving-the-worker-and-the-wasm). - For databases in the `public/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names @@ -725,17 +840,12 @@ that is it. ```js ... -import { defineCustomElements as jeepSqlite, applyPolyfills } from "jeep-sqlite/loader"; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; import { useState } from '@/composables/state'; ... -applyPolyfills().then(() => { - jeepSqlite(window); -}); - window.addEventListener('DOMContentLoaded', async () => { const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) @@ -760,10 +870,6 @@ window.addEventListener('DOMContentLoaded', async () => { try { if(platform === "web") { - // Create the 'jeep-sqlite' Stencil component - const jeepSqlite = document.createElement('jeep-sqlite'); - document.body.appendChild(jeepSqlite); - await customElements.whenDefined('jeep-sqlite'); // Initialize the Web store await sqlite.initWebStore(); } @@ -1112,7 +1218,7 @@ that is it. ## Ionic/React App -- copy manually the file `sql-wasm.wasm` from `node_modules/sql.js/dist/sql-wasm.wasm` to the `public/assets` folder of YOUR_APP +- Nothing to copy: see [Serving the worker and the wasm](#serving-the-worker-and-the-wasm). - For databases in the `public/assets/databases` folder if any, you have to create a `databases.json` file which includes only the non-encrypted database's names @@ -1128,39 +1234,19 @@ that is it. - open the `index.tsx` file and add the following +There is no global JSX augmentation to write any more: with no custom element, TypeScript has +nothing extra to be taught. + ```ts ... -import { defineCustomElements as jeepSqlite, applyPolyfills, JSX as LocalJSX } from "jeep-sqlite/loader"; -import { HTMLAttributes } from 'react'; import { Capacitor } from '@capacitor/core'; import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; -type StencilToReact = { - [P in keyof T]?: T[P] & Omit, 'className'> & { - class?: string; - }; -} ; - -declare global { - export namespace JSX { - interface IntrinsicElements extends StencilToReact { - } - } -} - -applyPolyfills().then(() => { - jeepSqlite(window); -}); - window.addEventListener('DOMContentLoaded', async () => { const platform = Capacitor.getPlatform(); const sqlite: SQLiteConnection = new SQLiteConnection(CapacitorSQLite) try { if(platform === "web") { - // add 'jeep-sqlite' Stencil component to the DOM - const jeepEl = document.createElement("jeep-sqlite"); - document.body.appendChild(jeepEl); - await customElements.whenDefined('jeep-sqlite'); // initialize the web store await sqlite.initWebStore(); } @@ -1508,4 +1594,31 @@ that is it. ## Troubleshooting -* The web-implementation uses IndexedDB to store the data. IndexedDB-support is checked via userAgent. Pay attention not to modify your userAgent (e.g. selecting an iPhone in the Chrome device simulator in your devtools), as this may break the user-agent check and result in an uninitialized DB (causing an error like this:`No available storage method found`). +* **`initWebStore()` rejects with a message about another tab or window.** The store is + single-owner per origin: OPFS access handles cannot be shared, so a second tab is refused + rather than being handed an empty database on top of your real data. Close the other tab. In + development, remember that a stale tab or a detached DevTools window still counts as a tab. + +* **`initWebStore()` rejects and the console shows a worker load failure or a `SyntaxError`.** + The browser is below the engine floor described under + [Requirements and browser support](#requirements-and-browser-support). This is not something the + plugin falls back from, and lowering your app's build target will not help: the syntax that + fails to parse belongs to `@sqlite.org/sqlite-wasm` itself. + +* **The worker or `sqlite3.wasm` cannot be found (a 404 in the network panel).** Your bundler did + not emit the plugin's assets where the plugin looks for them. Use + `setSqliteWebOptions({ wasmUrl })` or `setSqliteWorkerFactory()` from + [Serving the worker and the wasm](#serving-the-worker-and-the-wasm). + +* **Data disappears between reloads.** You are almost certainly on tier 2 and never reached a + flush point. Call `saveToStore(database)` after your writes, or `closeConnection`. To confirm + which tier you are on, look for the databases: tier 1 puts them in the Origin Private File + System, tier 2 in IndexedDB under `capacitor-sqlite-store`. + +* **`JSON.stringify` throws `TypeError: Do not know how to serialize a BigInt`.** A column holds + an integer above 2^53. See [Limitations](#limitations). + +* **Databases from an older version of the app are missing.** Check the console for a migration + warning. If the one-time import from `jeep-sqlite` failed, nothing was deleted: the legacy + IndexedDB store `jeepSqliteStore` is still there and the warning names the database that could + not be read. diff --git a/src/definitions.ts b/src/definitions.ts index fc0afff8..585ee69f 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -7,13 +7,23 @@ export interface CapacitorSQLitePlugin { /** * Initialize the web store * + * Mandatory on the web platform and must resolve before the first `createConnection`. It starts + * the plugin's SQLite worker, selects the durability tier (databases as files in OPFS, or as + * whole-file images in IndexedDB on browsers without OPFS sync access handles), and on its very + * first run imports any database left behind by the previous jeep-sqlite implementation. + * Rejects when another tab of the same origin already owns the store. + * * @return Promise * @since 3.2.3-1 */ initWebStore(): Promise; /** - * Save database to the web store + * Save database to the web store + * + * A no-op when the web store is on OPFS, where every committed write is already durable, and + * the real flush of the database image to IndexedDB on the fallback tier. Safe and cheap to + * call unconditionally after a batch of writes. * * @param options: capSQLiteOptions * @return Promise @@ -1058,12 +1068,20 @@ export interface capSQLiteVersionUpgrade { export interface ISQLiteConnection { /** * Init the web store + * + * Mandatory on the web platform and must resolve before the first `createConnection`. It starts + * the plugin's SQLite worker, selects the durability tier, and on its very first run imports any + * database left behind by the previous jeep-sqlite implementation. Rejects when another tab of + * the same origin already owns the store. * @returns Promise * @since 3.2.3-1 */ initWebStore(): Promise; /** - * Save the datbase to the web store + * Save the database to the web store + * + * A no-op when the web store is on OPFS, and the real flush of the database image to IndexedDB + * on the fallback tier. Safe and cheap to call unconditionally. * @param database * @returns Promise * @since 3.2.3-1 @@ -1138,10 +1156,10 @@ export interface ISQLiteConnection { /** * Create a connection to a database * @param database - * @param encrypted + * @param encrypted not available on the web platform * @param mode * @param version - * @param readonly + * @param readonly supported on every platform, the web included * @returns Promise * @since 2.9.0 refactor */ From ddd071dafd69e9ae8a16985f912700c5081f59c2 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 11:50:43 +0800 Subject: [PATCH 15/27] docs: add the 8.2.0 web engine release notes CHANGELOG follows the generated shape, breaking changes first. info_releases gains a CAPACITOR 8 bucket, since the file was still stale at Capacitor 4, and supersedes the 3.2.3-1 entry whose initWebStore snippet still shows the element bootstrap. --- CHANGELOG.md | 26 +++++++++++++++ docs/info_releases.md | 76 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c678f6..e1f15d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [8.2.0](https://github.com/capacitor-community/sqlite/compare/v8.1.0...v8.2.0) (2026-08-01) + + +### ⚠ BREAKING CHANGES + +* **web:** the web implementation no longer uses `jeep-sqlite`. Remove the `jeep-sqlite` dependency, the `defineCustomElements`/`applyPolyfills` import, the `` element and any React JSX augmentation for it from your app. `initWebStore()` is still mandatory and is still called the same way. +* **web:** the `sql-wasm.wasm` copy step is gone. The worker and `sqlite3.wasm` ship inside the package under `dist/`; delete any `copysqlwasm` script or assets entry. +* **web:** minimum browser versions are now Chrome/Android WebView 80, Safari 14 and Firefox 74. Below that the plugin fails to load rather than degrading, and no build target in your app changes that. +* **web:** the `` `autosave` attribute no longer exists. `saveToStore()` is a no-op when the store is on OPFS and the real image flush on the IndexedDB fallback. +* **web:** integer values above 2^53 are returned as `BigInt` instead of a silently truncated number. `JSON.stringify` throws on those; `exportToJson` encodes them as decimal strings. +* **web:** only one tab per origin may own the store. A second tab now gets an explicit error from `initWebStore()` instead of racing the first. + +### Features + +* **web:** replace the jeep-sqlite pass-through with an in-repo engine on `@sqlite.org/sqlite-wasm`, running in a dedicated worker, with databases stored as real files in OPFS through the `opfs-sahpool` VFS and no COOP/COEP headers required +* **web:** add an automatic `:memory:` + IndexedDB fallback tier for browsers without OPFS sync access handles, so the two tiers differ only in where the bytes rest +* **web:** import databases from the previous `jeep-sqlite` IndexedDB store once, on the first `initWebStore()`, verifying each one with `PRAGMA integrity_check` before retiring the legacy store +* **web:** support read-only connections, which were previously unsupported on this platform +* **web:** add a real browser test suite (vitest browser mode, Playwright Chromium) covering both durability tiers +* **web:** export `setSqliteWorkerFactory`, `setSqliteWebOptions` and `setSqliteLocalDiskAdapter` for bundlers and file pickers that need to override the defaults + +### Bug Fixes + +* **web:** a failed database upgrade now restores the pre-upgrade image and rejects, instead of returning a working connection at the old version with no signal that the migration failed +* **web:** errors keep their message instead of being re-thrown as the string `Error: Error: ...` + ## [8.1.0](https://github.com/capacitor-community/sqlite/compare/v8.0.1...v8.1.0) (2026-03-30) diff --git a/docs/info_releases.md b/docs/info_releases.md index 6dc48e18..a64371e4 100644 --- a/docs/info_releases.md +++ b/docs/info_releases.md @@ -1,4 +1,78 @@ -## CAPACITOR 4 (Master) +## CAPACITOR 8 (Master) + +🚨 Release 8.2.0 web only ->> 🚨 + + iOS, Android and Electron are unchanged in this release. Everything below concerns the Web + platform only. + + The Web plugin is now implemented on `@sqlite.org/sqlite-wasm`, the official SQLite wasm build, + running in a dedicated Worker that ships with the plugin. It replaces `jeep-sqlite`, `sql.js` + and `localforage`, which are no longer dependencies of anything here. + + There are two durability tiers, chosen automatically by `initWebStore()`: + + - tier 1, databases are real files in the browser's `Origin Private File System` through the + `opfs-sahpool` VFS. No COOP/COEP headers and no `SharedArrayBuffer` are required, so it works + inside Capacitor WebViews and on ordinary hosting. Every committed write is durable. + - tier 2, the automatic fallback for browsers without OPFS sync access handles: databases are + opened `:memory:` and their whole-file image is written to IndexedDB, which is the model + jeep-sqlite used everywhere. + + `initWebStore` is still `MANDATORY` and is still called exactly as before. What must go is the + element bootstrap that used to surround it, first documented in release 3.2.3-1 below. Replace + + ```js + if(platform === "web") { + await customElements.whenDefined('jeep-sqlite'); + const jeepSqliteEl = document.querySelector('jeep-sqlite'); + if(jeepSqliteEl != null) { + await sqlite.initWebStore(); + } + } + ``` + + with + + ```js + if(platform === "web") { + await sqlite.initWebStore(); + } + ``` + + and delete the `jeep-sqlite` dependency, the `defineCustomElements`/`applyPolyfills` import, the + `` element from your templates and any script that copied `sql-wasm.wasm` into your + assets folder. The worker and `sqlite3.wasm` ship inside the package. + + `saveToStore` is also restated: it is a no-op on tier 1, where the data is already on disk, and + the real flush of the database image to IndexedDB on tier 2. Calling it after a batch of writes + remains the portable pattern. The `` `autosave` attribute is gone with the element. + + Existing data is migrated for you. The first `initWebStore()` after upgrading reads the old + `jeepSqliteStore` IndexedDB store, imports every database into the active tier, verifies each + one with `PRAGMA integrity_check`, and only then retires the legacy store. If anything fails, + nothing is deleted and a warning naming the database is written to the console. + + Three behaviour changes worth knowing: + + - Read-only connections (`readonly: true`) now work on Web. + - Integers above 2^53 come back as `BigInt` rather than a silently truncated number. + `JSON.stringify` throws on those; `exportToJson` writes them as decimal strings. + - A failed upgrade now restores the pre-upgrade database and rejects, instead of handing back + a working connection at the old version with no signal that the migration failed. + + Two new limits, both loud rather than silent: + + - Minimum browser versions are Chrome/Android WebView 80, Safari 14, Firefox 74. Below that the + plugin does not load at all. + - Only one tab per origin may own the store; a second tab gets an explicit error. + + Encryption is still not supported on Web. + + See [Web Usage](https://github.com/capacitor-community/sqlite/blob/master/docs/Web-Usage.md). + +🚨 Release 8.2.0 <<- 🚨 + +## CAPACITOR 4 🚨 Release 4.0.1 all platforms ->> 🚨 From b78ef167aeb8bcc1deecb8cbfa6b80fc465ef506 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:54:38 +0800 Subject: [PATCH 16/27] fix(web)!: enable foreign key enforcement on every connection sqlite defaults PRAGMA foreign_keys to OFF, per connection, and nothing here ever turned it on. A web database therefore ignored every constraint its own schema declared: no cascade, no RESTRICT, no check on insert. Both port sources set it at open (electron utilsSQLite.ts:63, jeep Database.open), and the JSON-import and upgrade paths in this repo already toggled it off and back on as though ON were the resting state. BREAKING CHANGE: foreign key constraints declared in a schema are now enforced on the web platform, as they already were on Android, iOS and Electron. An app whose data violates its own declared constraints will begin receiving constraint errors on insert and update, and ON DELETE actions will start firing where they previously did nothing. --- src/web/worker/worker.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index bf9ab57a..c3e02bc9 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -143,13 +143,18 @@ function connectionsFor(database: string): Connection[] { } function openRaw(storage: string, readonly: boolean): Connection { - if (tier === 1) { - const db = new poolUtil.OpfsSAHPoolDb(storage, readonly ? 'r' : 'cw'); - return new Connection(storage, readonly, db, sqlite3); - } - // Tier 2 always opens writable: sqlite3_deserialize needs it. Read-only is applied after the - // image is loaded, with PRAGMA query_only. - return new Connection(storage, readonly, new sqlite3.oo1.DB(':memory:', 'c'), sqlite3); + const conn = + tier === 1 + ? new Connection(storage, readonly, new poolUtil.OpfsSAHPoolDb(storage, readonly ? 'r' : 'cw'), sqlite3) + : // Tier 2 always opens writable: sqlite3_deserialize needs it. Read-only is applied after + // the image is loaded, with PRAGMA query_only. + new Connection(storage, readonly, new sqlite3.oo1.DB(':memory:', 'c'), sqlite3); + // sqlite defaults foreign keys OFF, per connection. Every other platform of this plugin turns + // them on at open (electron `utilsSQLite.ts:63`, jeep `Database.open`), and the soft-delete + // cascade relies on the schema's declared actions being real, so web does the same. The upgrade + // and JSON-import paths that need them off already toggle them explicitly. + conn.setForeignKeyConstraintsEnabled(true); + return conn; } async function openTier2(storage: string, readonly: boolean): Promise { From 44f810b427d63a21220e6501c1f36fb22ac92103 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:55:17 +0800 Subject: [PATCH 17/27] fix(web): repair the soft-delete rewrite for quotes, literals, OR and RETURNING Five defects in the rewrite, all of them silent. A double-quoted table name extracted as WHERE, because stripNoise blanks quoted identifiers along with string literals and a greedy match read straight past them. The rewrite then targeted a table of that name, the per-table check found nothing, and the statement ran as an ordinary DELETE: the rows were destroyed and the server was never told. A string literal in the clause produced SQL that did not parse, since the blanked copy was returned rather than the original. RETURNING did the same, landing inside the generated WHERE. An unbracketed clause let AND bind tighter than OR, so the guard covered the last disjunct only and an already-deleted row was marked again on every repeat, republishing its tombstone. A table whose name is a reserved word made an internal PRAGMA a syntax error, which switched soft deletes off for the whole database. The rewrite is also gated on the target table now rather than on the database: a schema where one table is tracked and another is not is legal, and rewriting a DELETE against the second produced SET sql_deleted = 1 on a table with no such column. --- src/web/worker/engine.ts | 45 ++++++++++++++++-- src/web/worker/json/schema.ts | 3 +- src/web/worker/statements.ts | 88 +++++++++++++++++++++++++++++------ src/web/worker/sync.ts | 12 +++-- test/web/sync.test.ts | 44 +++++++++++++++++- 5 files changed, 171 insertions(+), 21 deletions(-) diff --git a/src/web/worker/engine.ts b/src/web/worker/engine.ts index 903cea60..86072367 100644 --- a/src/web/worker/engine.ts +++ b/src/web/worker/engine.ts @@ -15,7 +15,14 @@ import { prefixed } from '../errors'; import type { ExecResult } from '../protocol'; -import { producesRows, replaceUndefinedByNull, softDeleteRewrite } from './statements'; +import { + extractTableName, + producesRows, + quoteIdent, + replaceUndefinedByNull, + softDeleteRewrite, + statementKind, +} from './statements'; export class Connection { private transactionActive = false; @@ -25,6 +32,8 @@ export class Connection { * can add or remove the `last_modified` / `sql_deleted` pair. */ private syncEnabledCache: boolean | null = null; + /** Per-table answer to "does a DELETE here get recorded", same invalidation again. */ + private softDeleteCache = new Map(); constructor( readonly storage: string, @@ -91,8 +100,35 @@ export class Connection { return this.syncEnabledCache; } + /** + * Whether a DELETE against this particular table should be recorded rather than performed. + * + * `syncEnabled` is a property of the database, because that is what the port source checks, but + * it cannot be the whole answer: a schema where one table is sync-tracked and another is not is + * legal, and rewriting a DELETE against the second produces `SET sql_deleted = 1` on a table + * with no such column. The port source has the same hole; here the target table has to carry + * the column too. + */ + softDeletes(table: string | null): boolean { + if (!table || !this.syncEnabled) return false; + const key = table.toLowerCase(); + let known = this.softDeleteCache.get(key); + if (known === undefined) { + try { + known = this.query(`PRAGMA table_info(${quoteIdent(table)})`).some((row: any) => row.name === 'sql_deleted'); + } catch { + // A name this cannot resolve. Fall back to the database-wide answer, which is what this + // did before the gate existed, rather than failing a delete that used to work. + known = true; + } + this.softDeleteCache.set(key, known); + } + return known; + } + invalidateSyncCache(): void { this.syncEnabledCache = null; + this.softDeleteCache.clear(); } /** A batch of raw statements, as `execute()` receives it. */ @@ -116,7 +152,10 @@ export class Connection { * been told the row is gone. Rewriting those would make both operations no-ops. */ run(rawStatement: string, values: any[] | undefined, wantRows: boolean, rewriteDeletes = true): ExecResult { - const statement = rewriteDeletes ? softDeleteRewrite(rawStatement, this.syncEnabled) : rawStatement; + const isDelete = rewriteDeletes && statementKind(rawStatement) === 'DELETE'; + const statement = isDelete && this.softDeletes(extractTableName(rawStatement)) + ? softDeleteRewrite(rawStatement, true) + : rawStatement; const bind = replaceUndefinedByNull(values); const before = this.totalChanges(); let rows: any[] | undefined; @@ -229,7 +268,7 @@ function hasSyncColumns(conn: Connection): boolean { let sqlDeleted = false; for (const table of conn.tableList()) { if (table === 'sync_table') continue; - for (const row of conn.query(`PRAGMA table_info(${table})`)) { + for (const row of conn.query(`PRAGMA table_info(${quoteIdent(table)})`)) { if (row.name === 'last_modified') lastModified = true; if (row.name === 'sql_deleted') sqlDeleted = true; } diff --git a/src/web/worker/json/schema.ts b/src/web/worker/json/schema.ts index 1e6be807..8e12bb5e 100644 --- a/src/web/worker/json/schema.ts +++ b/src/web/worker/json/schema.ts @@ -10,6 +10,7 @@ */ import type { JsonSQLite, JsonTable, JsonView } from '../../../definitions'; import type { Connection } from '../engine'; +import { quoteIdent } from '../statements'; export function createSchemaStatements(jsonData: JsonSQLite): string[] { const statements: string[] = []; @@ -81,7 +82,7 @@ export function createViews(conn: Connection, views: JsonView[]): number { /** Column names and declared types for a table, as PRAGMA table_info reports them. */ export function tableColumnNamesTypes(conn: Connection, table: string): { names: string[]; types: string[] } { - const rows = conn.query(`PRAGMA table_info(${table})`); + const rows = conn.query(`PRAGMA table_info(${quoteIdent(table)})`); return { names: rows.map((row: any) => row.name), types: rows.map((row: any) => row.type), diff --git a/src/web/worker/statements.ts b/src/web/worker/statements.ts index 4d6338b0..ac3edbde 100644 --- a/src/web/worker/statements.ts +++ b/src/web/worker/statements.ts @@ -102,26 +102,37 @@ function matchesKeyword(sql: string, i: number, keyword: string): boolean { return after === undefined || !/[A-Za-z0-9_]/.test(after); } -/** Remove string literals and comments so keyword matching cannot be fooled by data. */ +/** + * Blank out string literals and comments so keyword matching cannot be fooled by data. + * + * Length preserving, deliberately: every blanked run becomes the same number of spaces, so an + * offset into the result is an offset into the original. `extractWhereClause` depends on that to + * hand back a clause with its literals intact. Collapsing each run to one space instead, which is + * what this did originally, silently deleted them. + */ export function stripNoise(sql: string): string { let out = ''; + const blank = (from: number, to: number) => ' '.repeat(to - from); for (let i = 0; i < sql.length; i++) { const ch = sql[i]; if (ch === "'" || ch === '"' || ch === '`') { - i = scanQuoted(sql, i, ch) - 1; - out += ' '; + const end = scanQuoted(sql, i, ch); + out += blank(i, end); + i = end - 1; continue; } if (ch === '-' && sql[i + 1] === '-') { const nl = sql.indexOf('\n', i); - i = (nl === -1 ? sql.length : nl) - 1; - out += ' '; + const end = nl === -1 ? sql.length : nl; + out += blank(i, end); + i = end - 1; continue; } if (ch === '/' && sql[i + 1] === '*') { - const end = sql.indexOf('*/', i + 2); - i = (end === -1 ? sql.length : end + 2) - 1; - out += ' '; + const found = sql.indexOf('*/', i + 2); + const end = found === -1 ? sql.length : found + 2; + out += blank(i, end); + i = end - 1; continue; } out += ch; @@ -129,6 +140,18 @@ export function stripNoise(sql: string): string { return out; } +/** + * A table or column name, quoted for interpolation into SQL. + * + * `tableList()` and `PRAGMA foreign_key_list` return bare names, and a schema is free to contain + * a table called `order` or `group`. Interpolating one of those unquoted turns an internal PRAGMA + * into a syntax error, which is how a reserved-word table used to switch the whole soft-delete + * protocol off for a database. + */ +export function quoteIdent(name: string): string { + return `"${String(name).replace(/"/g, '""')}"`; +} + /** The leading keyword, upper-cased: SELECT, INSERT, UPDATE, DELETE, CREATE, PRAGMA, ... */ export function statementKind(sql: string): string { const match = stripNoise(sql) @@ -172,13 +195,47 @@ export function replaceUndefinedByNull(values: any[] | undefined): any[] { * already-soft-deleted row reports zero changes rather than touching `last_modified` again. */ export function extractTableName(statement: string): string | null { - const match = stripNoise(statement).match(/(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([^\s;(]+)/i); - return match?.[1] ?? null; + const stripped = stripNoise(statement); + // The keyword is located in the blanked copy so a table name inside a literal cannot be picked + // up, but the name itself is read from the original. `stripNoise` blanks double-quoted + // identifiers along with strings, and a greedy `\s+` after the keyword would swallow the blanks + // where the name used to be: that is how `DELETE FROM "order" WHERE id = ?` yielded the table + // name `WHERE`, and a soft delete against a quoted table silently became a real one. + const match = stripped.match(/(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b/i); + if (!match || match.index === undefined) return null; + const after = statement.slice(match.index + match[0].length); + const lead = after.match(/^\s*/)?.[0].length ?? 0; + const token = after.slice(lead).match(/^(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]|[^\s;(]+)/); + return token ? token[0] : null; } +/** + * The WHERE clause as the caller wrote it, literals and all. + * + * The keyword hunt runs over the blanked copy so a `WHERE` inside a string cannot be mistaken for + * the real one, but the clause itself is sliced out of the original: returning the blanked text + * would drop every literal, and `DELETE FROM t WHERE name = 'bob'` would rewrite to + * `... WHERE name = AND sql_deleted = 0`, which is not valid SQL. + */ export function extractWhereClause(statement: string): string | null { - const match = stripNoise(statement).match(/WHERE\s(.+?)(?:ORDER\s+BY|LIMIT|$)/is); - return match?.[1] ? match[1].trim() : null; + const stripped = stripNoise(statement); + const match = stripped.match(/WHERE\s(.+?)(?:ORDER\s+BY|LIMIT|RETURNING|$)/is); + if (!match || match.index === undefined || !match[1]) return null; + const start = match.index + 'WHERE'.length + 1; + return statement.slice(start, start + match[1].length).trim(); +} + +/** + * The trailing `RETURNING ...`, if any. sqlite-wasm can step a RETURNING statement directly, so + * the clause is carried across the rewrite rather than dropped: a soft delete that was asked for + * its rows should still hand them back, and appending the guard after it would not even parse. + */ +export function extractReturningClause(statement: string): string | null { + const stripped = stripNoise(statement); + const match = stripped.match(/\bRETURNING\b/i); + if (!match || match.index === undefined) return null; + const clause = statement.slice(match.index).trim(); + return clause.endsWith(';') ? clause.slice(0, -1).trim() : clause; } /** @@ -195,5 +252,10 @@ export function softDeleteRewrite(statement: string, syncEnabled: boolean): stri if (!whereClause) throw new Error('deleteSQL: cannot find a WHERE clause'); const where = whereClause.endsWith(';') ? whereClause.slice(0, -1) : whereClause; - return `UPDATE ${tableName} SET sql_deleted = 1 WHERE ${where} AND sql_deleted = 0;`; + const returning = extractReturningClause(statement); + const suffix = returning ? ` ${returning}` : ''; + // The caller's clause is parenthesised because AND binds tighter than OR: without the brackets, + // `WHERE a = 1 OR b = 2` becomes `a = 1 OR (b = 2 AND sql_deleted = 0)`, so the guard covers + // only the last disjunct and an already-deleted row is marked again on every repeat. + return `UPDATE ${tableName} SET sql_deleted = 1 WHERE (${where}) AND sql_deleted = 0${suffix};`; } diff --git a/src/web/worker/sync.ts b/src/web/worker/sync.ts index 7aafcd01..b291dd7d 100644 --- a/src/web/worker/sync.ts +++ b/src/web/worker/sync.ts @@ -12,6 +12,7 @@ * is the last export date written by `exportToJson`. */ import type { Connection } from './engine'; +import { quoteIdent } from './statements'; export const SYNC_TABLE = 'sync_table'; @@ -19,7 +20,7 @@ export const SYNC_TABLE = 'sync_table'; function anyTableHasColumn(conn: Connection, column: string): boolean { for (const table of conn.tableList()) { if (table === SYNC_TABLE) continue; - const rows = conn.query(`PRAGMA table_info(${table})`); + const rows = conn.query(`PRAGMA table_info(${quoteIdent(table)})`); if (rows.some((row: any) => row.name === column)) return true; } return false; @@ -82,10 +83,15 @@ export function deleteExportedRows(conn: Connection): void { conn.withOptionalTransaction(true, () => { for (const table of tables) { - const columns = conn.query(`PRAGMA table_info(${table})`).map((row: any) => row.name); + const columns = conn.query(`PRAGMA table_info(${quoteIdent(table)})`).map((row: any) => row.name); if (!columns.includes('sql_deleted') || !columns.includes('last_modified')) continue; // Bypass the rewrite: reclaiming soft-deleted rows is the one place a real DELETE is meant. - conn.run(`DELETE FROM ${table} WHERE sql_deleted = 1 AND last_modified < ?`, [lastExportDate], false, false); + conn.run( + `DELETE FROM ${quoteIdent(table)} WHERE sql_deleted = 1 AND last_modified < ?`, + [lastExportDate], + false, + false, + ); } return 0; }); diff --git a/test/web/sync.test.ts b/test/web/sync.test.ts index 152e0cc5..3dd5e707 100644 --- a/test/web/sync.test.ts +++ b/test/web/sync.test.ts @@ -26,7 +26,7 @@ describe('soft-delete rewrite (unit)', () => { test('rewrites a DELETE into a guarded UPDATE', () => { expect(softDeleteRewrite('DELETE FROM items WHERE id = 3;', true)).toBe( - 'UPDATE items SET sql_deleted = 1 WHERE id = 3 AND sql_deleted = 0;', + 'UPDATE items SET sql_deleted = 1 WHERE (id = 3) AND sql_deleted = 0;', ); }); @@ -45,6 +45,48 @@ describe('soft-delete rewrite (unit)', () => { // The literal is blanked before matching, so ORDER BY still terminates the clause. expect(extractWhereClause('DELETE FROM items WHERE id = 4 ORDER BY id')).toBe('id = 4'); }); + + test('the clause keeps its literals, and a WHERE inside one is not mistaken for the real one', () => { + // Blanking the literal to find the keyword is right; returning the blanked text is not. This + // used to yield "name =", which rewrites to SQL that does not parse. + expect(extractWhereClause("DELETE FROM items WHERE name = 'bob'")).toBe("name = 'bob'"); + expect(softDeleteRewrite("DELETE FROM items WHERE name = 'bob';", true)).toBe( + "UPDATE items SET sql_deleted = 1 WHERE (name = 'bob') AND sql_deleted = 0;", + ); + // A literal containing the keyword must not become the clause. + expect(extractWhereClause("DELETE FROM items WHERE note = 'WHERE id = 1' AND id = 2")).toBe( + "note = 'WHERE id = 1' AND id = 2", + ); + // Comments are blanked in place, so what follows them is still found at the right offset. + expect(extractWhereClause('DELETE FROM items /* drop it */ WHERE id = 7')).toBe('id = 7'); + }); + + test('a quoted table name is the table, not the next keyword', () => { + // stripNoise blanks double-quoted identifiers along with string literals, so a greedy match + // over the blanked copy read straight past the name: this returned "WHERE", the rewrite + // targeted a table of that name, and the soft delete silently became a real one. + expect(extractTableName('DELETE FROM "order" WHERE id = ?')).toBe('"order"'); + expect(extractTableName('DELETE FROM [order] WHERE id = ?')).toBe('[order]'); + expect(extractTableName('DELETE FROM items WHERE id = ?')).toBe('items'); + expect(softDeleteRewrite('DELETE FROM "order" WHERE id = ?', true)).toBe( + 'UPDATE "order" SET sql_deleted = 1 WHERE (id = ?) AND sql_deleted = 0;', + ); + }); + + test('the clause is bracketed, because AND binds tighter than OR', () => { + // Unbracketed, this reads as `id = 1 OR (id = 2 AND sql_deleted = 0)`: the guard covers only + // the last disjunct, so a repeated delete marks row 1 again and bumps its last_modified. + expect(softDeleteRewrite('DELETE FROM items WHERE id = 1 OR id = 2', true)).toBe( + 'UPDATE items SET sql_deleted = 1 WHERE (id = 1 OR id = 2) AND sql_deleted = 0;', + ); + }); + + test('RETURNING survives the rewrite instead of ending up inside the WHERE clause', () => { + expect(extractWhereClause('DELETE FROM items WHERE id = 1 RETURNING *')).toBe('id = 1'); + expect(softDeleteRewrite('DELETE FROM items WHERE id = 1 RETURNING *', true)).toBe( + 'UPDATE items SET sql_deleted = 1 WHERE (id = 1) AND sql_deleted = 0 RETURNING *;', + ); + }); }); describe.each(TIERS)('%s', (tier) => { From d35fe8ccbda3b0a375b2029bc62a99d8a29158e0 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:55:50 +0800 Subject: [PATCH 18/27] feat(web): propagate a soft delete along foreign keys A DELETE on a sync-tracked database is recorded rather than performed, so sqlite never fires the ON DELETE actions the schema declares. A deleted parent therefore left its children live, and the next export told the server the parent was gone while still reporting the children: the two ends diverged with no error on either. Relationships come from PRAGMA foreign_key_list rather than a regex over sqlite_master. The port source's pattern reads only the first matching table, so a parent with two referencing tables cascaded into one of them, misses column-level REFERENCES entirely, and matches any table whose DDL merely mentions the name. It also propagates a single level, leaving grandchildren live under a deleted grandparent. The walk recurses, and marks each row before descending, which is what makes a cyclic or self-referencing schema terminate: a marked row no longer satisfies sql_deleted = 0 and cannot be reached twice. A WITHOUT ROWID table on the receiving end of an ON DELETE action is reported rather than skipped. A referencing table with no sql_deleted column is left to sqlite, which runs the real action when deleteExportedRows performs the physical delete. A DELETE inside an execute() batch now takes the same path, as it does in the port source; without that, which entry point the app used decided whether a deletion reached the server. DDL issued through run() invalidates the cached schema, so a foreign key added that way is visible to the next delete. --- src/web/worker/cascade.ts | 261 +++++++++++++++++ src/web/worker/engine.ts | 107 +++++-- test/web/cascade.test.ts | 571 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 917 insertions(+), 22 deletions(-) create mode 100644 src/web/worker/cascade.ts create mode 100644 test/web/cascade.test.ts diff --git a/src/web/worker/cascade.ts b/src/web/worker/cascade.ts new file mode 100644 index 00000000..c35de1d0 --- /dev/null +++ b/src/web/worker/cascade.ts @@ -0,0 +1,261 @@ +/** + * Foreign-key propagation for the soft delete. + * + * A DELETE against a sync-enabled database is rewritten into `UPDATE ... SET sql_deleted = 1` + * (`statements.ts`), which means sqlite never sees a delete and never fires the `ON DELETE` + * actions the schema declares. Without this module the parent row is marked and its children are + * not, so the next export tells the server the parent is gone while still reporting the children + * live, and the two ends diverge with no error on either side. + * + * Ported in behaviour from `electron/src/electron-utils/utilsDelete.ts` and the + * `findReferencesAndUpdate` / `getReferences` / `searchForRelatedItems` group in + * `utilsSQLite.ts` (MIT, this repo), with three deliberate differences: + * + * 1. The relationships come from `PRAGMA foreign_key_list`, not from regex-matching + * `sqlite_master`. The port source's pattern only recognises table-level `FOREIGN KEY (...) + * REFERENCES` clauses, misses column-level `REFERENCES`, and reads only the first matching + * table, so a parent with two referencing tables cascades into one of them. The pragma is + * sqlite's own answer to the same question and has none of those limits. + * 2. It recurses. The port source propagates one level, so grandchildren are left live under a + * deleted grandparent, which is the same divergence one level down. + * 3. Affected rows are identified by `rowid` and marked before the recursion descends, which is + * what makes a cyclic or self-referencing schema terminate: a marked row no longer satisfies + * `sql_deleted = 0` and cannot be reached twice. + */ +import { messageOf } from '../errors'; + +import type { Connection } from './engine'; +import { quoteIdent } from './statements'; + +/** sqlite's own spelling of the actions, as `PRAGMA foreign_key_list` reports them. */ +export type DeleteAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; + +export interface ForeignKey { + /** The table holding the reference. */ + child: string; + /** The parent it points at. */ + parent: string; + /** Referencing columns, in constraint order. */ + from: string[]; + /** Referenced columns, resolved to the parent's primary key when the schema left them implicit. */ + to: string[]; + onDelete: DeleteAction; +} + +/** How many rowids go into one `IN (...)` list. SQLITE_MAX_VARIABLE_NUMBER is far higher. */ +const CHUNK = 500; + +/** + * Depth guard. Marking before descending already terminates cycles, so this is a backstop against + * a case nobody foresaw rather than the mechanism that stops them. Set well above any tree an app + * would build so a legitimate deep hierarchy is never refused. + */ +const MAX_DEPTH = 256; + +function normalizeAction(value: unknown): DeleteAction { + const action = String(value ?? 'NO ACTION') + .toUpperCase() + .replace(/\s+/g, ' ') + .trim(); + switch (action) { + case 'CASCADE': + case 'SET NULL': + case 'SET DEFAULT': + case 'RESTRICT': + return action; + default: + return 'NO ACTION'; + } +} + +/** Quoted identifier, so a table or column called `order` or `group` still works. */ +const ident = quoteIdent; + +/** + * The bare table name behind whatever the caller wrote. `extractTableName` hands back the token + * as it appeared in the statement, which may be quoted (`"my table"`, `[items]`) or qualified + * (`main.items`), and neither form can be quoted again or compared against + * `PRAGMA foreign_key_list` output. + * + * A qualifier other than `main` would mean an attached database, which this plugin never creates. + * It is refused rather than silently skipped, because a cascade that quietly does nothing is the + * failure this module exists to remove. + */ +export function resolveTableName(raw: string): string { + let name = raw.trim(); + const qualified = name.match(/^([A-Za-z_][A-Za-z0-9_$]*)\.(.+)$/); + if (qualified) { + if (qualified[1].toLowerCase() !== 'main') { + throw new Error( + `Cascade: cannot follow foreign keys for ${raw}, which names an attached database. ` + + 'Reference the table without the schema qualifier.', + ); + } + name = qualified[2].trim(); + } + if (name.startsWith('"') && name.endsWith('"')) return name.slice(1, -1).replace(/""/g, '"'); + if (name.startsWith('[') && name.endsWith(']')) return name.slice(1, -1); + if (name.startsWith('`') && name.endsWith('`')) return name.slice(1, -1).replace(/``/g, '`'); + return name; +} + +function primaryKeyColumns(conn: Connection, table: string): string[] { + return conn + .query(`PRAGMA table_info(${ident(table)})`) + .filter((row: any) => Number(row.pk) > 0) + .sort((a: any, b: any) => Number(a.pk) - Number(b.pk)) + .map((row: any) => String(row.name)); +} + +/** + * Every foreign key in the database, grouped by constraint. `PRAGMA foreign_key_list` returns one + * row per column of a composite key, sharing an `id`. + */ +export function foreignKeys(conn: Connection): ForeignKey[] { + const out: ForeignKey[] = []; + for (const child of conn.tableList()) { + const rows = conn.query(`PRAGMA foreign_key_list(${ident(child)})`); + const byConstraint = new Map(); + for (const row of rows) { + const id = Number((row as any).id); + const list = byConstraint.get(id); + if (list) list.push(row); + else byConstraint.set(id, [row]); + } + for (const list of byConstraint.values()) { + list.sort((a: any, b: any) => Number(a.seq) - Number(b.seq)); + const parent = String(list[0].table); + const from = list.map((row: any) => String(row.from)); + // `to` is null when the constraint referenced the parent's primary key implicitly. + const to = list.every((row: any) => row.to != null) + ? list.map((row: any) => String(row.to)) + : primaryKeyColumns(conn, parent); + if (to.length !== from.length) continue; // malformed or unresolvable, leave it to sqlite + out.push({ child, parent, from, to, onDelete: normalizeAction(list[0].on_delete) }); + } + } + return out; +} + +function columnDefault(conn: Connection, table: string, column: string): string { + const row = conn.query(`PRAGMA table_info(${ident(table)})`).find((entry: any) => entry.name === column); + const value = row ? (row as any).dflt_value : null; + return value === null || value === undefined ? 'NULL' : String(value); +} + +/** + * `rowid` is how an affected row is carried between steps. A WITHOUT ROWID table has none, and + * silently skipping it would be exactly the divergence this module exists to prevent, so it is + * reported instead. + */ +function rowidsOf(conn: Connection, table: string, where: string, values: any[]): number[] { + try { + return conn + .query(`SELECT rowid AS rid FROM ${ident(table)} WHERE ${where}`, values) + .map((row: any) => Number(row.rid)); + } catch (err) { + if (/no such column: rowid/i.test(messageOf(err))) { + throw new Error( + `Cascade: ${table} is a WITHOUT ROWID table, which the soft-delete cascade cannot follow. ` + + 'Give it a rowid or drop the ON DELETE action from the constraint that points at it.', + ); + } + throw err; + } +} + +function chunked(rowids: number[]): number[][] { + const out: number[][] = []; + for (let i = 0; i < rowids.length; i += CHUNK) out.push(rowids.slice(i, i + CHUNK)); + return out; +} + +function inRowids(rowids: number[]): string { + return `rowid IN (${rowids.map(() => '?').join(', ')})`; +} + +/** + * Rows of `fk.child` that reference any of `parentRowids` and are still live. + * The join is on the constraint's own column pairs, so composite keys work unchanged, and a NULL + * referencing column never matches, which is what `ON DELETE` does too. + */ +function childRowids(conn: Connection, fk: ForeignKey, parentRowids: number[]): number[] { + const on = fk.from.map((from, index) => `c.${ident(from)} = p.${ident(fk.to[index])}`).join(' AND '); + const found: number[] = []; + for (const chunk of chunked(parentRowids)) { + const rows = conn.query( + `SELECT DISTINCT c.rowid AS rid FROM ${ident(fk.child)} c JOIN ${ident(fk.parent)} p ON ${on} ` + + `WHERE p.${inRowids(chunk)} AND c.sql_deleted = 0`, + chunk, + ); + for (const row of rows) found.push(Number((row as any).rid)); + } + return found; +} + +function updateRows(conn: Connection, table: string, setClause: string, rowids: number[]): void { + for (const chunk of chunked(rowids)) { + // rewriteDeletes stays off: this IS the rewrite's own work, and it is an UPDATE regardless. + conn.run(`UPDATE ${ident(table)} SET ${setClause} WHERE ${inRowids(chunk)}`, chunk, false, false); + } +} + +/** + * Apply every `ON DELETE` action that points at `table`, for the rows identified by `rowids`, + * then descend into whatever those actions marked. + * + * A constraint is followed only when the child table carries `sql_deleted`. A child without it is + * not part of the sync protocol and there is nothing to mark; its integrity is not at risk either, + * because the parent row physically remains until `deleteExportedRows` removes it, and that is a + * real DELETE which fires sqlite's own `ON DELETE` handling. + */ +function propagate(conn: Connection, table: string, rowids: number[], depth: number): void { + if (rowids.length === 0) return; + if (depth > MAX_DEPTH) { + throw new Error(`Cascade: foreign key propagation from ${table} exceeded ${MAX_DEPTH} levels`); + } + + for (const fk of conn.foreignKeyGraph.filter((entry) => equalNames(entry.parent, table))) { + if (fk.onDelete === 'NO ACTION') continue; + // Reuses the connection's own per-table cache rather than a PRAGMA per constraint per delete. + if (!conn.softDeletes(fk.child)) continue; + + const affected = childRowids(conn, fk, rowids); + if (affected.length === 0) continue; + + if (fk.onDelete === 'RESTRICT') { + throw new Error('Restrict mode related items exist, please delete them first'); + } + if (fk.onDelete === 'CASCADE') { + // Marked before descending: a row that is already marked cannot be selected again, which is + // what stops a cyclic or self-referencing schema from recursing forever. + updateRows(conn, fk.child, 'sql_deleted = 1', affected); + propagate(conn, fk.child, affected, depth + 1); + continue; + } + // SET NULL / SET DEFAULT: the child survives, so there is nothing to descend into. + const setClause = fk.from + .map((column) => { + const value = fk.onDelete === 'SET NULL' ? 'NULL' : columnDefault(conn, fk.child, column); + return `${ident(column)} = ${value}`; + }) + .join(', '); + updateRows(conn, fk.child, setClause, affected); + } +} + +function equalNames(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * Run the propagation for a DELETE that is about to be rewritten. Called with the statement's own + * WHERE clause and bind values, before the parent is marked, so the rows it selects are the ones + * the rewritten UPDATE will mark. + */ +export function cascadeSoftDelete(conn: Connection, table: string, whereClause: string, values: any[]): void { + if (conn.foreignKeyGraph.length === 0) return; + const name = resolveTableName(table); + const roots = rowidsOf(conn, name, `(${whereClause}) AND sql_deleted = 0`, values); + propagate(conn, name, roots, 0); +} diff --git a/src/web/worker/engine.ts b/src/web/worker/engine.ts index 86072367..78e2acc3 100644 --- a/src/web/worker/engine.ts +++ b/src/web/worker/engine.ts @@ -15,15 +15,23 @@ import { prefixed } from '../errors'; import type { ExecResult } from '../protocol'; +import type { ForeignKey } from './cascade'; +import { cascadeSoftDelete, foreignKeys, resolveTableName } from './cascade'; import { extractTableName, + extractWhereClause, producesRows, - quoteIdent, replaceUndefinedByNull, + quoteIdent, softDeleteRewrite, + splitStatements, statementKind, + stripNoise, } from './statements'; +/** Statement kinds that can change the schema, and therefore every cached answer about it. */ +const DDL = new Set(['CREATE', 'DROP', 'ALTER']); + export class Connection { private transactionActive = false; /** @@ -32,6 +40,8 @@ export class Connection { * can add or remove the `last_modified` / `sql_deleted` pair. */ private syncEnabledCache: boolean | null = null; + /** The foreign-key graph, cached for the same reason and invalidated at the same points. */ + private foreignKeyCache: ForeignKey[] | null = null; /** Per-table answer to "does a DELETE here get recorded", same invalidation again. */ private softDeleteCache = new Map(); @@ -100,6 +110,12 @@ export class Connection { return this.syncEnabledCache; } + /** Every foreign key in the database, for the soft-delete cascade. Same caching as syncEnabled. */ + get foreignKeyGraph(): ForeignKey[] { + if (this.foreignKeyCache === null) this.foreignKeyCache = foreignKeys(this); + return this.foreignKeyCache; + } + /** * Whether a DELETE against this particular table should be recorded rather than performed. * @@ -115,7 +131,9 @@ export class Connection { let known = this.softDeleteCache.get(key); if (known === undefined) { try { - known = this.query(`PRAGMA table_info(${quoteIdent(table)})`).some((row: any) => row.name === 'sql_deleted'); + // The name arrives as the caller wrote it, which may already be quoted or qualified. + const bare = resolveTableName(table); + known = this.query(`PRAGMA table_info(${quoteIdent(bare)})`).some((row: any) => row.name === 'sql_deleted'); } catch { // A name this cannot resolve. Fall back to the database-wide answer, which is what this // did before the gate existed, rather than failing a delete that used to work. @@ -128,15 +146,36 @@ export class Connection { invalidateSyncCache(): void { this.syncEnabledCache = null; + this.foreignKeyCache = null; this.softDeleteCache.clear(); } - /** A batch of raw statements, as `execute()` receives it. */ + /** + * A batch of raw statements, as `execute()` receives it. + * + * A DELETE in a batch is soft-deleted exactly as one passed to `run` would be. The port source + * does this too (`utilsSQLite.statementsToSQL92` routes every DELETE through `deleteSQL`), and + * without it `execute('DELETE FROM t WHERE ...')` physically removes rows from a sync-tracked + * database while `run` of the same statement records them, so which entry point the app happened + * to use decides whether the server ever hears about the deletion. + * + * The batch is only split when it needs to be. Handing the whole string to sqlite in one call is + * both faster and less exposed to the splitter, so that stays the path for everything else. + */ executeBatch(statements: string): ExecResult { this.invalidateSyncCache(); const before = this.totalChanges(); try { - this.db.exec(statements); + // Cheap test first: `syncEnabled` costs a PRAGMA per table, and the overwhelming majority + // of batches are schema and inserts with no DELETE in them at all. + if (/\bDELETE\b/i.test(stripNoise(statements)) && this.syncEnabled) { + for (const statement of splitStatements(statements)) { + if (statementKind(statement) === 'DELETE') this.run(statement, [], false); + else this.db.exec(statement); + } + } else { + this.db.exec(statements); + } } catch (err) { throw prefixed('Execute', err); } @@ -152,27 +191,51 @@ export class Connection { * been told the row is gone. Rewriting those would make both operations no-ops. */ run(rawStatement: string, values: any[] | undefined, wantRows: boolean, rewriteDeletes = true): ExecResult { - const isDelete = rewriteDeletes && statementKind(rawStatement) === 'DELETE'; - const statement = isDelete && this.softDeletes(extractTableName(rawStatement)) - ? softDeleteRewrite(rawStatement, true) - : rawStatement; const bind = replaceUndefinedByNull(values); + const isDelete = rewriteDeletes && statementKind(rawStatement) === 'DELETE'; + const table = isDelete ? extractTableName(rawStatement) : null; + const soft = isDelete && this.softDeletes(table); + const statement = soft ? softDeleteRewrite(rawStatement, true) : rawStatement; + const before = this.totalChanges(); - let rows: any[] | undefined; - try { - if (wantRows) { - rows = this.db.exec({ - sql: statement, - bind: bind.length > 0 ? bind : undefined, - rowMode: 'object', - returnValue: 'resultRows', - }); - } else { - this.db.exec({ sql: statement, bind: bind.length > 0 ? bind : undefined }); + const exec = () => { + // The cascade runs inside the changes window on purpose: a real DELETE with ON DELETE + // CASCADE counts the rows its actions touched in total_changes, and a soft delete of the + // same rows should not report a different number just because the deletion is being + // recorded rather than performed. + if (soft && table) { + const where = extractWhereClause(rawStatement); + if (where) cascadeSoftDelete(this, table, where.endsWith(';') ? where.slice(0, -1) : where, bind); } - } catch (err) { - throw prefixed('Run', err); - } + let rows: any[] | undefined; + try { + if (wantRows) { + rows = this.db.exec({ + sql: statement, + bind: bind.length > 0 ? bind : undefined, + rowMode: 'object', + returnValue: 'resultRows', + }); + } else { + this.db.exec({ sql: statement, bind: bind.length > 0 ? bind : undefined }); + } + } catch (err) { + throw prefixed('Run', err); + } + return rows; + }; + + // A soft delete is a tree of UPDATEs, so it has to be all or nothing: a RESTRICT discovered + // three levels down, or any other failure mid-walk, must not leave half the subtree marked. + // withOptionalTransaction is a no-op when the caller already opened one. + const rows = soft ? this.withOptionalTransaction(true, exec) : exec(); + + // DDL does not only arrive through execute(). A CREATE TABLE issued with run() used to leave + // the cached schema answers in place, so a foreign key added that way was invisible to the + // next cascade and its children were never marked: exactly the silent divergence the cascade + // exists to prevent. + if (DDL.has(statementKind(rawStatement))) this.invalidateSyncCache(); + const result: ExecResult = { changes: this.totalChanges() - before, lastId: this.lastInsertRowid() }; if (rows !== undefined) result.values = rows; return result; diff --git a/test/web/cascade.test.ts b/test/web/cascade.test.ts new file mode 100644 index 00000000..a0970571 --- /dev/null +++ b/test/web/cascade.test.ts @@ -0,0 +1,571 @@ +/** + * Foreign-key propagation for the soft delete (PLAN 13.4). + * + * The gap this closes is silent: without propagation a deleted parent is marked and its children + * are not, so the next export tells the server the parent is gone while still reporting the + * children live. The last test in the CASCADE block is the one that proves that divergence dead; + * the rest establish that each `ON DELETE` action means what the schema says it means. + */ +import { describe, expect, test } from 'vitest'; + +import { resolveTableName } from '../../src/web/worker/cascade'; + +import { TIERS, startHarness, tierLabel } from './harness'; + +describe('table names as the caller wrote them', () => { + test.each([ + ['items', 'items'], + ['main.items', 'items'], + ['"my table"', 'my table'], + ['[items]', 'items'], + ['`items`', 'items'], + ])('%s resolves to %s', (raw, expected) => { + expect(resolveTableName(raw)).toBe(expected); + }); + + test('an attached database is refused rather than silently skipped', () => { + expect(() => resolveTableName('other.items')).toThrow(/attached database/i); + }); +}); + +/** Columns every sync-tracked table needs, plus the trigger convention the docs describe. */ +const SYNC_COLUMNS = `last_modified INTEGER DEFAULT (strftime('%s','now')), sql_deleted BOOLEAN DEFAULT 0`; + +const TREE_SCHEMA = ` + CREATE TABLE parents ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + ${SYNC_COLUMNS} + ); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + name TEXT NOT NULL, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + ); + CREATE TABLE grandchildren ( + id INTEGER PRIMARY KEY NOT NULL, + child_id INTEGER, + name TEXT NOT NULL, + ${SYNC_COLUMNS}, + FOREIGN KEY (child_id) REFERENCES children(id) ON DELETE CASCADE + );`; + +const TREE_ROWS = ` + INSERT INTO parents (id, name) VALUES (1, 'keep'), (2, 'drop'); + INSERT INTO children (id, parent_id, name) VALUES (10, 1, 'keep-a'), (20, 2, 'drop-a'), (21, 2, 'drop-b'); + INSERT INTO grandchildren (id, child_id, name) VALUES (100, 10, 'keep-x'), (200, 20, 'drop-x'), (210, 21, 'drop-y');`; + +async function open(sqlite: any, name: string, schema: string, rows?: string) { + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute(schema); + if (rows) await db.execute(rows); + return db; +} + +/** id -> sql_deleted, so an assertion can talk about the whole table at once. */ +async function marks(db: any, table: string): Promise> { + const rows = await db.query(`SELECT id, sql_deleted FROM ${table} ORDER BY id`); + const out: Record = {}; + for (const row of rows.values ?? []) out[Number(row.id)] = Number(row.sql_deleted); + return out; +} + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: CASCADE marks children and grandchildren, and spares the other branch`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'casc', TREE_SCHEMA, TREE_ROWS); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1, 21: 1 }); + expect(await marks(db, 'grandchildren')).toEqual({ 100: 0, 200: 1, 210: 1 }); + + // Nothing was physically removed: a soft delete is a marking, at every level. + const counts = await db.query( + 'SELECT (SELECT count(*) FROM parents) AS p, (SELECT count(*) FROM children) AS c,' + + ' (SELECT count(*) FROM grandchildren) AS g', + ); + expect(counts.values?.[0]).toEqual({ p: 2, c: 3, g: 3 }); + await sqlite.closeConnection('casc', false); + }); + + test(`${label}: exportToJson reports a cascaded delete consistently at every level`, async () => { + // PLAN 13.4's scenario. Before the cascade existed, this export said the parent was deleted + // and its children were live, and the server had no way to notice. + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'cascexp', TREE_SCHEMA, TREE_ROWS); + await db.createSyncTable(); + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + const exported = await db.exportToJson('full'); + const tables = Object.fromEntries((exported.export?.tables ?? []).map((t: any) => [t.name, t.values ?? []])); + + const deletedOf = (table: string, id: number) => { + const row = tables[table].find((values: any[]) => Number(values[0]) === id); + const schema: any[] = (exported.export?.tables ?? []).find((t: any) => t.name === table).schema ?? []; + const index = schema.findIndex((column: any) => column.column === 'sql_deleted'); + return Number(row[index]); + }; + + // The parent and its whole subtree agree: all gone. + expect(deletedOf('parents', 2)).toBe(1); + expect(deletedOf('children', 20)).toBe(1); + expect(deletedOf('children', 21)).toBe(1); + expect(deletedOf('grandchildren', 200)).toBe(1); + expect(deletedOf('grandchildren', 210)).toBe(1); + // The untouched branch is still live, so the cascade did not over-reach either. + expect(deletedOf('parents', 1)).toBe(0); + expect(deletedOf('children', 10)).toBe(0); + expect(deletedOf('grandchildren', 100)).toBe(0); + await sqlite.closeConnection('cascexp', false); + }); + + test(`${label}: SET NULL clears the reference and leaves the child live`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'setnull', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE SET NULL + );`, + `INSERT INTO parents (id) VALUES (1), (2); + INSERT INTO children (id, parent_id) VALUES (10, 1), (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 0 }); + const rows = await db.query('SELECT id, parent_id FROM children ORDER BY id'); + expect(rows.values).toEqual([ + { id: 10, parent_id: 1 }, + { id: 20, parent_id: null }, + ]); + await sqlite.closeConnection('setnull', false); + }); + + test(`${label}: SET DEFAULT restores the column's declared default`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'setdef', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER DEFAULT 1, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE SET DEFAULT + );`, + `INSERT INTO parents (id) VALUES (1), (2); + INSERT INTO children (id, parent_id) VALUES (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + const rows = await db.query('SELECT id, parent_id, sql_deleted FROM children'); + expect(rows.values).toEqual([{ id: 20, parent_id: 1, sql_deleted: 0 }]); + await sqlite.closeConnection('setdef', false); + }); + + test(`${label}: RESTRICT refuses the delete while live children exist`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'restrict', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE RESTRICT + );`, + `INSERT INTO parents (id) VALUES (1), (2); + INSERT INTO children (id, parent_id) VALUES (20, 2);`, + ); + + await expect(db.run('DELETE FROM parents WHERE id = ?', [2])).rejects.toThrow(/related items exist/i); + // Refused means refused: the parent is untouched, not half-deleted. + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 0 }); + expect(await marks(db, 'children')).toEqual({ 20: 0 }); + + // A parent with no children is unaffected by the constraint. + await db.run('DELETE FROM parents WHERE id = ?', [1]); + expect(await marks(db, 'parents')).toEqual({ 1: 1, 2: 0 }); + + // And once the child is gone, the parent can go too. + await db.run('DELETE FROM children WHERE id = ?', [20]); + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect(await marks(db, 'parents')).toEqual({ 1: 1, 2: 1 }); + await sqlite.closeConnection('restrict', false); + }); + + test(`${label}: NO ACTION leaves the child alone, which is what it asks for`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'noaction', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER, + ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE NO ACTION + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO children (id, parent_id) VALUES (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect(await marks(db, 'parents')).toEqual({ 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 20: 0 }); + await sqlite.closeConnection('noaction', false); + }); + + test(`${label}: a self-referencing tree cascades to every descendant and terminates`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'selfref', + `CREATE TABLE nodes ( + id INTEGER PRIMARY KEY NOT NULL, + parent_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE, + ${SYNC_COLUMNS} + );`, + `INSERT INTO nodes (id, parent_id) VALUES (1, NULL), (2, 1), (3, 2), (4, 3), (5, NULL), (6, 5);`, + ); + + await db.run('DELETE FROM nodes WHERE id = ?', [1]); + + // 1 -> 2 -> 3 -> 4 all go; the 5 -> 6 branch is untouched. This also exercises a column-level + // REFERENCES clause, which the port source's regex never matched. + expect(await marks(db, 'nodes')).toEqual({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 0, 6: 0 }); + await sqlite.closeConnection('selfref', false); + }); + + test(`${label}: every table referencing the parent is followed, not just the first`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'multiref', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE alpha ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + ); + CREATE TABLE beta ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO alpha (id, parent_id) VALUES (10, 2); + INSERT INTO beta (id, parent_id) VALUES (20, 2);`, + ); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect(await marks(db, 'alpha')).toEqual({ 10: 1 }); + expect(await marks(db, 'beta')).toEqual({ 20: 1 }); + await sqlite.closeConnection('multiref', false); + }); + + test(`${label}: a composite foreign key cascades on the whole key`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'composite', + `CREATE TABLE parents ( + org TEXT NOT NULL, code TEXT NOT NULL, ${SYNC_COLUMNS}, + PRIMARY KEY (org, code) + ); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, org TEXT, code TEXT, ${SYNC_COLUMNS}, + FOREIGN KEY (org, code) REFERENCES parents(org, code) ON DELETE CASCADE + );`, + `INSERT INTO parents (org, code) VALUES ('a', 'x'), ('a', 'y'); + INSERT INTO children (id, org, code) VALUES (1, 'a', 'x'), (2, 'a', 'y');`, + ); + + await db.run('DELETE FROM parents WHERE org = ? AND code = ?', ['a', 'x']); + // Only the row matching BOTH key columns goes. + expect(await marks(db, 'children')).toEqual({ 1: 1, 2: 0 }); + await sqlite.closeConnection('composite', false); + }); + + test(`${label}: a child without sql_deleted is left to sqlite's own enforcement`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'mixed', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE notes ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO notes (id, parent_id) VALUES (20, 2);`, + ); + + // There is no column to mark, and nothing is orphaned: the parent row physically remains. + await db.run('DELETE FROM parents WHERE id = ?', [2]); + expect((await db.query('SELECT count(*) AS n FROM notes')).values?.[0].n).toBe(1); + + // The constraint is still real. deleteExportedRows performs an actual DELETE, and that is + // where sqlite runs the cascade itself. The backdate is what makes the row reclaimable: the + // export stamp has to be strictly newer than the row's last_modified. + await db.createSyncTable(); + await db.execute('UPDATE parents SET last_modified = 1 WHERE id = 2;'); + await db.exportToJson('full'); + await db.deleteExportedRows(); + expect((await db.query('SELECT count(*) AS n FROM parents')).values?.[0].n).toBe(0); + expect((await db.query('SELECT count(*) AS n FROM notes')).values?.[0].n).toBe(0); + await sqlite.closeConnection('mixed', false); + }); + + test(`${label}: a cascaded soft delete reports the same changes as the equivalent hard delete`, async () => { + const { sqlite } = await startHarness(tier); + + // Same shape, no sync columns: sqlite performs the real cascade. + const hard = await open( + sqlite, + 'hardc', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO children (id, parent_id) VALUES (20, 2), (21, 2);`, + ); + const hardChanges = (await hard.run('DELETE FROM parents WHERE id = ?', [2])).changes?.changes; + await sqlite.closeConnection('hardc', false); + + const soft = await open( + sqlite, + 'softc', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id) VALUES (2); + INSERT INTO children (id, parent_id) VALUES (20, 2), (21, 2);`, + ); + const softChanges = (await soft.run('DELETE FROM parents WHERE id = ?', [2])).changes?.changes; + await sqlite.closeConnection('softc', false); + + // 1 parent + 2 children either way. Recording a deletion must not count differently from + // performing one. + expect(hardChanges).toBe(3); + expect(softChanges).toBe(3); + }); + + test(`${label}: deleting an already-deleted parent is a no-op, not a second cascade`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'idem', TREE_SCHEMA, TREE_ROWS); + + await db.run('DELETE FROM parents WHERE id = ?', [2]); + const before = await db.query('SELECT last_modified FROM children WHERE id = 20'); + const second = await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(second.changes?.changes).toBe(0); + const after = await db.query('SELECT last_modified FROM children WHERE id = 20'); + expect(after.values?.[0].last_modified).toBe(before.values?.[0].last_modified); + await sqlite.closeConnection('idem', false); + }); + + test(`${label}: a delete written with literals rather than binds still cascades`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'literals', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, name TEXT, ${SYNC_COLUMNS}); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + `INSERT INTO parents (id, name) VALUES (1, 'keep'), (2, 'drop'); + INSERT INTO children (id, parent_id) VALUES (10, 1), (20, 2);`, + ); + + // The WHERE clause carries a string literal instead of a bind, which is ordinary SQL and used + // to produce a rewritten statement that did not parse. + await db.run("DELETE FROM parents WHERE name = 'drop'"); + + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1 }); + await sqlite.closeConnection('literals', false); + }); + + test(`${label}: a DELETE inside an execute batch is recorded, not performed`, async () => { + // Which entry point the app happened to use must not decide whether the server hears about + // the deletion. execute() used to hand the batch straight to sqlite, so a batched DELETE + // physically removed rows from a sync-tracked database while run() of the same statement + // marked them. + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'batch', TREE_SCHEMA, TREE_ROWS); + + await db.execute("DELETE FROM parents WHERE name = 'drop';"); + + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1 }); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1, 21: 1 }); + expect(await marks(db, 'grandchildren')).toEqual({ 100: 0, 200: 1, 210: 1 }); + expect((await db.query('SELECT count(*) AS n FROM parents')).values?.[0].n).toBe(2); + + // The rest of a mixed batch still runs normally. + await db.execute("INSERT INTO parents (id, name) VALUES (3, 'later'); DELETE FROM parents WHERE id = 3;"); + expect(await marks(db, 'parents')).toEqual({ 1: 0, 2: 1, 3: 1 }); + await sqlite.closeConnection('batch', false); + }); + + test(`${label}: a table without sql_deleted is deleted from for real, even in a sync database`, async () => { + // syncEnabled is a property of the database, but the rewrite has to be a property of the + // table: rewriting here would produce SET sql_deleted = 1 on a table with no such column. + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'pertable', + `CREATE TABLE tracked (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE plain (id INTEGER PRIMARY KEY NOT NULL, v TEXT);`, + `INSERT INTO tracked (id) VALUES (1); + INSERT INTO plain (id, v) VALUES (1, 'a'), (2, 'b');`, + ); + + const result = await db.run('DELETE FROM plain WHERE id = ?', [1]); + expect(result.changes?.changes).toBe(1); + expect((await db.query('SELECT id FROM plain ORDER BY id')).values).toEqual([{ id: 2 }]); + + // And the tracked table still behaves as a sync table. + await db.run('DELETE FROM tracked WHERE id = ?', [1]); + expect(await marks(db, 'tracked')).toEqual({ 1: 1 }); + await sqlite.closeConnection('pertable', false); + }); + + test(`${label}: a RESTRICT found mid-walk leaves nothing half-marked`, async () => { + // The cascade is a tree of UPDATEs, so it has to be all or nothing even when the caller opted + // out of the implicit transaction. + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'atomic', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE middle ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + ); + CREATE TABLE guarded ( + id INTEGER PRIMARY KEY NOT NULL, middle_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (middle_id) REFERENCES middle(id) ON DELETE RESTRICT + );`, + `INSERT INTO parents (id) VALUES (1); + INSERT INTO middle (id, parent_id) VALUES (10, 1); + INSERT INTO guarded (id, middle_id) VALUES (100, 10);`, + ); + + await expect(db.run('DELETE FROM parents WHERE id = ?', [1], false)).rejects.toThrow(/related items exist/i); + + // `middle` was marked before the walk reached the RESTRICT two levels down. All of it is back. + expect(await marks(db, 'parents')).toEqual({ 1: 0 }); + expect(await marks(db, 'middle')).toEqual({ 10: 0 }); + expect(await marks(db, 'guarded')).toEqual({ 100: 0 }); + await sqlite.closeConnection('atomic', false); + }); + + test(`${label}: a soft delete asked for its rows still hands them back`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'returning', TREE_SCHEMA, TREE_ROWS); + + // The RETURNING clause used to land inside the generated WHERE, producing SQL that did not + // parse, so this whole shape was unusable on a sync-tracked database. + const result = await db.run('DELETE FROM parents WHERE id = ? RETURNING id, name', [2], true, 'all'); + expect(result.changes?.values).toEqual([{ id: 2, name: 'drop' }]); + expect(await marks(db, 'children')).toEqual({ 10: 0, 20: 1, 21: 1 }); + await sqlite.closeConnection('returning', false); + }); + + test(`${label}: a quoted table name cascades like any other`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'quoted', + `CREATE TABLE "order" (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS}); + CREATE TABLE "order line" ( + id INTEGER PRIMARY KEY NOT NULL, order_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (order_id) REFERENCES "order"(id) ON DELETE CASCADE + );`, + `INSERT INTO "order" (id) VALUES (1); + INSERT INTO "order line" (id, order_id) VALUES (10, 1);`, + ); + + await db.run('DELETE FROM "order" WHERE id = ?', [1]); + expect(await marks(db, '"order"')).toEqual({ 1: 1 }); + expect(await marks(db, '"order line"')).toEqual({ 10: 1 }); + await sqlite.closeConnection('quoted', false); + }); + + test(`${label}: a delete with an OR stays idempotent, and the cascade agrees with it`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open(sqlite, 'ors', TREE_SCHEMA, TREE_ROWS); + + const first = await db.run('DELETE FROM parents WHERE id = ? OR name = ?', [2, 'nobody']); + expect(first.changes?.changes).toBe(5); // the parent, two children, two grandchildren + const stamp = (await db.query('SELECT last_modified FROM parents WHERE id = 2')).values?.[0].last_modified; + + // Repeating it must be a no-op. Without brackets round the caller's clause the guard applied + // to the last disjunct only, so this re-marked the row and moved its last_modified. + const second = await db.run('DELETE FROM parents WHERE id = ? OR name = ?', [2, 'nobody']); + expect(second.changes?.changes).toBe(0); + expect((await db.query('SELECT last_modified FROM parents WHERE id = 2')).values?.[0].last_modified).toBe(stamp); + await sqlite.closeConnection('ors', false); + }); + + test(`${label}: a foreign key added later is seen by the next delete`, async () => { + // DDL does not only arrive through execute(). A CREATE TABLE issued with run() used to leave + // the cached foreign-key graph in place, so the new constraint was invisible and its children + // were never marked. + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'latefk', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL, ${SYNC_COLUMNS});`, + 'INSERT INTO parents (id) VALUES (1), (2);', + ); + // Populates the caches: syncEnabled true, foreign-key graph empty. + await db.run('DELETE FROM parents WHERE id = ?', [1]); + + await db.run( + `CREATE TABLE children (id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, ${SYNC_COLUMNS}, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE)`, + ); + await db.run('INSERT INTO children (id, parent_id) VALUES (?, ?)', [20, 2]); + await db.run('DELETE FROM parents WHERE id = ?', [2]); + + expect(await marks(db, 'children')).toEqual({ 20: 1 }); + await sqlite.closeConnection('latefk', false); + }); + + test(`${label}: foreign keys are enforced, which they were not before`, async () => { + const { sqlite } = await startHarness(tier); + const db = await open( + sqlite, + 'fkon', + `CREATE TABLE parents (id INTEGER PRIMARY KEY NOT NULL); + CREATE TABLE children ( + id INTEGER PRIMARY KEY NOT NULL, parent_id INTEGER, + FOREIGN KEY (parent_id) REFERENCES parents(id) ON DELETE CASCADE + );`, + ); + expect((await db.query('PRAGMA foreign_keys')).values?.[0].foreign_keys).toBe(1); + await expect(db.run('INSERT INTO children (id, parent_id) VALUES (?, ?)', [1, 999])).rejects.toThrow( + /FOREIGN KEY constraint failed/i, + ); + await sqlite.closeConnection('fkon', false); + }); +}); From 5b551e2cf67640ba7f3f6db64f32f51c7c3e1855 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:56:39 +0800 Subject: [PATCH 19/27] feat(web): move IndexedDB databases into OPFS when the browser gains it The two durability tiers do not read each other. A browser that lacked OPFS sync access handles stores whole-file images in IndexedDB; when the same browser later gains them, tier selection picks tier 1, the pool is empty, and getDatabaseList returns nothing while the user's data sits in IndexedDB with no way back. That is not hypothetical: it is the normal update path for exactly the devices that run on tier 2, Android WebView reaching M132 and iOS 16.3 to 16.4. Every tier 1 init now adopts any image left in the fallback store through the same verified path the jeep-sqlite migration uses, checking integrity_check before removing the original. That ordering is what makes it safe to run on every boot instead of once: nothing is deleted until its replacement has been read back, so an interrupted run resumes on the next start and a browser that drops back to tier 2 still finds whatever has not moved. The pool wins a name conflict, because a database can only be in the pool if it was written after the flip. The losing image is kept rather than deleted: the only way both can exist is a promotion that failed and an app that then created the name itself, and in that case the image may be the copy that matters. The adoption contract the migration introduced moves to its own module, since both callers now need it. --- src/web.ts | 32 ++++- src/web/protocol.ts | 16 +++ src/web/worker/adoption.ts | 33 +++++ src/web/worker/migrate-jeep.ts | 30 ++--- src/web/worker/promote.ts | 102 +++++++++++++++ src/web/worker/worker.ts | 20 ++- test/web/promote.test.ts | 232 +++++++++++++++++++++++++++++++++ 7 files changed, 435 insertions(+), 30 deletions(-) create mode 100644 src/web/worker/adoption.ts create mode 100644 src/web/worker/promote.ts create mode 100644 test/web/promote.test.ts diff --git a/src/web.ts b/src/web.ts index 7526582b..79555338 100644 --- a/src/web.ts +++ b/src/web.ts @@ -39,7 +39,13 @@ import type { import { WorkerClient } from './web/client'; import { WEBSTORE_NOT_OPEN, messageOf, prefixed } from './web/errors'; import { connectionNameFromFile, getLocalDiskAdapter } from './web/localdisk'; -import type { JeepMigrationResult, SerializedUpgrade, Tier, WorkerInitResult } from './web/protocol'; +import type { + JeepMigrationResult, + SerializedUpgrade, + Tier, + TierPromotionResult, + WorkerInitResult, +} from './web/protocol'; import { EV_PICK_DATABASE_ENDED, EV_SAVE_TO_DISK, EV_HTTP_REQUEST_ENDED } from './web/protocol'; import { ConnectionRegistry, parseKey, reconcile } from './web/registry'; import { connKey, storageName } from './web/worker/paths'; @@ -62,6 +68,21 @@ function reportMigration(migration: WorkerInitResult['migration']): void { if (migration.warning) console.warn(`[capacitor-sqlite] ${migration.warning}`); } +/** + * The tier-2 to tier-1 promotion. Silence means the fallback store was empty, which is the case + * for every installation that has only ever run on OPFS. + */ +function reportPromotion(promotion: WorkerInitResult['promotion']): void { + if (!promotion) return; + if (promotion.promoted.length > 0) { + console.info( + `[capacitor-sqlite] moved ${promotion.promoted.length} database(s) out of the IndexedDB ` + + `fallback store into OPFS: ${promotion.promoted.join(', ')}.`, + ); + } + if (promotion.warning) console.warn(`[capacitor-sqlite] ${promotion.warning}`); +} + /** * Web implementation backed by `@sqlite.org/sqlite-wasm` in a dedicated worker. * @@ -82,6 +103,7 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug this.client.onEvent = (event, data) => this.notifyListeners(event, data); await this.client.start(); this.store = await this.client.call('init', getSqliteWebOptions()); + reportPromotion(this.store?.promotion); reportMigration(this.store?.migration); } catch (err) { this.store = null; @@ -103,6 +125,14 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug return this.store?.migration ?? null; } + /** + * What the tier-2 to tier-1 promotion did on this boot, or null when there was nothing in the + * fallback store to move. Additive; not part of CapacitorSQLitePlugin. + */ + getTierPromotion(): TierPromotionResult | null { + return this.store?.promotion ?? null; + } + /** * Shut the worker down and release the single-owner lock, so another tab can take over. * Additive; not part of CapacitorSQLitePlugin. Any open connection is dropped without a diff --git a/src/web/protocol.ts b/src/web/protocol.ts index fa21f490..6c6c2859 100644 --- a/src/web/protocol.ts +++ b/src/web/protocol.ts @@ -35,6 +35,22 @@ export interface WorkerInitResult { fallbackReason?: string; /** Outcome of the one-time jeep-sqlite migration. Absent when it had already run. */ migration?: JeepMigrationResult; + /** Outcome of the tier-2 to tier-1 promotion. Absent on tier 2 and when there was nothing to move. */ + promotion?: TierPromotionResult; +} + +/** + * What the tier-promotion pass did. Reported rather than thrown: an image that cannot be moved + * into the pool is still readable by a tier 2 context, so it is a warning, not a boot failure. + */ +export interface TierPromotionResult { + /** Storage names moved from the IndexedDB image store into the OPFS pool. */ + promoted: string[]; + /** Images whose name is already in the pool. The pool copy wins; the image is kept, not deleted. */ + conflicts: string[]; + /** Images that could not be moved. Still in the image store, retried on the next start. */ + failed: string[]; + warning?: string; } /** diff --git a/src/web/worker/adoption.ts b/src/web/worker/adoption.ts new file mode 100644 index 00000000..729569d2 --- /dev/null +++ b/src/web/worker/adoption.ts @@ -0,0 +1,33 @@ +/** + * What it means to take a whole database image and make it one of this store's databases. + * + * Two callers share it: the one-time import from jeep-sqlite (`migrate-jeep.ts`) and the + * tier-2 to tier-1 promotion (`promote.ts`). Both hand over bytes from somewhere else, both must + * prove the result is a readable database before they let go of the original, and neither may + * ever write over a database that is already in the store. + */ + +/** The first 16 bytes of every SQLite file are these 15 characters followed by a NUL. */ +const SQLITE_HEADER = 'SQLite format 3'; + +export interface AdoptionTarget { + /** True when the store already holds a database of that name. Checked before every adopt. */ + exists(storage: string): Promise; + adopt(storage: string, bytes: Uint8Array): Promise; + /** Open what was adopted and run `PRAGMA integrity_check`. Throws when it is not a database. */ + verify(storage: string): Promise; + /** Undo an adopt whose verification failed, so no unreadable file is left behind. */ + discard(storage: string): Promise; +} + +/** + * A cheap gate before handing bytes to the engine. On tier 2 `sqlite3_deserialize` accepts almost + * anything and only fails later, so without this a stray value would become a database. + */ +export function looksLikeSQLite(bytes: Uint8Array): boolean { + if (bytes.byteLength < SQLITE_HEADER.length) return false; + for (let i = 0; i < SQLITE_HEADER.length; i++) { + if (bytes[i] !== SQLITE_HEADER.charCodeAt(i)) return false; + } + return true; +} diff --git a/src/web/worker/migrate-jeep.ts b/src/web/worker/migrate-jeep.ts index f17af58e..f5931eeb 100644 --- a/src/web/worker/migrate-jeep.ts +++ b/src/web/worker/migrate-jeep.ts @@ -34,6 +34,9 @@ import { messageOf } from '../errors'; import type { JeepMigrationResult } from '../protocol'; +import type { AdoptionTarget } from './adoption'; +import { looksLikeSQLite } from './adoption'; + /** localforage `name` and `storeName` from jeep's `setConfig` (jeep-sqlite.js `setConfig`). */ export const JEEP_DB_NAME = 'jeepSqliteStore'; export const JEEP_STORE_NAME = 'databases'; @@ -43,8 +46,6 @@ export const JEEP_MIGRATION_MARKER = 'jeep-migration'; const BACKUP_PREFIX = 'backup-'; const SUFFIX = 'SQLite.db'; -/** The first 16 bytes of every SQLite file are these 15 characters followed by a NUL. */ -const SQLITE_HEADER = 'SQLite format 3'; /** How long to wait for a delete that another context is blocking before giving up on it. */ const DELETE_TIMEOUT_MS = 5000; @@ -55,19 +56,12 @@ export interface MarkerStore { } /** - * The tier-specific half. Nothing here ever writes over a database that is already in the store: - * `exists` is consulted first, and a taken name fails that database instead. The migration cannot - * know whether such a database is a leftover of its own or one the app has been using, and only - * one of those two is safe to overwrite. + * The tier-specific half, shared with the tier-promotion pass. Nothing here ever writes over a + * database that is already in the store: `exists` is consulted first, and a taken name fails that + * database instead. The migration cannot know whether such a database is a leftover of its own or + * one the app has been using, and only one of those two is safe to overwrite. */ -export interface JeepMigrationTarget { - exists(storage: string): Promise; - adopt(storage: string, bytes: Uint8Array): Promise; - /** Open what was adopted and run `PRAGMA integrity_check`. Throws when it is not a database. */ - verify(storage: string): Promise; - /** Undo an adopt whose verification failed, so no unreadable file is left behind. */ - discard(storage: string): Promise; -} +export type JeepMigrationTarget = AdoptionTarget; interface LegacyEntry { key: string; @@ -103,14 +97,6 @@ async function toBytes(value: unknown): Promise { return undefined; } -function looksLikeSQLite(bytes: Uint8Array): boolean { - if (bytes.byteLength < SQLITE_HEADER.length) return false; - for (let i = 0; i < SQLITE_HEADER.length; i++) { - if (bytes[i] !== SQLITE_HEADER.charCodeAt(i)) return false; - } - return true; -} - /** * True/false when the browser can answer cheaply, null when it cannot. `indexedDB.databases()` * is absent on Firefox before 126, which is why the caller also has the open-and-detect path. diff --git a/src/web/worker/promote.ts b/src/web/worker/promote.ts new file mode 100644 index 00000000..f407b4b4 --- /dev/null +++ b/src/web/worker/promote.ts @@ -0,0 +1,102 @@ +/** + * Tier promotion: moving databases from the IndexedDB image store into the OPFS pool. + * + * The two tiers do not read each other. A browser that lacked OPFS sync access handles stores + * whole-file images in IndexedDB; when the same browser later gains them, tier selection picks + * tier 1 and the pool is empty, so `getDatabaseList()` returns nothing while the user's data sits + * in IndexedDB with no way back. That is not hypothetical: it is the normal upgrade path for + * exactly the population that runs on tier 2 (Android WebView reaching M132, iOS 16.3 to 16.4). + * + * So on every tier 1 init, any image left in the store is adopted into the pool through the same + * verified path the jeep-sqlite migration uses, and only then removed. + * + * Two rules make this safe to run on every boot rather than once: + * + * - **The pool wins a name conflict.** A database can only be in the pool because it was written + * while on tier 1, which is after the flip, which makes it newer than any image left behind. + * The image is NOT deleted in that case: the only way both can exist is a promotion that + * failed or was interrupted and an app that then created the name itself, and in that + * situation the image may be the copy that matters. It is reported and left alone. + * - **An image is deleted only after its own adoption has verified.** Anything not yet promoted + * is therefore still exactly where tier 2 would look for it, so an interrupted run resumes on + * the next boot and a browser that drops back to tier 2 still finds what was not moved. + */ +import { messageOf } from '../errors'; +import type { TierPromotionResult } from '../protocol'; + +import type { AdoptionTarget } from './adoption'; +import { looksLikeSQLite } from './adoption'; + +/** The bit of ImageStore promotion needs. Narrowed so the tests can drive it directly. */ +export interface PromotableImages { + keys(): Promise; + get(storage: string): Promise; + delete(storage: string): Promise; +} + +export function noPromotion(): TierPromotionResult { + return { promoted: [], conflicts: [], failed: [] }; +} + +/** + * Never throws. A store that cannot be promoted is a reason to warn, not a reason to refuse to + * boot: the images are still readable by a tier 2 context and nothing has been destroyed. + */ +export async function promoteImages(images: PromotableImages, target: AdoptionTarget): Promise { + let keys: string[]; + try { + keys = await images.keys(); + } catch (err) { + return { ...noPromotion(), warning: `Could not read the image store: ${messageOf(err)}` }; + } + if (keys.length === 0) return noPromotion(); + + const promoted: string[] = []; + const conflicts: string[] = []; + const failed: string[] = []; + const problems: string[] = []; + + for (const storage of keys) { + try { + if (await target.exists(storage)) { + conflicts.push(storage); + continue; + } + const bytes = await images.get(storage); + if (!bytes || bytes.byteLength === 0 || !looksLikeSQLite(bytes)) { + failed.push(storage); + problems.push(`${storage}: the stored image is not a SQLite database`); + continue; + } + await target.adopt(storage, bytes); + await target.verify(storage); + // Only now, with a readable database in the pool, does the original go. + await images.delete(storage); + promoted.push(storage); + } catch (err) { + failed.push(storage); + problems.push(`${storage}: ${messageOf(err)}`); + try { + await target.discard(storage); + } catch { + // The image is still in the store, which is what matters. + } + } + } + + const warnings: string[] = []; + if (failed.length > 0) { + warnings.push( + `${failed.length} database(s) could not be moved out of the IndexedDB fallback store and were ` + + `left there (${problems.join('; ')}). They will be retried on the next start.`, + ); + } + if (conflicts.length > 0) { + warnings.push( + `${conflicts.length} database(s) in the IndexedDB fallback store share a name with a database ` + + `already in this store and were left untouched (${conflicts.join(', ')}). The one in use is ` + + 'the newer of the two; the older copy is still in IndexedDB if you need it.', + ); + } + return { promoted, conflicts, failed, ...(warnings.length > 0 ? { warning: warnings.join(' ') } : {}) }; +} diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index c3e02bc9..6198cbad 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -20,9 +20,10 @@ import { ImageStore, deserializeInto } from './images'; import { exportJson } from './json/export'; import { importJson } from './json/import'; import { isJsonSQLite, parseJsonSQLite } from './json/validate'; -import type { JeepMigrationTarget } from './migrate-jeep'; +import type { AdoptionTarget } from './adoption'; import { migrateFromJeep } from './migrate-jeep'; import { connKey, fromPoolPath, poolPath, reserveCapacity, storageName } from './paths'; +import { promoteImages } from './promote'; import * as sync from './sync'; import { selectTier } from './tiers'; import { runUpgrades } from './upgrades'; @@ -78,11 +79,12 @@ function assetTarget(): AssetTarget { } /** - * The tier-specific half of the jeep-sqlite migration. Adoption goes through the same import - * path a downloaded database takes; verification opens what was adopted and asks sqlite whether - * it is a database at all, which is the only check that would catch a truncated legacy image. + * Adoption into whichever tier is active, shared by the jeep-sqlite migration and the tier-2 to + * tier-1 promotion. Adoption goes through the same import path a downloaded database takes; + * verification opens what was adopted and asks sqlite whether it is a database at all, which is + * the only check that would catch a truncated image. */ -function migrationTarget(): JeepMigrationTarget { +function adoptionTarget(): AdoptionTarget { const target = assetTarget(); return { exists: (storage) => target.exists(storage), @@ -196,14 +198,18 @@ const ops: Record any> = { poolUtil = selection.poolUtil; if (tier === 1) await reserveCapacity(poolUtil, 4); - // Runs before any connection is opened, so an import can never race a live database. - const migration = args.skipJeepMigration ? null : await migrateFromJeep(images, migrationTarget()); + // Both passes run before any connection is opened, so neither can race a live database. + // Promotion goes first: an image in the fallback store is this app's own data from an earlier + // boot, so it outranks anything the jeep-sqlite store may name the same. + const promotion = tier === 1 ? await promoteImages(images, adoptionTarget()) : null; + const migration = args.skipJeepMigration ? null : await migrateFromJeep(images, adoptionTarget()); return { tier, sqliteVersion: sqlite3.version.libVersion, ...(selection.fallbackReason ? { fallbackReason: selection.fallbackReason } : {}), ...(migration && (migration.ran || migration.warning) ? { migration } : {}), + ...(promotion && (promotion.promoted.length > 0 || promotion.warning) ? { promotion } : {}), }; }, diff --git a/test/web/promote.test.ts b/test/web/promote.test.ts new file mode 100644 index 00000000..4056375e --- /dev/null +++ b/test/web/promote.test.ts @@ -0,0 +1,232 @@ +/** + * Tier promotion (PLAN 14.8): databases stored as IndexedDB images while the browser lacked OPFS + * move into the pool once it gains it. + * + * Without this, the normal upgrade path for a tier 2 user (Android WebView reaching M132, iOS 16.3 + * to 16.4) ends with an empty store and their data stranded in IndexedDB. Every test here boots + * the same pool name twice, once forced onto tier 2 and once not, which is exactly what that + * upgrade looks like from the plugin's side. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import type { Tier } from '../../src/web/protocol'; +import { IMAGE_STORE_NAME, IMAGE_STORE_VERSION, META_STORE_NAME, imageStoreDbName } from '../../src/web/protocol'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { jeepImage } from './fixtures/jeep-image'; +import { stopHarness } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const open: CapacitorSQLiteWeb[] = []; +let poolCounter = 0; + +async function boot(tier: Tier, poolName: string): Promise { + setSqliteWebOptions({ + forceTier2: tier === 2, + simulateInstallError: undefined, + skipJeepMigration: true, + poolName, + directory: `.${poolName}`, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await new SQLiteConnection(plugin).initWebStore(); + expect(plugin.getWebStoreTier()).toBe(tier); + return plugin; +} + +/** Create a database with one row, on whichever tier the plugin is currently on. */ +async function seedDatabase(plugin: CapacitorSQLiteWeb, name: string, note: string): Promise { + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, note TEXT);'); + await db.run('INSERT INTO notes (note) VALUES (?)', [note]); + await sqlite.closeConnection(name, false); + // Tier 2 only writes the image at a flush point, and closeConnection is one. +} + +async function noteIn(plugin: CapacitorSQLiteWeb, name: string): Promise { + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + const rows = await db.query('SELECT note FROM notes ORDER BY id'); + await sqlite.closeConnection(name, false); + return (rows.values ?? []).map((row: any) => row.note); +} + +function idb(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed')); + }); +} + +function openImageStore(poolName: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(imageStoreDbName(poolName), IMAGE_STORE_VERSION); + req.onupgradeneeded = () => { + for (const store of [IMAGE_STORE_NAME, META_STORE_NAME]) { + if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error('could not open the image store')); + }); +} + +/** Put bytes straight into the fallback store, for images the plugin would never write itself. */ +async function putImage(poolName: string, storage: string, bytes: Uint8Array): Promise { + const db = await openImageStore(poolName); + const store = db.transaction(IMAGE_STORE_NAME, 'readwrite').objectStore(IMAGE_STORE_NAME); + await idb(store.put(bytes, storage)); + db.close(); +} + +async function imageKeys(poolName: string): Promise { + const db = await openImageStore(poolName); + const store = db.transaction(IMAGE_STORE_NAME, 'readonly').objectStore(IMAGE_STORE_NAME); + const keys = await idb(store.getAllKeys()); + db.close(); + return keys.map(String).sort(); +} + +beforeEach(() => { + poolCounter += 1; +}); + +afterEach(async () => { + await Promise.all(open.splice(0).map((plugin) => plugin.closeWebStore())); + await stopHarness(); +}); + +describe('tier promotion', () => { + const pool = (suffix: string) => `promote-${poolCounter}-${suffix}`; + + test('databases written on tier 2 are in the pool after the browser gains OPFS', async () => { + const name = pool('clean'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'alpha', 'written on tier 2'); + await seedDatabase(fallback, 'beta', 'also tier 2'); + expect(await imageKeys(name)).toEqual(['alphaSQLite.db', 'betaSQLite.db']); + await fallback.closeWebStore(); + + const upgraded = await boot(1, name); + const promotion = upgraded.getTierPromotion(); + expect(promotion?.promoted.sort()).toEqual(['alphaSQLite.db', 'betaSQLite.db']); + expect(promotion?.failed).toEqual([]); + expect(promotion?.conflicts).toEqual([]); + expect(promotion?.warning).toBeUndefined(); + + // Visible, readable, and with their rows intact. + expect((await upgraded.getDatabaseList()).values.sort()).toEqual(['alphaSQLite.db', 'betaSQLite.db']); + expect(await noteIn(upgraded, 'alpha')).toEqual(['written on tier 2']); + expect(await noteIn(upgraded, 'beta')).toEqual(['also tier 2']); + + // The image store is empty now, so the next boot has nothing to do. + expect(await imageKeys(name)).toEqual([]); + }); + + test('an unreadable image fails alone and the rest still move', async () => { + const name = pool('partial'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'good', 'survives'); + await fallback.closeWebStore(); + await putImage(name, 'badSQLite.db', new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])); + + const upgraded = await boot(1, name); + const promotion = upgraded.getTierPromotion(); + expect(promotion?.promoted).toEqual(['goodSQLite.db']); + expect(promotion?.failed).toEqual(['badSQLite.db']); + expect(promotion?.warning).toMatch(/retried on the next start/i); + + // The good one moved and the bad one is still where it was: nothing is thrown away. + expect((await upgraded.getDatabaseList()).values).toEqual(['goodSQLite.db']); + expect(await imageKeys(name)).toEqual(['badSQLite.db']); + expect(await noteIn(upgraded, 'good')).toEqual(['survives']); + }); + + test('a partial failure resumes safely on the next boot', async () => { + const name = pool('resume'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'good', 'from tier 2'); + await fallback.closeWebStore(); + await putImage(name, 'badSQLite.db', new Uint8Array([9, 9, 9, 9])); + + const first = await boot(1, name); + expect(first.getTierPromotion()?.promoted).toEqual(['goodSQLite.db']); + // The app then writes to the database it just got back. + const sqlite = new SQLiteConnection(first); + const db = await sqlite.createConnection('good', false, 'no-encryption', 1, false); + await db.open(); + await db.run('INSERT INTO notes (note) VALUES (?)', ['written after promoting']); + await sqlite.closeConnection('good', false); + await first.closeWebStore(); + + const second = await boot(1, name); + // The bad image is retried, the good one is not touched again, and the post-promotion write + // is still there. Re-adopting it would have silently undone that row. + expect(second.getTierPromotion()?.promoted).toEqual([]); + expect(second.getTierPromotion()?.failed).toEqual(['badSQLite.db']); + expect(await noteIn(second, 'good')).toEqual(['from tier 2', 'written after promoting']); + }); + + test('the pool wins a name conflict and the losing image is kept, not deleted', async () => { + const name = pool('conflict'); + // A pool database exists first, then an image of the same name appears in the fallback store. + // Only an interrupted promotion can produce this, which is why the image is not assumed stale + // enough to throw away. + const store = await boot(1, name); + await seedDatabase(store, 'notes', 'the pool copy'); + await store.closeWebStore(); + await putImage(name, 'notesSQLite.db', jeepImage()); + + const again = await boot(1, name); + const promotion = again.getTierPromotion(); + expect(promotion?.conflicts).toEqual(['notesSQLite.db']); + expect(promotion?.promoted).toEqual([]); + expect(promotion?.warning).toMatch(/share a name/i); + + // The pool copy is the one in use, unchanged, and the image is still in IndexedDB. + expect(await noteIn(again, 'notes')).toEqual(['the pool copy']); + expect(await imageKeys(name)).toEqual(['notesSQLite.db']); + }); + + test('a tier 2 boot after a failed promotion still sees what was not moved', async () => { + const name = pool('backdown'); + const fallback = await boot(2, name); + await seedDatabase(fallback, 'moved', 'promotable'); + await seedDatabase(fallback, 'stuck', 'also promotable'); + await fallback.closeWebStore(); + + // Make one of them unreadable so its promotion fails. + await putImage(name, 'stuckSQLite.db', new Uint8Array([1, 2, 3, 4])); + + const upgraded = await boot(1, name); + expect(upgraded.getTierPromotion()?.promoted).toEqual(['movedSQLite.db']); + expect(upgraded.getTierPromotion()?.failed).toEqual(['stuckSQLite.db']); + await upgraded.closeWebStore(); + + // Back on tier 2, whatever did not move is exactly where it was. + const back = await boot(2, name); + expect(back.getTierPromotion()).toBeNull(); + expect((await back.getDatabaseList()).values).toEqual(['stuckSQLite.db']); + }); + + test('tier 2 never promotes, and an empty fallback store reports nothing', async () => { + const name = pool('quiet'); + const onTier2 = await boot(2, name); + await seedDatabase(onTier2, 'alpha', 'stays put'); + // Promotion is a tier 1 pass by definition: on tier 2 the images ARE the databases. + expect(onTier2.getTierPromotion()).toBeNull(); + await onTier2.closeWebStore(); + + const clean = await boot(1, pool('empty')); + expect(clean.getTierPromotion()).toBeNull(); + }); +}); From d319e51ae050a684f5071e08137b5e4e6a29760f Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:57:16 +0800 Subject: [PATCH 20/27] feat(web): follow the app in and out of the background, with a worker restart WKWebView invalidates OPFS access handles when the OS suspends the app, so the store cannot be left open across a background. The worker ops for this have existed since M1 but nothing drove them: there was no appStateChange wiring, no pause or resume on the facade, and no recovery path. pauseWebStore closes every connection and pauses the VFS, in that order, because pauseVfs throws SQLITE_MISUSE while any database is open. resumeWebStore unpauses and reopens what it closed, and falls through to tearing the worker down and starting a fresh one when it cannot, which is the recovery path 6.7 made binding and which a real suspension can require. A transaction open at pause time cannot survive the close, so it is rolled back and named on the console rather than silently abandoned. Auto-wiring uses visibilitychange rather than @capacitor/app, which is not a dependency of this plugin and should not become one for a single listener, and is installed only when running on a native Capacitor platform: a browser tab going hidden is not a suspension, and closing every connection on a tab switch would be a bug rather than a protection. Apps that want the more precise signal can call the three methods themselves, which is safe alongside the listener because all three are idempotent. --- src/web.ts | 105 ++++++++++++++++++- src/web/worker/worker.ts | 18 +++- test/web/lifecycle.test.ts | 208 +++++++++++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 test/web/lifecycle.test.ts diff --git a/src/web.ts b/src/web.ts index 79555338..f101e9f4 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,4 +1,4 @@ -import { WebPlugin } from '@capacitor/core'; +import { Capacitor, WebPlugin } from '@capacitor/core'; import type { CapacitorSQLitePlugin, @@ -97,6 +97,10 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug private upgrades = new Map(); private store: WorkerInitResult | null = null; + /** Connections closed by pauseWebStore, waiting to be reopened. Null when not paused. */ + private paused: { database: string; readonly: boolean }[] | null = null; + private visibilityListener: (() => void) | null = null; + async initWebStore(): Promise { if (this.store) return; try { @@ -105,12 +109,109 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug this.store = await this.client.call('init', getSqliteWebOptions()); reportPromotion(this.store?.promotion); reportMigration(this.store?.migration); + this.watchAppState(); } catch (err) { this.store = null; throw prefixed('initWebStore', err); } } + /** + * Follow the app in and out of the background, but only under Capacitor native. + * + * WKWebView invalidates OPFS access handles when the app is suspended, which is why the store + * has to be closed and the VFS paused before that happens (PLAN 6.7). `visibilitychange` is the + * signal available without taking a dependency on `@capacitor/app`; an app that wants the + * precision of `App.appStateChange` can call `pauseWebStore` / `resumeWebStore` itself, and + * calling them while this listener is also active is harmless because both are idempotent. + * + * Deliberately not wired on the plain web: a browser tab going hidden is not a suspension, and + * closing every connection on a tab switch would be a bug rather than a protection. + */ + private watchAppState(): void { + if (this.visibilityListener) return; + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return; + if (!Capacitor.isNativePlatform?.()) return; + this.visibilityListener = () => { + const going = document.visibilityState === 'hidden'; + void (going ? this.pauseWebStore() : this.resumeWebStore()).catch((err) => { + console.warn(`[capacitor-sqlite] ${going ? 'pausing' : 'resuming'} the store failed: ${messageOf(err)}`); + }); + }; + document.addEventListener('visibilitychange', this.visibilityListener); + } + + private unwatchAppState(): void { + if (this.visibilityListener && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.visibilityListener); + } + this.visibilityListener = null; + } + + /** + * Close every connection and pause the VFS, so the OS can suspend the app without leaving the + * pool's access handles half-alive. Idempotent, and a no-op on tier 2, which has no handles. + * + * `pauseVfs()` throws SQLITE_MISUSE while any database is open, so closing first is part of the + * operation rather than something the caller has to remember (M0 finding S4). + */ + async pauseWebStore(): Promise { + if (!this.store || this.paused) return; + const result = await this.client.call('pause', {}); + this.paused = result.reopen ?? []; + if (result.interrupted?.length > 0) { + // In-flight transactions cannot survive the close. Rolled back, and said out loud. + console.warn( + `[capacitor-sqlite] rolled back an open transaction on ${result.interrupted.join(', ')} ` + + 'while pausing the store for the background.', + ); + } + } + + /** + * Unpause and reopen what pauseWebStore closed. When that fails, which is what a real device + * suspension can do to a pool behind our back, fall back to the heavy path: tear the worker + * down, start a new one, re-run init, and reopen from the same record (PLAN 6.7). + */ + async resumeWebStore(): Promise { + if (!this.store || !this.paused) return; + const reopen = this.paused; + this.paused = null; + try { + await this.client.call('unpause', {}); + await this.reopenAll(reopen); + } catch (err) { + console.warn(`[capacitor-sqlite] unpause failed (${messageOf(err)}), restarting the worker.`); + await this.restartWebStore(reopen); + } + } + + /** + * The heavy recovery path, also usable on its own: a fresh worker, a fresh VFS install, and the + * given connections reopened. A second worker can take over a paused pool, which is what makes + * this work at the engine level (M0 spike item 6). + */ + async restartWebStore(reopen?: { database: string; readonly: boolean }[]): Promise { + const wanted = reopen ?? this.registry.keys().map((key) => parseKey(key)); + this.paused = null; + this.client.terminate(); + this.client.onEvent = (event, data) => this.notifyListeners(event, data); + await this.client.start(); + this.store = await this.client.call('init', getSqliteWebOptions()); + await this.reopenAll(wanted); + } + + private async reopenAll(entries: { database: string; readonly: boolean }[]): Promise { + for (const { database, readonly } of entries) { + const version = this.registry.get(database, readonly)?.version ?? 1; + await this.client.call( + 'open', + { database, readonly, version, upgrades: this.upgrades.get(database) ?? [] }, + connKey(database, readonly), + ); + } + } + /** Which durability tier the store selected. Additive; not part of CapacitorSQLitePlugin. */ getWebStoreTier(): Tier | null { return this.store?.tier ?? null; @@ -139,8 +240,10 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug * flush, so call `saveToStore` first if you are on tier 2 and care about unsaved changes. */ async closeWebStore(): Promise { + this.unwatchAppState(); this.client.terminate(); this.registry.clear(); + this.paused = null; this.store = null; } diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index 6198cbad..9240b9a6 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -564,16 +564,28 @@ const ops: Record any> = { */ async pause() { requireInit(); - if (tier !== 1) return { paused: false, reopen: [] }; + if (tier !== 1) return { paused: false, reopen: [], interrupted: [] }; const reopen: { database: string; readonly: boolean }[] = []; + // A transaction cannot survive the close, so it is rolled back and named. Reporting it is the + // honest option (PLAN 6.7): the caller that opened it will never hear about it otherwise. + const interrupted: string[] = []; for (const [key, conn] of connections) { + const database = key.substring(3); + if (conn.isTransactionActive) { + interrupted.push(database); + try { + conn.rollbackTransaction(); + } catch { + // Closing the connection discards it either way. + } + } await flushIfTier2(conn); conn.close(); - reopen.push({ database: key.substring(3), readonly: conn.isReadonly }); + reopen.push({ database, readonly: conn.isReadonly }); } connections.clear(); poolUtil.pauseVfs(); - return { paused: poolUtil.isPaused(), reopen }; + return { paused: poolUtil.isPaused(), reopen, interrupted }; }, async unpause() { diff --git a/test/web/lifecycle.test.ts b/test/web/lifecycle.test.ts new file mode 100644 index 00000000..02d11aac --- /dev/null +++ b/test/web/lifecycle.test.ts @@ -0,0 +1,208 @@ +/** + * Backgrounding and recovery (PLAN 6.7), plus the error shapes a developer actually meets. + * + * WKWebView invalidates OPFS access handles when the app is suspended. The gentle path closes + * every connection and pauses the VFS before that happens, then unpauses and reopens. The heavy + * path exists because the gentle one is unproven against real device suspension: when unpause + * cannot be reached, a fresh worker takes the pool over instead. + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import { SQLiteConnection } from '../../src/definitions'; +import { CapacitorSQLiteWeb } from '../../src/web'; +import { workerLoadFailure } from '../../src/web/errors'; +import type { Tier } from '../../src/web/protocol'; +import { ownerLockName } from '../../src/web/protocol'; +import { setSqliteWebOptions, setSqliteWorkerFactory } from '../../src/web/worker-factory'; + +import { TIERS, stopHarness, tierLabel } from './harness'; + +setSqliteWorkerFactory( + () => new Worker(new URL('../../src/web/worker/worker.ts', import.meta.url), { type: 'module' }), +); + +const open: CapacitorSQLiteWeb[] = []; +let poolCounter = 0; + +async function boot(tier: Tier, suffix: string): Promise<{ plugin: CapacitorSQLiteWeb; pool: string }> { + poolCounter += 1; + const pool = `life-${poolCounter}-${suffix}`; + setSqliteWebOptions({ + forceTier2: tier === 2, + simulateInstallError: undefined, + skipJeepMigration: true, + poolName: pool, + directory: `.${pool}`, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await new SQLiteConnection(plugin).initWebStore(); + return { plugin, pool }; +} + +async function seeded(plugin: CapacitorSQLiteWeb, name: string): Promise { + const sqlite = new SQLiteConnection(plugin); + const db = await sqlite.createConnection(name, false, 'no-encryption', 1, false); + await db.open(); + await db.execute('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT);'); + await db.run('INSERT INTO t (v) VALUES (?)', ['before']); + return db; +} + +afterEach(async () => { + await Promise.all(open.splice(0).map((plugin) => plugin.closeWebStore())); + await stopHarness(); +}); + +describe('worker load failures name the right cause', () => { + test('a parse failure is reported as an unsupported browser, not a bundler problem', () => { + const err = workerLoadFailure('Uncaught SyntaxError: Unexpected token .'); + expect(err.message).toMatch(/below the minimum this plugin supports/i); + expect(err.message).toMatch(/Chrome\/Android WebView 80, Safari 14, Firefox 74/); + // The raw evidence survives: a guess that hides it is worse than no guess. + expect(err.message).toMatch(/Unexpected token \./); + expect(err.message).not.toMatch(/bundler/i); + expect(err.code).toBe('UNSUPPORTED_ENGINE'); + }); + + test('anything else points at the asset the bundler did not serve', () => { + const err = workerLoadFailure('Failed to load script'); + expect(err.message).toMatch(/dist\/web-worker\.js/); + expect(err.message).toMatch(/setSqliteWorkerFactory/); + expect(err.code).toBe('WORKER_LOAD_FAILED'); + }); + + test('an empty browser message still produces a usable error', () => { + expect(workerLoadFailure('').message).toMatch(/no further detail from the browser/); + }); +}); + +describe('multi-tab ownership', () => { + test('a store whose owner lock is already held fails with the documented error', async () => { + poolCounter += 1; + const pool = `life-${poolCounter}-locked`; + let release: (() => void) | null = null; + const held = new Promise((resolve) => { + release = resolve; + }); + + // Take the lock the way another tab's worker would, and hold it. + const granted = new Promise((resolve) => { + void navigator.locks.request(ownerLockName(pool), async () => { + resolve(); + await held; + }); + }); + await granted; + + setSqliteWebOptions({ + forceTier2: false, + simulateInstallError: undefined, + skipJeepMigration: true, + poolName: pool, + directory: `.${pool}`, + }); + const plugin = new CapacitorSQLiteWeb(); + open.push(plugin); + await expect(new SQLiteConnection(plugin).initWebStore()).rejects.toThrow( + /open in another tab or window[\s\S]*single owning context per origin/i, + ); + // And it did not quietly become a tier 2 store on top of the other tab's data. + expect(plugin.getWebStoreTier()).toBeNull(); + + release?.(); + }); +}); + +describe.each(TIERS)('%s', (tier) => { + const label = tierLabel(tier); + + test(`${label}: pause closes everything and resume brings it back`, async () => { + const { plugin } = await boot(tier, 'pause'); + const db = await seeded(plugin, 'paused'); + + await plugin.pauseWebStore(); + // While paused the store is genuinely unavailable rather than quietly serving stale data. + if (tier === 1) await expect(db.query('SELECT v FROM t')).rejects.toThrow(); + + await plugin.resumeWebStore(); + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + + // Writes work again afterwards, so the reopened connection is a real one. + await db.run('INSERT INTO t (v) VALUES (?)', ['after']); + expect((await db.query('SELECT v FROM t ORDER BY id')).values).toEqual([{ v: 'before' }, { v: 'after' }]); + }); + + test(`${label}: pause and resume are idempotent`, async () => { + const { plugin } = await boot(tier, 'idem'); + const db = await seeded(plugin, 'idem'); + await plugin.pauseWebStore(); + await plugin.pauseWebStore(); + await plugin.resumeWebStore(); + await plugin.resumeWebStore(); + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + }); + + test(`${label}: a transaction open at pause time is rolled back and reported`, async () => { + const { plugin } = await boot(tier, 'txn'); + const db = await seeded(plugin, 'txn'); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => warnings.push(args.join(' ')); + try { + await db.beginTransaction(); + await db.run('INSERT INTO t (v) VALUES (?)', ['uncommitted'], false); + await plugin.pauseWebStore(); + } finally { + console.warn = originalWarn; + } + await plugin.resumeWebStore(); + + if (tier === 1) { + expect(warnings.join(' ')).toMatch(/rolled back an open transaction on txn/i); + // Rolled back means rolled back: the uncommitted row is not there. + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + } + }); + + test(`${label}: restartWebStore rebuilds the store and reopens what was open`, async () => { + const { plugin } = await boot(tier, 'restart'); + const db = await seeded(plugin, 'restart'); + + await plugin.restartWebStore(); + + expect(plugin.getWebStoreTier()).toBe(tier); + if (tier === 1) { + // Tier 1 is durable across a worker teardown; tier 2 keeps its image only to a flush point, + // which is why the assertion is scoped. + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + } + }); +}); + +describe('recovery when the gentle path cannot run', () => { + test('a worker that dies while paused is replaced, and the data comes back', async () => { + const { plugin } = await boot(1, 'dead'); + const db = await seeded(plugin, 'dead'); + await plugin.pauseWebStore(); + + // What a real suspension can do behind our back: the worker is simply gone, so `unpause` + // never arrives and the heavy path is the only way back. + (plugin as any).client.terminate(); + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => warnings.push(args.join(' ')); + try { + await plugin.resumeWebStore(); + } finally { + console.warn = originalWarn; + } + + expect(warnings.join(' ')).toMatch(/restarting the worker/i); + expect(plugin.getWebStoreTier()).toBe(1); + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + await db.run('INSERT INTO t (v) VALUES (?)', ['after recovery']); + expect((await db.query('SELECT v FROM t ORDER BY id')).values).toEqual([{ v: 'before' }, { v: 'after recovery' }]); + }); +}); From 45f4535d3190da65dbdf2c763429229515dbb3ea Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:57:29 +0800 Subject: [PATCH 21/27] fix(web): name the real cause when the worker fails to load The message blamed bundler resolution for every failure, including the one F1 measured on WebView 61, where the real cause is a SyntaxError inside the shipped worker because the engine is below the floor this plugin supports. Telling those two apart is the difference between a five-minute bundler fix and an unsupported browser. A parse failure is now reported as an unsupported engine, naming the floors and saying that no build target in the app can change it, since the syntax belongs to @sqlite.org/sqlite-wasm and BigInt is a runtime dependency of int64. Anything else keeps the bundler message. Both quote what the browser actually reported: a guess that hides its evidence is worse than no guess. --- src/web/client.ts | 7 ++----- src/web/errors.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/web/client.ts b/src/web/client.ts index b68c62d2..d70e1bee 100644 --- a/src/web/client.ts +++ b/src/web/client.ts @@ -10,7 +10,7 @@ * - Errors come back as a payload and are rebuilt into a real Error here, instead of the * `throw new Error(\`${err}\`)` stringification the jeep facade used. */ -import { fromErrorPayload } from './errors'; +import { fromErrorPayload, workerLoadFailure } from './errors'; import type { WorkerEvent, WorkerResponse } from './protocol'; import { BOOT_ID, EVENT_ID } from './protocol'; import { createSqliteWorker } from './worker-factory'; @@ -58,10 +58,7 @@ export class WorkerClient { else entry.reject(fromErrorPayload(message.error)); }; worker.onerror = (event) => { - const error = new Error( - `The SQLite worker failed to load (${(event as ErrorEvent).message || 'unknown error'}). ` + - 'If your bundler cannot resolve the shipped worker, provide one with setSqliteWorkerFactory().', - ); + const error = workerLoadFailure((event as ErrorEvent).message ?? ''); reject(error); this.failAll(error); }; diff --git a/src/web/errors.ts b/src/web/errors.ts index ef239748..9b81cdea 100644 --- a/src/web/errors.ts +++ b/src/web/errors.ts @@ -13,6 +13,36 @@ export const MULTI_TAB_LOCKED = 'Database is open in another tab or window. @capacitor-community/sqlite supports a single ' + 'owning context per origin on the web; close the other tab and retry.'; +/** + * A worker that fails to load has two very different causes, and telling them apart is the + * difference between a five-minute bundler fix and an unsupported browser. + * + * A parse failure means the engine is below the floor documented in docs/Web-Usage.md: the + * shipped worker and `@sqlite.org/sqlite-wasm` inside it are ES2020, and BigInt is a runtime + * dependency of int64 that no transpiler can supply. Anything else is almost always the bundler + * failing to serve `dist/web-worker.js` or `dist/sqlite3.wasm`. + * + * The raw browser message is always appended: a guess that hides the evidence is worse than no + * guess at all. + */ +export function workerLoadFailure(raw: string): SQLiteWebError { + const detail = raw && raw.trim().length > 0 ? raw.trim() : 'no further detail from the browser'; + if (/SyntaxError|Unexpected (token|identifier|end of input)/i.test(detail)) { + return new SQLiteWebError( + 'The SQLite worker could not be parsed by this browser, which means it is below the minimum ' + + 'this plugin supports (Chrome/Android WebView 80, Safari 14, Firefox 74). This cannot be ' + + `fixed by changing your build target. Browser reported: ${detail}`, + { name: 'SQLiteWebUnsupportedEngineError', code: 'UNSUPPORTED_ENGINE' }, + ); + } + return new SQLiteWebError( + 'The SQLite worker failed to load. Check that your bundler serves dist/web-worker.js and ' + + 'dist/sqlite3.wasm, or supply your own worker with setSqliteWorkerFactory(). ' + + `Browser reported: ${detail}`, + { name: 'SQLiteWebWorkerLoadError', code: 'WORKER_LOAD_FAILED' }, + ); +} + export class SQLiteWebError extends Error { readonly code?: string; From 2504c1e722cd6f063291e55397516ccfe59f807c Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:57:41 +0800 Subject: [PATCH 22/27] fix(web): keep the caller's connection open across importFromJson overwrite An overwrite with a full import has to close and unlink before it can rebuild, but it never reopened. The app was still holding those connections, so its next statement failed with "Database X is not open" while the facade registry still believed the connection was open. The closed set is now recorded and restored once the import has finished. --- src/web/worker/worker.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/web/worker/worker.ts b/src/web/worker/worker.ts index 9240b9a6..ffea96d0 100644 --- a/src/web/worker/worker.ts +++ b/src/web/worker/worker.ts @@ -13,6 +13,7 @@ import { toErrorPayload } from '../errors'; import type { ExecResult, OpenArgs, WorkerInitArgs, WorkerInitResult, WorkerRequest } from '../protocol'; import { BOOT_ID, EVENT_ID, EV_EXPORT_PROGRESS, EV_IMPORT_PROGRESS } from '../protocol'; +import type { AdoptionTarget } from './adoption'; import type { AssetTarget } from './assets'; import { copyFromAssets, getFromHTTPRequest } from './assets'; import { Connection, wantsRows } from './engine'; @@ -20,7 +21,6 @@ import { ImageStore, deserializeInto } from './images'; import { exportJson } from './json/export'; import { importJson } from './json/import'; import { isJsonSQLite, parseJsonSQLite } from './json/validate'; -import type { AdoptionTarget } from './adoption'; import { migrateFromJeep } from './migrate-jeep'; import { connKey, fromPoolPath, poolPath, reserveCapacity, storageName } from './paths'; import { promoteImages } from './promote'; @@ -452,8 +452,13 @@ const ops: Record any> = { const storage = storageName(database); // `overwrite` with a full import means start from an empty database, not merge into one. + // Whatever was open has to be closed first, since the file itself goes, but the caller is + // still holding those connections and gets "Database X is not open" on its next statement + // unless they are put back afterwards. + const reopen: boolean[] = []; if (jsonData.overwrite && mode === 'full') { for (const conn of connectionsFor(database)) { + reopen.push(conn.isReadonly); conn.close(); connections.delete(connKey(database, conn.isReadonly)); } @@ -477,6 +482,11 @@ const ops: Record any> = { return importJson(conn, jsonData, progress); }); + for (const readonly of reopen) { + const conn = tier === 1 ? openRaw(storage, readonly) : await openTier2(storage, readonly); + connections.set(connKey(database, readonly), conn); + } + progress(`Import completed, changes: ${changes}`); return { changes, lastId: -1 }; }, From 8ab983191f4069c2488ac9cd576729db53fdb2f8 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 12:57:54 +0800 Subject: [PATCH 23/27] docs(web): document the cascade, tier promotion and backgrounding Adds the Backgrounding under Capacitor section, the tier-promotion paragraph under Durability tiers, and two entries under Limitations: that foreign keys are enforced, and what the soft-delete cascade needs from a schema. Extends the unreleased 8.2.0 notes with the behaviour changes and the fixes that came with them. --- CHANGELOG.md | 14 ++++++++++++++ README.md | 3 +++ docs/Web-Usage.md | 44 +++++++++++++++++++++++++++++++++++++++++++ docs/info_releases.md | 24 ++++++++++++++++++++++- 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f15d91..e1982c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. See [commit * **web:** the `` `autosave` attribute no longer exists. `saveToStore()` is a no-op when the store is on OPFS and the real image flush on the IndexedDB fallback. * **web:** integer values above 2^53 are returned as `BigInt` instead of a silently truncated number. `JSON.stringify` throws on those; `exportToJson` encodes them as decimal strings. * **web:** only one tab per origin may own the store. A second tab now gets an explicit error from `initWebStore()` instead of racing the first. +* **web:** foreign keys are now enforced. `PRAGMA foreign_keys` is set ON at open, as it already was on every other platform, so a schema that was quietly violating its own constraints will start reporting them. ### Features @@ -22,11 +23,24 @@ All notable changes to this project will be documented in this file. See [commit * **web:** support read-only connections, which were previously unsupported on this platform * **web:** add a real browser test suite (vitest browser mode, Playwright Chromium) covering both durability tiers * **web:** export `setSqliteWorkerFactory`, `setSqliteWebOptions` and `setSqliteLocalDiskAdapter` for bundlers and file pickers that need to override the defaults +* **web:** propagate a soft delete along foreign keys, applying each constraint's `ON DELETE` action to the `sql_deleted` marking, recursively and for every referencing table +* **web:** move databases out of the IndexedDB fallback store and into OPFS automatically when a browser gains OPFS support, so an engine update no longer strands them +* **web:** close and pause the store when a Capacitor app is backgrounded and restore it on return, with a worker restart as the fallback when the gentle path cannot run ### Bug Fixes * **web:** a failed database upgrade now restores the pre-upgrade image and rejects, instead of returning a working connection at the old version with no signal that the migration failed * **web:** errors keep their message instead of being re-thrown as the string `Error: Error: ...` +* **web:** a worker that fails to load now says which of the two causes it was, an unsupported browser or an asset the bundler did not serve, and quotes what the browser reported +* **web:** a `DELETE` inside an `execute()` batch is now recorded on a sync-tracked database instead of physically removing the rows, matching `run()` and every other platform +* **web:** a soft delete whose `WHERE` clause contained a string literal produced SQL that did not parse; the clause now keeps its literals +* **web:** `DELETE ... RETURNING` on a sync-tracked database produced SQL that did not parse; the clause is now carried to the rewritten statement and the rows come back +* **web:** a `DELETE` against a table without a `sql_deleted` column is no longer rewritten just because some other table in the database has one +* **web:** a `DELETE` against a double-quoted table name on a sync-tracked database performed a real delete instead of recording one +* **web:** a `DELETE` whose `WHERE` clause contained a top-level `OR` re-marked already-deleted rows, because `AND` binds tighter than `OR` in the rewritten statement +* **web:** a table whose name is a reserved word, such as `order`, switched soft deletes off for the whole database +* **web:** `CREATE TABLE` issued through `run()` rather than `execute()` left the cached schema stale, so a foreign key added that way was invisible to the next delete +* **web:** `importFromJson` with `overwrite` left the caller's open connection closed behind its back ## [8.1.0](https://github.com/capacitor-community/sqlite/compare/v8.0.1...v8.1.0) (2026-03-30) diff --git a/README.md b/README.md index b1366a7c..fbf2b859 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,9 @@ There are two separate floors, and they mean different things: - **One tab at a time.** OPFS access handles are single-owner by design, so `initWebStore()` fails with an explicit error if another tab of the same origin already owns the store. - **Migration is automatic.** The first `initWebStore()` after upgrading imports every database left behind by the previous `jeep-sqlite`/IndexedDB implementation, verifies each one with `PRAGMA integrity_check`, and only then retires the old store. A failure leaves the old data untouched and warns on the console. +- **Tier 2 databases follow you up to tier 1.** When a browser that lacked OPFS gains it, which is the normal upgrade path for the devices that run on tier 2, `initWebStore()` moves the stored images into OPFS and only then removes them. +- **Foreign keys are enforced**, as on every other platform. On a database that participates in sync, a soft delete also applies each constraint's `ON DELETE` action to the children, and to their children. +- **Backgrounding is handled** under Capacitor native: the store is closed and paused before the OS suspends the app, and reopened on return. `pauseWebStore()`, `resumeWebStore()` and `restartWebStore()` are there if you would rather drive it from `App.appStateChange`. - **Integers above 2^53 are returned as `BigInt`.** The previous web engine silently lost precision on those. `JSON.stringify` refuses to serialise a `BigInt`, so code that stringifies query results may need `exportToJson`, which handles this, or a replacer. - **No encryption.** There is no SQLCipher build for wasm, so encrypted connections and every secret-related method still reject on Web. - **Read-only connections work on Web**, on both tiers. diff --git a/docs/Web-Usage.md b/docs/Web-Usage.md index 106dc009..5e0931ab 100644 --- a/docs/Web-Usage.md +++ b/docs/Web-Usage.md @@ -28,6 +28,7 @@ imports any database left behind by the previous `jeep-sqlite` implementation. * [`Requirements and browser support`](#requirements-and-browser-support) * [`Durability tiers`](#durability-tiers) +* [`Backgrounding under Capacitor`](#backgrounding-under-capacitor) * [`Serving the worker and the wasm`](#serving-the-worker-and-the-wasm) * [`Migrating from jeep-sqlite`](#migrating-from-jeep-sqlite) * [`Limitations`](#limitations) @@ -86,6 +87,39 @@ Only one browsing context may own the store, because OPFS access handles are sin design. If another tab of the same origin already holds it, `initWebStore()` rejects with an explicit error rather than quietly opening an empty database over your data. +**Tier 2 is not a one-way door.** A browser that lacked OPFS sync access handles and later gains +them, which is the normal update path for the devices that land on tier 2, would otherwise select +tier 1 and find an empty pool while the data sat in IndexedDB. Each `initWebStore()` on tier 1 +therefore moves any image left in the fallback store into the pool, verifies it, and only then +removes the original. Nothing is deleted until its replacement has been read back, so an +interrupted move resumes on the next start and a browser that drops back to tier 2 still finds +whatever has not moved yet. + +## Backgrounding under Capacitor + +WKWebView invalidates OPFS access handles when the OS suspends the app, so the store cannot simply +be left open across a background. When the plugin detects a native Capacitor platform it follows +the app itself: on the way out it closes every connection and pauses the VFS, and on the way back +it unpauses and reopens what it closed. A transaction open at that moment cannot survive the +close; it is rolled back and reported on the console rather than silently abandoned. + +If the gentle path cannot run, which is what a long suspension can do to a pool behind the +plugin's back, the worker is torn down and a fresh one takes the pool over, then reopens the same +connections. This is the recovery path, and it is why a suspension does not end in a dead store. + +Three methods are exported for apps that would rather drive this from `App.appStateChange`, which +is more precise than the visibility signal the plugin uses on its own. They are safe to call in +addition to the automatic wiring, since all three are idempotent: + +```ts +await (CapacitorSQLite as any).pauseWebStore(); // close everything, pause the VFS +await (CapacitorSQLite as any).resumeWebStore(); // unpause and reopen, restarting if needed +await (CapacitorSQLite as any).restartWebStore(); // the heavy path on its own +``` + +On the plain web none of this is wired up: a browser tab going hidden is not a suspension, and +closing every connection on a tab switch would be a bug rather than a protection. + ## Serving the worker and the wasm The default path needs no configuration: the plugin builds its worker from `dist/web-worker.js` @@ -147,6 +181,16 @@ The databases keep their names, so no application code changes. `isInConfigEncryption` and `isInConfigBiometricAuth`. - **No WAL.** `opfs-sahpool` has no shared-memory support, so tier 1 stays on the `delete` journal and a `PRAGMA journal_mode=WAL` is silently refused. Tier 2 runs on the `memory` journal. +- **Foreign keys are enforced.** `PRAGMA foreign_keys` is ON from the moment a database is opened, + which matches every other platform of this plugin. If your schema declares constraints it was + quietly violating on the old web engine, they will now be reported. +- **The soft-delete cascade needs rowids.** On a database that participates in sync, a DELETE is + recorded rather than performed, so sqlite never runs the `ON DELETE` actions itself and the + plugin applies them instead. It identifies affected rows by `rowid`, so a `WITHOUT ROWID` table + on the receiving end of a constraint with an `ON DELETE` action is reported as an error rather + than skipped. A referencing table that has no `sql_deleted` column is left alone: there is + nothing to mark, the parent row physically remains, and sqlite runs the real action later when + `deleteExportedRows` performs the actual delete. - **Integers above 2^53 come back as `BigInt`.** This is a correctness improvement over the previous engine, which silently lost precision, but `JSON.stringify` throws a `TypeError` on a `BigInt`. Use `exportToJson`, which encodes out-of-range integers as decimal strings that SQLite diff --git a/docs/info_releases.md b/docs/info_releases.md index a64371e4..9d7a6fde 100644 --- a/docs/info_releases.md +++ b/docs/info_releases.md @@ -52,7 +52,29 @@ one with `PRAGMA integrity_check`, and only then retires the legacy store. If anything fails, nothing is deleted and a warning naming the database is written to the console. - Three behaviour changes worth knowing: + Two things happen for you, once each, at `initWebStore`: + + - Databases left in the IndexedDB fallback store by an older browser are moved into OPFS as + soon as the browser supports it. Without that an engine update, which is exactly what a + tier 2 user is waiting for, would leave them looking at an empty store. + - Databases from the previous `jeep-sqlite` store are imported, as described above. + + Under Capacitor native the store now follows the app in and out of the background: every + connection is closed and the VFS paused before the OS suspends the app, then reopened on + return, because WKWebView invalidates OPFS access handles across a suspension. If the gentle + path cannot run, the worker is restarted and the connections reopened from a fresh one. + `pauseWebStore()`, `resumeWebStore()` and `restartWebStore()` are available if you would rather + drive it from `App.appStateChange` yourself. + + Four behaviour changes worth knowing: + + - Foreign keys are enforced. `PRAGMA foreign_keys` is set ON at open, as it already was on + every other platform of this plugin. A schema that was quietly violating its own constraints + on the web will start saying so. + - A soft delete now follows foreign keys. When a database participates in sync, deleting a + parent applies each referencing constraint's `ON DELETE` action to the children, and to + their children, so an export no longer tells the server a parent is gone while reporting its + children live. - Read-only connections (`readonly: true`) now work on Web. - Integers above 2^53 come back as `BigInt` rather than a silently truncated number. From 59293be3d0db2ec8311fa24a11dfd9406a1f8b84 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 1 Aug 2026 23:56:29 +0800 Subject: [PATCH 24/27] docs(web): state the tier 2 conditions, market share, and why the tier stays --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index fbf2b859..08341710 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,31 @@ There are two separate floors, and they mean different things: | Engine (`BigInt`, optional chaining, nullish coalescing) | 80 | 14 | 74 | **The plugin does not load at all.** The failure is a syntax error inside the SQLite build, not a fallback, and no transpiler setting in your app can change it. | | Durability (OPFS sync access handles) | 108 | 16.4 | 111 | Tier 2 above: everything works, the database is an image in IndexedDB rather than a file. Android WebView reached this in M132, January 2025. | +#### Who actually lands on tier 2 + +Tier 2 is chosen at `initWebStore()` when the browser lacks OPFS sync access handles: +concretely, `navigator.storage.getDirectory` or +`FileSystemFileHandle.prototype.createSyncAccessHandle` is missing, or the SQLite build's own +API version check rejects. In browser versions that means Safari / iOS WebKit 14 to 16.3, +Chromium 80 to 107, Firefox 74 to 110, and Android System WebView before M132 (January 2025). +Two version-independent cases: Safari private browsing always runs tier 2 (OPFS is unavailable +there on every Safari version), and a store already owned by another tab is an explicit error, +never a silent drop to tier 2. + +How much traffic that is: measured against caniuse-lite 1.0.30001806 (StatCounter data, +checked 2026-08), the whole tier 2 version band is about **1.4% of global browser usage**: +Safari / iOS 14-16.3 at ~0.5%, Chromium 80-107 remnants at ~0.9%, Firefox 74-110 at ~0.01%. +Put differently, of the usage that can load this plugin at all, **roughly 98% runs tier 1 and +under 2% lands on tier 2**, and the band shrinks as evergreen browsers update. In-app WebView +versions are not visible to StatCounter, so treat WebViews older than M132 (devices without +Google Play services, or with WebView updates disabled) as a small qualitative extra on top. + +Why the tier stays despite the small share: Capacitor 8 itself supports iOS 15.0+ and Android +API 24+ with WebViews as old as Chrome 60, so the platform's official support envelope reaches +below the tier 1 floor, and a Capacitor plugin should not fail where Capacitor does not; and +Safari private browsing is tier 2 forever regardless of version, so the tier is the difference +between an app that works ephemerally in a private tab and one that is simply broken there. + #### Other things worth knowing - **One tab at a time.** OPFS access handles are single-owner by design, so `initWebStore()` fails with an explicit error if another tab of the same origin already owns the store. From d60950ce14c80d9e5598556f5a1bfcc4db21c05a Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sun, 2 Aug 2026 02:20:58 +0800 Subject: [PATCH 25/27] fix(web): serialise pauseWebStore against resumeWebStore pauseWebStore recorded the connections it had closed only after its worker round trip resolved, and resumeWebStore returned a no-op when it found nothing recorded. On a physical iPhone the OS freezes the page inside that round trip and then delivers the foreground signal first, so the resume ran and returned before the pause completed, and the pause then closed every connection with nothing left to reopen them. Every query afterwards failed with "Database X is not open" until the app went round the background cycle a second time. Two of three real background cycles on an iPhone 16 Pro hit it, including one of only 30 seconds with the screen on, so it is the ordinary case rather than an edge of long suspensions: nothing keeps a hidden WKWebView running, and whether a round trip started at visibilitychange completes is the OS's decision. pauseWebStore now records the pause as soon as it starts, and resumeWebStore waits for that before deciding there is nothing to restore. restartWebStore deliberately does not wait: it is the recovery path and has to stay reachable even when a pause cannot finish. The tests drive the same interleaving without a device, both as two unawaited calls and as the unawaited pause the plugin's own visibilitychange listener issues. --- src/web.ts | 45 +++++++++++++++++++++++++++++++------- test/web/lifecycle.test.ts | 30 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/web.ts b/src/web.ts index f101e9f4..4c47b908 100644 --- a/src/web.ts +++ b/src/web.ts @@ -99,6 +99,12 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug /** Connections closed by pauseWebStore, waiting to be reopened. Null when not paused. */ private paused: { database: string; readonly: boolean }[] | null = null; + /** + * The pause currently in flight. `paused` above cannot be published until the worker answers, + * and on a real device the OS can freeze the page inside exactly that round trip, so a resume + * needs something to wait for that exists from the moment the pause starts. + */ + private pausing: Promise | null = null; private visibilityListener: (() => void) | null = null; async initWebStore(): Promise { @@ -156,15 +162,26 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug * operation rather than something the caller has to remember (M0 finding S4). */ async pauseWebStore(): Promise { + // A second caller joins the first rather than starting a competing pause, which is what keeps + // this idempotent alongside the automatic listener. + if (this.pausing) return this.pausing; if (!this.store || this.paused) return; - const result = await this.client.call('pause', {}); - this.paused = result.reopen ?? []; - if (result.interrupted?.length > 0) { - // In-flight transactions cannot survive the close. Rolled back, and said out loud. - console.warn( - `[capacitor-sqlite] rolled back an open transaction on ${result.interrupted.join(', ')} ` + - 'while pausing the store for the background.', - ); + const pausing = (async () => { + const result = await this.client.call('pause', {}); + this.paused = result.reopen ?? []; + if (result.interrupted?.length > 0) { + // In-flight transactions cannot survive the close. Rolled back, and said out loud. + console.warn( + `[capacitor-sqlite] rolled back an open transaction on ${result.interrupted.join(', ')} ` + + 'while pausing the store for the background.', + ); + } + })(); + this.pausing = pausing; + try { + await pausing; + } finally { + if (this.pausing === pausing) this.pausing = null; } } @@ -174,6 +191,18 @@ export class CapacitorSQLiteWeb extends WebPlugin implements CapacitorSQLitePlug * down, start a new one, re-run init, and reopen from the same record (PLAN 6.7). */ async resumeWebStore(): Promise { + // Wait for a pause that has not finished publishing its state. Measured on an iPhone: iOS + // suspends the page inside the pause's worker round trip and then delivers the foreground + // signal first, so a resume that reads `paused` straight away sees null, returns a no-op, and + // the pause completes afterwards and closes every connection. The store would be left shut + // with nothing to reopen it until the app went round the cycle again (PLAN 18.4). + if (this.pausing) { + try { + await this.pausing; + } catch { + // A pause that failed published no state to restore; fall through to what it did set. + } + } if (!this.store || !this.paused) return; const reopen = this.paused; this.paused = null; diff --git a/test/web/lifecycle.test.ts b/test/web/lifecycle.test.ts index 02d11aac..73a9dadd 100644 --- a/test/web/lifecycle.test.ts +++ b/test/web/lifecycle.test.ts @@ -143,6 +143,36 @@ describe.each(TIERS)('%s', (tier) => { expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); }); + test(`${label}: a resume that arrives before the pause has finished still restores the store`, async () => { + const { plugin } = await boot(tier, 'race'); + const db = await seeded(plugin, 'race'); + + // The device sequence from PLAN 18.4, reproduced without a device: iOS freezes the page + // inside pauseWebStore's worker round trip and delivers the foreground signal first, so the + // resume runs while the pause is still in flight and before it has published any state. + const pausing = plugin.pauseWebStore(); + const resuming = plugin.resumeWebStore(); + await Promise.all([pausing, resuming]); + + // Before the fix this read failed with "Database race is not open": the resume had returned a + // no-op and the pause then closed every connection with nothing left to reopen them. + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + await db.run('INSERT INTO t (v) VALUES (?)', ['after the race']); + expect((await db.query('SELECT v FROM t ORDER BY id')).values).toEqual([{ v: 'before' }, { v: 'after the race' }]); + }); + + test(`${label}: the store is usable again after a raced pause and resume, without a second cycle`, async () => { + const { plugin } = await boot(tier, 'race2'); + const db = await seeded(plugin, 'race2'); + + // Same race, driven the way the plugin's own listener drives it: neither call is awaited by + // the caller, which is what `void (going ? pause : resume)()` does in watchAppState. + void plugin.pauseWebStore(); + await plugin.resumeWebStore(); + + expect((await db.query('SELECT v FROM t')).values).toEqual([{ v: 'before' }]); + }); + test(`${label}: a transaction open at pause time is rolled back and reported`, async () => { const { plugin } = await boot(tier, 'txn'); const db = await seeded(plugin, 'txn'); From 08141bb34052e7d97f666abbc577e2ab521284f5 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sun, 2 Aug 2026 02:21:14 +0800 Subject: [PATCH 26/27] fix(web): read a worker that answers with HTML as a bundler failure A worker URL that does not resolve is answered with the application's own HTML, and the browser then reports "Uncaught SyntaxError: Unexpected token '<'". The classifier matched SyntaxError first, so a Vite build on Chrome 150 was told its browser is below Chrome 80 and that changing the build target cannot help, while the one message that names the fix, the bundler branch with setSqliteWorkerFactory, was suppressed. HTML is now recognised before the parse-failure branch and reported as WORKER_LOAD_FAILED. The signal is unambiguous: an old engine fails on the syntax it cannot parse, as WebView 61 did with "Unexpected token .", never on a tag. --- src/web/errors.ts | 14 ++++++++++++++ test/web/lifecycle.test.ts | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/web/errors.ts b/src/web/errors.ts index 9b81cdea..448da781 100644 --- a/src/web/errors.ts +++ b/src/web/errors.ts @@ -22,11 +22,25 @@ export const MULTI_TAB_LOCKED = * dependency of int64 that no transpiler can supply. Anything else is almost always the bundler * failing to serve `dist/web-worker.js` or `dist/sqlite3.wasm`. * + * One parse failure is not an old engine at all, and it is the common one: a worker URL that + * 404s is answered with the application's own HTML, and the browser then reports + * `Unexpected token '<'` on a perfectly modern engine. Checked first, because a missing asset + * blamed on the user's browser sends them somewhere there is no fix. + * * The raw browser message is always appended: a guess that hides the evidence is worse than no * guess at all. */ export function workerLoadFailure(raw: string): SQLiteWebError { const detail = raw && raw.trim().length > 0 ? raw.trim() : 'no further detail from the browser'; + if (/Unexpected token ['"`<]? { expect(err.code).toBe('WORKER_LOAD_FAILED'); }); + test('HTML served where the worker should be is a bundler problem, not an old browser', () => { + // What a 404 looks like from the browser: the dev server answers with index.html and the + // parser stops on its first tag. Measured on Chrome 150 with a Vite build (PLAN 18.1 P3). + for (const raw of ["Uncaught SyntaxError: Unexpected token '<'", 'SyntaxError: Unexpected token <']) { + const err = workerLoadFailure(raw); + expect(err.code).toBe('WORKER_LOAD_FAILED'); + expect(err.message).toMatch(/returned HTML instead of JavaScript/i); + expect(err.message).toMatch(/setSqliteWorkerFactory/); + // The claim that would send a developer chasing a browser floor that is not the problem. + expect(err.message).not.toMatch(/below the minimum this plugin supports/i); + expect(err.message).toContain(raw); + } + }); + + test('a DOCTYPE in the message is read the same way', () => { + const err = workerLoadFailure('Uncaught SyntaxError: Unexpected token '); + expect(err.code).toBe('WORKER_LOAD_FAILED'); + }); + test('an empty browser message still produces a usable error', () => { expect(workerLoadFailure('').message).toMatch(/no further detail from the browser/); }); From 5149665688a041b9f53de95fbbb3f374704eb26f Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sun, 2 Aug 2026 02:21:26 +0800 Subject: [PATCH 27/27] docs(web): correct the bundler story for serving the worker Web-Usage.md claimed that Vite, the Angular builder and webpack 5 all resolve and emit the worker and the wasm without help. Vite 7 does not, measured with a real build of a consumer app: the plugin's worker URL is computed at runtime rather than written as a literal new URL(..., import.meta.url), so Vite rewrites it to point at a module of the plugin's own that it emits as an asset, web-worker.js is never emitted, and the request 404s. Serving both files from the web root and pointing the plugin at them is now the documented default because it always works, with a note that Vite needs it and that Capacitor copies the web root into the native apps on sync. The claim about the other bundlers is gone rather than restated: it was never measured. The troubleshooting section separates a worker that 404s from an engine below the floor, since the two produce the same SyntaxError and only one of them is the user's browser. --- docs/Web-Usage.md | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/docs/Web-Usage.md b/docs/Web-Usage.md index 5e0931ab..42f8a78f 100644 --- a/docs/Web-Usage.md +++ b/docs/Web-Usage.md @@ -122,13 +122,30 @@ closing every connection on a tab switch would be a bug rather than a protection ## Serving the worker and the wasm -The default path needs no configuration: the plugin builds its worker from `dist/web-worker.js` -inside the package, and that worker loads `dist/sqlite3.wasm` sitting next to it. Vite, Angular's -builder and webpack 5 all resolve and emit both files without help, and Capacitor copies them into -the native web assets on `npx cap sync`. +The plugin builds its worker from `dist/web-worker.js` inside the package, and that worker loads +`dist/sqlite3.wasm` sitting next to it. Whether your bundler follows that on its own depends on +the bundler, because the worker URL is computed at runtime rather than written as a literal +`new URL('...', import.meta.url)`. Serving the two files yourself is two lines and always works, +so prefer it if you would rather not find out: -Two escape hatches exist for setups where that resolution fails, both additive exports of the -plugin and both to be called before `initWebStore()`: +```ts +import { setSqliteWorkerFactory } from '@capacitor-community/sqlite'; + +// Copy dist/web-worker.js and dist/sqlite3.wasm from the package into the folder your app serves +// as its web root (public/ for Vite, src/assets/ for Angular, and so on). Keeping them side by +// side is all the wasm needs: the worker resolves it as its own sibling. +setSqliteWorkerFactory(() => new Worker(new URL('web-worker.js', document.baseURI))); +``` + +Under Capacitor, files in the web root are copied into the native app by `npx cap sync`, so the +same two lines cover iOS and Android. + +**Vite needs this.** Measured with Vite 7: the plugin's runtime-computed URL is rewritten to point +at a module of the plugin's own that Vite emits as an asset, `web-worker.js` is never emitted, and +the request 404s. The symptom is an `initWebStore()` rejection quoting +`Unexpected token '<'`, which is the application's HTML answering the worker request. + +Two further escape hatches, both additive exports and both to be called before `initWebStore()`: ```ts import { setSqliteWebOptions, setSqliteWorkerFactory } from '@capacitor-community/sqlite'; @@ -136,7 +153,7 @@ import { setSqliteWebOptions, setSqliteWorkerFactory } from '@capacitor-communit // 1. The wasm is served from somewhere else, for instance a CDN or a hashed asset path. setSqliteWebOptions({ wasmUrl: '/assets/sqlite3.wasm' }); -// 2. The bundler inlines the plugin and loses the worker URL. Build the worker yourself. +// 2. Your bundler does emit the worker, and you would rather it owned the URL. setSqliteWorkerFactory(() => new Worker(new URL('./web-worker.js', import.meta.url))); ``` @@ -1643,15 +1660,21 @@ that is it. rather than being handed an empty database on top of your real data. Close the other tab. In development, remember that a stale tab or a detached DevTools window still counts as a tab. -* **`initWebStore()` rejects and the console shows a worker load failure or a `SyntaxError`.** +* **`initWebStore()` rejects quoting `Unexpected token '<'`.** The worker URL 404s and the server + answered with your application's HTML, so this is a bundler problem and not a browser one, on + any browser. See [Serving the worker and the wasm](#serving-the-worker-and-the-wasm); with Vite + it is the expected default. The error the plugin raises says the same thing and carries the + code `WORKER_LOAD_FAILED`. + +* **`initWebStore()` rejects with `UNSUPPORTED_ENGINE` and some other `SyntaxError`.** The browser is below the engine floor described under [Requirements and browser support](#requirements-and-browser-support). This is not something the plugin falls back from, and lowering your app's build target will not help: the syntax that fails to parse belongs to `@sqlite.org/sqlite-wasm` itself. -* **The worker or `sqlite3.wasm` cannot be found (a 404 in the network panel).** Your bundler did - not emit the plugin's assets where the plugin looks for them. Use - `setSqliteWebOptions({ wasmUrl })` or `setSqliteWorkerFactory()` from +* **`sqlite3.wasm` cannot be found (a 404 in the network panel).** The worker looks for it as its + own sibling. Either put it next to the worker or point at it with + `setSqliteWebOptions({ wasmUrl })` from [Serving the worker and the wasm](#serving-the-worker-and-the-wasm). * **Data disappears between reloads.** You are almost certainly on tier 2 and never reached a