@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.
- package/README.md +12 -1
- package/bin/create.mjs +18 -25
- package/lib/templates.mjs +56 -0
- package/package.json +13 -5
- package/templates/f7-app/auto-imports.d.ts +9 -0
- package/templates/f7-app/package.json +6 -3
- package/templates/f7-app/src/env.d.ts +1 -0
- package/templates/f7-app/src/main.ts +3 -0
- package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
- package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
- package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
- package/templates/f7-app/vite.config.ts +1 -0
- package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
- package/templates/m3e-app/.claude/rules/database.md +106 -0
- package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
- package/templates/m3e-app/.claude/rules/modules.md +43 -0
- package/templates/m3e-app/.claude/rules/native.md +60 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
- package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
- package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
- package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
- package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
- package/templates/m3e-app/.env.example +32 -0
- package/templates/m3e-app/CLAUDE.md +93 -0
- package/templates/m3e-app/auto-imports.d.ts +851 -0
- package/templates/m3e-app/capacitor.config.ts +44 -0
- package/templates/m3e-app/components.d.ts +252 -0
- package/templates/m3e-app/index.html +16 -0
- package/templates/m3e-app/package.json +108 -0
- package/templates/m3e-app/src/App.vue +129 -0
- package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
- package/templates/m3e-app/src/app/scroll.config.ts +20 -0
- package/templates/m3e-app/src/app/storage.config.ts +65 -0
- package/templates/m3e-app/src/app/tabs.ts +33 -0
- package/templates/m3e-app/src/app/theme.config.ts +51 -0
- package/templates/m3e-app/src/assets/css/app.css +18 -0
- package/templates/m3e-app/src/assets/css/base.css +53 -0
- package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
- package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
- package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
- package/templates/m3e-app/src/assets/css/m3e.css +6 -0
- package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
- package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
- package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
- package/templates/m3e-app/src/env.d.ts +40 -0
- package/templates/m3e-app/src/locales/ar.json +1260 -0
- package/templates/m3e-app/src/locales/en.json +1260 -0
- package/templates/m3e-app/src/locales/fr.json +1260 -0
- package/templates/m3e-app/src/main.ts +41 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
- package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
- package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
- package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
- package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
- package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
- package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
- package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
- package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
- package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
- package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
- package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
- package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
- package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
- package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
- package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
- package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
- package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
- package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
- package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
- package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
- package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
- package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
- package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
- package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
- package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
- package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
- package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
- package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
- package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
- package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
- package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
- package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
- package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
- package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
- package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
- package/templates/m3e-app/src/modules/settings/types.ts +7 -0
- package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
- package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
- package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
- package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
- package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
- package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
- package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
- package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
- package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
- package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
- package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
- package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
- package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
- package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
- package/templates/m3e-app/src/router/index.ts +20 -0
- package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
- package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
- package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
- package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
- package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
- package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
- package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
- package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
- package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
- package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
- package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
- package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
- package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
- package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
- package/templates/m3e-app/src/shared/database/database.ts +219 -0
- package/templates/m3e-app/src/shared/database/index.ts +3 -0
- package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
- package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
- package/templates/m3e-app/src/shared/database/queries.ts +26 -0
- package/templates/m3e-app/src/shared/database/schema.ts +153 -0
- package/templates/m3e-app/src/shared/database/storage.ts +64 -0
- package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
- package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
- package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
- package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
- package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
- package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
- package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
- package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
- package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
- package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
- package/templates/m3e-app/tests/locales.test.ts +73 -0
- package/templates/m3e-app/tests/migrations.test.ts +74 -0
- package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
- package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
- package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
- package/templates/m3e-app/tests/storage.test.ts +117 -0
- package/templates/m3e-app/tests/textDirection.test.ts +11 -0
- package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
- package/templates/m3e-app/tsconfig.json +21 -0
- 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,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,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;
|