@cavulsqa/create 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/bin/create.mjs +18 -19
  2. package/lib/scaffold.mjs +12 -2
  3. package/package.json +2 -2
  4. package/templates/f7-app/.claude/rules/data-fetching.md +8 -7
  5. package/templates/f7-app/.claude/rules/database.md +22 -20
  6. package/templates/f7-app/.claude/skills/module-architecture/file-templates.md +3 -2
  7. package/templates/f7-app/.claude/skills/reactive-data/SKILL.md +5 -4
  8. package/templates/f7-app/CLAUDE.md +2 -1
  9. package/templates/f7-app/package.json +11 -3
  10. package/templates/f7-app/src/app/pragmas.config.ts +6 -22
  11. package/templates/f7-app/src/app/storage.config.ts +19 -22
  12. package/templates/f7-app/src/app/tabs.ts +1 -9
  13. package/templates/f7-app/src/domains/sales/sales.repository.ts +1 -1
  14. package/templates/f7-app/src/env.d.ts +4 -0
  15. package/templates/f7-app/src/locales/en.json +4 -4
  16. package/templates/f7-app/src/locales/fr.json +4 -4
  17. package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +6 -7
  18. package/templates/f7-app/src/modules/demo/views/OrderDetailView.vue +2 -2
  19. package/templates/f7-app/src/modules/demo/views/OrderSearchView.vue +2 -4
  20. package/templates/f7-app/src/plugins/seed.plugin.ts +1 -11
  21. package/templates/f7-app/src/plugins/sqlite.plugin.ts +21 -10
  22. package/templates/f7-app/src/shared/database/candidates/opfsSahPool.ts +2 -2
  23. package/templates/f7-app/src/shared/database/candidates/types.ts +23 -9
  24. package/templates/f7-app/src/shared/database/candidates/waSqlite.ts +2 -4
  25. package/templates/f7-app/src/shared/database/database.ts +67 -63
  26. package/templates/f7-app/src/shared/database/migrations.ts +1 -1
  27. package/templates/f7-app/src/shared/database/queries.ts +11 -7
  28. package/templates/f7-app/src/shared/database/storage.ts +7 -37
  29. package/templates/f7-app/tests/openDatabase.test.ts +54 -0
package/bin/create.mjs CHANGED
@@ -61,27 +61,25 @@ function listTemplates() {
61
61
 
62
62
  // The manifest is what `vp create` reads; the directory is what actually shipped. Trust the
63
63
  // directory, and let the manifest supply the descriptions.
64
- return readdirSync(BUNDLED).map((name) => ({
65
- name,
66
- description: declared.find((entry) => entry.name === name)?.description ?? "",
67
- }));
64
+ return readdirSync(BUNDLED, { withFileTypes: true })
65
+ .filter((entry) => entry.isDirectory())
66
+ .map((entry) => ({
67
+ name: entry.name,
68
+ description: declared.find((listed) => listed.name === entry.name)?.description ?? "",
69
+ }));
68
70
  }
69
71
 
70
72
  /**
71
- * The engine ids a template actually offers, read out of its own candidate list rather than kept in
72
- * a second list here - a generator that knows more engines than the template does is a generator
73
- * that writes a `.env` the app ignores.
73
+ * The engine ids a template offers, from the template's own manifest. `bundleTemplates.mjs` refuses
74
+ * to publish a template whose manifest disagrees with the engines its code implements, so this is
75
+ * data rather than a second opinion.
74
76
  */
75
77
  function listEngines(templateDir) {
76
78
  try {
77
- const source = readFileSync(
78
- join(templateDir, "src/shared/database/candidates/types.ts"),
79
- "utf8",
80
- );
81
- const union = /export type StorageId =([\s\S]*?);/.exec(source)?.[1] ?? "";
82
- return [...union.matchAll(/"([a-z0-9-]+)"/g)].map((match) => match[1]);
79
+ const pkg = JSON.parse(readFileSync(join(templateDir, "package.json"), "utf8"));
80
+ return pkg.cavulsqa?.storageEngines ?? [];
83
81
  } catch {
84
- // A template without the candidate module simply has no engine choice to offer.
82
+ // A template that declares none simply has no engine choice to offer.
85
83
  return [];
86
84
  }
87
85
  }
@@ -133,7 +131,12 @@ async function main() {
133
131
  const appId =
134
132
  flags.get("app-id") ?? (await ask("Android application id", `com.ayb.${bareName}`));
135
133
 
136
- const engines = listEngines(join(BUNDLED, template));
134
+ const from = flags.get("from");
135
+ const templateDir =
136
+ typeof from === "string" ? resolve(process.cwd(), from) : join(BUNDLED, template);
137
+
138
+ // Read from the directory that will actually be copied, so --from offers its own engines.
139
+ const engines = listEngines(templateDir);
137
140
  let engine = flags.get("engine");
138
141
  if (typeof engine !== "string" && engines.length > 1) {
139
142
  engine = await ask(`Storage engine [${engines.join(", ")}]`, engines[0]);
@@ -150,10 +153,6 @@ async function main() {
150
153
  const dir = flags.get("dir") ?? (await ask("Directory", `./${name}`));
151
154
  const out = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
152
155
 
153
- const from = flags.get("from");
154
- const templateDir =
155
- typeof from === "string" ? resolve(process.cwd(), from) : join(BUNDLED, template);
156
-
157
156
  scaffold({
158
157
  templateDir,
159
158
  out,
package/lib/scaffold.mjs CHANGED
@@ -22,7 +22,7 @@ function personalise(entry, text, { name, appName }) {
22
22
 
23
23
  function manifest(source, { name, appName, templateName }) {
24
24
  const pkg = JSON.parse(source);
25
- const { private: _private, ...rest } = pkg;
25
+ const { private: _private, cavulsqa: _cavulsqa, ...rest } = pkg;
26
26
  return `${JSON.stringify(
27
27
  {
28
28
  ...rest,
@@ -37,9 +37,19 @@ function manifest(source, { name, appName, templateName }) {
37
37
  )}\n`;
38
38
  }
39
39
 
40
+ /**
41
+ * Installed, generated or platform-specific: none of it belongs in a generated app.
42
+ *
43
+ * A bundled template never contains these, but `--from` points at a live working copy that usually
44
+ * does - and `node_modules` is full of directory symlinks, which `isDirectory()` reports as files and
45
+ * `readFileSync` then rejects with EISDIR. `--from` could not generate anything at all.
46
+ */
47
+ const SKIP = new Set(["node_modules", "dist", "android", "ios", ".git", "pnpm-lock.yaml"]);
48
+
40
49
  function copyTree(from, to, transform) {
41
50
  mkdirSync(to, { recursive: true });
42
51
  for (const entry of readdirSync(from, { withFileTypes: true })) {
52
+ if (SKIP.has(entry.name)) continue;
43
53
  const source = join(from, entry.name);
44
54
  const target = join(to, entry.name);
45
55
  if (entry.isDirectory()) {
@@ -123,7 +133,7 @@ Generated from \`@cavulsqa/create\` (${templateName}).
123
133
 
124
134
  \`\`\`bash
125
135
  pnpm install
126
- pnpm dev # browser, sql.js in memory - data does not survive a reload
136
+ pnpm dev # browser, SQLite in a worker on OPFS - data survives a reload
127
137
  npx cap add android # once
128
138
  pnpm build && npx cap sync android && npx cap run android
129
139
  \`\`\`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavulsqa/create",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "Create a Vue + Framework7 + Capacitor + SQLite app from the cavulsqa templates.",
5
5
  "keywords": [
6
6
  "capacitor",
@@ -52,7 +52,7 @@
52
52
  }
53
53
  ]
54
54
  },
55
- "templatesFingerprint": "sha256-9dD3cJPCgdO6nEfyea3vG2eZNUNd4gzbYnde1KPIZIM=",
55
+ "templatesFingerprint": "sha256-tG0kS6m1X1FkrDBDpzezvdSxTxaLazPunAi3a0xab7o=",
56
56
  "scripts": {
57
57
  "build": "node scripts/bundleTemplates.mjs",
58
58
  "check": "vp check",
@@ -16,13 +16,14 @@ which is the one failure an offline app cannot afford: the user has no network t
16
16
  product names and the customer's tags, so it lists all five. Count the tables in the SQL, not the
17
17
  ones you were thinking about.
18
18
 
19
- - **`queryKey` is a process-wide identity, not a label.** Two mounted queries sharing a key await
20
- one request and share its result — right for the same list rendered twice, wrong for two
21
- different queries that happen to be named alike, and the second silently receives the first's
22
- rows. Framework7 keeps pages mounted, so two instances of one screen genuinely coexist.
23
-
24
- Default to `uniqueQueryKey("prefix")`. A stable literal is opt-in sharing, and anything
25
- parameterised by a route param or a ref must never use one.
19
+ - **`queryKey` is a process-wide identity built from arguments, not a label.** It is an array, and
20
+ two mounted queries whose keys match await one request and share its result. Framework7 keeps
21
+ pages mounted, so two instances of one screen genuinely coexist — which is exactly why the key
22
+ has to carry what distinguishes them: `["demo:order", orderId]`, never `["demo:order"]`.
23
+
24
+ Put every value the query reads in the key. A ref belongs there directly — `["demo:search", term]`
25
+ — and the query re-runs through its own `debounce` when the ref moves, so a filtered screen never
26
+ calls `refetch()` by hand.
26
27
 
27
28
  - **`debounce`** collapses a burst. A loop of twenty inserts should refetch once, not twenty times.
28
29
 
@@ -36,26 +36,28 @@ const orderId = Number(inserted.insertId ?? 0);
36
36
  if (!orderId) throw new Error("the order was written but the database reported no id for it");
37
37
  ```
38
38
 
39
- The SQLite plugin runs a statement issued inside an open transaction through `query()`, which
40
- executes it and drops its RETURNING rows. `.returning("id").executeTakeFirstOrThrow()` therefore
41
- threw `no result` from an insert that had in fact succeeded — a message that sends you hunting for a
42
- failed write. `@cavulsqa/mobile-db` now throws a message that says so, and fills `insertId` from
43
- `last_insert_rowid()` on both sides of a transaction boundary. The sql.js test dialect reports it
44
- too, so a repository written this way behaves the same in tests as on a device.
45
-
46
- ## The web path is not the device path
47
-
48
- On a device the database is a real file behind the Capacitor plugin. In a browser it is sql.js in
49
- memory, through the dialect `@cavulsqa/mobile-db` ships for its own tests. Two consequences:
50
-
51
- - Browser data does not survive a reload. That is expected, not a bug to fix.
52
- - The plugin's own web mode is deliberately unused: it needs a `jeep-sqlite` element and a
53
- `sql-wasm.wasm` whose build must match the glue jeep-sqlite bundles, a pairing outside this
54
- template's control that fails as a `WebAssembly LinkError` on an upstream bump.
55
-
56
- Measurements taken in a browser do not transfer. Concurrent reads are several times faster than
57
- sequential ones **on a device**, because the native bridge pipelines them; in memory there is no
58
- bridge and the ratio sits at 1.
39
+ `insertId` is the portable answer. Every engine reports it — the worker engines from
40
+ `last_insert_rowid()`, the sql.js test dialect the same way — so a repository written against it
41
+ behaves identically in tests and on a device. `.returning(...)` does work on the worker engines, but
42
+ it is the one thing that differs between them: the Capacitor plugin runs a statement issued inside an
43
+ open transaction through `query()`, which executes it and silently drops its RETURNING rows, so
44
+ `.returning("id").executeTakeFirstOrThrow()` threw `no result` from an insert that had in fact
45
+ succeeded. `@cavulsqa/mobile-db` now throws a message that says so instead.
46
+
47
+ ## The web path is the device path
48
+
49
+ Both run the same engine: SQLite compiled to WebAssembly, in a worker, with the database file in
50
+ OPFS. There is no Capacitor SQLite plugin in this template and no sql.js outside the tests. So:
51
+
52
+ - **Browser data survives a reload.** OPFS is durable storage, not memory. Clear it from
53
+ Diagnostics, or through the browser's site-data controls.
54
+ - A bug reproduced in the browser is very likely the same bug as on the device, which was not true
55
+ when the two ran different engines.
56
+ - `localStorage["app.storage.force"]` pins the chain to one engine id, for comparing them.
57
+
58
+ What still does not transfer is **timing**. A phone's storage and CPU are nothing like a laptop's,
59
+ and the worker is serial either way, so a ratio measured in a browser says nothing about the device.
60
+ Run the Diagnostics benchmark on hardware.
59
61
 
60
62
  ## Proof obligations
61
63
 
@@ -89,7 +89,7 @@ No `ref`, no lifecycle, no Framework7, no imports from `modules/`.
89
89
  ```ts
90
90
  import { listThings, saveThing, type ThingRow } from "@/domains/thing/thing.repository";
91
91
  import { getDatabase, rdb } from "@/shared/database/database";
92
- import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
92
+ import { useReactiveQuery } from "@/shared/database/queries";
93
93
 
94
94
  export function useFeature() {
95
95
  const term = ref("");
@@ -98,7 +98,8 @@ export function useFeature() {
98
98
  const query = useReactiveQuery(() => listThings(getDatabase().db, term.value), {
99
99
  // Every table the SQL touches. A join means each joined table.
100
100
  tables: ["thing"],
101
- queryKey: uniqueQueryKey("feature:things"),
101
+ // The term is part of the identity, so the query re-runs (debounced) as it moves.
102
+ queryKey: ["feature:things", term],
102
103
  debounce: 250,
103
104
  });
104
105
 
@@ -13,7 +13,7 @@ tables it touched and every query watching one of them refetches. Nothing calls
13
13
  ```ts
14
14
  const query = useReactiveQuery(() => searchOrders(getDatabase().db, term.value), {
15
15
  tables: ["sales_order", "order_line", "customer"],
16
- queryKey: uniqueQueryKey("demo:orders"),
16
+ queryKey: ["demo:orders", term],
17
17
  debounce: 250,
18
18
  });
19
19
  ```
@@ -24,9 +24,10 @@ const query = useReactiveQuery(() => searchOrders(getDatabase().db, term.value),
24
24
  2. **List every table the SQL touches in `tables`.** Count them in the query, not from memory: a
25
25
  join means each joined table. Under-list and the screen goes stale with no error; over-list and
26
26
  an unrelated write re-runs an expensive query.
27
- 3. `queryKey`: default to `uniqueQueryKey("prefix")`. A stable literal means "share this result with
28
- any other query using the same key", which is right for one list rendered twice and wrong for
29
- anything parameterised.
27
+ 3. `queryKey` is an array of the values the query reads. Two mounted queries whose keys match
28
+ share one request, so anything that distinguishes them belongs in the key — a route param, a
29
+ filter ref. Refs are unwrapped and tracked: when one moves the query re-runs through its own
30
+ `debounce`, so never pair a key with a manual `refetch()`.
30
31
  4. `debounce` so a burst of writes causes one refetch.
31
32
 
32
33
  ## Adding a write
@@ -64,7 +64,8 @@ The short version of the rules, each earned by a real bug:
64
64
  screen goes stale with no error. See `.claude/rules/data-fetching.md`.
65
65
  - **Every write goes through `rdb`**, never `getDatabase().db`. `rdb` announces the tables it
66
66
  touched; a raw write is invisible to every query watching them.
67
- - **`queryKey` is an identity, not a label.** Default to `uniqueQueryKey()`.
67
+ - **`queryKey` is an identity built from arguments**, not a label: `["demo:order", orderId]`.
68
+ Refs in the key are tracked, so a filter belongs in it rather than in a manual `refetch()`.
68
69
  - **An inserted id comes from `insertId`, never `.returning(...)`.** Inside a transaction the
69
70
  SQLite plugin executes the statement and drops its RETURNING rows, so the insert succeeds and
70
71
  kysely reports `no result`. See `.claude/rules/database.md`.
@@ -19,9 +19,9 @@
19
19
  "@capacitor/preferences": "^8.0.0",
20
20
  "@capacitor/splash-screen": "^8.0.0",
21
21
  "@capacitor/status-bar": "^8.0.0",
22
- "@cavulsqa/mobile-db": "^0.5.0",
23
- "@cavulsqa/reactive-db": "^0.2.0",
24
- "@cavulsqa/reactive-vue": "^0.2.0",
22
+ "@cavulsqa/mobile-db": "^0.6.0",
23
+ "@cavulsqa/reactive-db": "^0.3.0",
24
+ "@cavulsqa/reactive-vue": "^0.3.0",
25
25
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
26
26
  "@tailwindcss/vite": "^4.3.3",
27
27
  "@vueuse/core": "^14.4.0",
@@ -47,5 +47,13 @@
47
47
  "vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
48
48
  "vite-plus": "0.3.0",
49
49
  "vue-tsc": "^3.2.4"
50
+ },
51
+ "cavulsqa": {
52
+ "storageEngines": [
53
+ "sqlite-wasm-opfs-sahpool",
54
+ "wa-sqlite-access-handle-pool",
55
+ "wa-sqlite-opfs-async",
56
+ "wa-sqlite-idb-batch-atomic"
57
+ ]
50
58
  }
51
59
  }
@@ -1,19 +1,11 @@
1
1
  /**
2
- * PRAGMAs applied to every engine, identically, right after it opens.
2
+ * PRAGMAs applied to every engine identically, because SQLite's defaults are per-build and two
3
+ * engines left on their own are not comparable - an unpinned `synchronous` alone produced a 1.47x
4
+ * "ranking" between engines that were the same speed.
3
5
  *
4
- * They live here rather than inside a candidate for one reason: SQLite's defaults are per-build, and
5
- * two engines left on their own defaults are not comparable. Measuring them without pinning this
6
- * produced a 1.47x "ranking" that may have been nothing but a difference in `synchronous`.
7
- *
8
- * `fast` is what a benchmark should use - both engines pushed as hard as they go, so the comparison
9
- * is between engines rather than between journal settings. It is not a safe default for an app that
10
- * holds a day's orders: `synchronous = OFF` means the OS, not SQLite, decides when bytes reach
11
- * storage, so a crash or a battery pull can leave the database corrupt rather than merely stale.
12
- *
13
- * `safe` keeps SQLite's durability guarantee and pays for it on single writes, which the benchmark
14
- * measures at 19-57 ms depending on engine. Both settings batch equally well - inside one
15
- * transaction the cost per row falls under 3 ms either way - so an app that batches its writes gives
16
- * up very little by staying safe.
6
+ * `fast` is for measuring only: `synchronous = OFF` hands durability to the OS, so a battery pull can
7
+ * leave the database corrupt rather than merely stale. `safe` costs 19-57 ms on a single write and
8
+ * under 3 ms per row inside a transaction, so an app that batches gives up very little.
17
9
  */
18
10
  export type PragmaProfile = "fast" | "safe";
19
11
 
@@ -35,14 +27,6 @@ const PROFILES: Record<PragmaProfile, readonly string[]> = {
35
27
  ],
36
28
  };
37
29
 
38
- /**
39
- * From `VITE_PRAGMA_PROFILE`, defaulting to `safe`.
40
- *
41
- * Safe by default because the cost is small once writes are batched - inside one transaction the
42
- * per-row difference is under a millisecond - and the failure it prevents is a corrupt database
43
- * rather than a slow one. Set `fast` when measuring, where the point is to compare engines rather
44
- * than journal settings.
45
- */
46
30
  export const pragmaProfile: PragmaProfile =
47
31
  import.meta.env.VITE_PRAGMA_PROFILE === "fast" ? "fast" : "safe";
48
32
 
@@ -1,35 +1,24 @@
1
1
  import {
2
+ isStorageId,
2
3
  opfsSahPool,
3
4
  waAccessHandlePool,
4
5
  waIdbBatchAtomic,
5
6
  waOriginPrivateFileSystem,
7
+ STORAGE_IDS,
6
8
  type StorageCandidate,
7
9
  } from "@/shared/database/candidates";
8
10
 
9
11
  /**
10
- * Which SQLite implementation this app uses, and what it falls back to.
12
+ * The knob. Walked in order, first candidate that opens wins, and the rest are never imported - so a
13
+ * chain that stops at the first entry never downloads wa-sqlite's wasm.
11
14
  *
12
- * This is the knob. The list is walked in order: each candidate is asked whether the device supports
13
- * it, then asked to open; the first that succeeds wins, and the rest are never imported - so a chain
14
- * that stops at the first entry never downloads wa-sqlite's wasm at all.
15
+ * Reorder freely to trade speed for reach: `waIdbBatchAtomic` first is the compatibility choice,
16
+ * since IndexedDB is the only durable route on a WebView between Chromium 86 and 108. Only
17
+ * `sqlite-wasm-opfs-sahpool` has been measured on a phone; the rest carry `evidence: "expected"`.
15
18
  *
16
- * Ordered fastest-first, and that ordering is a claim worth being precise about. Only
17
- * `sqlite-wasm-opfs-sahpool` has been measured on a phone; the three below it are placed on the
18
- * vendor's own description of them and each says so through `evidence`. The Diagnostics benchmark
19
- * exists to replace that guess with numbers.
20
- *
21
- * Reorder freely - this is the supported way to trade speed for reach:
22
- *
23
- * - Compatibility first: put `waIdbBatchAtomic` at the top. IndexedDB is the only durable route on a
24
- * WebView between Chromium 86 and 108, which runs this bundle but has no synchronous access
25
- * handles. Slowest, works almost everywhere.
26
- * - One vendor only: drop `opfsSahPool` and keep the wa-sqlite three, or the reverse. Both engines
27
- * implement the same dialect contract, so nothing above this file changes either way.
28
- *
29
- * There is deliberately no in-memory entry. A chain that silently ends somewhere data is not kept is
30
- * worse than one that fails and says why, and the error screen names every attempt.
19
+ * No in-memory entry on purpose: a chain that ends somewhere data is not kept is worse than one that
20
+ * fails and names every attempt.
31
21
  */
32
- /** The ranking, before any environment preference reorders it. */
33
22
  export const DEFAULT_ORDER: StorageCandidate[] = [
34
23
  opfsSahPool,
35
24
  waAccessHandlePool,
@@ -42,13 +31,21 @@ export const DEFAULT_ORDER: StorageCandidate[] = [
42
31
  * env var reorders the chain rather than replacing it - a device that cannot open the preferred
43
32
  * engine still gets a working app.
44
33
  *
45
- * An unknown id is ignored rather than fatal: a typo in a `.env` should not brick the build's
46
- * output, and the app reports which candidate actually opened in Settings.
34
+ * A typo is reported and ignored rather than fatal: it should not brick the build's output, but a
35
+ * setting that was silently dropped looks exactly like one that was applied and did nothing.
47
36
  */
48
37
  function preferredFirst(candidates: StorageCandidate[]): StorageCandidate[] {
49
38
  const preferred = import.meta.env.VITE_STORAGE_ENGINE;
50
39
  if (!preferred) return candidates;
51
40
 
41
+ if (!isStorageId(preferred)) {
42
+ console.warn(
43
+ `[storage] VITE_STORAGE_ENGINE="${preferred}" is not an engine this app has. ` +
44
+ `Expected one of: ${STORAGE_IDS.join(", ")}. Falling back to the default order.`,
45
+ );
46
+ return candidates;
47
+ }
48
+
52
49
  const match = candidates.find((candidate) => candidate.id === preferred);
53
50
  return match ? [match, ...candidates.filter((candidate) => candidate !== match)] : candidates;
54
51
  }
@@ -9,15 +9,7 @@ export interface TabDefinition {
9
9
  iconMd: string;
10
10
  }
11
11
 
12
- /**
13
- * The tab bar is data: add an entry, add a route file in the matching module, and the shell, the bar
14
- * and the navigation all follow. Each tab is a Framework7 view with its own history, so switching
15
- * away and back returns to the same screen.
16
- *
17
- * Icons are given per theme rather than once. Framework7 picks `icon-ios` or `icon-md` from the
18
- * active theme, which is what makes the bar look native on both platforms instead of iOS glyphs on
19
- * Android.
20
- */
12
+ /** Add an entry plus a route file in the matching module; the shell and the bar follow. */
21
13
  export const tabs: TabDefinition[] = [
22
14
  { id: "home", labelKey: "tabs.home", iconIos: "house_fill", iconMd: "home" },
23
15
  { id: "demo", labelKey: "tabs.demo", iconIos: "bolt_fill", iconMd: "bolt" },
@@ -1,5 +1,5 @@
1
1
  import { sql, type Kysely } from "kysely";
2
- import { nowISO } from "@cavulsqa/mobile-db/core";
2
+ import { nowISO } from "@cavulsqa/mobile-db";
3
3
  import type { Database } from "@/shared/database/schema";
4
4
 
5
5
  export interface DashboardStats {
@@ -26,6 +26,10 @@ declare const __APP_VERSION__: string;
26
26
  * The environment this app reads. Declared, so a typo in a variable name is a type error rather than
27
27
  * a silent `undefined` that falls back to the default and looks like the setting was ignored.
28
28
  *
29
+ * The engine is `string`, not `StorageId`, because a `.env` is unchecked text: a union here would
30
+ * claim a guarantee nothing enforces. `storage.config.ts` narrows it with `isStorageId` and reports
31
+ * a value it does not recognise.
32
+ *
29
33
  * See `.env.example` for what each value means.
30
34
  */
31
35
  interface ImportMetaEnv {
@@ -24,10 +24,10 @@
24
24
  "parallel": "Together",
25
25
  "sequential": "One by one",
26
26
  "ratio": "Faster by",
27
- "pipeliningNative": "{count} reads issued together, then the same {count} awaited one at a time. Reads stay outside the write lock and the native bridge pipelines them, so together should win by several times.",
28
- "pipeliningWeb": "{count} reads issued together, then the same {count} awaited one at a time. In a browser this measures nothing useful — see the note under the result.",
29
- "ratioNative": "Above 1x means concurrent reads are genuinely overlapping on the native bridge. Near 1x would mean something is serialising them.",
30
- "ratioWeb": "Expected on {platform}: the web path is sql.js in memory with no bridge to pipeline, so issuing reads together buys nothing and adds a little overhead. The several-times gain is a device property — measure it there.",
27
+ "pipeliningNative": "{count} reads issued together, then the same {count} awaited one at a time. The engine runs in a worker that executes statements one at a time, so issuing them together overlaps the message round trips and nothing else.",
28
+ "pipeliningWeb": "{count} reads issued together, then the same {count} awaited one at a time. In a desktop browser the round trip is nearly free - see the note under the result.",
29
+ "ratioNative": "Above 1x means the message round trips are overlapping. The worker is serial, so this can never show queries themselves running in parallel.",
30
+ "ratioWeb": "Expected on {platform}: the worker is serial and a desktop round trip is nearly free, so there is little to overlap. Measure on a device, where the round trip costs something.",
31
31
  "data": "Data",
32
32
  "diagnostics": "Diagnostics",
33
33
  "searchPlaceholder": "Search orders or customers",
@@ -24,10 +24,10 @@
24
24
  "parallel": "Ensemble",
25
25
  "sequential": "Une par une",
26
26
  "ratio": "Plus rapide de",
27
- "pipeliningNative": "{count} lectures lancées ensemble, puis les mêmes {count} une par une. Les lectures restent hors du verrou d'écriture et le pont natif les parallélise : « ensemble » devrait gagner largement.",
28
- "pipeliningWeb": "{count} lectures lancées ensemble, puis les mêmes {count} une par une. Dans un navigateur, cette mesure n'a pas de sens — voir la note sous le résultat.",
29
- "ratioNative": "Au-dessus de 1x, les lectures se chevauchent réellement sur le pont natif. Proche de 1x signifierait que quelque chose les sérialise.",
30
- "ratioWeb": "Attendu sur {platform} : le chemin web est sql.js en mémoire, sans pont à paralléliser — les lancer ensemble n'apporte rien. Le gain se mesure sur un appareil.",
27
+ "pipeliningNative": "{count} lectures lancées ensemble, puis les mêmes {count} attendues une par une. Le moteur tourne dans un worker qui exécute les requêtes une à la fois : les lancer ensemble ne recouvre que les allers-retours de messages.",
28
+ "pipeliningWeb": "{count} lectures lancées ensemble, puis les mêmes {count} attendues une par une. Dans un navigateur de bureau l'aller-retour est quasi gratuit - voir la note sous le résultat.",
29
+ "ratioNative": "Au-dessus de 1x, les allers-retours de messages se recouvrent. Le worker est séquentiel : ce test ne montrera jamais des requêtes exécutées en parallèle.",
30
+ "ratioWeb": "Attendu sur {platform} : le worker est séquentiel et l'aller-retour de bureau est quasi gratuit, il n'y a donc presque rien à recouvrir. Mesurez sur un appareil.",
31
31
  "data": "Données",
32
32
  "diagnostics": "Diagnostics",
33
33
  "searchPlaceholder": "Chercher une commande ou un client",
@@ -15,7 +15,7 @@ import {
15
15
  type OrderRow,
16
16
  } from "@/domains/sales/sales.repository";
17
17
  import { activeStorageLabel, changeBus, getDatabase, rdb } from "@/shared/database/database";
18
- import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
18
+ import { useReactiveQuery } from "@/shared/database/queries";
19
19
 
20
20
  export interface BusEntry {
21
21
  /** Monotonic, because a millisecond timestamp is not unique - several events share one. */
@@ -62,13 +62,13 @@ export function useReactiveDemo() {
62
62
  const statsQuery = useReactiveQuery(() => loadDashboardStats(getDatabase().db), {
63
63
  // Four tables, because the tiles aggregate across all of them.
64
64
  tables: ["customer", "product", "sales_order", "order_line"],
65
- queryKey: uniqueQueryKey("demo:stats"),
65
+ queryKey: ["demo:stats"],
66
66
  debounce: 250,
67
67
  });
68
68
 
69
69
  const ordersQuery = useReactiveQuery(() => searchOrders(getDatabase().db, ""), {
70
70
  tables: ["sales_order", "order_line", "customer"],
71
- queryKey: uniqueQueryKey("demo:orders"),
71
+ queryKey: ["demo:orders"],
72
72
  debounce: 250,
73
73
  });
74
74
 
@@ -118,10 +118,9 @@ export function useReactiveDemo() {
118
118
  /**
119
119
  * Reads issued together against reads awaited one by one.
120
120
  *
121
- * On a device the ratio is the point: the native bridge pipelines concurrent calls and the dialect
122
- * keeps reads out of the write lock, so a screen loading with `Promise.all` pays once rather than
123
- * N times. In a browser it sits near 1x because sql.js in memory has no bridge to pipeline, and
124
- * the screen says so rather than reporting a number that reads as a regression.
121
+ * The worker executes statements one at a time, so this measures how much of a read is the message
122
+ * round trip rather than the query - it cannot show queries overlapping, and a ratio near 1x is
123
+ * the expected result rather than a regression.
125
124
  */
126
125
  async function measurePipelining(): Promise<void> {
127
126
  measuring.value = true;
@@ -82,7 +82,7 @@ import {
82
82
  } from "@/domains/sales/sales.repository";
83
83
  import { useHiddenTabbar } from "@/shared/composables/useTabbarVisibility";
84
84
  import { getDatabase, rdb } from "@/shared/database/database";
85
- import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
85
+ import { useReactiveQuery } from "@/shared/database/queries";
86
86
 
87
87
  const { t } = useI18n();
88
88
 
@@ -100,7 +100,7 @@ const statuses = ["draft", "confirmed", "delivered"] as const;
100
100
  */
101
101
  const query = useReactiveQuery(() => loadOrderDetail(getDatabase().db, orderId), {
102
102
  tables: ["sales_order", "order_line", "customer", "product", "customer_tag"],
103
- queryKey: uniqueQueryKey(`demo:order-${String(orderId)}`),
103
+ queryKey: ["demo:order", orderId],
104
104
  });
105
105
 
106
106
  const order = computed<OrderDetail | null>(() => query.data.value ?? null);
@@ -60,7 +60,7 @@
60
60
  import { useHiddenTabbar } from "@/shared/composables/useTabbarVisibility";
61
61
  import { searchOrders, type OrderRow } from "@/domains/sales/sales.repository";
62
62
  import { getDatabase } from "@/shared/database/database";
63
- import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
63
+ import { useReactiveQuery } from "@/shared/database/queries";
64
64
 
65
65
  const { t } = useI18n();
66
66
 
@@ -75,7 +75,7 @@ const term = ref("");
75
75
  */
76
76
  const query = useReactiveQuery(() => searchOrders(getDatabase().db, term.value), {
77
77
  tables: ["sales_order", "order_line", "customer"],
78
- queryKey: uniqueQueryKey("demo:search"),
78
+ queryKey: ["demo:search", term],
79
79
  debounce: 200,
80
80
  });
81
81
 
@@ -83,12 +83,10 @@ const results = computed<OrderRow[]>(() => query.data.value ?? []);
83
83
 
84
84
  function onInput(event: Event) {
85
85
  term.value = (event.target as HTMLInputElement).value;
86
- void query.refetch();
87
86
  }
88
87
 
89
88
  function onClear() {
90
89
  term.value = "";
91
- void query.refetch();
92
90
  }
93
91
 
94
92
  const formatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
@@ -1,17 +1,7 @@
1
1
  import { ensureReferenceData } from "@/domains/sales/sales.repository";
2
2
  import { rdb } from "@/shared/database";
3
3
 
4
- /**
5
- * Reference data the app cannot work without: the product catalogue, the tags, a few customers.
6
- *
7
- * A template has to be usable the moment it opens. Without this, the first thing a new install asks
8
- * you to do is press "seed sample data" before the New order sheet has anything to pick from - the
9
- * schema is there and every screen is empty, which reads as broken rather than as new.
10
- *
11
- * `ensureReferenceData` is idempotent, so this runs on every start and does nothing after the first.
12
- * Replace it with your own once the app has real data to load; delete it if data arrives from a
13
- * server. Demo orders stay behind the button - those are a demo, this is a working state.
14
- */
4
+ /** Runs on every start: `ensureReferenceData` is idempotent and does nothing after the first. */
15
5
  export async function seedPlugin(): Promise<void> {
16
6
  await ensureReferenceData(rdb);
17
7
  }
@@ -8,14 +8,25 @@ const OPEN_TIMEOUT_MS = 10_000;
8
8
  * empty `#app` and nothing in the console. The timeout turns a hang into a message.
9
9
  */
10
10
  export async function sqlitePlugin(): Promise<void> {
11
- await Promise.race([
12
- openDatabase(),
13
- new Promise<never>((_, reject) => {
14
- setTimeout(
15
- () =>
16
- reject(new Error(`opening the database did not finish in ${String(OPEN_TIMEOUT_MS)}ms`)),
17
- OPEN_TIMEOUT_MS,
18
- );
19
- }),
20
- ]);
11
+ let timer: ReturnType<typeof setTimeout> | undefined;
12
+
13
+ try {
14
+ await Promise.race([
15
+ openDatabase(),
16
+ new Promise<never>((_, reject) => {
17
+ timer = setTimeout(
18
+ () =>
19
+ reject(
20
+ new Error(`opening the database did not finish in ${String(OPEN_TIMEOUT_MS)}ms`),
21
+ ),
22
+ OPEN_TIMEOUT_MS,
23
+ );
24
+ }),
25
+ ]);
26
+ } finally {
27
+ // Losing the race does not stop the timer, which then held the event loop for the full timeout
28
+ // after a fast open. The walk itself is not cancellable, but `openDatabase` memoises it, so a
29
+ // retry joins the attempt already running instead of racing it for the same OPFS directory.
30
+ clearTimeout(timer);
31
+ }
21
32
  }
@@ -1,4 +1,4 @@
1
- import { OpfsSQLiteDialect } from "@cavulsqa/mobile-db/opfs";
1
+ import { createOpfsDialect } from "@cavulsqa/mobile-db/opfs";
2
2
  import OpfsWorker from "../opfs.worker?worker";
3
3
  import { probeOpfsCapable } from "../storage";
4
4
  import type { StorageCandidate } from "./types";
@@ -21,5 +21,5 @@ export const opfsSahPool: StorageCandidate = {
21
21
  evidence: "measured",
22
22
  probe: probeOpfsCapable,
23
23
  createDialect: () =>
24
- Promise.resolve(new OpfsSQLiteDialect({ worker: new OpfsWorker(), name: "app.sqlite3" })),
24
+ Promise.resolve(createOpfsDialect({ worker: new OpfsWorker(), name: "app.sqlite3" })),
25
25
  };
@@ -1,17 +1,31 @@
1
1
  import type { Dialect } from "kysely";
2
2
 
3
- export type StorageId =
4
- | "sqlite-wasm-opfs-sahpool"
5
- | "wa-sqlite-access-handle-pool"
6
- | "wa-sqlite-opfs-async"
7
- | "wa-sqlite-idb-batch-atomic";
3
+ /**
4
+ * Every engine this template offers, as values rather than only as a type.
5
+ *
6
+ * A types-only union cannot be read at runtime, and everything that needs the list had to invent
7
+ * its own copy: `@cavulsqa/create` was regexing this very file to populate `--engine`, and
8
+ * `localStorage`'s override was cast rather than checked.
9
+ */
10
+ export const STORAGE_IDS = [
11
+ "sqlite-wasm-opfs-sahpool",
12
+ "wa-sqlite-access-handle-pool",
13
+ "wa-sqlite-opfs-async",
14
+ "wa-sqlite-idb-batch-atomic",
15
+ ] as const;
16
+
17
+ export type StorageId = (typeof STORAGE_IDS)[number];
8
18
 
9
- export interface StorageProbe {
10
- supported: boolean;
11
- /** Present when unsupported, phrased so the person reading it can act on it. */
12
- reason?: string;
19
+ export function isStorageId(value: string): value is StorageId {
20
+ return (STORAGE_IDS as readonly string[]).includes(value);
13
21
  }
14
22
 
23
+ /**
24
+ * A union rather than an optional field, so an unsupported probe cannot forget its reason and a
25
+ * supported one cannot carry a stale one.
26
+ */
27
+ export type StorageProbe = { supported: true } | { supported: false; reason: string };
28
+
15
29
  /**
16
30
  * One way to persist SQLite, described well enough to choose between them without reading the code.
17
31
  *
@@ -1,4 +1,4 @@
1
- import { WaSQLiteDialect, type WaVfsKind } from "@cavulsqa/mobile-db/wa";
1
+ import { createWaDialect, type WaVfsKind } from "@cavulsqa/mobile-db/wa";
2
2
  import WaWorker from "../wa.worker?worker";
3
3
  import { probeIndexedDb, probeOpfsCapable } from "../storage";
4
4
  import type { StorageCandidate } from "./types";
@@ -24,9 +24,7 @@ function waCandidate(
24
24
  // Nothing below the official pool has been on a phone yet.
25
25
  evidence: "expected",
26
26
  createDialect: () =>
27
- Promise.resolve(
28
- new WaSQLiteDialect({ worker: new WaWorker(), name: "app-wa.sqlite3", kind }),
29
- ),
27
+ Promise.resolve(createWaDialect({ worker: new WaWorker(), name: "app-wa.sqlite3", kind })),
30
28
  };
31
29
  }
32
30
 
@@ -1,60 +1,52 @@
1
1
  import { Kysely, sql, type Dialect } from "kysely";
2
2
  import { Migrator } from "kysely/migration";
3
- import { runWrite, type MobileDatabase } from "@cavulsqa/mobile-db/core";
3
+ import { runWrite, type MobileDatabase } from "@cavulsqa/mobile-db";
4
4
  import { createChangeBus, createReactiveDb } from "@cavulsqa/reactive-db";
5
5
  import { pragmaProfile, pragmasFor } from "@/app/pragmas.config";
6
6
  import { storageChain } from "@/app/storage.config";
7
- import type { StorageId } from "./candidates";
8
- import type { StorageAttempt, StorageCandidate } from "./candidates";
7
+ import { isStorageId, type StorageAttempt, type StorageCandidate } from "./candidates";
9
8
  import { migrations } from "./migrations";
10
9
  import { describeOpenFailure } from "./storage";
11
10
  import type { Database } from "./schema";
12
11
 
13
- /**
14
- * One bus for the whole app. Writes announce the tables they touched on it and reactive queries
15
- * listen to it, so both sides have to be the same instance - which is why it is created here and
16
- * passed outward rather than constructed wherever it is needed.
17
- */
18
- export const changeBus = createChangeBus();
12
+ /** Writes and reactive queries must share one instance, so it is created here and passed outward. */
13
+ export const changeBus = createChangeBus<Database>();
19
14
 
20
15
  const RETRY_DELAY_MS = 400;
21
16
 
22
17
  let database: MobileDatabase<Database> | null = null;
18
+ let opening: Promise<MobileDatabase<Database>> | null = null;
23
19
  let chosen: StorageCandidate | null = null;
24
20
  let attempts: StorageAttempt[] = [];
25
- let applied: string[] = [];
21
+ let applied: readonly string[] = [];
22
+
23
+ interface OpenedDatabase {
24
+ handle: MobileDatabase<Database>;
25
+ pragmas: readonly string[];
26
+ }
26
27
 
27
28
  /**
28
- * Wraps a Kysely instance in the shape the app consumes, and runs the migrations.
29
- *
30
- * Shared by every candidate: they differ in how bytes reach storage and in nothing above that, which
31
- * is what makes the chain swappable at all.
29
+ * Wraps a Kysely instance in the shape the app consumes, and runs the migrations. Shared by every
30
+ * candidate: they differ in how bytes reach storage and in nothing above it, which is what makes the
31
+ * chain swappable at all.
32
32
  */
33
- async function fromDialect(dialect: Dialect): Promise<MobileDatabase<Database>> {
33
+ async function fromDialect(candidate: StorageCandidate, dialect: Dialect): Promise<OpenedDatabase> {
34
34
  const db = new Kysely<Database>({ dialect });
35
35
 
36
- /**
37
- * Before the migrations, and identically for every engine. SQLite's defaults are per-build, so two
38
- * engines left on their own are not comparable - a difference in `synchronous` alone can look like
39
- * one engine being half as fast as the other.
40
- */
41
- applied = [];
36
+ // Before the migrations, and identically for every engine - see pragmas.config.ts for why.
37
+ const pragmas: string[] = [];
42
38
  for (const pragma of pragmasFor(pragmaProfile)) {
43
39
  try {
44
40
  await sql.raw(pragma).execute(db);
45
- applied.push(pragma);
41
+ pragmas.push(pragma);
46
42
  } catch (error) {
47
43
  // A VFS that refuses a journal mode is worth knowing about, not worth failing over.
48
- applied.push(`${pragma} -> rejected: ${error instanceof Error ? error.message : "unknown"}`);
44
+ pragmas.push(`${pragma} -> rejected: ${error instanceof Error ? error.message : "unknown"}`);
49
45
  }
50
46
  }
51
47
 
52
- /**
53
- * `migrateToLatest` reports failure in its return value rather than throwing, so an unchecked call
54
- * boots an app whose tables were never created - which is exactly what happened: a deleted
55
- * migration made kysely refuse the whole set, and the failure only surfaced later as
56
- * "no such table" from the first query that needed one.
57
- */
48
+ // `migrateToLatest` reports failure in its return value rather than throwing, so an unchecked
49
+ // call boots an app whose tables were never created - it surfaced as "no such table" much later.
58
50
  const migration = await new Migrator({
59
51
  db,
60
52
  provider: { getMigrations: () => Promise.resolve(migrations) },
@@ -69,16 +61,16 @@ async function fromDialect(dialect: Dialect): Promise<MobileDatabase<Database>>
69
61
  }
70
62
 
71
63
  return {
72
- db,
73
- write: (ctx, work) =>
74
- runWrite(ctx, () => db.transaction().execute((trx) => work(trx)), {
75
- runInTransaction: <R>(task: () => Promise<R>) => task(),
76
- emitTableChange: (table) => changeBus.emit(table, "bulk"),
77
- }),
78
- getRawConnection: () => {
79
- throw new Error("the OPFS engine has no native connection");
64
+ pragmas,
65
+ handle: {
66
+ db,
67
+ write: (ctx, work) =>
68
+ runWrite(ctx, () => db.transaction().execute((trx) => work(trx)), {
69
+ runInTransaction: <R>(task: () => Promise<R>) => task(),
70
+ emitTableChange: (table) => changeBus.emit(table, "bulk"),
71
+ }),
72
+ close: () => db.destroy(),
80
73
  },
81
- close: () => db.destroy(),
82
74
  };
83
75
  }
84
76
 
@@ -102,22 +94,13 @@ export function storageAttempts(): readonly StorageAttempt[] {
102
94
  return attempts;
103
95
  }
104
96
 
105
- /**
106
- * Walks the chain in `storage.config.ts` and keeps the first candidate that opens.
107
- *
108
- * A candidate is skipped when it says the device cannot support it, and dropped when it says so by
109
- * throwing. Every step is recorded: which were skipped and why, which failed and with what, and
110
- * which won - because a silent fallback to a slower or non-durable engine is the kind of thing that
111
- * gets discovered weeks later by someone wondering why the app is slow.
112
- */
97
+ // Every step is recorded, because a silent fallback to a slower engine gets discovered weeks later
98
+ // by someone wondering why the app is slow.
113
99
  const FORCE_KEY = "app.storage.force";
114
100
 
115
101
  /**
116
- * Pins the chain to one candidate, for benchmarking.
117
- *
118
- * Set `localStorage.app.storage.force` to a candidate id and only that engine is tried - not moved
119
- * to the front, the *only* one - because a benchmark that quietly fell through to a different engine
120
- * would report the wrong engine's numbers. An unknown id is ignored rather than bricking the app.
102
+ * Pins the chain to one candidate for benchmarking: the *only* one tried, not merely promoted, since
103
+ * a benchmark that fell through to another engine would report the wrong engine's numbers.
121
104
  */
122
105
  function forcedChain(): StorageCandidate[] {
123
106
  let forced: string | null = null;
@@ -126,14 +109,33 @@ function forcedChain(): StorageCandidate[] {
126
109
  } catch {
127
110
  // Storage disabled; the full chain is the right answer anyway.
128
111
  }
129
- if (!forced) return storageChain;
112
+ if (!forced || !isStorageId(forced)) return storageChain;
130
113
 
131
- const pinned = storageChain.find((candidate) => candidate.id === (forced as StorageId));
114
+ const pinned = storageChain.find((candidate) => candidate.id === forced);
132
115
  return pinned ? [pinned] : storageChain;
133
116
  }
134
117
 
135
- export async function openDatabase(): Promise<MobileDatabase<Database>> {
136
- if (database) return database;
118
+ /**
119
+ * Memoised on the promise, not the result. Two callers in the same tick both passed a `if (database)`
120
+ * guard and both walked the chain - and since the pool VFSes hold their OPFS directory exclusively,
121
+ * the second collided with the first and fell through to a slower engine the app then reported as
122
+ * its choice.
123
+ */
124
+ export function openDatabase(): Promise<MobileDatabase<Database>> {
125
+ if (database) return Promise.resolve(database);
126
+ opening ??= walkStorageChain();
127
+ return opening;
128
+ }
129
+
130
+ async function walkStorageChain(): Promise<MobileDatabase<Database>> {
131
+ try {
132
+ return await tryEveryCandidate();
133
+ } finally {
134
+ opening = null;
135
+ }
136
+ }
137
+
138
+ async function tryEveryCandidate(): Promise<MobileDatabase<Database>> {
137
139
  if (!storageChain.length) throw new Error("storageChain is empty: nothing can open the database");
138
140
 
139
141
  attempts = [];
@@ -148,7 +150,9 @@ export async function openDatabase(): Promise<MobileDatabase<Database>> {
148
150
  }
149
151
 
150
152
  try {
151
- database = await openCandidate(candidate);
153
+ const opened = await openCandidate(candidate);
154
+ database = opened.handle;
155
+ applied = opened.pragmas;
152
156
  chosen = candidate;
153
157
  attempts.push({ id: candidate.id, outcome: "opened" });
154
158
  return database;
@@ -168,18 +172,17 @@ export async function openDatabase(): Promise<MobileDatabase<Database>> {
168
172
  }
169
173
 
170
174
  /**
171
- * One retry per candidate, because the pool VFSes take an exclusive lock on their directory and the
172
- * usual reason it is held is a process on its way out - a crash, or a relaunch racing the old
173
- * WebView's teardown. Its handles are released when that process dies, so the same open succeeds a
174
- * moment later. Exactly one retry: past that, the chain moving on is the better answer.
175
+ * One retry, because the usual reason a pool VFS's directory lock is held is a process on its way out
176
+ * - its handles are released when that process dies, so the same open succeeds a moment later. Past
177
+ * one, the chain moving on is the better answer.
175
178
  */
176
- async function openCandidate(candidate: StorageCandidate): Promise<MobileDatabase<Database>> {
179
+ async function openCandidate(candidate: StorageCandidate): Promise<OpenedDatabase> {
177
180
  try {
178
- return await fromDialect(await candidate.createDialect());
181
+ return await fromDialect(candidate, await candidate.createDialect());
179
182
  } catch (first) {
180
183
  await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
181
184
  try {
182
- return await fromDialect(await candidate.createDialect());
185
+ return await fromDialect(candidate, await candidate.createDialect());
183
186
  } catch {
184
187
  // The first error is the honest one; the retry's is a duplicate of it.
185
188
  throw first;
@@ -201,6 +204,7 @@ export const rdb = createReactiveDb<Database>({
201
204
  export async function closeDatabase(): Promise<void> {
202
205
  await database?.close();
203
206
  database = null;
207
+ opening = null;
204
208
  chosen = null;
205
209
  attempts = [];
206
210
  applied = [];
@@ -1,4 +1,4 @@
1
- import { createTableWithDefaults, type MigrationSet } from "@cavulsqa/mobile-db/core";
1
+ import { createTableWithDefaults, type MigrationSet } from "@cavulsqa/mobile-db";
2
2
  import { sql } from "kysely";
3
3
 
4
4
  /**
@@ -1,6 +1,7 @@
1
1
  import { createReactiveQuery, createVueQueryMetrics } from "@cavulsqa/reactive-vue";
2
2
  import { usePageVisibility } from "@cavulsqa/reactive-vue/framework7";
3
3
  import { changeBus } from "./database";
4
+ import type { Database } from "./schema";
4
5
 
5
6
  const metrics = createVueQueryMetrics({
6
7
  exposeOnWindowAs: import.meta.env.DEV ? "__appMetrics" : undefined,
@@ -10,13 +11,16 @@ const metrics = createVueQueryMetrics({
10
11
  * The composables every screen uses. They are built once, here, because the change bus and the
11
12
  * metrics recorder are app-wide singletons - a query created with a different bus would never hear
12
13
  * about a write.
14
+ *
15
+ * Typed on `Database`, so every `tables` entry is checked against the schema. A misspelt table name
16
+ * is otherwise invisible: the query subscribes to a table nobody writes to and never refetches.
13
17
  */
14
- export const { useReactiveQuery, useStructuralQuery, useStaticQuery } = createReactiveQuery({
15
- onTableChange: changeBus.on,
16
- metrics: metrics.recorder,
17
- // Without this a tab the user cannot see keeps refetching in the background.
18
- useVisibility: usePageVisibility,
19
- });
18
+ export const { useReactiveQuery, useStructuralQuery, useStaticQuery } =
19
+ createReactiveQuery<Database>({
20
+ onTableChange: changeBus.on,
21
+ metrics: metrics.recorder,
22
+ // Without this a tab the user cannot see keeps refetching in the background.
23
+ useVisibility: usePageVisibility,
24
+ });
20
25
 
21
26
  export const { useQueryMetrics } = metrics;
22
- export { uniqueQueryKey } from "@cavulsqa/reactive-vue";
@@ -3,24 +3,10 @@ import type { StorageProbe } from "./candidates/types";
3
3
  export type { StorageProbe };
4
4
 
5
5
  /**
6
- * Capability checks and failure diagnosis, shared by the candidates.
7
- *
8
- * The chain itself lives in `app/storage.config.ts` and each engine in `candidates/`; this is only
9
- * the part they have in common - deciding whether a device can host OPFS at all, and turning an
10
- * engine's failure into a sentence somebody can act on.
11
- */
12
-
13
- /**
14
- * Only what the main thread can honestly observe.
15
- *
16
- * The tempting check - `"createSyncAccessHandle" in FileSystemFileHandle.prototype` - is wrong here
17
- * and rejected a phone on WebView 150 that had been running OPFS happily: synchronous access handles
18
- * are Worker-only in Chromium, so the method is absent from the main-thread prototype on *every*
19
- * device, new or old. `navigator.storage.getDirectory` is visible from both scopes, so that is the
20
- * whole of what can be pre-checked.
21
- *
22
- * Anything finer belongs to the engine. `describeOpenFailure` turns its error into something a
23
- * person can act on, which is what the pre-check was reaching for in the first place.
6
+ * `navigator.storage.getDirectory` is the whole of what a main-thread probe can see. Synchronous
7
+ * access handles are Worker-only in Chromium, so checking `createSyncAccessHandle` on
8
+ * `FileSystemFileHandle.prototype` is false on *every* device - it rejected a phone that had been
9
+ * running OPFS for hours. Anything finer belongs to the engine, via `describeOpenFailure`.
24
10
  */
25
11
  export function probeOpfsCapable(): StorageProbe {
26
12
  if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) {
@@ -35,11 +21,6 @@ export function probeOpfsCapable(): StorageProbe {
35
21
  return { supported: true };
36
22
  }
37
23
 
38
- /**
39
- * The engine is the authority on whether it can open, so its failure is wrapped rather than
40
- * predicted. A missing synchronous access handle surfaces from inside wasm initialisation, where the
41
- * message names an internal symbol and not the thing to do about it.
42
- */
43
24
  export function probeIndexedDb(): StorageProbe {
44
25
  if (typeof indexedDB === "undefined") {
45
26
  return {
@@ -65,11 +46,8 @@ export function describeOpenFailure(error: unknown): string {
65
46
  }
66
47
 
67
48
  /**
68
- * The Chromium version behind this WebView, which is the number that decides whether OPFS works.
69
- *
70
- * Worth surfacing rather than reasoning about: Android System WebView updates from the Play Store
71
- * independently of Android itself, so the OS version says nothing useful. A phone on Android 7 with
72
- * a current WebView is fine; a phone on Android 14 that has never reached the Play Store may not be.
49
+ * Android System WebView updates from the Play Store independently of Android itself, so the OS
50
+ * version says nothing useful here - a phone on Android 7 can be current and one on Android 14 old.
73
51
  */
74
52
  export function webviewVersion(): number | null {
75
53
  if (typeof navigator === "undefined") return null;
@@ -77,15 +55,7 @@ export function webviewVersion(): number | null {
77
55
  return match ? Number(match[1]) : null;
78
56
  }
79
57
 
80
- /**
81
- * OPFS arrived in Chromium 86, synchronous access handles followed, and the combination is reported
82
- * stable in WebView from 109. 109 is used rather than the earlier number because this value only
83
- * ever produces advice: too low and a phone in the gap is told its WebView is fine before failing
84
- * anyway, while too high only ever suggests an update that does no harm.
85
- *
86
- * A floor, never a gate. A vendor WebView build can differ either way, so the app probes and wraps
87
- * the engine's real failure rather than letting this number decide anything.
88
- */
58
+ /** Advice, never a gate: a vendor WebView build can differ either way, so the engine decides. */
89
59
  export const MINIMUM_CHROMIUM_FOR_OPFS = 109;
90
60
 
91
61
  export function webviewLikelyTooOld(): boolean {
@@ -0,0 +1,54 @@
1
+ import { afterEach, expect, test, vi } from "vite-plus/test";
2
+ import { storageChain } from "../src/app/storage.config.js";
3
+ import { closeDatabase, openDatabase } from "../src/shared/database/database.js";
4
+
5
+ afterEach(async () => {
6
+ await closeDatabase();
7
+ vi.restoreAllMocks();
8
+ });
9
+
10
+ /**
11
+ * Nothing in this environment can open a database, which is the point: what is under test is how
12
+ * many times the chain gets walked, not whether it succeeds.
13
+ *
14
+ * The guard used to be `if (database) return database` - a value, not a promise - so two callers in
15
+ * the same tick both walked it. The pool VFSes take their OPFS directory exclusively, so the second
16
+ * walk collided with the first, retried, failed, and fell through to a slower engine that the app
17
+ * then reported as its choice.
18
+ */
19
+ test("concurrent callers share one walk of the chain", async () => {
20
+ const probes = storageChain.map((candidate) => vi.spyOn(candidate, "probe"));
21
+
22
+ const [first, second] = await Promise.allSettled([openDatabase(), openDatabase()]);
23
+
24
+ expect(first.status).toBe("rejected");
25
+ expect(second.status).toBe("rejected");
26
+
27
+ for (const [index, probe] of probes.entries()) {
28
+ expect(probe, storageChain[index]?.id).toHaveBeenCalledTimes(1);
29
+ }
30
+ });
31
+
32
+ test("a caller arriving after a failed walk gets a fresh attempt", async () => {
33
+ await expect(openDatabase()).rejects.toThrow(/No storage engine could open the database/);
34
+
35
+ const probes = storageChain.map((candidate) => vi.spyOn(candidate, "probe"));
36
+ await expect(openDatabase()).rejects.toThrow(/No storage engine could open the database/);
37
+
38
+ // Memoising the promise must not memoise the failure: a retry has to be able to try again.
39
+ for (const probe of probes) expect(probe).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ test("the failure names every candidate and why it was passed over", async () => {
43
+ const failure = await openDatabase().then(
44
+ () => null,
45
+ (error: unknown) => (error instanceof Error ? error.message : String(error)),
46
+ );
47
+
48
+ // "the database would not open" on its own helps nobody: every attempt has to say what happened.
49
+ expect(failure).toBeTruthy();
50
+ for (const candidate of storageChain) {
51
+ expect(failure).toContain(candidate.id);
52
+ }
53
+ expect(failure).toContain("unsupported");
54
+ });