@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.
- package/bin/create.mjs +18 -19
- package/lib/scaffold.mjs +12 -2
- package/package.json +2 -2
- package/templates/f7-app/.claude/rules/data-fetching.md +8 -7
- package/templates/f7-app/.claude/rules/database.md +22 -20
- package/templates/f7-app/.claude/skills/module-architecture/file-templates.md +3 -2
- package/templates/f7-app/.claude/skills/reactive-data/SKILL.md +5 -4
- package/templates/f7-app/CLAUDE.md +2 -1
- package/templates/f7-app/package.json +11 -3
- package/templates/f7-app/src/app/pragmas.config.ts +6 -22
- package/templates/f7-app/src/app/storage.config.ts +19 -22
- package/templates/f7-app/src/app/tabs.ts +1 -9
- package/templates/f7-app/src/domains/sales/sales.repository.ts +1 -1
- package/templates/f7-app/src/env.d.ts +4 -0
- package/templates/f7-app/src/locales/en.json +4 -4
- package/templates/f7-app/src/locales/fr.json +4 -4
- package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +6 -7
- package/templates/f7-app/src/modules/demo/views/OrderDetailView.vue +2 -2
- package/templates/f7-app/src/modules/demo/views/OrderSearchView.vue +2 -4
- package/templates/f7-app/src/plugins/seed.plugin.ts +1 -11
- package/templates/f7-app/src/plugins/sqlite.plugin.ts +21 -10
- package/templates/f7-app/src/shared/database/candidates/opfsSahPool.ts +2 -2
- package/templates/f7-app/src/shared/database/candidates/types.ts +23 -9
- package/templates/f7-app/src/shared/database/candidates/waSqlite.ts +2 -4
- package/templates/f7-app/src/shared/database/database.ts +67 -63
- package/templates/f7-app/src/shared/database/migrations.ts +1 -1
- package/templates/f7-app/src/shared/database/queries.ts +11 -7
- package/templates/f7-app/src/shared/database/storage.ts +7 -37
- 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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
|
78
|
-
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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-
|
|
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.**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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.
|
|
23
|
-
"@cavulsqa/reactive-db": "^0.
|
|
24
|
-
"@cavulsqa/reactive-vue": "^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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
46
|
-
*
|
|
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" },
|
|
@@ -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.
|
|
28
|
-
"pipeliningWeb": "{count} reads issued together, then the same {count} awaited one at a time. In a browser
|
|
29
|
-
"ratioNative": "Above 1x means
|
|
30
|
-
"ratioWeb": "Expected on {platform}: the
|
|
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.
|
|
28
|
-
"pipeliningWeb": "{count} lectures lancées ensemble, puis les mêmes {count} une par une. Dans un navigateur
|
|
29
|
-
"ratioNative": "Au-dessus de 1x, les
|
|
30
|
-
"ratioWeb": "Attendu sur {platform} : le
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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 {
|
|
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:
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
10
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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<
|
|
33
|
+
async function fromDialect(candidate: StorageCandidate, dialect: Dialect): Promise<OpenedDatabase> {
|
|
34
34
|
const db = new Kysely<Database>({ dialect });
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
pragmas.push(`${pragma} -> rejected: ${error instanceof Error ? error.message : "unknown"}`);
|
|
49
45
|
}
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
114
|
+
const pinned = storageChain.find((candidate) => candidate.id === forced);
|
|
132
115
|
return pinned ? [pinned] : storageChain;
|
|
133
116
|
}
|
|
134
117
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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<
|
|
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,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 } =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
+
});
|