@cavulsqa/create 2.9.3 → 2.10.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 (199) hide show
  1. package/README.md +12 -1
  2. package/bin/create.mjs +18 -25
  3. package/lib/templates.mjs +56 -0
  4. package/package.json +13 -5
  5. package/templates/f7-app/auto-imports.d.ts +9 -0
  6. package/templates/f7-app/package.json +6 -3
  7. package/templates/f7-app/src/env.d.ts +1 -0
  8. package/templates/f7-app/src/main.ts +3 -0
  9. package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
  10. package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
  11. package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
  12. package/templates/f7-app/vite.config.ts +1 -0
  13. package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
  14. package/templates/m3e-app/.claude/rules/database.md +106 -0
  15. package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
  16. package/templates/m3e-app/.claude/rules/modules.md +43 -0
  17. package/templates/m3e-app/.claude/rules/native.md +60 -0
  18. package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
  19. package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
  20. package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
  21. package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
  22. package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
  23. package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
  24. package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
  25. package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
  26. package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
  27. package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
  28. package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
  29. package/templates/m3e-app/.env.example +32 -0
  30. package/templates/m3e-app/CLAUDE.md +93 -0
  31. package/templates/m3e-app/auto-imports.d.ts +851 -0
  32. package/templates/m3e-app/capacitor.config.ts +44 -0
  33. package/templates/m3e-app/components.d.ts +252 -0
  34. package/templates/m3e-app/index.html +16 -0
  35. package/templates/m3e-app/package.json +107 -0
  36. package/templates/m3e-app/src/App.vue +129 -0
  37. package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
  38. package/templates/m3e-app/src/app/scroll.config.ts +20 -0
  39. package/templates/m3e-app/src/app/storage.config.ts +65 -0
  40. package/templates/m3e-app/src/app/tabs.ts +33 -0
  41. package/templates/m3e-app/src/app/theme.config.ts +51 -0
  42. package/templates/m3e-app/src/assets/css/app.css +18 -0
  43. package/templates/m3e-app/src/assets/css/base.css +53 -0
  44. package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
  45. package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
  46. package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
  47. package/templates/m3e-app/src/assets/css/m3e.css +6 -0
  48. package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
  49. package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
  50. package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
  51. package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
  52. package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
  53. package/templates/m3e-app/src/env.d.ts +40 -0
  54. package/templates/m3e-app/src/locales/ar.json +1260 -0
  55. package/templates/m3e-app/src/locales/en.json +1260 -0
  56. package/templates/m3e-app/src/locales/fr.json +1260 -0
  57. package/templates/m3e-app/src/main.ts +41 -0
  58. package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
  59. package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
  60. package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
  61. package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
  62. package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
  63. package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
  64. package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
  65. package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
  66. package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
  67. package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
  68. package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
  69. package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
  70. package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
  71. package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
  72. package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
  73. package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
  74. package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
  75. package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
  76. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
  77. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
  78. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
  79. package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
  80. package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
  81. package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
  82. package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
  83. package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
  84. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
  85. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
  86. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
  87. package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
  88. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
  89. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
  90. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
  91. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
  92. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
  93. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
  94. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
  95. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
  96. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
  97. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
  98. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
  99. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
  100. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
  101. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
  102. package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
  103. package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
  104. package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
  105. package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
  106. package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
  107. package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
  108. package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
  109. package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
  110. package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
  111. package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
  112. package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
  113. package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
  114. package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
  115. package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
  116. package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
  117. package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
  118. package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
  119. package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
  120. package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
  121. package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
  122. package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
  123. package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
  124. package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
  125. package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
  126. package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
  127. package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
  128. package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
  129. package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
  130. package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
  131. package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
  132. package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
  133. package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
  134. package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
  135. package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
  136. package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
  137. package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
  138. package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
  139. package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
  140. package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
  141. package/templates/m3e-app/src/modules/settings/types.ts +7 -0
  142. package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
  143. package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
  144. package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
  145. package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
  146. package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
  147. package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
  148. package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
  149. package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
  150. package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
  151. package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
  152. package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
  153. package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
  154. package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
  155. package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
  156. package/templates/m3e-app/src/router/index.ts +20 -0
  157. package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
  158. package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
  159. package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
  160. package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
  161. package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
  162. package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
  163. package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
  164. package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
  165. package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
  166. package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
  167. package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
  168. package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
  169. package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
  170. package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
  171. package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
  172. package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
  173. package/templates/m3e-app/src/shared/database/database.ts +219 -0
  174. package/templates/m3e-app/src/shared/database/index.ts +3 -0
  175. package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
  176. package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
  177. package/templates/m3e-app/src/shared/database/queries.ts +26 -0
  178. package/templates/m3e-app/src/shared/database/schema.ts +153 -0
  179. package/templates/m3e-app/src/shared/database/storage.ts +64 -0
  180. package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
  181. package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
  182. package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
  183. package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
  184. package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
  185. package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
  186. package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
  187. package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
  188. package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
  189. package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
  190. package/templates/m3e-app/tests/locales.test.ts +73 -0
  191. package/templates/m3e-app/tests/migrations.test.ts +74 -0
  192. package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
  193. package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
  194. package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
  195. package/templates/m3e-app/tests/storage.test.ts +117 -0
  196. package/templates/m3e-app/tests/textDirection.test.ts +11 -0
  197. package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
  198. package/templates/m3e-app/tsconfig.json +21 -0
  199. package/templates/m3e-app/vite.config.ts +144 -0
@@ -0,0 +1,56 @@
1
+ import type { Dialect } from "kysely";
2
+
3
+ /**
4
+ * Every engine this template offers, as values rather than only as a type.
5
+ *
6
+ * A types-only union cannot be read at runtime, and everything that needs the list had to invent
7
+ * its own copy: `@cavulsqa/create` was regexing this very file to populate `--engine`, and
8
+ * `localStorage`'s override was cast rather than checked.
9
+ */
10
+ export const STORAGE_IDS = [
11
+ "sqlite-wasm-opfs-sahpool",
12
+ "wa-sqlite-access-handle-pool",
13
+ "wa-sqlite-opfs-async",
14
+ "wa-sqlite-idb-batch-atomic",
15
+ "capacitor-sqlite",
16
+ ] as const;
17
+
18
+ export type StorageId = (typeof STORAGE_IDS)[number];
19
+
20
+ export function isStorageId(value: string): value is StorageId {
21
+ return (STORAGE_IDS as readonly string[]).includes(value);
22
+ }
23
+
24
+ /**
25
+ * A union rather than an optional field, so an unsupported probe cannot forget its reason and a
26
+ * supported one cannot carry a stale one.
27
+ */
28
+ export type StorageProbe = { supported: true } | { supported: false; reason: string };
29
+
30
+ /**
31
+ * One way to persist SQLite, described well enough to choose between them without reading the code.
32
+ *
33
+ * A candidate is not a config flag: it owns its own capability check and its own lazy import, so a
34
+ * chain that never reaches the fourth entry never downloads the fourth entry's wasm.
35
+ */
36
+ export interface StorageCandidate {
37
+ id: StorageId;
38
+ label: string;
39
+ /** What it costs. Every entry has one, and an entry claiming none is a lie. */
40
+ tradeoff: string;
41
+ durable: boolean;
42
+ /**
43
+ * Whether the numbers behind its position in the chain came from a device or from a vendor's
44
+ * README. Only one entry is currently `measured`, and the ordering says so rather than implying
45
+ * more confidence than we have.
46
+ */
47
+ evidence: "measured" | "expected";
48
+ probe: () => StorageProbe;
49
+ createDialect: () => Promise<Dialect>;
50
+ }
51
+
52
+ export interface StorageAttempt {
53
+ id: StorageId;
54
+ outcome: "opened" | "unsupported" | "failed";
55
+ detail?: string;
56
+ }
@@ -0,0 +1,56 @@
1
+ import { createWaDialect, 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(createWaDialect({ worker: new WaWorker(), name: "app-wa.sqlite3", kind })),
28
+ };
29
+ }
30
+
31
+ export const waAccessHandlePool = waCandidate("access-handle-pool", {
32
+ id: "wa-sqlite-access-handle-pool",
33
+ label: "wa-sqlite · OPFS access handle pool",
34
+ tradeoff:
35
+ "The same exclusive directory lock as the official pool, and the same serial worker. Here to " +
36
+ "measure one vendor's pool against the other's on identical storage.",
37
+ probe: probeOpfsCapable,
38
+ });
39
+
40
+ export const waOriginPrivateFileSystem = waCandidate("origin-private-file-system", {
41
+ id: "wa-sqlite-opfs-async",
42
+ label: "wa-sqlite · OPFS, no pool",
43
+ tradeoff:
44
+ "Runs on the Asyncify build, which wraps every call SQLite believes is synchronous - expected " +
45
+ "to cost raw speed and to handle concurrent access more gracefully than a pool that locks.",
46
+ probe: probeOpfsCapable,
47
+ });
48
+
49
+ export const waIdbBatchAtomic = waCandidate("idb-batch-atomic", {
50
+ id: "wa-sqlite-idb-batch-atomic",
51
+ label: "wa-sqlite · IndexedDB",
52
+ tradeoff:
53
+ "The slowest durable route: pages go through IndexedDB transactions rather than to a file. The " +
54
+ "only one that works without synchronous access handles at all.",
55
+ probe: probeIndexedDb,
56
+ });
@@ -0,0 +1,219 @@
1
+ import { Kysely, sql, type Dialect } from "kysely";
2
+ import { Migrator } from "kysely/migration";
3
+ import { runWrite, type MobileDatabase } from "@cavulsqa/mobile-db";
4
+ import { createChangeBus, createReactiveDb } from "@cavulsqa/reactive-db";
5
+ import { pragmaProfile, pragmasFor } from "@/app/pragmas.config";
6
+ import { preferredEngine, storageChain } from "@/app/storage.config";
7
+ import { isStorageId, type StorageAttempt, type StorageCandidate } from "./candidates";
8
+ import { migrations } from "./migrations";
9
+ import { describeOpenFailure } from "./storage";
10
+ import type { Database } from "./schema";
11
+
12
+ /** Writes and reactive queries must share one instance, so it is created here and passed outward. */
13
+ export const changeBus = createChangeBus<Database>();
14
+
15
+ const RETRY_DELAY_MS = 400;
16
+
17
+ let database: MobileDatabase<Database> | null = null;
18
+ let opening: Promise<MobileDatabase<Database>> | null = null;
19
+ let chosen: StorageCandidate | null = null;
20
+ let attempts: StorageAttempt[] = [];
21
+ let applied: readonly string[] = [];
22
+
23
+ interface OpenedDatabase {
24
+ handle: MobileDatabase<Database>;
25
+ pragmas: readonly string[];
26
+ }
27
+
28
+ /**
29
+ * Wraps a Kysely instance in the shape the app consumes, and runs the migrations. Shared by every
30
+ * candidate: they differ in how bytes reach storage and in nothing above it, which is what makes the
31
+ * chain swappable at all.
32
+ */
33
+ async function fromDialect(candidate: StorageCandidate, dialect: Dialect): Promise<OpenedDatabase> {
34
+ const db = new Kysely<Database>({ dialect });
35
+
36
+ // Before the migrations, and identically for every engine - see pragmas.config.ts for why.
37
+ const pragmas: string[] = [];
38
+ for (const pragma of pragmasFor(pragmaProfile)) {
39
+ try {
40
+ await sql.raw(pragma).execute(db);
41
+ pragmas.push(pragma);
42
+ } catch (error) {
43
+ // A VFS that refuses a journal mode is worth knowing about, not worth failing over.
44
+ pragmas.push(`${pragma} -> rejected: ${error instanceof Error ? error.message : "unknown"}`);
45
+ }
46
+ }
47
+
48
+ // `migrateToLatest` reports failure in its return value rather than throwing, so an unchecked
49
+ // call boots an app whose tables were never created - it surfaced as "no such table" much later.
50
+ const migration = await new Migrator({
51
+ db,
52
+ provider: { getMigrations: () => Promise.resolve(migrations) },
53
+ }).migrateToLatest();
54
+
55
+ if (migration.error) {
56
+ const failed = migration.results?.find((result) => result.status === "Error")?.migrationName;
57
+ // kysely types the error as unknown, and a non-Error would stringify to [object Object].
58
+ const detail =
59
+ migration.error instanceof Error ? migration.error.message : JSON.stringify(migration.error);
60
+ throw new Error(`migration failed${failed ? ` at ${failed}` : ""}: ${detail}`);
61
+ }
62
+
63
+ return {
64
+ pragmas,
65
+ handle: {
66
+ db,
67
+ write: (ctx, work) =>
68
+ runWrite(ctx, () => db.transaction().execute((trx) => work(trx)), {
69
+ runInTransaction: <R>(task: () => Promise<R>) => task(),
70
+ emitTableChange: (table) => changeBus.emit(table, "bulk"),
71
+ }),
72
+ close: () => db.destroy(),
73
+ },
74
+ };
75
+ }
76
+
77
+ /** Which candidate the open database is using, for anything that reports or measures. */
78
+ export function activeStorage(): StorageCandidate {
79
+ if (!chosen) throw new Error("openDatabase() must be awaited before the storage is known");
80
+ return chosen;
81
+ }
82
+
83
+ export function activeStorageLabel(): string {
84
+ return activeStorage().label;
85
+ }
86
+
87
+ /** The PRAGMAs that actually took, so a measurement can state its own configuration. */
88
+ export function activePragmas(): readonly string[] {
89
+ return applied;
90
+ }
91
+
92
+ /** Every step of the walk, including the candidates that were skipped and why. */
93
+ export function storageAttempts(): readonly StorageAttempt[] {
94
+ return attempts;
95
+ }
96
+
97
+ // Every step is recorded, because a silent fallback to a slower engine gets discovered weeks later
98
+ // by someone wondering why the app is slow.
99
+ const FORCE_KEY = "app.storage.force";
100
+
101
+ /**
102
+ * Pins the chain to one candidate for benchmarking: the *only* one tried, not merely promoted, since
103
+ * a benchmark that fell through to another engine would report the wrong engine's numbers.
104
+ */
105
+ function forcedChain(): StorageCandidate[] {
106
+ let forced: string | null = null;
107
+ try {
108
+ forced = localStorage.getItem(FORCE_KEY);
109
+ } catch {
110
+ // Storage disabled; the full chain is the right answer anyway.
111
+ }
112
+ if (!forced || !isStorageId(forced)) return storageChain;
113
+
114
+ const pinned = storageChain.find((candidate) => candidate.id === forced);
115
+ return pinned ? [pinned] : storageChain;
116
+ }
117
+
118
+ /**
119
+ * Memoised on the promise, not the result. Two callers in the same tick both passed a `if (database)`
120
+ * guard and both walked the chain - and since the pool VFSes hold their OPFS directory exclusively,
121
+ * the second collided with the first and fell through to a slower engine the app then reported as
122
+ * its choice.
123
+ */
124
+ export function openDatabase(): Promise<MobileDatabase<Database>> {
125
+ if (database) return Promise.resolve(database);
126
+ opening ??= walkStorageChain();
127
+ return opening;
128
+ }
129
+
130
+ async function walkStorageChain(): Promise<MobileDatabase<Database>> {
131
+ try {
132
+ return await tryEveryCandidate();
133
+ } finally {
134
+ opening = null;
135
+ }
136
+ }
137
+
138
+ async function tryEveryCandidate(): Promise<MobileDatabase<Database>> {
139
+ if (!storageChain.length) throw new Error("storageChain is empty: nothing can open the database");
140
+
141
+ attempts = [];
142
+
143
+ const chain = forcedChain();
144
+
145
+ for (const candidate of chain) {
146
+ const probe = candidate.probe();
147
+ if (!probe.supported) {
148
+ attempts.push({ id: candidate.id, outcome: "unsupported", detail: probe.reason });
149
+ continue;
150
+ }
151
+
152
+ try {
153
+ const opened = await openCandidate(candidate);
154
+ database = opened.handle;
155
+ applied = opened.pragmas;
156
+ chosen = candidate;
157
+ attempts.push({ id: candidate.id, outcome: "opened" });
158
+ // Every launch names its engine. A preference that never arrived - a `.env` the build could
159
+ // not parse, a typo - otherwise looks exactly like one that was applied, and the app reports
160
+ // an engine nobody chose.
161
+ console.info(
162
+ `[storage] opened on ${candidate.id}` +
163
+ (preferredEngine ? ` (VITE_STORAGE_ENGINE=${preferredEngine})` : " (no preference set)"),
164
+ attempts,
165
+ );
166
+ return database;
167
+ } catch (error) {
168
+ attempts.push({ id: candidate.id, outcome: "failed", detail: describeOpenFailure(error) });
169
+ }
170
+ }
171
+
172
+ // Every candidate's reason, because "the database would not open" on its own helps nobody.
173
+ const summary = attempts
174
+ .map(
175
+ (attempt) =>
176
+ `${attempt.id}: ${attempt.outcome}${attempt.detail ? ` - ${attempt.detail}` : ""}`,
177
+ )
178
+ .join("; ");
179
+ throw new Error(`No storage engine could open the database. ${summary}`);
180
+ }
181
+
182
+ /**
183
+ * One retry, because the usual reason a pool VFS's directory lock is held is a process on its way out
184
+ * - its handles are released when that process dies, so the same open succeeds a moment later. Past
185
+ * one, the chain moving on is the better answer.
186
+ */
187
+ async function openCandidate(candidate: StorageCandidate): Promise<OpenedDatabase> {
188
+ try {
189
+ return await fromDialect(candidate, await candidate.createDialect());
190
+ } catch (first) {
191
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
192
+ try {
193
+ return await fromDialect(candidate, await candidate.createDialect());
194
+ } catch {
195
+ // The first error is the honest one; the retry's is a duplicate of it.
196
+ throw first;
197
+ }
198
+ }
199
+ }
200
+
201
+ export function getDatabase(): MobileDatabase<Database> {
202
+ if (!database) throw new Error("openDatabase() must be awaited before the database is used");
203
+ return database;
204
+ }
205
+
206
+ /** Writes go through this so every mutation announces its tables without the caller remembering. */
207
+ export const rdb = createReactiveDb<Database>({
208
+ getDb: () => getDatabase().db,
209
+ emitChange: changeBus.emit,
210
+ });
211
+
212
+ export async function closeDatabase(): Promise<void> {
213
+ await database?.close();
214
+ database = null;
215
+ opening = null;
216
+ chosen = null;
217
+ attempts = [];
218
+ applied = [];
219
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./database";
2
+ export * from "./queries";
3
+ export type { Database } from "./schema";
@@ -0,0 +1,223 @@
1
+ import { createTableWithDefaults, type MigrationSet } from "@cavulsqa/mobile-db";
2
+ import { sql } from "kysely";
3
+
4
+ /**
5
+ * Keys are ordered lexically and recorded once applied, so they are numbered and never renamed:
6
+ * renaming one makes it run again on a database that already has it.
7
+ *
8
+ * Foreign keys are declared for the shape, and indexed on the columns the screens actually filter
9
+ * and join by - without them every dashboard aggregate is a full scan.
10
+ */
11
+ export const migrations: MigrationSet = {
12
+ "001_sales": {
13
+ up: async (db) => {
14
+ await createTableWithDefaults(db, "customer")
15
+ .addColumn("name", "text", (col) => col.notNull())
16
+ .addColumn("city", "text", (col) => col.notNull().defaultTo(""))
17
+ .execute();
18
+
19
+ await createTableWithDefaults(db, "product")
20
+ .addColumn("name", "text", (col) => col.notNull())
21
+ .addColumn("price_cents", "integer", (col) => col.notNull().defaultTo(0))
22
+ .execute();
23
+
24
+ await createTableWithDefaults(db, "sales_order")
25
+ .addColumn("customer_id", "integer", (col) =>
26
+ col.notNull().references("customer.id").onDelete("cascade"),
27
+ )
28
+ .addColumn("reference", "text", (col) => col.notNull())
29
+ .addColumn("status", "text", (col) => col.notNull().defaultTo("draft"))
30
+ .execute();
31
+
32
+ await db.schema
33
+ .createTable("order_line")
34
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
35
+ .addColumn("order_id", "integer", (col) =>
36
+ col.notNull().references("sales_order.id").onDelete("cascade"),
37
+ )
38
+ .addColumn("product_id", "integer", (col) => col.notNull().references("product.id"))
39
+ .addColumn("quantity", "integer", (col) => col.notNull().defaultTo(1))
40
+ .addColumn("unit_price_cents", "integer", (col) => col.notNull().defaultTo(0))
41
+ .execute();
42
+
43
+ await db.schema
44
+ .createTable("tag")
45
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
46
+ .addColumn("label", "text", (col) => col.notNull().unique())
47
+ .execute();
48
+
49
+ await db.schema
50
+ .createTable("customer_tag")
51
+ .addColumn("customer_id", "integer", (col) =>
52
+ col.notNull().references("customer.id").onDelete("cascade"),
53
+ )
54
+ .addColumn("tag_id", "integer", (col) =>
55
+ col.notNull().references("tag.id").onDelete("cascade"),
56
+ )
57
+ .addPrimaryKeyConstraint("customer_tag_pk", ["customer_id", "tag_id"])
58
+ .execute();
59
+
60
+ await db.schema
61
+ .createIndex("idx_sales_order_customer")
62
+ .on("sales_order")
63
+ .column("customer_id")
64
+ .execute();
65
+ await db.schema
66
+ .createIndex("idx_order_line_order")
67
+ .on("order_line")
68
+ .column("order_id")
69
+ .execute();
70
+ await db.schema
71
+ .createIndex("idx_order_line_product")
72
+ .on("order_line")
73
+ .column("product_id")
74
+ .execute();
75
+ await db.schema.createIndex("idx_customer_city").on("customer").column("city").execute();
76
+
77
+ // SQLite ignores foreign keys unless asked, and the plugin opens with them off.
78
+ await sql`PRAGMA foreign_keys = ON`.execute(db);
79
+ },
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
+ },
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();
@@ -0,0 +1,26 @@
1
+ import { createReactiveQuery, createVueQueryMetrics } from "@cavulsqa/reactive-vue";
2
+ import { usePageVisibility } from "@cavulsqa/reactive-vue/framework7";
3
+ import { changeBus } from "./database";
4
+ import type { Database } from "./schema";
5
+
6
+ const metrics = createVueQueryMetrics({
7
+ exposeOnWindowAs: import.meta.env.DEV ? "__appMetrics" : undefined,
8
+ });
9
+
10
+ /**
11
+ * The composables every screen uses. They are built once, here, because the change bus and the
12
+ * metrics recorder are app-wide singletons - a query created with a different bus would never hear
13
+ * about a write.
14
+ *
15
+ * Typed on `Database`, so every `tables` entry is checked against the schema. A misspelt table name
16
+ * is otherwise invisible: the query subscribes to a table nobody writes to and never refetches.
17
+ */
18
+ export const { useReactiveQuery, useStructuralQuery, useStaticQuery } =
19
+ createReactiveQuery<Database>({
20
+ onTableChange: changeBus.on,
21
+ metrics: metrics.recorder,
22
+ // Without this a tab the user cannot see keeps refetching in the background.
23
+ useVisibility: usePageVisibility,
24
+ });
25
+
26
+ export const { useQueryMetrics } = metrics;