@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.
- package/README.md +28 -10
- package/bin/create.mjs +44 -1
- package/lib/scaffold.mjs +15 -1
- package/package.json +2 -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 +56 -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 +117 -0
- package/templates/f7-app/vite.config.ts +3 -1
- package/templates/f7-app/tsconfig.node.json +0 -14
|
@@ -14,8 +14,7 @@ export function Framework7VueResolver(): ComponentResolver {
|
|
|
14
14
|
"f7-nav-title",
|
|
15
15
|
"f7-nav-title-large",
|
|
16
16
|
"f7-toolbar",
|
|
17
|
-
|
|
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,117 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "vite-plus/test";
|
|
2
|
+
import { DEFAULT_ORDER, 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
|
+
|
|
63
|
+
// The ranking is asserted against the default order, not the live chain: VITE_STORAGE_ENGINE
|
|
64
|
+
// reorders the chain on purpose, and a generated app that chose another engine is not broken.
|
|
65
|
+
expect(DEFAULT_ORDER[0]?.id).toBe("sqlite-wasm-opfs-sahpool");
|
|
66
|
+
expect(DEFAULT_ORDER[0]?.evidence).toBe("measured");
|
|
67
|
+
|
|
68
|
+
// Whatever the environment asked for, the chain must still be every candidate exactly once -
|
|
69
|
+
// promoting one may not drop the others, or the fallback silently disappears.
|
|
70
|
+
expect([...storageChain].sort((a, b) => (a.id < b.id ? -1 : 1))).toEqual(
|
|
71
|
+
[...DEFAULT_ORDER].sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
for (const candidate of DEFAULT_ORDER) {
|
|
75
|
+
expect(candidate.tradeoff.length, candidate.id).toBeGreaterThan(20);
|
|
76
|
+
expect(candidate.label.length, candidate.id).toBeGreaterThan(5);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Nothing in the chain may be volatile: a silent stop somewhere data is not kept is worse than
|
|
80
|
+
// an error that names every attempt.
|
|
81
|
+
expect(storageChain.filter((candidate) => !candidate.durable)).toEqual([]);
|
|
82
|
+
|
|
83
|
+
// Every engine gets a probe of its own; sharing one would hide that IndexedDB needs no OPFS.
|
|
84
|
+
expect(storageChain.every((candidate) => typeof candidate.probe === "function")).toBe(true);
|
|
85
|
+
|
|
86
|
+
// Ids are unique, or results keyed by id would overwrite each other.
|
|
87
|
+
expect(new Set(storageChain.map((c) => c.id)).size).toBe(storageChain.length);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** The floor is reported, never enforced: a vendor build can differ, so it informs and nothing else. */
|
|
91
|
+
test("the WebView version is read from the user agent and judged against the floor", () => {
|
|
92
|
+
withNavigator({
|
|
93
|
+
storage: { getDirectory: () => Promise.resolve({}) },
|
|
94
|
+
userAgent: "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 Chrome/150.0.7871.183 Mobile",
|
|
95
|
+
});
|
|
96
|
+
expect(webviewVersion()).toBe(150);
|
|
97
|
+
expect(webviewLikelyTooOld()).toBe(false);
|
|
98
|
+
// Support is still asserted, because the floor must not become a second gate on a working device.
|
|
99
|
+
expect(probeOpfsCapable().supported).toBe(true);
|
|
100
|
+
|
|
101
|
+
withNavigator({
|
|
102
|
+
storage: { getDirectory: () => Promise.resolve({}) },
|
|
103
|
+
userAgent: "Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 Chrome/51.0.2704.90 Mobile",
|
|
104
|
+
});
|
|
105
|
+
expect(webviewVersion()).toBe(51);
|
|
106
|
+
expect(webviewLikelyTooOld()).toBe(true);
|
|
107
|
+
|
|
108
|
+
withNavigator({
|
|
109
|
+
storage: { getDirectory: () => Promise.resolve({}) },
|
|
110
|
+
userAgent: "something else",
|
|
111
|
+
});
|
|
112
|
+
expect(webviewVersion()).toBe(null);
|
|
113
|
+
// Unknown is not old: an unparseable agent must not lock anybody out.
|
|
114
|
+
expect(webviewLikelyTooOld()).toBe(false);
|
|
115
|
+
|
|
116
|
+
expect(MINIMUM_CHROMIUM_FOR_OPFS).toBe(109);
|
|
117
|
+
});
|
|
@@ -41,7 +41,9 @@ export default defineConfig({
|
|
|
41
41
|
|
|
42
42
|
Icons({ autoInstall: true, compiler: "vue3" }),
|
|
43
43
|
|
|
44
|
-
VueI18nPlugin({
|
|
44
|
+
VueI18nPlugin({
|
|
45
|
+
include: [fileURLToPath(new URL("./src/locales/**", import.meta.url))],
|
|
46
|
+
}),
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* Composition API, i18n, vueuse and the Framework7 helpers are available without an import
|
|
@@ -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
|
-
}
|