@cavulsqa/create 0.1.0 → 2.2.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/README.md +56 -10
- package/bin/create.mjs +44 -1
- package/lib/scaffold.mjs +15 -1
- package/lib/templateFingerprint.mjs +29 -0
- package/package.json +4 -2
- package/templates/f7-app/.claude/rules/database.md +39 -0
- package/templates/f7-app/.env.example +23 -0
- package/templates/f7-app/CLAUDE.md +8 -3
- package/templates/f7-app/auto-imports.d.ts +7 -0
- package/templates/f7-app/components.d.ts +2 -0
- package/templates/f7-app/package.json +6 -6
- package/templates/f7-app/src/app/pragmas.config.ts +51 -0
- package/templates/f7-app/src/app/storage.config.ts +55 -0
- package/templates/f7-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
- package/templates/f7-app/src/domains/benchmark/benchmark.suite.ts +662 -0
- package/templates/f7-app/src/domains/sales/sales.repository.ts +1 -1
- package/templates/f7-app/src/env.d.ts +15 -0
- package/templates/f7-app/src/locales/en.json +37 -2
- package/templates/f7-app/src/locales/fr.json +37 -2
- package/templates/f7-app/src/main.ts +6 -16
- package/templates/f7-app/src/modules/demo/components/DemoBenchmark.vue +112 -0
- package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +10 -0
- package/templates/f7-app/src/modules/demo/composables/useBenchmark.ts +139 -0
- package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +5 -1
- package/templates/f7-app/src/modules/demo/views/DemoView.vue +9 -2
- package/templates/f7-app/src/modules/settings/views/SettingsView.vue +31 -0
- package/templates/f7-app/src/plugins/bootstrapError.ts +60 -0
- package/templates/f7-app/src/shared/database/candidates/index.ts +3 -0
- package/templates/f7-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
- package/templates/f7-app/src/shared/database/candidates/types.ts +41 -0
- package/templates/f7-app/src/shared/database/candidates/waSqlite.ts +58 -0
- package/templates/f7-app/src/shared/database/database.ts +151 -30
- package/templates/f7-app/src/shared/database/migrations.ts +143 -1
- package/templates/f7-app/src/shared/database/opfs.worker.ts +5 -0
- package/templates/f7-app/src/shared/database/schema.ts +94 -0
- package/templates/f7-app/src/shared/database/storage.ts +94 -0
- package/templates/f7-app/src/shared/database/wa.worker.ts +5 -0
- package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +1 -2
- package/templates/f7-app/tests/benchmark.suite.test.ts +104 -0
- package/templates/f7-app/tests/migrations.test.ts +74 -0
- package/templates/f7-app/tests/storage.test.ts +108 -0
- package/templates/f7-app/tsconfig.node.json +0 -14
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Dialect } from "kysely";
|
|
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";
|
|
8
|
+
|
|
9
|
+
export interface StorageProbe {
|
|
10
|
+
supported: boolean;
|
|
11
|
+
/** Present when unsupported, phrased so the person reading it can act on it. */
|
|
12
|
+
reason?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One way to persist SQLite, described well enough to choose between them without reading the code.
|
|
17
|
+
*
|
|
18
|
+
* A candidate is not a config flag: it owns its own capability check and its own lazy import, so a
|
|
19
|
+
* chain that never reaches the fourth entry never downloads the fourth entry's wasm.
|
|
20
|
+
*/
|
|
21
|
+
export interface StorageCandidate {
|
|
22
|
+
id: StorageId;
|
|
23
|
+
label: string;
|
|
24
|
+
/** What it costs. Every entry has one, and an entry claiming none is a lie. */
|
|
25
|
+
tradeoff: string;
|
|
26
|
+
durable: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the numbers behind its position in the chain came from a device or from a vendor's
|
|
29
|
+
* README. Only one entry is currently `measured`, and the ordering says so rather than implying
|
|
30
|
+
* more confidence than we have.
|
|
31
|
+
*/
|
|
32
|
+
evidence: "measured" | "expected";
|
|
33
|
+
probe: () => StorageProbe;
|
|
34
|
+
createDialect: () => Promise<Dialect>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StorageAttempt {
|
|
38
|
+
id: StorageId;
|
|
39
|
+
outcome: "opened" | "unsupported" | "failed";
|
|
40
|
+
detail?: string;
|
|
41
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { WaSQLiteDialect, type WaVfsKind } from "@cavulsqa/mobile-db/wa";
|
|
2
|
+
import WaWorker from "../wa.worker?worker";
|
|
3
|
+
import { probeIndexedDb, probeOpfsCapable } from "../storage";
|
|
4
|
+
import type { StorageCandidate } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* wa-sqlite, once per virtual file system.
|
|
8
|
+
*
|
|
9
|
+
* The alternative to the official build, and the reason it is here: its VFS layer is JavaScript, so
|
|
10
|
+
* it reaches storage the official engine has no VFS for. IndexedDB in particular is the only durable
|
|
11
|
+
* option on a WebView too old for synchronous access handles - the Chromium 86 to 108 band, which
|
|
12
|
+
* runs this app's bundle perfectly well.
|
|
13
|
+
*
|
|
14
|
+
* All three share one worker file and one dialect; only `kind` differs, and it also decides which of
|
|
15
|
+
* wa-sqlite's two wasm builds gets loaded.
|
|
16
|
+
*/
|
|
17
|
+
function waCandidate(
|
|
18
|
+
kind: WaVfsKind,
|
|
19
|
+
fields: Pick<StorageCandidate, "id" | "label" | "tradeoff" | "probe">,
|
|
20
|
+
): StorageCandidate {
|
|
21
|
+
return {
|
|
22
|
+
...fields,
|
|
23
|
+
durable: true,
|
|
24
|
+
// Nothing below the official pool has been on a phone yet.
|
|
25
|
+
evidence: "expected",
|
|
26
|
+
createDialect: () =>
|
|
27
|
+
Promise.resolve(
|
|
28
|
+
new WaSQLiteDialect({ worker: new WaWorker(), name: "app-wa.sqlite3", kind }),
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const waAccessHandlePool = waCandidate("access-handle-pool", {
|
|
34
|
+
id: "wa-sqlite-access-handle-pool",
|
|
35
|
+
label: "wa-sqlite · OPFS access handle pool",
|
|
36
|
+
tradeoff:
|
|
37
|
+
"The same exclusive directory lock as the official pool, and the same serial worker. Here to " +
|
|
38
|
+
"measure one vendor's pool against the other's on identical storage.",
|
|
39
|
+
probe: probeOpfsCapable,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const waOriginPrivateFileSystem = waCandidate("origin-private-file-system", {
|
|
43
|
+
id: "wa-sqlite-opfs-async",
|
|
44
|
+
label: "wa-sqlite · OPFS, no pool",
|
|
45
|
+
tradeoff:
|
|
46
|
+
"Runs on the Asyncify build, which wraps every call SQLite believes is synchronous - expected " +
|
|
47
|
+
"to cost raw speed and to handle concurrent access more gracefully than a pool that locks.",
|
|
48
|
+
probe: probeOpfsCapable,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const waIdbBatchAtomic = waCandidate("idb-batch-atomic", {
|
|
52
|
+
id: "wa-sqlite-idb-batch-atomic",
|
|
53
|
+
label: "wa-sqlite · IndexedDB",
|
|
54
|
+
tradeoff:
|
|
55
|
+
"The slowest durable route: pages go through IndexedDB transactions rather than to a file. The " +
|
|
56
|
+
"only one that works without synchronous access handles at all.",
|
|
57
|
+
probe: probeIndexedDb,
|
|
58
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Kysely } from "kysely";
|
|
1
|
+
import { Kysely, sql, type Dialect } from "kysely";
|
|
3
2
|
import { Migrator } from "kysely/migration";
|
|
4
|
-
import {
|
|
3
|
+
import { runWrite, type MobileDatabase } from "@cavulsqa/mobile-db/core";
|
|
5
4
|
import { createChangeBus, createReactiveDb } from "@cavulsqa/reactive-db";
|
|
5
|
+
import { pragmaProfile, pragmasFor } from "@/app/pragmas.config";
|
|
6
|
+
import { storageChain } from "@/app/storage.config";
|
|
7
|
+
import type { StorageId } from "./candidates";
|
|
8
|
+
import type { StorageAttempt, StorageCandidate } from "./candidates";
|
|
6
9
|
import { migrations } from "./migrations";
|
|
10
|
+
import { describeOpenFailure } from "./storage";
|
|
7
11
|
import type { Database } from "./schema";
|
|
8
12
|
|
|
9
13
|
/**
|
|
@@ -13,27 +17,57 @@ import type { Database } from "./schema";
|
|
|
13
17
|
*/
|
|
14
18
|
export const changeBus = createChangeBus();
|
|
15
19
|
|
|
20
|
+
const RETRY_DELAY_MS = 400;
|
|
21
|
+
|
|
16
22
|
let database: MobileDatabase<Database> | null = null;
|
|
23
|
+
let chosen: StorageCandidate | null = null;
|
|
24
|
+
let attempts: StorageAttempt[] = [];
|
|
25
|
+
let applied: string[] = [];
|
|
17
26
|
|
|
18
27
|
/**
|
|
19
|
-
*
|
|
28
|
+
* Wraps a Kysely instance in the shape the app consumes, and runs the migrations.
|
|
20
29
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* `sql-wasm.wasm` whose build must match the glue jeep-sqlite bundles - a pairing outside this
|
|
24
|
-
* template's control that breaks on any upstream bump. This path has no assets to serve and no
|
|
25
|
-
* version to keep in step; the cost is that browser data does not survive a reload, which for a
|
|
26
|
-
* template running `vp dev` is the honest trade.
|
|
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.
|
|
27
32
|
*/
|
|
28
|
-
async function
|
|
29
|
-
const
|
|
30
|
-
|
|
33
|
+
async function fromDialect(dialect: Dialect): Promise<MobileDatabase<Database>> {
|
|
34
|
+
const db = new Kysely<Database>({ dialect });
|
|
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 = [];
|
|
42
|
+
for (const pragma of pragmasFor(pragmaProfile)) {
|
|
43
|
+
try {
|
|
44
|
+
await sql.raw(pragma).execute(db);
|
|
45
|
+
applied.push(pragma);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// 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"}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
31
51
|
|
|
32
|
-
|
|
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
|
+
*/
|
|
58
|
+
const migration = await new Migrator({
|
|
33
59
|
db,
|
|
34
60
|
provider: { getMigrations: () => Promise.resolve(migrations) },
|
|
35
61
|
}).migrateToLatest();
|
|
36
62
|
|
|
63
|
+
if (migration.error) {
|
|
64
|
+
const failed = migration.results?.find((result) => result.status === "Error")?.migrationName;
|
|
65
|
+
// kysely types the error as unknown, and a non-Error would stringify to [object Object].
|
|
66
|
+
const detail =
|
|
67
|
+
migration.error instanceof Error ? migration.error.message : JSON.stringify(migration.error);
|
|
68
|
+
throw new Error(`migration failed${failed ? ` at ${failed}` : ""}: ${detail}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
return {
|
|
38
72
|
db,
|
|
39
73
|
write: (ctx, work) =>
|
|
@@ -42,31 +76,115 @@ async function openWebDatabase(): Promise<MobileDatabase<Database>> {
|
|
|
42
76
|
emitTableChange: (table) => changeBus.emit(table, "bulk"),
|
|
43
77
|
}),
|
|
44
78
|
getRawConnection: () => {
|
|
45
|
-
throw new Error("no native connection
|
|
79
|
+
throw new Error("the OPFS engine has no native connection");
|
|
46
80
|
},
|
|
47
81
|
close: () => db.destroy(),
|
|
48
82
|
};
|
|
49
83
|
}
|
|
50
84
|
|
|
85
|
+
/** Which candidate the open database is using, for anything that reports or measures. */
|
|
86
|
+
export function activeStorage(): StorageCandidate {
|
|
87
|
+
if (!chosen) throw new Error("openDatabase() must be awaited before the storage is known");
|
|
88
|
+
return chosen;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function activeStorageLabel(): string {
|
|
92
|
+
return activeStorage().label;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The PRAGMAs that actually took, so a measurement can state its own configuration. */
|
|
96
|
+
export function activePragmas(): readonly string[] {
|
|
97
|
+
return applied;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every step of the walk, including the candidates that were skipped and why. */
|
|
101
|
+
export function storageAttempts(): readonly StorageAttempt[] {
|
|
102
|
+
return attempts;
|
|
103
|
+
}
|
|
104
|
+
|
|
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
|
+
*/
|
|
113
|
+
const FORCE_KEY = "app.storage.force";
|
|
114
|
+
|
|
115
|
+
/**
|
|
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.
|
|
121
|
+
*/
|
|
122
|
+
function forcedChain(): StorageCandidate[] {
|
|
123
|
+
let forced: string | null = null;
|
|
124
|
+
try {
|
|
125
|
+
forced = localStorage.getItem(FORCE_KEY);
|
|
126
|
+
} catch {
|
|
127
|
+
// Storage disabled; the full chain is the right answer anyway.
|
|
128
|
+
}
|
|
129
|
+
if (!forced) return storageChain;
|
|
130
|
+
|
|
131
|
+
const pinned = storageChain.find((candidate) => candidate.id === (forced as StorageId));
|
|
132
|
+
return pinned ? [pinned] : storageChain;
|
|
133
|
+
}
|
|
134
|
+
|
|
51
135
|
export async function openDatabase(): Promise<MobileDatabase<Database>> {
|
|
52
136
|
if (database) return database;
|
|
137
|
+
if (!storageChain.length) throw new Error("storageChain is empty: nothing can open the database");
|
|
53
138
|
|
|
54
|
-
|
|
55
|
-
Capacitor.getPlatform() === "web"
|
|
56
|
-
? await openWebDatabase()
|
|
57
|
-
: await createMobileDatabase<Database>({
|
|
58
|
-
name: "app",
|
|
59
|
-
migrations,
|
|
60
|
-
emitTableChange: (table) => changeBus.emit(table, "bulk"),
|
|
61
|
-
/**
|
|
62
|
-
* One native connection cannot serve two writers. This serialises writes and transactions
|
|
63
|
-
* while leaving reads parallel, because the native bridge pipelines concurrent calls and
|
|
64
|
-
* queueing reads costs several times the latency on a screen loading with `Promise.all`.
|
|
65
|
-
*/
|
|
66
|
-
serializeAccess: true,
|
|
67
|
-
});
|
|
139
|
+
attempts = [];
|
|
68
140
|
|
|
69
|
-
|
|
141
|
+
const chain = forcedChain();
|
|
142
|
+
|
|
143
|
+
for (const candidate of chain) {
|
|
144
|
+
const probe = candidate.probe();
|
|
145
|
+
if (!probe.supported) {
|
|
146
|
+
attempts.push({ id: candidate.id, outcome: "unsupported", detail: probe.reason });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
database = await openCandidate(candidate);
|
|
152
|
+
chosen = candidate;
|
|
153
|
+
attempts.push({ id: candidate.id, outcome: "opened" });
|
|
154
|
+
return database;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
attempts.push({ id: candidate.id, outcome: "failed", detail: describeOpenFailure(error) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Every candidate's reason, because "the database would not open" on its own helps nobody.
|
|
161
|
+
const summary = attempts
|
|
162
|
+
.map(
|
|
163
|
+
(attempt) =>
|
|
164
|
+
`${attempt.id}: ${attempt.outcome}${attempt.detail ? ` - ${attempt.detail}` : ""}`,
|
|
165
|
+
)
|
|
166
|
+
.join("; ");
|
|
167
|
+
throw new Error(`No storage engine could open the database. ${summary}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
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
|
+
*/
|
|
176
|
+
async function openCandidate(candidate: StorageCandidate): Promise<MobileDatabase<Database>> {
|
|
177
|
+
try {
|
|
178
|
+
return await fromDialect(await candidate.createDialect());
|
|
179
|
+
} catch (first) {
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
181
|
+
try {
|
|
182
|
+
return await fromDialect(await candidate.createDialect());
|
|
183
|
+
} catch {
|
|
184
|
+
// The first error is the honest one; the retry's is a duplicate of it.
|
|
185
|
+
throw first;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
70
188
|
}
|
|
71
189
|
|
|
72
190
|
export function getDatabase(): MobileDatabase<Database> {
|
|
@@ -83,4 +201,7 @@ export const rdb = createReactiveDb<Database>({
|
|
|
83
201
|
export async function closeDatabase(): Promise<void> {
|
|
84
202
|
await database?.close();
|
|
85
203
|
database = null;
|
|
204
|
+
chosen = null;
|
|
205
|
+
attempts = [];
|
|
206
|
+
applied = [];
|
|
86
207
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createTableWithDefaults, type MigrationSet } from "@cavulsqa/mobile-db";
|
|
1
|
+
import { createTableWithDefaults, type MigrationSet } from "@cavulsqa/mobile-db/core";
|
|
2
2
|
import { sql } from "kysely";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -78,4 +78,146 @@ export const migrations: MigrationSet = {
|
|
|
78
78
|
await sql`PRAGMA foreign_keys = ON`.execute(db);
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A tombstone, and it has to stay.
|
|
84
|
+
*
|
|
85
|
+
* This migration created the two-table benchmark that 003 replaced. Deleting the entry rather than
|
|
86
|
+
* emptying it is what broke the app: kysely records applied migrations by key, finds one recorded
|
|
87
|
+
* that the provider no longer offers, calls the history corrupted and applies *nothing* - so 003
|
|
88
|
+
* never ran and the first query failed with "no such table: bench_order_line".
|
|
89
|
+
*
|
|
90
|
+
* An applied migration is a fact about databases in the world. It can stop doing anything; it
|
|
91
|
+
* cannot stop existing.
|
|
92
|
+
*/
|
|
93
|
+
"002_benchmark": {
|
|
94
|
+
up: async () => {
|
|
95
|
+
// Its tables are created and dropped by 003; there is nothing left for this to do.
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The benchmark's tables, replacing the two-table version outright - this is R&D and nothing
|
|
101
|
+
* depends on the old shape.
|
|
102
|
+
*
|
|
103
|
+
* Indexed where a screen would index: foreign keys, the columns joins and filters use, and one
|
|
104
|
+
* composite for the stock lookup. `bench_customer.notes` is deliberately left unindexed so the
|
|
105
|
+
* LIKE case measures a genuine scan, and `bench_order.total_cents` too, so one case has to sort
|
|
106
|
+
* without help.
|
|
107
|
+
*/
|
|
108
|
+
"003_benchmark_scale": {
|
|
109
|
+
up: async (db) => {
|
|
110
|
+
await db.schema.dropTable("bench_child").ifExists().execute();
|
|
111
|
+
await db.schema.dropTable("bench_parent").ifExists().execute();
|
|
112
|
+
|
|
113
|
+
await db.schema
|
|
114
|
+
.createTable("bench_region")
|
|
115
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
116
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
117
|
+
.execute();
|
|
118
|
+
|
|
119
|
+
await db.schema
|
|
120
|
+
.createTable("bench_city")
|
|
121
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
122
|
+
.addColumn("region_id", "integer", (col) => col.notNull().references("bench_region.id"))
|
|
123
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
124
|
+
.execute();
|
|
125
|
+
|
|
126
|
+
await db.schema
|
|
127
|
+
.createTable("bench_customer")
|
|
128
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
129
|
+
.addColumn("city_id", "integer", (col) => col.notNull().references("bench_city.id"))
|
|
130
|
+
.addColumn("code", "text", (col) => col.notNull().unique())
|
|
131
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
132
|
+
.addColumn("notes", "text", (col) => col.notNull().defaultTo(""))
|
|
133
|
+
.addColumn("credit_cents", "integer", (col) => col.notNull().defaultTo(0))
|
|
134
|
+
.addColumn("created_at", "text", (col) => col.notNull())
|
|
135
|
+
.execute();
|
|
136
|
+
|
|
137
|
+
await db.schema
|
|
138
|
+
.createTable("bench_category")
|
|
139
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
140
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
141
|
+
.execute();
|
|
142
|
+
|
|
143
|
+
await db.schema
|
|
144
|
+
.createTable("bench_product")
|
|
145
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
146
|
+
.addColumn("category_id", "integer", (col) => col.notNull().references("bench_category.id"))
|
|
147
|
+
.addColumn("sku", "text", (col) => col.notNull().unique())
|
|
148
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
149
|
+
.addColumn("price_cents", "integer", (col) => col.notNull().defaultTo(0))
|
|
150
|
+
.execute();
|
|
151
|
+
|
|
152
|
+
await db.schema
|
|
153
|
+
.createTable("bench_warehouse")
|
|
154
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
155
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
156
|
+
.execute();
|
|
157
|
+
|
|
158
|
+
await db.schema
|
|
159
|
+
.createTable("bench_stock")
|
|
160
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
161
|
+
.addColumn("warehouse_id", "integer", (col) =>
|
|
162
|
+
col.notNull().references("bench_warehouse.id"),
|
|
163
|
+
)
|
|
164
|
+
.addColumn("product_id", "integer", (col) => col.notNull().references("bench_product.id"))
|
|
165
|
+
.addColumn("quantity", "integer", (col) => col.notNull().defaultTo(0))
|
|
166
|
+
.execute();
|
|
167
|
+
|
|
168
|
+
await db.schema
|
|
169
|
+
.createTable("bench_order")
|
|
170
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
171
|
+
.addColumn("customer_id", "integer", (col) =>
|
|
172
|
+
col.notNull().references("bench_customer.id").onDelete("cascade"),
|
|
173
|
+
)
|
|
174
|
+
.addColumn("reference", "text", (col) => col.notNull())
|
|
175
|
+
.addColumn("status", "text", (col) => col.notNull().defaultTo("draft"))
|
|
176
|
+
.addColumn("created_at", "text", (col) => col.notNull())
|
|
177
|
+
.addColumn("total_cents", "integer", (col) => col.notNull().defaultTo(0))
|
|
178
|
+
.execute();
|
|
179
|
+
|
|
180
|
+
await db.schema
|
|
181
|
+
.createTable("bench_order_line")
|
|
182
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
183
|
+
.addColumn("order_id", "integer", (col) =>
|
|
184
|
+
col.notNull().references("bench_order.id").onDelete("cascade"),
|
|
185
|
+
)
|
|
186
|
+
.addColumn("product_id", "integer", (col) => col.notNull().references("bench_product.id"))
|
|
187
|
+
.addColumn("quantity", "integer", (col) => col.notNull().defaultTo(1))
|
|
188
|
+
.addColumn("unit_price_cents", "integer", (col) => col.notNull().defaultTo(0))
|
|
189
|
+
.execute();
|
|
190
|
+
|
|
191
|
+
await db.schema
|
|
192
|
+
.createTable("bench_payment")
|
|
193
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
194
|
+
.addColumn("order_id", "integer", (col) =>
|
|
195
|
+
col.notNull().references("bench_order.id").onDelete("cascade"),
|
|
196
|
+
)
|
|
197
|
+
.addColumn("amount_cents", "integer", (col) => col.notNull().defaultTo(0))
|
|
198
|
+
.addColumn("method", "text", (col) => col.notNull())
|
|
199
|
+
.addColumn("created_at", "text", (col) => col.notNull())
|
|
200
|
+
.execute();
|
|
201
|
+
|
|
202
|
+
for (const [name, table, column] of [
|
|
203
|
+
["idx_bench_city_region", "bench_city", "region_id"],
|
|
204
|
+
["idx_bench_customer_city", "bench_customer", "city_id"],
|
|
205
|
+
["idx_bench_product_category", "bench_product", "category_id"],
|
|
206
|
+
["idx_bench_order_customer", "bench_order", "customer_id"],
|
|
207
|
+
["idx_bench_order_status", "bench_order", "status"],
|
|
208
|
+
["idx_bench_line_order", "bench_order_line", "order_id"],
|
|
209
|
+
["idx_bench_line_product", "bench_order_line", "product_id"],
|
|
210
|
+
["idx_bench_payment_order", "bench_payment", "order_id"],
|
|
211
|
+
] as const) {
|
|
212
|
+
await db.schema.createIndex(name).on(table).column(column).execute();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Composite, because a stock lookup filters on both and neither alone is selective enough.
|
|
216
|
+
await db.schema
|
|
217
|
+
.createIndex("idx_bench_stock_warehouse_product")
|
|
218
|
+
.on("bench_stock")
|
|
219
|
+
.columns(["warehouse_id", "product_id"])
|
|
220
|
+
.execute();
|
|
221
|
+
},
|
|
222
|
+
},
|
|
81
223
|
};
|
|
@@ -56,4 +56,98 @@ export interface Database {
|
|
|
56
56
|
order_line: OrderLineTable;
|
|
57
57
|
tag: TagTable;
|
|
58
58
|
customer_tag: CustomerTagTable;
|
|
59
|
+
bench_region: BenchRegionTable;
|
|
60
|
+
bench_city: BenchCityTable;
|
|
61
|
+
bench_customer: BenchCustomerTable;
|
|
62
|
+
bench_category: BenchCategoryTable;
|
|
63
|
+
bench_product: BenchProductTable;
|
|
64
|
+
bench_warehouse: BenchWarehouseTable;
|
|
65
|
+
bench_stock: BenchStockTable;
|
|
66
|
+
bench_order: BenchOrderTable;
|
|
67
|
+
bench_order_line: BenchOrderLineTable;
|
|
68
|
+
bench_payment: BenchPaymentTable;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The benchmark's own tables, deliberately separate from the app's.
|
|
73
|
+
*
|
|
74
|
+
* Ten tables at roughly 100k rows, shaped like a distribution domain - regions down to order lines,
|
|
75
|
+
* plus stock and payments - because the questions worth answering are about joins across a real
|
|
76
|
+
* cardinality spread, not about two tables with a foreign key. A five-level join here touches a
|
|
77
|
+
* 10-row table and a 40k-row table in the same query, which is where planners and indexes start to
|
|
78
|
+
* matter.
|
|
79
|
+
*
|
|
80
|
+
* Measuring against the app's own tables would either corrupt them or force a rollback, and a
|
|
81
|
+
* rolled-back transaction never pays the commit - most of what a write costs on a phone.
|
|
82
|
+
*/
|
|
83
|
+
export interface BenchRegionTable {
|
|
84
|
+
id: Generated<number>;
|
|
85
|
+
name: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface BenchCityTable {
|
|
89
|
+
id: Generated<number>;
|
|
90
|
+
region_id: number;
|
|
91
|
+
name: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface BenchCustomerTable {
|
|
95
|
+
id: Generated<number>;
|
|
96
|
+
city_id: number;
|
|
97
|
+
code: string;
|
|
98
|
+
name: string;
|
|
99
|
+
/** Unindexed on purpose: the LIKE case needs a genuine full scan over 5k rows. */
|
|
100
|
+
notes: string;
|
|
101
|
+
credit_cents: number;
|
|
102
|
+
created_at: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface BenchCategoryTable {
|
|
106
|
+
id: Generated<number>;
|
|
107
|
+
name: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BenchProductTable {
|
|
111
|
+
id: Generated<number>;
|
|
112
|
+
category_id: number;
|
|
113
|
+
sku: string;
|
|
114
|
+
name: string;
|
|
115
|
+
price_cents: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface BenchWarehouseTable {
|
|
119
|
+
id: Generated<number>;
|
|
120
|
+
name: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface BenchStockTable {
|
|
124
|
+
id: Generated<number>;
|
|
125
|
+
warehouse_id: number;
|
|
126
|
+
product_id: number;
|
|
127
|
+
quantity: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface BenchOrderTable {
|
|
131
|
+
id: Generated<number>;
|
|
132
|
+
customer_id: number;
|
|
133
|
+
reference: string;
|
|
134
|
+
status: string;
|
|
135
|
+
created_at: string;
|
|
136
|
+
total_cents: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface BenchOrderLineTable {
|
|
140
|
+
id: Generated<number>;
|
|
141
|
+
order_id: number;
|
|
142
|
+
product_id: number;
|
|
143
|
+
quantity: number;
|
|
144
|
+
unit_price_cents: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface BenchPaymentTable {
|
|
148
|
+
id: Generated<number>;
|
|
149
|
+
order_id: number;
|
|
150
|
+
amount_cents: number;
|
|
151
|
+
method: string;
|
|
152
|
+
created_at: string;
|
|
59
153
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { StorageProbe } from "./candidates/types";
|
|
2
|
+
|
|
3
|
+
export type { StorageProbe };
|
|
4
|
+
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
export function probeOpfsCapable(): StorageProbe {
|
|
26
|
+
if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) {
|
|
27
|
+
return {
|
|
28
|
+
supported: false,
|
|
29
|
+
reason:
|
|
30
|
+
"This WebView has no Origin Private File System, so the database cannot be stored. Update " +
|
|
31
|
+
"Android System WebView from the Play Store - it updates separately from Android itself.",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { supported: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
export function probeIndexedDb(): StorageProbe {
|
|
44
|
+
if (typeof indexedDB === "undefined") {
|
|
45
|
+
return {
|
|
46
|
+
supported: false,
|
|
47
|
+
reason: "This WebView has no IndexedDB, which leaves nowhere to keep the database.",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { supported: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function describeOpenFailure(error: unknown): string {
|
|
54
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
55
|
+
|
|
56
|
+
if (/SyncAccessHandle|createSyncAccessHandle|sahpool|SAH/i.test(detail)) {
|
|
57
|
+
return (
|
|
58
|
+
"SQLite could not take a synchronous file handle. Either this WebView is too old - update " +
|
|
59
|
+
"Android System WebView from the Play Store, it updates separately from Android - or another " +
|
|
60
|
+
`copy of the app still holds the database open. (${detail})`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `The database could not be opened: ${detail}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
export function webviewVersion(): number | null {
|
|
75
|
+
if (typeof navigator === "undefined") return null;
|
|
76
|
+
const match = /Chrome\/(\d+)/.exec(navigator.userAgent);
|
|
77
|
+
return match ? Number(match[1]) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
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
|
+
*/
|
|
89
|
+
export const MINIMUM_CHROMIUM_FOR_OPFS = 109;
|
|
90
|
+
|
|
91
|
+
export function webviewLikelyTooOld(): boolean {
|
|
92
|
+
const version = webviewVersion();
|
|
93
|
+
return version !== null && version < MINIMUM_CHROMIUM_FOR_OPFS;
|
|
94
|
+
}
|