@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.
Files changed (42) hide show
  1. package/README.md +56 -10
  2. package/bin/create.mjs +44 -1
  3. package/lib/scaffold.mjs +15 -1
  4. package/lib/templateFingerprint.mjs +29 -0
  5. package/package.json +4 -2
  6. package/templates/f7-app/.claude/rules/database.md +39 -0
  7. package/templates/f7-app/.env.example +23 -0
  8. package/templates/f7-app/CLAUDE.md +8 -3
  9. package/templates/f7-app/auto-imports.d.ts +7 -0
  10. package/templates/f7-app/components.d.ts +2 -0
  11. package/templates/f7-app/package.json +6 -6
  12. package/templates/f7-app/src/app/pragmas.config.ts +51 -0
  13. package/templates/f7-app/src/app/storage.config.ts +55 -0
  14. package/templates/f7-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
  15. package/templates/f7-app/src/domains/benchmark/benchmark.suite.ts +662 -0
  16. package/templates/f7-app/src/domains/sales/sales.repository.ts +1 -1
  17. package/templates/f7-app/src/env.d.ts +15 -0
  18. package/templates/f7-app/src/locales/en.json +37 -2
  19. package/templates/f7-app/src/locales/fr.json +37 -2
  20. package/templates/f7-app/src/main.ts +6 -16
  21. package/templates/f7-app/src/modules/demo/components/DemoBenchmark.vue +112 -0
  22. package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +10 -0
  23. package/templates/f7-app/src/modules/demo/composables/useBenchmark.ts +139 -0
  24. package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +5 -1
  25. package/templates/f7-app/src/modules/demo/views/DemoView.vue +9 -2
  26. package/templates/f7-app/src/modules/settings/views/SettingsView.vue +31 -0
  27. package/templates/f7-app/src/plugins/bootstrapError.ts +60 -0
  28. package/templates/f7-app/src/shared/database/candidates/index.ts +3 -0
  29. package/templates/f7-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
  30. package/templates/f7-app/src/shared/database/candidates/types.ts +41 -0
  31. package/templates/f7-app/src/shared/database/candidates/waSqlite.ts +58 -0
  32. package/templates/f7-app/src/shared/database/database.ts +151 -30
  33. package/templates/f7-app/src/shared/database/migrations.ts +143 -1
  34. package/templates/f7-app/src/shared/database/opfs.worker.ts +5 -0
  35. package/templates/f7-app/src/shared/database/schema.ts +94 -0
  36. package/templates/f7-app/src/shared/database/storage.ts +94 -0
  37. package/templates/f7-app/src/shared/database/wa.worker.ts +5 -0
  38. package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +1 -2
  39. package/templates/f7-app/tests/benchmark.suite.test.ts +104 -0
  40. package/templates/f7-app/tests/migrations.test.ts +74 -0
  41. package/templates/f7-app/tests/storage.test.ts +108 -0
  42. package/templates/f7-app/tsconfig.node.json +0 -14
@@ -0,0 +1,5 @@
1
+ import { runWaWorker } from "@cavulsqa/mobile-db/wa";
2
+
3
+ // The whole worker. It lives in the app because only the app's bundler can emit a worker and the
4
+ // wa-sqlite wasm it loads; the logic is all in @cavulsqa/mobile-db.
5
+ runWaWorker();
@@ -14,8 +14,7 @@ export function Framework7VueResolver(): ComponentResolver {
14
14
  "f7-nav-title",
15
15
  "f7-nav-title-large",
16
16
  "f7-toolbar",
17
- // No "f7-toolbar-pane": Framework7 9 ships the CSS class but framework7-vue 8 exports no
18
- // such component, and resolving it fails at runtime instead of warning. Use the class.
17
+ "f7-toolbar-pane",
19
18
  "f7-subnavbar",
20
19
  "f7-searchbar",
21
20
 
@@ -0,0 +1,104 @@
1
+ import { expect, test } from "vite-plus/test";
2
+ import { Kysely } from "kysely";
3
+ import { Migrator } from "kysely/migration";
4
+ import { createSqlJsDialect } from "@cavulsqa/mobile-db/testing";
5
+ import { runBenchmark } from "../src/domains/benchmark/benchmark.suite.js";
6
+ import { datasetRowCount, SCALE, TOTAL_ROWS } from "../src/domains/benchmark/benchmark.dataset.js";
7
+ import { migrations } from "../src/shared/database/migrations.js";
8
+ import type { Database } from "../src/shared/database/schema.js";
9
+
10
+ async function open(): Promise<Kysely<Database>> {
11
+ const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
12
+ await new Migrator({
13
+ db,
14
+ provider: { getMigrations: () => Promise.resolve(migrations) },
15
+ }).migrateToLatest();
16
+ return db;
17
+ }
18
+
19
+ /**
20
+ * The suite seeds roughly 100k rows across ten tables and runs 24 cases, several of them six-table
21
+ * joins. A broken case would otherwise surface halfway through a run on a phone, as a number nobody
22
+ * could interpret - so it executes here against real SQLite first.
23
+ *
24
+ * Slow by design: this is the only place the whole thing runs without a device.
25
+ */
26
+ test("the whole suite runs at scale and every case reports a timing", async () => {
27
+ const db = await open();
28
+
29
+ const seen: string[] = [];
30
+ const result = await runBenchmark(db, (name) => seen.push(name));
31
+
32
+ expect(result.cases.length).toBeGreaterThanOrEqual(27);
33
+ expect(result.seedMs, "the first run builds the dataset").toBeGreaterThan(0);
34
+ expect(result.rowsSeeded).toBeGreaterThanOrEqual(SCALE.orderLines + SCALE.stock);
35
+
36
+ for (const entry of result.cases) {
37
+ expect(entry.operations, entry.name).toBeGreaterThan(0);
38
+ expect(entry.msPerOperation, entry.name).toBeGreaterThanOrEqual(0);
39
+ expect(entry.worstMs, entry.name).toBeGreaterThanOrEqual(entry.medianMs);
40
+ }
41
+
42
+ // All six groups represented, or the suite is not covering what it claims to.
43
+ expect([...new Set(result.cases.map((entry) => entry.group))].sort()).toEqual([
44
+ "concurrency",
45
+ "join",
46
+ "read",
47
+ "schema",
48
+ "sync",
49
+ "transaction",
50
+ "write",
51
+ ]);
52
+
53
+ /**
54
+ * The sync cases measure how long a read waits, so a zero would mean the read never queued behind
55
+ * the write and the case proved nothing.
56
+ */
57
+ for (const entry of result.cases.filter((c) => c.group === "sync")) {
58
+ expect(entry.medianMs, entry.name).toBeGreaterThan(0);
59
+ }
60
+
61
+ // Read and join cases must actually return rows; a fast case that returns none proves nothing.
62
+ for (const entry of result.cases.filter((c) => c.group === "join")) {
63
+ expect(entry.rows, `${entry.name} returned no rows`).toBeGreaterThan(0);
64
+ }
65
+ }, 600_000);
66
+
67
+ /**
68
+ * The dataset has to survive a run untouched, or every subsequent run measures a different database
69
+ * and two runs cannot be compared.
70
+ */
71
+ test("a run leaves the dataset exactly as it found it", async () => {
72
+ const db = await open();
73
+ await runBenchmark(db);
74
+ const after = await datasetRowCount(db);
75
+
76
+ await runBenchmark(db);
77
+ expect(await datasetRowCount(db)).toBe(after);
78
+
79
+ // And nothing the write cases created is left behind.
80
+ const strays = await db
81
+ .selectFrom("bench_payment")
82
+ .select(({ fn }) => fn.countAll<number>().as("n"))
83
+ .where("method", "like", "bench-%")
84
+ .executeTakeFirstOrThrow();
85
+ expect(Number(strays.n)).toBe(0);
86
+ }, 600_000);
87
+
88
+ test("the scale constants add up to about 100k rows", () => {
89
+ expect(TOTAL_ROWS).toBeGreaterThan(100_000);
90
+ expect(SCALE.orderLines).toBe(40_000);
91
+ });
92
+
93
+ test("the benchmark never touches the app's tables", async () => {
94
+ const db = await open();
95
+ await db
96
+ .insertInto("customer")
97
+ .values({ created_at: "2026-01-01T00:00:00.000Z", name: "Untouched", city: "Algiers" })
98
+ .execute();
99
+
100
+ await runBenchmark(db);
101
+
102
+ const customers = await db.selectFrom("customer").select("name").execute();
103
+ expect(customers.map((row) => row.name)).toEqual(["Untouched"]);
104
+ }, 600_000);
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "vite-plus/test";
2
+ import { Kysely } from "kysely";
3
+ import { Migrator } from "kysely/migration";
4
+ import { createSqlJsDialect } from "@cavulsqa/mobile-db/testing";
5
+ import { migrations } from "../src/shared/database/migrations.js";
6
+ import type { Database } from "../src/shared/database/schema.js";
7
+
8
+ async function migrate(db: Kysely<Database>, set = migrations) {
9
+ return new Migrator({
10
+ db,
11
+ provider: { getMigrations: () => Promise.resolve(set) },
12
+ }).migrateToLatest();
13
+ }
14
+
15
+ test("a fresh database gets every table the schema declares", async () => {
16
+ const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
17
+ const result = await migrate(db);
18
+ expect(result.error).toBeUndefined();
19
+
20
+ const names = (await db.introspection.getTables()).map((t) => t.name);
21
+ for (const table of [
22
+ "customer",
23
+ "product",
24
+ "sales_order",
25
+ "order_line",
26
+ "tag",
27
+ "customer_tag",
28
+ "bench_region",
29
+ "bench_city",
30
+ "bench_customer",
31
+ "bench_category",
32
+ "bench_product",
33
+ "bench_warehouse",
34
+ "bench_stock",
35
+ "bench_order",
36
+ "bench_order_line",
37
+ "bench_payment",
38
+ ]) {
39
+ expect(names, table).toContain(table);
40
+ }
41
+
42
+ // The two-table benchmark 003 replaced must be gone, not merely unused.
43
+ expect(names).not.toContain("bench_parent");
44
+ expect(names).not.toContain("bench_child");
45
+ });
46
+
47
+ /**
48
+ * The failure this exists for: deleting an already-applied migration instead of emptying it. Kysely
49
+ * records applied migrations by key, so a recorded key the provider no longer offers makes it refuse
50
+ * the entire set - applying nothing, reporting the error in its return value rather than throwing,
51
+ * and leaving an app whose first query fails with "no such table".
52
+ */
53
+ test("dropping an applied migration is refused, and the refusal is visible", async () => {
54
+ const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
55
+ expect((await migrate(db)).error).toBeUndefined();
56
+
57
+ const { "002_benchmark": _removed, ...withoutTombstone } = migrations;
58
+ const result = await migrate(db, withoutTombstone);
59
+
60
+ expect(result.error, "kysely must object to the missing migration").toBeDefined();
61
+ });
62
+
63
+ test("running twice is a no-op, and every key is still there", async () => {
64
+ const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
65
+ const first = await migrate(db);
66
+ const second = await migrate(db);
67
+
68
+ expect(first.error).toBeUndefined();
69
+ expect(second.error).toBeUndefined();
70
+ expect(second.results ?? []).toEqual([]);
71
+
72
+ // The tombstone counts: a key may stop doing work but may not stop existing.
73
+ expect(Object.keys(migrations)).toEqual(["001_sales", "002_benchmark", "003_benchmark_scale"]);
74
+ });
@@ -0,0 +1,108 @@
1
+ import { afterEach, expect, test } from "vite-plus/test";
2
+ import { storageChain } from "../src/app/storage.config.js";
3
+ import {
4
+ describeOpenFailure,
5
+ MINIMUM_CHROMIUM_FOR_OPFS,
6
+ probeOpfsCapable,
7
+ webviewLikelyTooOld,
8
+ webviewVersion,
9
+ } from "../src/shared/database/storage.js";
10
+
11
+ const original = globalThis.navigator;
12
+
13
+ afterEach(() => {
14
+ if (original)
15
+ Object.defineProperty(globalThis, "navigator", { value: original, configurable: true });
16
+ });
17
+
18
+ function withNavigator(value: unknown): void {
19
+ Object.defineProperty(globalThis, "navigator", { value, configurable: true });
20
+ }
21
+
22
+ /**
23
+ * The regression this exists for: the probe used to require `createSyncAccessHandle` on
24
+ * `FileSystemFileHandle.prototype`. That method is Worker-only in Chromium, so it is absent on the
25
+ * main thread of every device - and the check rejected a phone on WebView 150 that had been running
26
+ * OPFS for hours. A main-thread probe may only look at what the main thread can see.
27
+ */
28
+ test("a WebView that supports OPFS passes even without the worker-only method", () => {
29
+ withNavigator({ storage: { getDirectory: () => Promise.resolve({}) } });
30
+ expect("createSyncAccessHandle" in (globalThis.FileSystemFileHandle?.prototype ?? {})).toBe(
31
+ false,
32
+ );
33
+ expect(probeOpfsCapable().supported).toBe(true);
34
+ });
35
+
36
+ test("a WebView with no OPFS at all is refused, and told what to do", () => {
37
+ withNavigator({ storage: {} });
38
+ const probe = probeOpfsCapable();
39
+ expect(probe.supported).toBe(false);
40
+ expect(probe.reason).toMatch(/Android System WebView/);
41
+ });
42
+
43
+ test("an open failure is translated into something actionable", () => {
44
+ const sah = describeOpenFailure(new Error("createSyncAccessHandle is not a function"));
45
+ expect(sah).toMatch(/Android System WebView/);
46
+ // The other cause of the same failure, and the one a person can actually fix themselves.
47
+ expect(sah).toMatch(/still holds the database open/);
48
+ // The engine's own words are kept, so a bug report has something to go on.
49
+ expect(sah).toContain("createSyncAccessHandle is not a function");
50
+
51
+ expect(describeOpenFailure(new Error("disk I/O error"))).toBe(
52
+ "The database could not be opened: disk I/O error",
53
+ );
54
+ });
55
+
56
+ /**
57
+ * The chain is a claim about ordering, so it is checked like one: fastest first, durable before
58
+ * volatile, and nothing in it without a stated cost.
59
+ */
60
+ test("the storage chain is ordered and honest about what each entry costs", () => {
61
+ expect(storageChain.length).toBeGreaterThan(0);
62
+ expect(storageChain[0]?.id).toBe("sqlite-wasm-opfs-sahpool");
63
+ expect(storageChain[0]?.evidence).toBe("measured");
64
+
65
+ for (const candidate of storageChain) {
66
+ expect(candidate.tradeoff.length, candidate.id).toBeGreaterThan(20);
67
+ expect(candidate.label.length, candidate.id).toBeGreaterThan(5);
68
+ }
69
+
70
+ // Nothing in the chain may be volatile: a silent stop somewhere data is not kept is worse than
71
+ // an error that names every attempt.
72
+ expect(storageChain.filter((candidate) => !candidate.durable)).toEqual([]);
73
+
74
+ // Every engine gets a probe of its own; sharing one would hide that IndexedDB needs no OPFS.
75
+ expect(storageChain.every((candidate) => typeof candidate.probe === "function")).toBe(true);
76
+
77
+ // Ids are unique, or results keyed by id would overwrite each other.
78
+ expect(new Set(storageChain.map((c) => c.id)).size).toBe(storageChain.length);
79
+ });
80
+
81
+ /** The floor is reported, never enforced: a vendor build can differ, so it informs and nothing else. */
82
+ test("the WebView version is read from the user agent and judged against the floor", () => {
83
+ withNavigator({
84
+ storage: { getDirectory: () => Promise.resolve({}) },
85
+ userAgent: "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 Chrome/150.0.7871.183 Mobile",
86
+ });
87
+ expect(webviewVersion()).toBe(150);
88
+ expect(webviewLikelyTooOld()).toBe(false);
89
+ // Support is still asserted, because the floor must not become a second gate on a working device.
90
+ expect(probeOpfsCapable().supported).toBe(true);
91
+
92
+ withNavigator({
93
+ storage: { getDirectory: () => Promise.resolve({}) },
94
+ userAgent: "Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 Chrome/51.0.2704.90 Mobile",
95
+ });
96
+ expect(webviewVersion()).toBe(51);
97
+ expect(webviewLikelyTooOld()).toBe(true);
98
+
99
+ withNavigator({
100
+ storage: { getDirectory: () => Promise.resolve({}) },
101
+ userAgent: "something else",
102
+ });
103
+ expect(webviewVersion()).toBe(null);
104
+ // Unknown is not old: an unparseable agent must not lock anybody out.
105
+ expect(webviewLikelyTooOld()).toBe(false);
106
+
107
+ expect(MINIMUM_CHROMIUM_FOR_OPFS).toBe(109);
108
+ });
@@ -1,14 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "types": ["node"],
5
- /**
6
- * vite-plus bundles its own Vite while the unplugin packages depend on Vite as a peer, so the
7
- * plugin array is compared across two copies of the same types and TS gives up with
8
- * "excessive stack depth". `vp check` type-checks this file with a single resolution, so the
9
- * comparison never happens there - which is why the app project below leaves it out.
10
- */
11
- "skipLibCheck": true
12
- },
13
- "include": ["vite.config.ts"]
14
- }