@cavulsqa/create 2.4.0 → 2.7.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 CHANGED
@@ -44,7 +44,7 @@ The `f7-app` template: a tabbed shell, a `domains` / `modules` / `shared` layout
44
44
  sales demo over a six-table schema — dashboard aggregates, search, an order sheet, swipe actions,
45
45
  a detail screen — with tests against real SQLite.
46
46
 
47
- Underneath it, the data layer this repository publishes: `@cavulsqa/mobile-db` for Capacitor SQLite
47
+ Underneath it, the data layer this repository publishes: `@cavulsqa/mobile-db` for OPFS SQLite
48
48
  under Kysely, `@cavulsqa/reactive-db` for the change bus, and `@cavulsqa/reactive-vue` for
49
49
  `useReactiveQuery`. A write announces the tables it touched and every query watching them refetches;
50
50
  nothing in a screen asks for a refresh.
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The workspace catalog, as a flat map of `name` to range.
6
+ *
7
+ * A deliberately small reader: the catalog is a block of `name: range` and nothing else.
8
+ */
9
+ export function catalog(repoRoot) {
10
+ const entries = new Map();
11
+ let inside = false;
12
+ for (const line of readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8").split(/\r?\n/)) {
13
+ if (/^catalog:\s*$/.test(line)) {
14
+ inside = true;
15
+ continue;
16
+ }
17
+ if (inside && /^\S/.test(line)) break;
18
+ if (!inside) continue;
19
+ const match = /^\s+"?([^":]+)"?:\s*(.+?)\s*$/.exec(line);
20
+ if (match) entries.set(match[1], match[2].replace(/^["']|["']$/g, ""));
21
+ }
22
+ return entries;
23
+ }
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Removes every storage engine the generated app did not ask for.
6
+ *
7
+ * Without this the choice is cosmetic: one template means one `package.json`, so picking OPFS still
8
+ * installed `@capacitor-community/sqlite` and put the native plugin in the APK for code that never
9
+ * runs - which is the whole reason the engine sits behind its own entry point in `mobile-db`.
10
+ *
11
+ * The template describes its own engines in `cavulsqa.engineModules`, so nothing here has a list of
12
+ * engine names in it. Each module is one candidate file, and every place that references it does so
13
+ * on a line of its own - a `STORAGE_IDS` entry, an `export` in the candidates barrel, an import
14
+ * specifier and an entry in `DEFAULT_ORDER`. Dropping whole lines is why this stays a few rules
15
+ * rather than a parser.
16
+ */
17
+ export function pruneEngines(out, manifest, engine) {
18
+ const modules = manifest?.cavulsqa?.engineModules;
19
+ if (!modules || !engine) return { kept: null, dropped: [] };
20
+
21
+ const kept = Object.entries(modules).find(([, module]) => module.engines.includes(engine));
22
+ if (!kept) return { kept: null, dropped: [] };
23
+
24
+ const dropped = Object.entries(modules).filter(([name]) => name !== kept[0]);
25
+ if (dropped.length === 0) return { kept: kept[0], dropped: [] };
26
+
27
+ // A dependency two engines share is not removable, and neither is an engine id that survives.
28
+ const keptDeps = new Set(kept[1].dependencies ?? []);
29
+ const deadDeps = new Set();
30
+ const deadEngines = new Set();
31
+ const deadExports = new Set();
32
+ for (const [, module] of dropped) {
33
+ for (const dep of module.dependencies ?? []) if (!keptDeps.has(dep)) deadDeps.add(dep);
34
+ for (const id of module.engines) deadEngines.add(id);
35
+ for (const symbol of module.exports ?? []) deadExports.add(symbol);
36
+ const file = join(out, module.file);
37
+ if (existsSync(file)) rmSync(file);
38
+ }
39
+
40
+ dropLines(join(out, "src/shared/database/candidates/types.ts"), (line) =>
41
+ [...deadEngines].some((id) => line.trim() === `"${id}",`),
42
+ );
43
+ dropLines(join(out, "src/shared/database/candidates/index.ts"), (line) =>
44
+ dropped.some(([, module]) => line.includes(`"./${basenameOf(module.file)}"`)),
45
+ );
46
+ dropLines(join(out, "src/app/storage.config.ts"), (line) =>
47
+ [...deadExports].some((symbol) => line.trim() === `${symbol},`),
48
+ );
49
+
50
+ // `.env.example` documents every engine, one line each. An app that keeps the line for an engine
51
+ // it no longer has is documentation that lies, and `VITE_STORAGE_ENGINE` would name a candidate
52
+ // the chain cannot offer.
53
+ dropLines(join(out, ".env.example"), (line) =>
54
+ [...deadEngines].some((id) => line.startsWith(`# ${id} `)),
55
+ );
56
+ rewriteLine(join(out, ".env.example"), /^VITE_STORAGE_ENGINE=/, `VITE_STORAGE_ENGINE=${engine}`);
57
+
58
+ pruneDependencies(join(out, "package.json"), deadDeps);
59
+ return { kept: kept[0], dropped: dropped.map(([name]) => name) };
60
+ }
61
+
62
+ function basenameOf(file) {
63
+ return file.split("/").pop().replace(/\.ts$/, "");
64
+ }
65
+
66
+ function dropLines(path, matches) {
67
+ if (!existsSync(path)) return;
68
+ const kept = readFileSync(path, "utf8")
69
+ .split("\n")
70
+ .filter((line) => !matches(line));
71
+ writeFileSync(path, kept.join("\n"));
72
+ }
73
+
74
+ function rewriteLine(path, matches, replacement) {
75
+ if (!existsSync(path)) return;
76
+ const lines = readFileSync(path, "utf8")
77
+ .split("\n")
78
+ .map((line) => (matches.test(line) ? replacement : line));
79
+ writeFileSync(path, lines.join("\n"));
80
+ }
81
+
82
+ function pruneDependencies(path, dead) {
83
+ if (!existsSync(path) || dead.size === 0) return;
84
+ const pkg = JSON.parse(readFileSync(path, "utf8"));
85
+ for (const group of ["dependencies", "devDependencies"]) {
86
+ if (!pkg[group]) continue;
87
+ for (const dep of dead) delete pkg[group][dep];
88
+ }
89
+ writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
90
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Every publishable package under `packages/`, mapped to the version it currently declares. */
5
+ export function publishedVersions(repoRoot) {
6
+ const versions = new Map();
7
+ for (const dir of readdirSync(join(repoRoot, "packages")).sort()) {
8
+ const manifest = join(repoRoot, "packages", dir, "package.json");
9
+ if (!existsSync(manifest)) continue;
10
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
11
+ if (!pkg.private) versions.set(pkg.name, pkg.version);
12
+ }
13
+ return versions;
14
+ }
15
+
16
+ /**
17
+ * The sibling packages the templates declare as `workspace:` deps, in the order they are named.
18
+ *
19
+ * These are the ones the bundler rewrites to a concrete `^` range, so they are the ones whose
20
+ * versions change what a published creator scaffolds.
21
+ */
22
+ export function workspaceDependencies(repoRoot) {
23
+ const names = new Set();
24
+ const templates = join(repoRoot, "templates");
25
+ if (!existsSync(templates)) return [];
26
+
27
+ for (const name of readdirSync(templates).sort()) {
28
+ const manifest = join(templates, name, "package.json");
29
+ if (!existsSync(manifest)) continue;
30
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
31
+ for (const group of [pkg.dependencies, pkg.devDependencies]) {
32
+ for (const [dep, range] of Object.entries(group ?? {})) {
33
+ if (typeof range === "string" && range.startsWith("workspace:")) names.add(dep);
34
+ }
35
+ }
36
+ }
37
+ // Codepoint order, not locale order: this list is hashed, and the hash has to match on every
38
+ // machine that computes it.
39
+ return [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
40
+ }
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { pruneEngines } from "./pruneEngines.mjs";
1
2
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import { basename, join } from "node:path";
3
4
 
@@ -90,6 +91,13 @@ export function scaffold({ templateDir, out, name, appId, appName, engine, pragm
90
91
  * and change, while the choice of engine for one deployment is configuration. `.env.example` is
91
92
  * copied in by the template and documents every value.
92
93
  */
94
+ // Before the .env: picking an engine also removes the ones you did not pick, so the app installs
95
+ // only the plugin or wasm it actually reaches.
96
+ if (engine) {
97
+ const templateManifest = JSON.parse(readFileSync(join(templateDir, "package.json"), "utf8"));
98
+ pruneEngines(out, templateManifest, engine);
99
+ }
100
+
93
101
  if (engine || pragmas) {
94
102
  const lines = ["# Written by @cavulsqa/create. See .env.example for what these mean.", ""];
95
103
  if (engine) lines.push(`VITE_STORAGE_ENGINE=${engine}`);
@@ -1,19 +1,28 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
+ import { publishedVersions, workspaceDependencies } from "./publishedVersions.mjs";
3
4
 
4
5
  /**
5
- * A content hash of every tracked file under `templates/`, used to prove that `@cavulsqa/create`
6
- * was republished after the templates changed.
6
+ * A content hash of everything a published creator would bundle, used to prove that
7
+ * `@cavulsqa/create` was republished after any of it changed.
7
8
  *
8
9
  * The creator bundles the templates at pack time, so a template fix reaches nobody until a new
9
10
  * version of the creator goes out. Nothing about that is visible in a diff, which is exactly the
10
11
  * kind of coupling that gets forgotten - so it is checked rather than remembered.
11
12
  *
12
- * It asks git rather than walking the directory. Walking gave two different answers on a Windows
13
- * working copy and on a CI checkout, because the disk holds whatever each machine happens to have:
14
- * ignored files, build leftovers, and CRLF where the runner has LF. `git ls-files -s` reports the
15
- * blob hash recorded in the index, which is normalised, platform-independent, and - the point -
16
- * describes exactly what a `git clone` of this repository would produce.
13
+ * Two inputs, because the bundle has two:
14
+ *
15
+ * - The tracked files under `templates/`. It asks git rather than walking the directory. Walking
16
+ * gave two different answers on a Windows working copy and on a CI checkout, because the disk
17
+ * holds whatever each machine happens to have: ignored files, build leftovers, and CRLF where
18
+ * the runner has LF. `git ls-files -s` reports the blob hash recorded in the index, which is
19
+ * normalised, platform-independent, and - the point - describes exactly what a `git clone` of
20
+ * this repository would produce.
21
+ * - The sibling versions the bundler pins those templates to. A template declares
22
+ * `workspace:*` and the bundler rewrites it to `^<current version>`, so bumping a library
23
+ * changes what the creator scaffolds while leaving every tracked file byte-identical. Hashing
24
+ * the files alone said "unchanged" and let the creator keep shipping ranges that no longer
25
+ * admitted the packages it was meant to install.
17
26
  */
18
27
  export function fingerprintTemplates(repoRoot) {
19
28
  const listing = execFileSync("git", ["ls-files", "-s", "--", "templates"], {
@@ -25,5 +34,10 @@ export function fingerprintTemplates(repoRoot) {
25
34
  const lines = listing.split("\n").filter(Boolean).sort();
26
35
  if (!lines.length) throw new Error("git reports no tracked files under templates/");
27
36
 
28
- return `sha256-${createHash("sha256").update(lines.join("\n")).digest("base64")}`;
37
+ const versions = publishedVersions(repoRoot);
38
+ const pins = workspaceDependencies(repoRoot).map((name) => `${name}@${versions.get(name)}`);
39
+
40
+ return `sha256-${createHash("sha256")
41
+ .update([...lines, ...pins].join("\n"))
42
+ .digest("base64")}`;
29
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavulsqa/create",
3
- "version": "2.4.0",
3
+ "version": "2.7.0",
4
4
  "description": "Create a Vue + Framework7 + Capacitor + SQLite app from the cavulsqa templates.",
5
5
  "keywords": [
6
6
  "capacitor",
@@ -52,7 +52,7 @@
52
52
  }
53
53
  ]
54
54
  },
55
- "templatesFingerprint": "sha256-tG0kS6m1X1FkrDBDpzezvdSxTxaLazPunAi3a0xab7o=",
55
+ "templatesFingerprint": "sha256-FbgQOdRBk/8d4JoHOGyxK9/r8aLGGRY9ipzvKvfADNk=",
56
56
  "scripts": {
57
57
  "build": "node scripts/bundleTemplates.mjs",
58
58
  "check": "vp check",
@@ -6,15 +6,11 @@
6
6
  # The engine tried first. The rest of the chain in src/app/storage.config.ts still follows it as
7
7
  # fallback, so this reorders rather than restricts.
8
8
  #
9
- # sqlite-wasm-opfs-sahpool official @sqlite.org/sqlite-wasm, OPFS pool. Faster at writes and
10
- # transactions, seeds ~1.8x quicker, and it is the SQLite team's own
11
- # build. The default for those reasons.
12
- # wa-sqlite-access-handle-pool wa-sqlite, OPFS pool. Faster at joins and scans on measured
13
- # hardware; a single-maintainer project.
9
+ # sqlite-wasm-opfs-sahpool official @sqlite.org/sqlite-wasm on an OPFS pool. Fastest measured, the SQLite team's own build, and the default.
10
+ # wa-sqlite-access-handle-pool wa-sqlite on an OPFS pool. Faster at joins and scans on measured hardware; a single-maintainer project.
14
11
  # wa-sqlite-opfs-async wa-sqlite over OPFS without the pool, on the Asyncify build.
15
- # wa-sqlite-idb-batch-atomic SQLite pages in IndexedDB. The slowest, and the only one that works
16
- # without synchronous access handles - the option for a WebView
17
- # between Chromium 86 and 108.
12
+ # wa-sqlite-idb-batch-atomic SQLite pages in IndexedDB. The slowest, and the only durable route on a WebView below Chromium 109.
13
+ # capacitor-sqlite the native plugin. Slower on every measured axis and it ships in the APK, but a read is not blocked by an in-flight write.
18
14
  VITE_STORAGE_ENGINE=sqlite-wasm-opfs-sahpool
19
15
 
20
16
  # PRAGMA profile. `safe` keeps SQLite's durability guarantee; `fast` trades it for speed by handing
@@ -10,15 +10,15 @@ SQLite — it does not become the thing screens read.
10
10
  ## Stack
11
11
 
12
12
  Vue 3.5 `<script setup lang="ts">` · Framework7 9 (+ framework7-vue 8) · Capacitor 8 (Android
13
- first) · Kysely over `@capacitor-community/sqlite` · vue-i18n · Tailwind 4 · Vite+ (`vp`).
13
+ first) · Kysely over SQLite in an OPFS worker · vue-i18n · Tailwind 4 · Vite+ (`vp`).
14
14
 
15
15
  Data layer comes from three published packages, not from this repo:
16
16
 
17
- | Package | What it gives you |
18
- | ------------------------ | ------------------------------------------------------------------ |
19
- | `@cavulsqa/mobile-db` | The Capacitor SQLite dialect, migrations, transaction-aware writes |
20
- | `@cavulsqa/reactive-db` | Change bus, result cache, visibility gate, query metrics |
21
- | `@cavulsqa/reactive-vue` | `useReactiveQuery` and the Framework7 page-visibility adapter |
17
+ | Package | What it gives you |
18
+ | ------------------------ | ------------------------------------------------------------- |
19
+ | `@cavulsqa/mobile-db` | The OPFS worker dialect, migrations, transaction-aware writes |
20
+ | `@cavulsqa/reactive-db` | Change bus, result cache, visibility gate, query metrics |
21
+ | `@cavulsqa/reactive-vue` | `useReactiveQuery` and the Framework7 page-visibility adapter |
22
22
 
23
23
  Do not vendor or fork them. If one is wrong, fix it there and bump the version.
24
24
 
@@ -12,6 +12,7 @@
12
12
  "check": "vp check"
13
13
  },
14
14
  "dependencies": {
15
+ "@capacitor-community/sqlite": "^8.1.1",
15
16
  "@capacitor/android": "^8.5.0",
16
17
  "@capacitor/app": "^8.0.0",
17
18
  "@capacitor/core": "^8.0.0",
@@ -19,9 +20,9 @@
19
20
  "@capacitor/preferences": "^8.0.0",
20
21
  "@capacitor/splash-screen": "^8.0.0",
21
22
  "@capacitor/status-bar": "^8.0.0",
22
- "@cavulsqa/mobile-db": "^0.6.0",
23
- "@cavulsqa/reactive-db": "^0.3.0",
24
- "@cavulsqa/reactive-vue": "^0.3.0",
23
+ "@cavulsqa/mobile-db": "^1.1.0",
24
+ "@cavulsqa/reactive-db": "^1.1.0",
25
+ "@cavulsqa/reactive-vue": "^1.1.0",
25
26
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
26
27
  "@tailwindcss/vite": "^4.3.3",
27
28
  "@vueuse/core": "^14.4.0",
@@ -53,7 +54,50 @@
53
54
  "sqlite-wasm-opfs-sahpool",
54
55
  "wa-sqlite-access-handle-pool",
55
56
  "wa-sqlite-opfs-async",
56
- "wa-sqlite-idb-batch-atomic"
57
- ]
57
+ "wa-sqlite-idb-batch-atomic",
58
+ "capacitor-sqlite"
59
+ ],
60
+ "engineModules": {
61
+ "opfsSahPool": {
62
+ "file": "src/shared/database/candidates/opfsSahPool.ts",
63
+ "engines": [
64
+ "sqlite-wasm-opfs-sahpool"
65
+ ],
66
+ "exports": [
67
+ "opfsSahPool"
68
+ ],
69
+ "dependencies": [
70
+ "@sqlite.org/sqlite-wasm"
71
+ ]
72
+ },
73
+ "waSqlite": {
74
+ "file": "src/shared/database/candidates/waSqlite.ts",
75
+ "engines": [
76
+ "wa-sqlite-access-handle-pool",
77
+ "wa-sqlite-opfs-async",
78
+ "wa-sqlite-idb-batch-atomic"
79
+ ],
80
+ "exports": [
81
+ "waAccessHandlePool",
82
+ "waOriginPrivateFileSystem",
83
+ "waIdbBatchAtomic"
84
+ ],
85
+ "dependencies": [
86
+ "wa-sqlite"
87
+ ]
88
+ },
89
+ "capacitorSqlite": {
90
+ "file": "src/shared/database/candidates/capacitorSqlite.ts",
91
+ "engines": [
92
+ "capacitor-sqlite"
93
+ ],
94
+ "exports": [
95
+ "capacitorSqlite"
96
+ ],
97
+ "dependencies": [
98
+ "@capacitor-community/sqlite"
99
+ ]
100
+ }
101
+ }
58
102
  }
59
103
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ capacitorSqlite,
2
3
  isStorageId,
3
4
  opfsSahPool,
4
5
  waAccessHandlePool,
@@ -10,11 +11,12 @@ import {
10
11
 
11
12
  /**
12
13
  * The knob. Walked in order, first candidate that opens wins, and the rest are never imported - so a
13
- * chain that stops at the first entry never downloads wa-sqlite's wasm.
14
+ * chain that stops at the first entry never downloads the others' wasm.
14
15
  *
15
- * Reorder freely to trade speed for reach: `waIdbBatchAtomic` first is the compatibility choice,
16
- * since IndexedDB is the only durable route on a WebView between Chromium 86 and 108. Only
17
- * `sqlite-wasm-opfs-sahpool` has been measured on a phone; the rest carry `evidence: "expected"`.
16
+ * Reorder freely to trade speed for reach: the entries lower down reach older WebViews, and each
17
+ * one states its own `tradeoff` and whether its `evidence` came from a device or a vendor's README.
18
+ * Read the list rather than this comment - `@cavulsqa/create` removes the engines an app did not
19
+ * ask for, so what is below is what this app actually has.
18
20
  *
19
21
  * No in-memory entry on purpose: a chain that ends somewhere data is not kept is worse than one that
20
22
  * fails and names every attempt.
@@ -24,6 +26,7 @@ export const DEFAULT_ORDER: StorageCandidate[] = [
24
26
  waAccessHandlePool,
25
27
  waOriginPrivateFileSystem,
26
28
  waIdbBatchAtomic,
29
+ capacitorSqlite,
27
30
  ];
28
31
 
29
32
  /**
@@ -0,0 +1,47 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+ import type { StorageCandidate, StorageProbe } from "./types";
3
+
4
+ /**
5
+ * `@capacitor-community/sqlite`: a real file behind a native bridge.
6
+ *
7
+ * Slower than the OPFS engine on every axis measured on a device - batched writes about 2.4x, the
8
+ * app's own screen queries about 1.3x - and it needs the plugin in the APK. It is here for the one
9
+ * thing the worker cannot do: the worker is serial, so a read waits behind an in-flight write,
10
+ * where this dialect keeps reads outside the write lock. Choose it for an app that writes
11
+ * continuously while the UI reads, or that needs SQLCipher or native access to the file.
12
+ */
13
+ function probeNativePlatform(): StorageProbe {
14
+ if (!Capacitor.isNativePlatform()) {
15
+ return {
16
+ supported: false,
17
+ reason: "the SQLite plugin is native-only; on web it needs jeep-sqlite and a separate store",
18
+ };
19
+ }
20
+ return { supported: true };
21
+ }
22
+
23
+ export const capacitorSqlite: StorageCandidate = {
24
+ id: "capacitor-sqlite",
25
+ label: "Capacitor SQLite · native plugin",
26
+ tradeoff:
27
+ "Needs the native plugin in the APK, and every statement crosses the bridge. Slower than the " +
28
+ "worker engines, but reads are not blocked by an in-flight write.",
29
+ durable: true,
30
+ evidence: "measured",
31
+ probe: probeNativePlatform,
32
+ createDialect: async () => {
33
+ const [{ CapacitorSQLite, SQLiteConnection }, { SharedConnectionSQLiteDialect }] =
34
+ await Promise.all([
35
+ import("@capacitor-community/sqlite"),
36
+ import("@cavulsqa/mobile-db/capacitor"),
37
+ ]);
38
+
39
+ const sqlite = new SQLiteConnection(CapacitorSQLite);
40
+ const name = "app.sqlite3";
41
+ await sqlite.createConnection(name, false, "no-encryption", 1, false);
42
+ const database = await sqlite.retrieveConnection(name, false);
43
+ await database.open();
44
+
45
+ return new SharedConnectionSQLiteDialect({ database, sqlite, name, serializeAccess: true });
46
+ },
47
+ };
@@ -1,3 +1,4 @@
1
1
  export * from "./types";
2
2
  export { opfsSahPool } from "./opfsSahPool";
3
3
  export { waAccessHandlePool, waIdbBatchAtomic, waOriginPrivateFileSystem } from "./waSqlite";
4
+ export { capacitorSqlite } from "./capacitorSqlite";
@@ -12,6 +12,7 @@ export const STORAGE_IDS = [
12
12
  "wa-sqlite-access-handle-pool",
13
13
  "wa-sqlite-opfs-async",
14
14
  "wa-sqlite-idb-batch-atomic",
15
+ "capacitor-sqlite",
15
16
  ] as const;
16
17
 
17
18
  export type StorageId = (typeof STORAGE_IDS)[number];