@cavulsqa/create 0.1.2 → 2.2.1

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 (42) hide show
  1. package/README.md +28 -10
  2. package/bin/create.mjs +44 -1
  3. package/lib/scaffold.mjs +15 -1
  4. package/package.json +2 -2
  5. package/templates/f7-app/.claude/rules/database.md +39 -0
  6. package/templates/f7-app/.env.example +23 -0
  7. package/templates/f7-app/CLAUDE.md +8 -3
  8. package/templates/f7-app/auto-imports.d.ts +7 -0
  9. package/templates/f7-app/components.d.ts +2 -0
  10. package/templates/f7-app/package.json +6 -6
  11. package/templates/f7-app/src/app/pragmas.config.ts +51 -0
  12. package/templates/f7-app/src/app/storage.config.ts +56 -0
  13. package/templates/f7-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
  14. package/templates/f7-app/src/domains/benchmark/benchmark.suite.ts +662 -0
  15. package/templates/f7-app/src/domains/sales/sales.repository.ts +1 -1
  16. package/templates/f7-app/src/env.d.ts +15 -0
  17. package/templates/f7-app/src/locales/en.json +37 -2
  18. package/templates/f7-app/src/locales/fr.json +37 -2
  19. package/templates/f7-app/src/main.ts +6 -16
  20. package/templates/f7-app/src/modules/demo/components/DemoBenchmark.vue +112 -0
  21. package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +10 -0
  22. package/templates/f7-app/src/modules/demo/composables/useBenchmark.ts +139 -0
  23. package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +5 -1
  24. package/templates/f7-app/src/modules/demo/views/DemoView.vue +9 -2
  25. package/templates/f7-app/src/modules/settings/views/SettingsView.vue +31 -0
  26. package/templates/f7-app/src/plugins/bootstrapError.ts +60 -0
  27. package/templates/f7-app/src/shared/database/candidates/index.ts +3 -0
  28. package/templates/f7-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
  29. package/templates/f7-app/src/shared/database/candidates/types.ts +41 -0
  30. package/templates/f7-app/src/shared/database/candidates/waSqlite.ts +58 -0
  31. package/templates/f7-app/src/shared/database/database.ts +151 -30
  32. package/templates/f7-app/src/shared/database/migrations.ts +143 -1
  33. package/templates/f7-app/src/shared/database/opfs.worker.ts +5 -0
  34. package/templates/f7-app/src/shared/database/schema.ts +94 -0
  35. package/templates/f7-app/src/shared/database/storage.ts +94 -0
  36. package/templates/f7-app/src/shared/database/wa.worker.ts +5 -0
  37. package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +1 -2
  38. package/templates/f7-app/tests/benchmark.suite.test.ts +104 -0
  39. package/templates/f7-app/tests/migrations.test.ts +74 -0
  40. package/templates/f7-app/tests/storage.test.ts +117 -0
  41. package/templates/f7-app/vite.config.ts +3 -1
  42. 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 { Capacitor } from "@capacitor/core";
2
- import { Kysely } from "kysely";
1
+ import { Kysely, sql, type Dialect } from "kysely";
3
2
  import { Migrator } from "kysely/migration";
4
- import { createMobileDatabase, runWrite, type MobileDatabase } from "@cavulsqa/mobile-db";
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
- * On a device the database is a real SQLite file behind the Capacitor plugin.
28
+ * Wraps a Kysely instance in the shape the app consumes, and runs the migrations.
20
29
  *
21
- * In a browser it is sql.js in memory, via the dialect `@cavulsqa/mobile-db` already ships for its
22
- * own tests. That is deliberate: the plugin's web mode needs a `jeep-sqlite` element plus a
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 openWebDatabase(): Promise<MobileDatabase<Database>> {
29
- const { createSqlJsDialect } = await import("@cavulsqa/mobile-db/testing");
30
- const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
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
- await new Migrator({
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 in a browser");
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
- database =
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
- return database;
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
  };
@@ -0,0 +1,5 @@
1
+ import { runOpfsWorker } from "@cavulsqa/mobile-db/opfs";
2
+
3
+ // The whole worker. It lives in the app rather than the package because only the app's bundler can
4
+ // emit a worker and the sqlite3.wasm it loads; the logic is all in @cavulsqa/mobile-db.
5
+ runOpfsWorker();
@@ -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
+ }