@cavulsqa/create 2.9.3 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +108 -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
package/README.md CHANGED
@@ -29,7 +29,7 @@ pnpm create @cavulsqa --name caputa --app-id com.example.caputa --yes
29
29
  | flag | default | |
30
30
  | ------------ | ---------------- | ------------------------------------------------------- |
31
31
  | `--name` | asked | package and directory name |
32
- | `--template` | the only one | which bundled template |
32
+ | `--template` | `f7-app` | `f7-app` or `m3e-app`; asked from a numbered menu |
33
33
  | `--dir` | `./<name>` | where to write it |
34
34
  | `--app-name` | `Name` | launcher name, window title, Settings screen |
35
35
  | `--app-id` | `com.ayb.<name>` | Android application id |
@@ -40,6 +40,13 @@ pnpm create @cavulsqa --name caputa --app-id com.example.caputa --yes
40
40
 
41
41
  ## What you get
42
42
 
43
+ Two templates, one data layer:
44
+
45
+ | Template | Look and feel |
46
+ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
47
+ | `f7-app` | Framework7's own components, native on iOS and on Material (`theme: auto`) |
48
+ | `m3e-app` | Material 3 Expressive: `@cavulsqa/m3e-vue` components on 2025 dynamic colour, Google Sans Flex, spring motion, a colour studio and a component gallery. Framework7 stays underneath as the navigation engine only, so it is Android-first by design |
49
+
43
50
  The `f7-app` template: a tabbed shell, a `domains` / `modules` / `shared` layout, and a working
44
51
  sales demo over a six-table schema — dashboard aggregates, search, an order sheet, swipe actions,
45
52
  a detail screen — with tests against real SQLite.
@@ -49,6 +56,10 @@ under Kysely, `@cavulsqa/reactive-db` for the change bus, and `@cavulsqa/reactiv
49
56
  `useReactiveQuery`. A write announces the tables it touched and every query watching them refetches;
50
57
  nothing in a screen asks for a refresh.
51
58
 
59
+ `m3e-app` has the same layout and data layer, with every screen built from `M3*` components and an
60
+ `m3-expressive` skill that carries the design system: which component, which colour role, which
61
+ spring, and a link to the spec page for each.
62
+
52
63
  `CLAUDE.md`, `.claude/rules/` and `.claude/skills/` come with it, so an agent opening the generated
53
64
  repository knows the architecture, the conventions, and the traps that have already cost someone a
54
65
  day.
package/bin/create.mjs CHANGED
@@ -7,11 +7,17 @@
7
7
  * silent default, because a generated app named "my-app" in the wrong directory is worse than a
8
8
  * failed command.
9
9
  */
10
- import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { readFileSync } from "node:fs";
11
11
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
12
12
  import { createInterface } from "node:readline/promises";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { scaffold } from "../lib/scaffold.mjs";
15
+ import {
16
+ defaultTemplate,
17
+ listTemplates,
18
+ resolveTemplateChoice,
19
+ templateMenu,
20
+ } from "../lib/templates.mjs";
15
21
 
16
22
  const PACKAGE = dirname(dirname(fileURLToPath(import.meta.url)));
17
23
  const BUNDLED = join(PACKAGE, "templates");
@@ -38,7 +44,7 @@ function usage() {
38
44
 
39
45
  Options:
40
46
  --name NAME package and directory name
41
- --template NAME which template (default: the only one, or you are asked)
47
+ --template NAME which template (default: the manifest's default, or you are asked)
42
48
  --dir PATH where to write it (default: ./<name>)
43
49
  --app-name NAME launcher name and window title (default: Name)
44
50
  --app-id ID android application id (default: com.ayb.<name>)
@@ -49,24 +55,7 @@ Options:
49
55
  --help this
50
56
 
51
57
  Templates bundled in this package:
52
- ${listTemplates()
53
- .map((entry) => ` ${entry.name} ${entry.description}`)
54
- .join("\n")}`);
55
- }
56
-
57
- function listTemplates() {
58
- const manifest = JSON.parse(readFileSync(join(PACKAGE, "package.json"), "utf8"));
59
- const declared = manifest.createConfig?.templates ?? [];
60
- if (!existsSync(BUNDLED)) return declared;
61
-
62
- // The manifest is what `vp create` reads; the directory is what actually shipped. Trust the
63
- // directory, and let the manifest supply the descriptions.
64
- return readdirSync(BUNDLED, { withFileTypes: true })
65
- .filter((entry) => entry.isDirectory())
66
- .map((entry) => ({
67
- name: entry.name,
68
- description: declared.find((listed) => listed.name === entry.name)?.description ?? "",
69
- }));
58
+ ${templateMenu(listTemplates(PACKAGE))}`);
70
59
  }
71
60
 
72
61
  /**
@@ -109,15 +98,19 @@ async function main() {
109
98
  throw new Error(`"${String(name)}" is not a usable package name`);
110
99
  }
111
100
 
112
- const templates = listTemplates();
101
+ const templates = listTemplates(PACKAGE);
113
102
  if (!templates.length) throw new Error("this build of @cavulsqa/create bundles no templates");
114
103
 
115
104
  let template = flags.get("template");
116
105
  if (typeof template !== "string") {
117
- template =
118
- templates.length === 1
119
- ? templates[0].name
120
- : await ask(`Template [${templates.map((t) => t.name).join(", ")}]`, templates[0].name);
106
+ const fallback = defaultTemplate(templates);
107
+ if (templates.length === 1 || !rl) {
108
+ template = fallback.name;
109
+ } else {
110
+ console.log(`\nTemplates:\n${templateMenu(templates)}\n`);
111
+ const answer = await ask("Template", String(templates.indexOf(fallback) + 1));
112
+ template = resolveTemplateChoice(answer, templates) ?? answer;
113
+ }
121
114
  }
122
115
  if (!templates.some((entry) => entry.name === template)) {
123
116
  throw new Error(
@@ -0,0 +1,56 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The templates this build offers, in the manifest's order, with the manifest's descriptions.
6
+ *
7
+ * The manifest is what `vp create` reads; the directory is what actually shipped. A template is
8
+ * offered only when both agree, and a shipped directory the manifest forgot still appears - last,
9
+ * without a description - rather than silently vanishing.
10
+ */
11
+ export function listTemplates(packageDir) {
12
+ const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
13
+ const declared = manifest.createConfig?.templates ?? [];
14
+ const bundled = join(packageDir, "templates");
15
+ if (!existsSync(bundled)) return declared.map(toEntry);
16
+
17
+ const shipped = new Set(
18
+ readdirSync(bundled, { withFileTypes: true })
19
+ .filter((entry) => entry.isDirectory())
20
+ .map((entry) => entry.name),
21
+ );
22
+ const listed = declared.filter((entry) => shipped.has(entry.name)).map(toEntry);
23
+ const unlisted = [...shipped]
24
+ .filter((name) => !declared.some((entry) => entry.name === name))
25
+ .sort((a, b) => a.localeCompare(b))
26
+ .map((name) => toEntry({ name }));
27
+ return [...listed, ...unlisted];
28
+ }
29
+
30
+ function toEntry(entry) {
31
+ return {
32
+ name: entry.name,
33
+ description: entry.description ?? "",
34
+ isDefault: entry.default === true,
35
+ };
36
+ }
37
+
38
+ /** The one marked `default` in the manifest, else the first - never whatever readdir returns first. */
39
+ export function defaultTemplate(templates) {
40
+ return templates.find((entry) => entry.isDefault) ?? templates[0];
41
+ }
42
+
43
+ /** A typed answer as a template: its number in the list, or its name. Null when it is neither. */
44
+ export function resolveTemplateChoice(answer, templates) {
45
+ const value = String(answer).trim();
46
+ if (/^\d+$/.test(value)) return templates[Number(value) - 1]?.name ?? null;
47
+ return templates.some((entry) => entry.name === value) ? value : null;
48
+ }
49
+
50
+ /** The numbered menu the interactive prompt prints. */
51
+ export function templateMenu(templates) {
52
+ const width = Math.max(...templates.map((entry) => entry.name.length));
53
+ return templates
54
+ .map((entry, index) => ` ${index + 1}) ${entry.name.padEnd(width)} ${entry.description}`)
55
+ .join("\n");
56
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@cavulsqa/create",
3
- "version": "2.9.3",
4
- "description": "Create a Vue + Framework7 + Capacitor + SQLite app from the cavulsqa templates.",
3
+ "version": "2.10.1",
4
+ "description": "Create a Vue + Capacitor + SQLite app from the cavulsqa templates: Framework7 (iOS and Material) or Material 3 Expressive.",
5
5
  "keywords": [
6
6
  "capacitor",
7
7
  "create",
8
8
  "framework7",
9
+ "material-3",
10
+ "material-expressive",
9
11
  "scaffold",
10
12
  "sqlite",
11
13
  "template",
@@ -47,12 +49,18 @@
47
49
  "templates": [
48
50
  {
49
51
  "name": "f7-app",
50
- "description": "Vue 3 + Framework7 + Capacitor + SQLite, tabbed shell, offline-first data layer",
51
- "template": "./templates/f7-app"
52
+ "description": "Vue 3 + Framework7 + Capacitor + SQLite, tabbed shell, iOS and Material themes, offline-first data layer",
53
+ "template": "./templates/f7-app",
54
+ "default": true
55
+ },
56
+ {
57
+ "name": "m3e-app",
58
+ "description": "Material 3 Expressive: m3e-vue components, 2025 dynamic colour with a colour studio, adaptive bar/rail shell, offline-first data layer",
59
+ "template": "./templates/m3e-app"
52
60
  }
53
61
  ]
54
62
  },
55
- "templatesFingerprint": "sha256-6R7B6B3lY7wTuktecqa5vTipEb1bILSBEAhOvLXmiTM=",
63
+ "templatesFingerprint": "sha256-rGqnILJ0mL3Tjp8wsotbcajPFpJWqAEtS9G80QBcA/Q=",
56
64
  "scripts": {
57
65
  "build": "node scripts/bundleTemplates.mjs",
58
66
  "check": "vp check",
@@ -98,6 +98,8 @@ declare global {
98
98
  const reactiveOmit: typeof import('@vueuse/core').reactiveOmit
99
99
  const reactivePick: typeof import('@vueuse/core').reactivePick
100
100
  const readonly: typeof import('vue').readonly
101
+ const recorderDiagnostics: typeof import('./src/plugins/recorder.plugin').recorderDiagnostics
102
+ const recorderPlugin: typeof import('./src/plugins/recorder.plugin').recorderPlugin
101
103
  const ref: typeof import('vue').ref
102
104
  const refAutoReset: typeof import('@vueuse/core').refAutoReset
103
105
  const refDebounced: typeof import('@vueuse/core').refDebounced
@@ -106,6 +108,7 @@ declare global {
106
108
  const refThrottled: typeof import('@vueuse/core').refThrottled
107
109
  const refWithControl: typeof import('@vueuse/core').refWithControl
108
110
  const renderBootstrapError: typeof import('./src/plugins/bootstrapError').renderBootstrapError
111
+ const reportProblem: typeof import('./src/plugins/recorder.plugin').reportProblem
109
112
  const request: typeof import('framework7/lite').request
110
113
  const resolveComponent: typeof import('vue').resolveComponent
111
114
  const seedPlugin: typeof import('./src/plugins/seed.plugin').seedPlugin
@@ -341,6 +344,9 @@ declare global {
341
344
  export type { TabbarVisibility } from './src/shared/composables/useTabbarVisibility'
342
345
  import('./src/shared/composables/useTabbarVisibility')
343
346
  // @ts-ignore
347
+ export type { RecorderBackend, RecorderDiagnostics } from './src/plugins/recorder.plugin'
348
+ import('./src/plugins/recorder.plugin')
349
+ // @ts-ignore
344
350
  export type { CaseComparison } from './src/modules/demo/composables/useBenchmark'
345
351
  import('./src/modules/demo/composables/useBenchmark')
346
352
  // @ts-ignore
@@ -446,6 +452,8 @@ declare module 'vue' {
446
452
  readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
447
453
  readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
448
454
  readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
455
+ readonly recorderDiagnostics: UnwrapRef<typeof import('./src/plugins/recorder.plugin')['recorderDiagnostics']>
456
+ readonly recorderPlugin: UnwrapRef<typeof import('./src/plugins/recorder.plugin')['recorderPlugin']>
449
457
  readonly ref: UnwrapRef<typeof import('vue')['ref']>
450
458
  readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
451
459
  readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
@@ -454,6 +462,7 @@ declare module 'vue' {
454
462
  readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
455
463
  readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
456
464
  readonly renderBootstrapError: UnwrapRef<typeof import('./src/plugins/bootstrapError')['renderBootstrapError']>
465
+ readonly reportProblem: UnwrapRef<typeof import('./src/plugins/recorder.plugin')['reportProblem']>
457
466
  readonly request: UnwrapRef<typeof import('framework7/lite')['request']>
458
467
  readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
459
468
  readonly seedPlugin: UnwrapRef<typeof import('./src/plugins/seed.plugin')['seedPlugin']>
@@ -16,13 +16,16 @@
16
16
  "@capacitor/android": "^8.5.0",
17
17
  "@capacitor/app": "^8.0.0",
18
18
  "@capacitor/core": "^8.0.0",
19
+ "@capacitor/filesystem": "^8.0.0",
19
20
  "@capacitor/keyboard": "^8.0.0",
20
21
  "@capacitor/preferences": "^8.0.0",
22
+ "@capacitor/share": "^8.0.0",
21
23
  "@capacitor/splash-screen": "^8.0.0",
22
24
  "@capacitor/status-bar": "^8.0.0",
23
- "@cavulsqa/mobile-db": "^1.2.1",
24
- "@cavulsqa/reactive-db": "^1.2.1",
25
- "@cavulsqa/reactive-vue": "^1.2.1",
25
+ "@cavulsqa/mobile-db": "^1.4.0",
26
+ "@cavulsqa/reactive-db": "^1.4.0",
27
+ "@cavulsqa/reactive-vue": "^1.4.0",
28
+ "@cavulsqa/recorder": "^1.4.0",
26
29
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
27
30
  "@tailwindcss/vite": "^4.3.3",
28
31
  "@vueuse/core": "^14.4.0",
@@ -35,6 +35,7 @@ declare const __APP_VERSION__: string;
35
35
  interface ImportMetaEnv {
36
36
  readonly VITE_STORAGE_ENGINE?: string;
37
37
  readonly VITE_PRAGMA_PROFILE?: "safe" | "fast";
38
+ readonly VITE_FIELD_RECORDER?: "on" | "off";
38
39
  }
39
40
 
40
41
  interface ImportMeta {
@@ -9,6 +9,7 @@ import "./assets/css/app.css";
9
9
  import App from "./App.vue";
10
10
  import { i18n } from "./plugins/i18n.plugin";
11
11
  import { renderBootstrapError } from "./plugins/bootstrapError";
12
+ import { recorderPlugin } from "./plugins/recorder.plugin";
12
13
  import { seedPlugin } from "./plugins/seed.plugin";
13
14
  import { sqlitePlugin } from "./plugins/sqlite.plugin";
14
15
 
@@ -31,6 +32,8 @@ async function bootstrap(): Promise<void> {
31
32
  return;
32
33
  }
33
34
 
35
+ void recorderPlugin();
36
+
34
37
  // Components arrive through Framework7VueResolver, so nothing is registered by hand.
35
38
  const app = createApp(App);
36
39
  app.use(i18n);
@@ -3,6 +3,7 @@ import {
3
3
  webviewLikelyTooOld,
4
4
  webviewVersion,
5
5
  } from "@/shared/database/storage";
6
+ import { hideSplashScreen } from "@/plugins/capacitor/useSplashScreen";
6
7
 
7
8
  /**
8
9
  * The screen shown when the app cannot start, which in practice means the database did not open.
@@ -15,6 +16,7 @@ import {
15
16
  * nothing they could act on.
16
17
  */
17
18
  export function renderBootstrapError(error: unknown): void {
19
+ hideSplashScreen();
18
20
  const root = document.getElementById("app");
19
21
  if (!root) return;
20
22
 
@@ -0,0 +1,155 @@
1
+ import {
2
+ createConsoleTrack,
3
+ createDatabasesTrack,
4
+ createNetworkTrack,
5
+ createPerfTrack,
6
+ createRecorder,
7
+ createRrwebTrack,
8
+ type Recorder,
9
+ type TrackContext,
10
+ type TrackRecorder,
11
+ } from "@cavulsqa/recorder";
12
+ import { changeBus } from "@/shared/database/database";
13
+ import { createCapacitorSink } from "@/shared/recorder/capacitorSink";
14
+
15
+ const RRWEB_VERIFY_WINDOW_MS = 2000;
16
+
17
+ export type RecorderBackend = "memory" | "opfs" | null;
18
+
19
+ export interface RecorderDiagnostics {
20
+ enabled: boolean;
21
+ recording: boolean;
22
+ backend: RecorderBackend;
23
+ rrwebAvailable: boolean;
24
+ lastError: string | null;
25
+ }
26
+
27
+ export const recorderDiagnostics = ref<RecorderDiagnostics>({
28
+ enabled: false,
29
+ recording: false,
30
+ backend: null,
31
+ rrwebAvailable: true,
32
+ lastError: null,
33
+ });
34
+
35
+ let recorder: Recorder | null = null;
36
+
37
+ function resolveFieldRecorderMode(): "on" | "off" {
38
+ const raw = import.meta.env.VITE_FIELD_RECORDER;
39
+ if (raw === "on" || raw === "off") return raw;
40
+ return import.meta.env.DEV ? "on" : "off";
41
+ }
42
+
43
+ function isCapuError(data: unknown): boolean {
44
+ return typeof data === "object" && data !== null && "__capuError" in data;
45
+ }
46
+
47
+ /**
48
+ * Wraps `createRrwebTrack` with a runtime check: rrweb's published package resolves to an empty
49
+ * module outside a bundler, and a `record()` call against that module fails silently inside the
50
+ * track's own try/catch, so the recorder would otherwise report a healthy `rrweb` track that never
51
+ * emitted a frame. This watches the first events pushed and flips `rrwebAvailable` to `false` if
52
+ * nothing but a `__capuError` payload arrives within {@link RRWEB_VERIFY_WINDOW_MS}.
53
+ */
54
+ function createVerifiedRrwebTrack(): TrackRecorder {
55
+ const inner = createRrwebTrack();
56
+ let sawRealFrame = false;
57
+
58
+ return {
59
+ name: "rrweb",
60
+ async start(ctx: TrackContext<unknown>) {
61
+ const wrapped: TrackContext<unknown> = {
62
+ ...ctx,
63
+ push(data, wallMs) {
64
+ if (!sawRealFrame && !isCapuError(data)) sawRealFrame = true;
65
+ ctx.push(data, wallMs);
66
+ },
67
+ };
68
+ await inner.start(wrapped);
69
+ setTimeout(() => {
70
+ recorderDiagnostics.value = { ...recorderDiagnostics.value, rrwebAvailable: sawRealFrame };
71
+ if (!sawRealFrame) {
72
+ console.error(
73
+ "[recorder] rrweb produced no frames; recording is running without screen replay.",
74
+ );
75
+ }
76
+ }, RRWEB_VERIFY_WINDOW_MS);
77
+ },
78
+ async stop() {
79
+ await inner.stop();
80
+ },
81
+ };
82
+ }
83
+
84
+ function buildTracks(): TrackRecorder[] {
85
+ return [
86
+ createVerifiedRrwebTrack(),
87
+ createConsoleTrack(),
88
+ createNetworkTrack(),
89
+ createPerfTrack(),
90
+ createDatabasesTrack({ changeBus }),
91
+ ];
92
+ }
93
+
94
+ function syncDiagnostics(): void {
95
+ if (!recorder) return;
96
+ const status = recorder.status;
97
+ recorderDiagnostics.value = {
98
+ ...recorderDiagnostics.value,
99
+ recording: status.recording,
100
+ backend: status.backend,
101
+ lastError: status.lastError,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Builds and starts the field recorder, gated by `VITE_FIELD_RECORDER` (`"on"` or `"off"`,
107
+ * defaulting to `on` in dev and `off` in a production build). Recovers a leftover session from a
108
+ * previous run before starting a new one. Never throws: every failure lands in
109
+ * {@link recorderDiagnostics}.`lastError` instead, so a broken recorder can never block `main.ts`
110
+ * from mounting the app.
111
+ */
112
+ export async function recorderPlugin(): Promise<void> {
113
+ const mode = resolveFieldRecorderMode();
114
+ recorderDiagnostics.value = { ...recorderDiagnostics.value, enabled: mode === "on" };
115
+ if (mode === "off") return;
116
+
117
+ try {
118
+ recorder = createRecorder({
119
+ tracks: buildTracks(),
120
+ sink: createCapacitorSink(),
121
+ label: "field recording",
122
+ appId: __APP_NAME__,
123
+ appVersion: __APP_VERSION__,
124
+ });
125
+
126
+ await recorder.recover();
127
+ await recorder.start();
128
+ syncDiagnostics();
129
+ } catch (error) {
130
+ recorderDiagnostics.value = {
131
+ ...recorderDiagnostics.value,
132
+ lastError: error instanceof Error ? error.message : String(error),
133
+ };
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Finalizes the current buffer into a `.capu` and hands it to the sink without stopping the
139
+ * recorder, for the Settings "Report a problem" action. Returns `true` when the capture reached
140
+ * the sink with no error.
141
+ */
142
+ export async function reportProblem(): Promise<boolean> {
143
+ if (!recorder) return false;
144
+ try {
145
+ await recorder.capture("user report");
146
+ } catch (error) {
147
+ recorderDiagnostics.value = {
148
+ ...recorderDiagnostics.value,
149
+ lastError: error instanceof Error ? error.message : String(error),
150
+ };
151
+ return false;
152
+ }
153
+ syncDiagnostics();
154
+ return recorderDiagnostics.value.lastError === null;
155
+ }
@@ -0,0 +1,124 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+ import { Directory, Filesystem } from "@capacitor/filesystem";
3
+ import { Share } from "@capacitor/share";
4
+ import type { RecorderSink, SessionManifest } from "@cavulsqa/recorder";
5
+
6
+ const CAPTURE_DIR = "capu";
7
+ const BASE64_CHUNK = 0x8000;
8
+
9
+ export interface CaptureFile {
10
+ name: string;
11
+ size: number;
12
+ modifiedAt: number;
13
+ }
14
+
15
+ interface BrowserCapture {
16
+ name: string;
17
+ blob: Blob;
18
+ size: number;
19
+ savedAt: number;
20
+ }
21
+
22
+ const browserCaptures = new Map<string, BrowserCapture>();
23
+
24
+ function bytesToBase64(bytes: Uint8Array): string {
25
+ let binary = "";
26
+ for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) {
27
+ const chunk = bytes.subarray(offset, offset + BASE64_CHUNK);
28
+ binary += String.fromCharCode(...chunk);
29
+ }
30
+ return btoa(binary);
31
+ }
32
+
33
+ function fileNameFor(manifest: SessionManifest): string {
34
+ return `${manifest.sessionId}-${String(Date.now())}.capu`;
35
+ }
36
+
37
+ function downloadBlob(name: string, blob: Blob): void {
38
+ const url = URL.createObjectURL(blob);
39
+ const link = document.createElement("a");
40
+ link.href = url;
41
+ link.download = name;
42
+ document.body.appendChild(link);
43
+ link.click();
44
+ link.remove();
45
+ URL.revokeObjectURL(url);
46
+ }
47
+
48
+ async function saveNative(archive: Uint8Array, manifest: SessionManifest): Promise<void> {
49
+ const path = `${CAPTURE_DIR}/${fileNameFor(manifest)}`;
50
+ await Filesystem.writeFile({
51
+ path,
52
+ data: bytesToBase64(archive),
53
+ directory: Directory.External,
54
+ recursive: true,
55
+ });
56
+ const { uri } = await Filesystem.getUri({ path, directory: Directory.External });
57
+ await Share.share({ url: uri, title: manifest.label });
58
+ }
59
+
60
+ function saveBrowser(archive: Uint8Array, manifest: SessionManifest): void {
61
+ const name = fileNameFor(manifest);
62
+ const blob = new Blob([archive as Uint8Array<ArrayBuffer>], { type: "application/zip" });
63
+ browserCaptures.set(name, { name, blob, size: blob.size, savedAt: Date.now() });
64
+ downloadBlob(name, blob);
65
+ }
66
+
67
+ /**
68
+ * On native, writes a finished `.capu` under `capu/<sessionId>-<ts>.capu` in the app's external
69
+ * storage and opens the Android share sheet. Off-device (`vp dev` in a browser) it triggers a blob
70
+ * download instead, since `Filesystem`/`Share` have nothing to write to there.
71
+ */
72
+ export function createCapacitorSink(): RecorderSink {
73
+ return {
74
+ async save(archive, manifest) {
75
+ if (Capacitor.isNativePlatform()) {
76
+ await saveNative(archive, manifest);
77
+ } else {
78
+ saveBrowser(archive, manifest);
79
+ }
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Lists previously captured `.capu` files for the Settings diagnostics list, newest first. */
85
+ export async function listCaptures(): Promise<CaptureFile[]> {
86
+ if (Capacitor.isNativePlatform()) {
87
+ try {
88
+ const result = await Filesystem.readdir({
89
+ path: CAPTURE_DIR,
90
+ directory: Directory.External,
91
+ });
92
+ return result.files
93
+ .filter((file) => file.type === "file")
94
+ .map((file) => ({ name: file.name, size: file.size, modifiedAt: file.mtime }))
95
+ .sort((a, b) => b.modifiedAt - a.modifiedAt);
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+ return [...browserCaptures.values()]
101
+ .map((capture) => ({ name: capture.name, size: capture.size, modifiedAt: capture.savedAt }))
102
+ .sort((a, b) => b.modifiedAt - a.modifiedAt);
103
+ }
104
+
105
+ /** Re-opens the Android share sheet for a previously captured file; on browser, re-triggers the download. */
106
+ export async function shareCapture(name: string): Promise<void> {
107
+ if (Capacitor.isNativePlatform()) {
108
+ const path = `${CAPTURE_DIR}/${name}`;
109
+ const { uri } = await Filesystem.getUri({ path, directory: Directory.External });
110
+ await Share.share({ url: uri });
111
+ return;
112
+ }
113
+ const capture = browserCaptures.get(name);
114
+ if (capture) downloadBlob(name, capture.blob);
115
+ }
116
+
117
+ /** Permanently deletes a previously captured file. */
118
+ export async function deleteCapture(name: string): Promise<void> {
119
+ if (Capacitor.isNativePlatform()) {
120
+ await Filesystem.deleteFile({ path: `${CAPTURE_DIR}/${name}`, directory: Directory.External });
121
+ return;
122
+ }
123
+ browserCaptures.delete(name);
124
+ }
@@ -52,6 +52,7 @@ export default defineConfig({
52
52
  */
53
53
  AutoImport({
54
54
  include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/],
55
+ exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]dist[\\/]/],
55
56
  imports: ["vue", "vue-i18n", "@vueuse/core", getFramework7AutoImports()],
56
57
  dirs: [
57
58
  "src/shared/composables/**",
@@ -0,0 +1,68 @@
1
+ # Reading and writing data
2
+
3
+ Every read is a reactive query or a deliberate on-demand call. Every write goes through `rdb`. A
4
+ third shape — a hand-rolled cache, or a raw write — produces "stale screen, no error, no symptom",
5
+ which is the one failure an offline app cannot afford: the user has no network to blame.
6
+
7
+ ## Reads
8
+
9
+ `useReactiveQuery(fn, { tables, queryKey, debounce })` from `@/shared/database/queries`.
10
+
11
+ - **`tables` is the invalidation contract.** It must list exactly the tables `fn` reads — no more,
12
+ no less. Under-list and a write to the missing table leaves the screen stale with no error.
13
+ Over-list and an unrelated write re-runs an expensive query for nothing.
14
+
15
+ A join means every joined table. `loadOrderDetail` reads the order, its lines, the customer, the
16
+ product names and the customer's tags, so it lists all five. Count the tables in the SQL, not the
17
+ ones you were thinking about.
18
+
19
+ - **`queryKey` is a process-wide identity built from arguments, not a label.** It is an array, and
20
+ two mounted queries whose keys match await one request and share its result. Framework7 keeps
21
+ pages mounted, so two instances of one screen genuinely coexist — which is exactly why the key
22
+ has to carry what distinguishes them: `["demo:order", orderId]`, never `["demo:order"]`.
23
+
24
+ Put every value the query reads in the key. A ref belongs there directly — `["demo:search", term]`
25
+ — and the query re-runs through its own `debounce` when the ref moves, so a filtered screen never
26
+ calls `refetch()` by hand.
27
+
28
+ - **`debounce`** collapses a burst. A loop of twenty inserts should refetch once, not twenty times.
29
+
30
+ - Reads may take the database directly: `searchOrders(getDatabase().db, term)`. Repositories take
31
+ the database as a parameter rather than reaching for the singleton, which is what makes them
32
+ testable — `tests/sales.repository.test.ts` runs them against sql.js.
33
+
34
+ ## Writes
35
+
36
+ Always `rdb`, never `getDatabase().db`:
37
+
38
+ ```ts
39
+ await saveOrder(rdb, { customerId, reference, lines }); // announces sales_order, order_line
40
+ await saveOrder(getDatabase().db, …); // writes, and nothing notices
41
+ ```
42
+
43
+ `rdb` wraps Kysely so a mutation publishes the tables it touched on the change bus. A raw write
44
+ lands in SQLite and no query hears about it, so the screen keeps showing the old rows until
45
+ something unrelated triggers a refetch. There is no error and nothing to see in review.
46
+
47
+ Anything that must be all-or-nothing goes in one transaction. `saveOrder` writes the order and its
48
+ lines together because a half-written order is worse than no order.
49
+
50
+ ## Forbidden
51
+
52
+ A module-scope `Map` or `ref` holding fetched rows with neither a TTL nor a bus subscription. Once
53
+ populated it never refreshes for the session, and the next write is invisible until restart.
54
+ In-flight dedup (a `Map<key, Promise>` cleared in `finally`) is concurrency control, not a cache,
55
+ and is fine — `useReactiveQuery` already does it.
56
+
57
+ ## Seed and demo helpers
58
+
59
+ Anything a person can press twice must survive being pressed twice. `seedSampleData` inserts the
60
+ catalogue only where missing and upserts tags, because a plain insert threw
61
+ `UNIQUE constraint failed: tag.label` on the second press.
62
+
63
+ ## Proof obligations
64
+
65
+ - A new query: state which tables its SQL touches and that `tables` matches.
66
+ - A new write: state that it goes through `rdb`, and whether it needs a transaction.
67
+ - Either: a test in `tests/` exercising it against sql.js. Type-checking a query proves nothing
68
+ about what it returns.