@cavulsqa/create 0.1.2 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -10
- package/bin/create.mjs +44 -1
- package/lib/scaffold.mjs +15 -1
- package/package.json +2 -2
- package/templates/f7-app/.claude/rules/database.md +39 -0
- package/templates/f7-app/.env.example +23 -0
- package/templates/f7-app/CLAUDE.md +8 -3
- package/templates/f7-app/auto-imports.d.ts +7 -0
- package/templates/f7-app/components.d.ts +2 -0
- package/templates/f7-app/package.json +6 -6
- package/templates/f7-app/src/app/pragmas.config.ts +51 -0
- package/templates/f7-app/src/app/storage.config.ts +56 -0
- package/templates/f7-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
- package/templates/f7-app/src/domains/benchmark/benchmark.suite.ts +662 -0
- package/templates/f7-app/src/domains/sales/sales.repository.ts +1 -1
- package/templates/f7-app/src/env.d.ts +15 -0
- package/templates/f7-app/src/locales/en.json +37 -2
- package/templates/f7-app/src/locales/fr.json +37 -2
- package/templates/f7-app/src/main.ts +6 -16
- package/templates/f7-app/src/modules/demo/components/DemoBenchmark.vue +112 -0
- package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +10 -0
- package/templates/f7-app/src/modules/demo/composables/useBenchmark.ts +139 -0
- package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +5 -1
- package/templates/f7-app/src/modules/demo/views/DemoView.vue +9 -2
- package/templates/f7-app/src/modules/settings/views/SettingsView.vue +31 -0
- package/templates/f7-app/src/plugins/bootstrapError.ts +60 -0
- package/templates/f7-app/src/shared/database/candidates/index.ts +3 -0
- package/templates/f7-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
- package/templates/f7-app/src/shared/database/candidates/types.ts +41 -0
- package/templates/f7-app/src/shared/database/candidates/waSqlite.ts +58 -0
- package/templates/f7-app/src/shared/database/database.ts +151 -30
- package/templates/f7-app/src/shared/database/migrations.ts +143 -1
- package/templates/f7-app/src/shared/database/opfs.worker.ts +5 -0
- package/templates/f7-app/src/shared/database/schema.ts +94 -0
- package/templates/f7-app/src/shared/database/storage.ts +94 -0
- package/templates/f7-app/src/shared/database/wa.worker.ts +5 -0
- package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +1 -2
- package/templates/f7-app/tests/benchmark.suite.test.ts +104 -0
- package/templates/f7-app/tests/migrations.test.ts +74 -0
- package/templates/f7-app/tests/storage.test.ts +117 -0
- package/templates/f7-app/vite.config.ts +3 -1
- package/templates/f7-app/tsconfig.node.json +0 -14
|
@@ -69,7 +69,9 @@
|
|
|
69
69
|
"deleteConfirm": "Delete this order and its lines?",
|
|
70
70
|
"tapHint": "Tap an order to open it.",
|
|
71
71
|
"allOrders": "Recent orders",
|
|
72
|
-
"matches": "{count} matches"
|
|
72
|
+
"matches": "{count} matches",
|
|
73
|
+
"engineSwitch": "One engine everywhere - no bridge to pipeline",
|
|
74
|
+
"measuredOn": "Measured on {engine}"
|
|
73
75
|
},
|
|
74
76
|
"metrics": {
|
|
75
77
|
"totalQueries": "Queries run",
|
|
@@ -112,7 +114,12 @@
|
|
|
112
114
|
"dialogText": "Dialogs, sheets and toasts all come from Framework7 - no extra dependency.",
|
|
113
115
|
"sheetText": "A sheet modal. The Android back button closes this before it navigates.",
|
|
114
116
|
"aboutText": "Built on {'@'}cavulsqa/mobile-db, reactive-db and reactive-vue. Replace this text with your own.",
|
|
115
|
-
"footer": "Change these screens in src/modules/settings/."
|
|
117
|
+
"footer": "Change these screens in src/modules/settings/.",
|
|
118
|
+
"storage": "Storage",
|
|
119
|
+
"engine": "Database engine",
|
|
120
|
+
"engineHint": "Every query reaches SQLite compiled to WebAssembly, against a real file in the Origin Private File System, in a worker - no native bridge. The same engine in a browser as on a phone.",
|
|
121
|
+
"webview": "WebView",
|
|
122
|
+
"notDurable": "Storage is not persisted"
|
|
116
123
|
},
|
|
117
124
|
"errors": {
|
|
118
125
|
"notFoundTitle": "Not found",
|
|
@@ -175,5 +182,33 @@
|
|
|
175
182
|
"scrollMe": "Scroll to see the navbar react",
|
|
176
183
|
"row": "Row",
|
|
177
184
|
"next": "Open the next one"
|
|
185
|
+
},
|
|
186
|
+
"bench": {
|
|
187
|
+
"title": "Benchmark",
|
|
188
|
+
"intro": "Every operation a screen in this app performs, run against the benchmark's own two tables so it never touches your data. Switch engines in Settings and run it again - the second run is compared against the first.",
|
|
189
|
+
"run": "Run benchmark",
|
|
190
|
+
"running": "Running",
|
|
191
|
+
"against": "vs {engine}",
|
|
192
|
+
"cases": "Cases",
|
|
193
|
+
"rows": "Rows seeded",
|
|
194
|
+
"wall": "Wall clock",
|
|
195
|
+
"worst": "worst",
|
|
196
|
+
"faster": "{times}x faster here",
|
|
197
|
+
"slower": "{times}x slower here",
|
|
198
|
+
"copy": "Copy JSON",
|
|
199
|
+
"clear": "Clear results",
|
|
200
|
+
"copied": "Copied",
|
|
201
|
+
"copyFailed": "Could not reach the clipboard.",
|
|
202
|
+
"group": {
|
|
203
|
+
"read": "Reads",
|
|
204
|
+
"join": "Joins",
|
|
205
|
+
"write": "Writes",
|
|
206
|
+
"transaction": "Transactions",
|
|
207
|
+
"schema": "Schema",
|
|
208
|
+
"concurrency": "Concurrency",
|
|
209
|
+
"sync": "Sync strategies"
|
|
210
|
+
},
|
|
211
|
+
"pragmas": "PRAGMA {profile}",
|
|
212
|
+
"seeded": "Dataset built in {ms} ms - later runs reuse it"
|
|
178
213
|
}
|
|
179
214
|
}
|
|
@@ -69,7 +69,9 @@
|
|
|
69
69
|
"deleteConfirm": "Supprimer cette commande et ses lignes ?",
|
|
70
70
|
"tapHint": "Touchez une commande pour l'ouvrir.",
|
|
71
71
|
"allOrders": "Commandes récentes",
|
|
72
|
-
"matches": "{count} résultats"
|
|
72
|
+
"matches": "{count} résultats",
|
|
73
|
+
"engineSwitch": "Un seul moteur partout - aucun pont à paralléliser",
|
|
74
|
+
"measuredOn": "Mesuré sur {engine}"
|
|
73
75
|
},
|
|
74
76
|
"metrics": {
|
|
75
77
|
"totalQueries": "Requêtes exécutées",
|
|
@@ -112,7 +114,12 @@
|
|
|
112
114
|
"dialogText": "Dialogues, feuilles et toasts viennent de Framework7 - sans dépendance supplémentaire.",
|
|
113
115
|
"sheetText": "Une feuille modale. Le bouton retour Android la ferme avant de naviguer.",
|
|
114
116
|
"aboutText": "Construit sur {'@'}cavulsqa/mobile-db, reactive-db et reactive-vue. Remplacez ce texte par le vôtre.",
|
|
115
|
-
"footer": "Modifiez ces écrans dans src/modules/settings/."
|
|
117
|
+
"footer": "Modifiez ces écrans dans src/modules/settings/.",
|
|
118
|
+
"storage": "Stockage",
|
|
119
|
+
"engine": "Moteur de base de données",
|
|
120
|
+
"engineHint": "Chaque requête atteint SQLite compilé en WebAssembly, sur un vrai fichier dans l'Origin Private File System, dans un worker - sans pont natif. Le même moteur dans un navigateur que sur un téléphone.",
|
|
121
|
+
"webview": "WebView",
|
|
122
|
+
"notDurable": "Le stockage n'est pas persistant"
|
|
116
123
|
},
|
|
117
124
|
"errors": {
|
|
118
125
|
"notFoundTitle": "Introuvable",
|
|
@@ -175,5 +182,33 @@
|
|
|
175
182
|
"scrollMe": "Défilez pour voir la navbar réagir",
|
|
176
183
|
"row": "Ligne",
|
|
177
184
|
"next": "Ouvrir la suivante"
|
|
185
|
+
},
|
|
186
|
+
"bench": {
|
|
187
|
+
"title": "Benchmark",
|
|
188
|
+
"intro": "Chaque opération qu'un écran de cette application effectue, exécutée sur les deux tables propres au benchmark : vos données ne sont jamais touchées. Changez de moteur dans Réglages et relancez - la seconde exécution est comparée à la première.",
|
|
189
|
+
"run": "Lancer le benchmark",
|
|
190
|
+
"running": "En cours",
|
|
191
|
+
"against": "vs {engine}",
|
|
192
|
+
"cases": "Cas",
|
|
193
|
+
"rows": "Lignes créées",
|
|
194
|
+
"wall": "Durée totale",
|
|
195
|
+
"worst": "pire",
|
|
196
|
+
"faster": "{times}x plus rapide ici",
|
|
197
|
+
"slower": "{times}x plus lent ici",
|
|
198
|
+
"copy": "Copier le JSON",
|
|
199
|
+
"clear": "Effacer les résultats",
|
|
200
|
+
"copied": "Copié",
|
|
201
|
+
"copyFailed": "Presse-papiers inaccessible.",
|
|
202
|
+
"group": {
|
|
203
|
+
"read": "Lectures",
|
|
204
|
+
"join": "Jointures",
|
|
205
|
+
"write": "Écritures",
|
|
206
|
+
"transaction": "Transactions",
|
|
207
|
+
"schema": "Schéma",
|
|
208
|
+
"concurrency": "Concurrence",
|
|
209
|
+
"sync": "Stratégies de synchro"
|
|
210
|
+
},
|
|
211
|
+
"pragmas": "PRAGMA {profile}",
|
|
212
|
+
"seeded": "Jeu de données construit en {ms} ms - réutilisé ensuite"
|
|
178
213
|
}
|
|
179
214
|
}
|
|
@@ -8,36 +8,26 @@ import "./assets/css/app.css";
|
|
|
8
8
|
|
|
9
9
|
import App from "./App.vue";
|
|
10
10
|
import { i18n } from "./plugins/i18n.plugin";
|
|
11
|
+
import { renderBootstrapError } from "./plugins/bootstrapError";
|
|
11
12
|
import { seedPlugin } from "./plugins/seed.plugin";
|
|
12
13
|
import { sqlitePlugin } from "./plugins/sqlite.plugin";
|
|
13
14
|
|
|
14
15
|
Framework7.use(Framework7Vue);
|
|
15
16
|
|
|
16
|
-
function reportFatal(error: unknown): void {
|
|
17
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
-
console.error("[bootstrap] the app could not start:", error);
|
|
19
|
-
|
|
20
|
-
const root = document.getElementById("app");
|
|
21
|
-
if (!root) return;
|
|
22
|
-
const box = document.createElement("div");
|
|
23
|
-
box.setAttribute("style", "font:14px/1.5 system-ui;padding:24px;color:#b00020");
|
|
24
|
-
box.textContent = `The app could not start: ${message}`;
|
|
25
|
-
root.replaceChildren(box);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
17
|
/**
|
|
29
18
|
* The database opens before mount so the first screen never renders against a missing schema.
|
|
30
19
|
*
|
|
31
|
-
* If it fails,
|
|
32
|
-
*
|
|
33
|
-
*
|
|
20
|
+
* If it fails, `renderBootstrapError` takes the screen: a sentence, what to do about it, a retry
|
|
21
|
+
* button and the WebView version behind a details toggle. Awaiting this at the top level of the
|
|
22
|
+
* module and letting it reject renders an empty `#app` with an unhandled rejection nobody reads -
|
|
23
|
+
* which is exactly what this template did the first time it was opened in a browser.
|
|
34
24
|
*/
|
|
35
25
|
async function bootstrap(): Promise<void> {
|
|
36
26
|
try {
|
|
37
27
|
await sqlitePlugin();
|
|
38
28
|
await seedPlugin();
|
|
39
29
|
} catch (error) {
|
|
40
|
-
|
|
30
|
+
renderBootstrapError(error);
|
|
41
31
|
return;
|
|
42
32
|
}
|
|
43
33
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<F7BlockTitle>{{ t("bench.title") }}</F7BlockTitle>
|
|
3
|
+
<F7Block strong inset class="rounded-2xl!">
|
|
4
|
+
<p class="mt-0 text-sm opacity-70">{{ t("bench.intro") }}</p>
|
|
5
|
+
|
|
6
|
+
<div class="mb-3 flex flex-wrap items-center gap-2">
|
|
7
|
+
<F7Chip :text="engineName" color="teal" outline />
|
|
8
|
+
<F7Chip v-if="baseline" :text="t('bench.against', { engine: baseline.engine })" outline />
|
|
9
|
+
<F7Chip :text="t('bench.pragmas', { profile })" color="orange" outline />
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<F7Button fill :disabled="running" @click="run">
|
|
13
|
+
{{ running ? t("bench.running") : t("bench.run") }}
|
|
14
|
+
</F7Button>
|
|
15
|
+
|
|
16
|
+
<p v-if="running && progress" class="mb-0 mt-2 text-center text-xs opacity-60">
|
|
17
|
+
{{ progress }}
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p v-if="failure" class="text-color-red mb-0 mt-2 text-xs">{{ failure }}</p>
|
|
21
|
+
|
|
22
|
+
<div v-if="result" class="mt-3 grid grid-cols-3 gap-2 text-center">
|
|
23
|
+
<div>
|
|
24
|
+
<div class="text-xs opacity-60">{{ t("bench.cases") }}</div>
|
|
25
|
+
<div class="font-mono text-base">{{ result.cases.length }}</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div>
|
|
28
|
+
<div class="text-xs opacity-60">{{ t("bench.rows") }}</div>
|
|
29
|
+
<div class="font-mono text-base">{{ result.rowsSeeded }}</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div>
|
|
32
|
+
<div class="text-xs opacity-60">{{ t("bench.wall") }}</div>
|
|
33
|
+
<div class="font-mono text-base">{{ Math.round(result.totalMs) }} ms</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</F7Block>
|
|
37
|
+
|
|
38
|
+
<template v-for="group in groups" :key="group">
|
|
39
|
+
<template v-if="byGroup(group).length">
|
|
40
|
+
<F7BlockTitle>{{ t(`bench.group.${group}`) }}</F7BlockTitle>
|
|
41
|
+
<F7List media-list strong inset dividers class="rounded-2xl!">
|
|
42
|
+
<F7ListItem v-for="entry in byGroup(group)" :key="entry.name" :title="entry.name">
|
|
43
|
+
<template #after>
|
|
44
|
+
<span class="font-mono text-sm tabular-nums">
|
|
45
|
+
{{ entry.current.msPerOperation }} ms
|
|
46
|
+
</span>
|
|
47
|
+
</template>
|
|
48
|
+
<template #subtitle>
|
|
49
|
+
<span class="text-[13px] tabular-nums opacity-60">
|
|
50
|
+
{{ entry.current.operations }} ops · p50 {{ entry.current.medianMs }} ms
|
|
51
|
+
· {{ t("bench.worst") }} {{ entry.current.worstMs }} ms
|
|
52
|
+
</span>
|
|
53
|
+
</template>
|
|
54
|
+
<template #text>
|
|
55
|
+
<span v-if="entry.speedup" class="text-[13px]" :class="verdictClass(entry.speedup)">
|
|
56
|
+
{{ speedupText(entry.speedup) }}
|
|
57
|
+
<span class="opacity-55">({{ entry.other?.msPerOperation }} ms)</span>
|
|
58
|
+
</span>
|
|
59
|
+
<span v-else-if="entry.note" class="text-[13px] opacity-55">{{ entry.note }}</span>
|
|
60
|
+
</template>
|
|
61
|
+
</F7ListItem>
|
|
62
|
+
</F7List>
|
|
63
|
+
</template>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<F7Block v-if="result" class="flex gap-2">
|
|
67
|
+
<F7Button outline small class="flex-1" @click="copy">{{ t("bench.copy") }}</F7Button>
|
|
68
|
+
<F7Button outline small color="red" class="flex-1" @click="clear">
|
|
69
|
+
{{ t("bench.clear") }}
|
|
70
|
+
</F7Button>
|
|
71
|
+
</F7Block>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<script setup lang="ts">
|
|
75
|
+
import { pragmaProfile } from "@/app/pragmas.config";
|
|
76
|
+
import { useBenchmark, type CaseComparison } from "@/modules/demo/composables/useBenchmark";
|
|
77
|
+
|
|
78
|
+
const { t } = useI18n();
|
|
79
|
+
const { engineName, running, progress, failure, result, baseline, comparison, run, clear, asJson } =
|
|
80
|
+
useBenchmark();
|
|
81
|
+
|
|
82
|
+
// Part of the measurement, not a detail: two runs under different PRAGMAs are not comparable.
|
|
83
|
+
const profile = pragmaProfile;
|
|
84
|
+
|
|
85
|
+
const groups = ["read", "join", "write", "transaction", "schema", "concurrency", "sync"] as const;
|
|
86
|
+
|
|
87
|
+
function byGroup(group: CaseComparison["group"]): CaseComparison[] {
|
|
88
|
+
return comparison.value.filter((entry) => entry.group === group);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Both directions read plainly; "0.4x faster" would not. */
|
|
92
|
+
function speedupText(speedup: number): string {
|
|
93
|
+
return speedup >= 1
|
|
94
|
+
? t("bench.faster", { times: speedup.toFixed(2) })
|
|
95
|
+
: t("bench.slower", { times: (1 / speedup).toFixed(2) });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function verdictClass(speedup: number): string {
|
|
99
|
+
if (speedup >= 1.2) return "text-color-green";
|
|
100
|
+
if (speedup <= 0.83) return "text-color-red";
|
|
101
|
+
return "opacity-55";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function copy() {
|
|
105
|
+
try {
|
|
106
|
+
await navigator.clipboard.writeText(asJson());
|
|
107
|
+
f7.toast.create({ text: t("bench.copied"), position: "center", closeTimeout: 1500 }).open();
|
|
108
|
+
} catch {
|
|
109
|
+
f7.dialog.alert(t("bench.copyFailed"), t("bench.title"));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
}}
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
+
<div class="mb-3 flex items-center gap-2">
|
|
13
|
+
<F7Chip :text="engineName" color="teal" outline />
|
|
14
|
+
<span class="text-[13px] opacity-55">{{ t("demo.engineSwitch") }}</span>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
12
17
|
<F7Button fill :disabled="measuring" @click="$emit('measure')">
|
|
13
18
|
{{ measuring ? t("demo.measuring") : t("demo.measure") }}
|
|
14
19
|
</F7Button>
|
|
@@ -29,6 +34,10 @@
|
|
|
29
34
|
</div>
|
|
30
35
|
</div>
|
|
31
36
|
|
|
37
|
+
<p class="mb-0 mt-2 text-center text-xs opacity-55">
|
|
38
|
+
{{ t("demo.measuredOn", { engine: result.engine }) }}
|
|
39
|
+
</p>
|
|
40
|
+
|
|
32
41
|
<p class="mb-0 mt-3 text-xs opacity-70">
|
|
33
42
|
{{ isNative ? t("demo.ratioNative") : t("demo.ratioWeb", { platform }) }}
|
|
34
43
|
</p>
|
|
@@ -45,6 +54,7 @@ defineProps<{
|
|
|
45
54
|
readsPerRun: number;
|
|
46
55
|
isNative: boolean;
|
|
47
56
|
platform: string;
|
|
57
|
+
engineName: string;
|
|
48
58
|
}>();
|
|
49
59
|
defineEmits<{ measure: [] }>();
|
|
50
60
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runBenchmark,
|
|
3
|
+
type CaseResult,
|
|
4
|
+
type SuiteResult,
|
|
5
|
+
} from "@/domains/benchmark/benchmark.suite";
|
|
6
|
+
import { activePragmas, activeStorage, activeStorageLabel, rdb } from "@/shared/database/database";
|
|
7
|
+
import type { StorageId } from "@/shared/database/candidates";
|
|
8
|
+
|
|
9
|
+
const STORAGE_KEY = "app.benchmark.results";
|
|
10
|
+
|
|
11
|
+
export interface CaseComparison {
|
|
12
|
+
name: string;
|
|
13
|
+
group: CaseResult["group"];
|
|
14
|
+
note?: string;
|
|
15
|
+
current: CaseResult;
|
|
16
|
+
other?: CaseResult;
|
|
17
|
+
/** How many times faster the current engine is at this case. Below 1 means slower. */
|
|
18
|
+
speedup?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Results are kept per engine in localStorage, because the comparison is the point and an engine
|
|
23
|
+
* switch needs a restart - so the two halves can never be in memory at the same time.
|
|
24
|
+
*/
|
|
25
|
+
function readStored(): Partial<Record<StorageId, SuiteResult>> {
|
|
26
|
+
try {
|
|
27
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
28
|
+
return raw ? (JSON.parse(raw) as Partial<Record<StorageId, SuiteResult>>) : {};
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeStored(all: Partial<Record<StorageId, SuiteResult>>): void {
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
|
37
|
+
} catch {
|
|
38
|
+
// Out of quota or storage disabled: the run still displayed, it just will not be remembered.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useBenchmark() {
|
|
43
|
+
const engine = activeStorage().id;
|
|
44
|
+
const stored = ref(readStored());
|
|
45
|
+
const running = ref(false);
|
|
46
|
+
const progress = ref("");
|
|
47
|
+
const failure = ref<string | null>(null);
|
|
48
|
+
|
|
49
|
+
const result = computed<SuiteResult | null>(() => stored.value[engine] ?? null);
|
|
50
|
+
|
|
51
|
+
/** The most recent run on any other engine, which is what the current one is measured against. */
|
|
52
|
+
const baseline = computed<SuiteResult | null>(() => {
|
|
53
|
+
const others = Object.entries(stored.value)
|
|
54
|
+
.filter(([key]) => key !== engine)
|
|
55
|
+
.map(([, value]) => value)
|
|
56
|
+
.filter((value): value is SuiteResult => Boolean(value));
|
|
57
|
+
return others.sort((a, b) => b.at - a.at)[0] ?? null;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const comparison = computed<CaseComparison[]>(() => {
|
|
61
|
+
const current = result.value;
|
|
62
|
+
if (!current) return [];
|
|
63
|
+
|
|
64
|
+
return current.cases.map((entry) => {
|
|
65
|
+
const other = baseline.value?.cases.find((candidate) => candidate.name === entry.name);
|
|
66
|
+
const speedup =
|
|
67
|
+
other && entry.msPerOperation > 0
|
|
68
|
+
? Number((other.msPerOperation / entry.msPerOperation).toFixed(2))
|
|
69
|
+
: undefined;
|
|
70
|
+
return {
|
|
71
|
+
name: entry.name,
|
|
72
|
+
group: entry.group,
|
|
73
|
+
note: entry.note,
|
|
74
|
+
current: entry,
|
|
75
|
+
other,
|
|
76
|
+
speedup,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
async function run(): Promise<void> {
|
|
82
|
+
if (running.value) return;
|
|
83
|
+
running.value = true;
|
|
84
|
+
failure.value = null;
|
|
85
|
+
progress.value = "";
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
/**
|
|
89
|
+
* Reads take the raw Kysely instance and writes go through `rdb`, exactly as the app does -
|
|
90
|
+
* measuring a write that skipped the change bus would measure something the app never runs.
|
|
91
|
+
* The suite's own tables are not watched by any query, so nothing refetches while it runs.
|
|
92
|
+
*/
|
|
93
|
+
const finished = await runBenchmark(rdb, (name) => {
|
|
94
|
+
progress.value = name;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const next = {
|
|
98
|
+
...stored.value,
|
|
99
|
+
[engine]: {
|
|
100
|
+
...finished,
|
|
101
|
+
engine: activeStorageLabel(),
|
|
102
|
+
pragmas: activePragmas(),
|
|
103
|
+
at: Date.now(),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
stored.value = next;
|
|
107
|
+
writeStored(next);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
failure.value = error instanceof Error ? error.message : String(error);
|
|
110
|
+
} finally {
|
|
111
|
+
running.value = false;
|
|
112
|
+
progress.value = "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clear(): void {
|
|
117
|
+
stored.value = {};
|
|
118
|
+
writeStored({});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** For pasting a run into an issue or a commit message rather than retyping numbers. */
|
|
122
|
+
function asJson(): string {
|
|
123
|
+
return JSON.stringify(stored.value, null, 2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
engine,
|
|
128
|
+
engineName: activeStorageLabel(),
|
|
129
|
+
running,
|
|
130
|
+
progress,
|
|
131
|
+
failure,
|
|
132
|
+
result,
|
|
133
|
+
baseline,
|
|
134
|
+
comparison,
|
|
135
|
+
run,
|
|
136
|
+
clear,
|
|
137
|
+
asJson,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type DraftLine,
|
|
15
15
|
type OrderRow,
|
|
16
16
|
} from "@/domains/sales/sales.repository";
|
|
17
|
-
import { changeBus, getDatabase, rdb } from "@/shared/database/database";
|
|
17
|
+
import { activeStorageLabel, changeBus, getDatabase, rdb } from "@/shared/database/database";
|
|
18
18
|
import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
|
|
19
19
|
|
|
20
20
|
export interface BusEntry {
|
|
@@ -29,6 +29,8 @@ export interface PipelineResult {
|
|
|
29
29
|
parallelMs: number;
|
|
30
30
|
sequentialMs: number;
|
|
31
31
|
ratio: number;
|
|
32
|
+
/** Recorded with the numbers, because a measurement without its engine is not comparable. */
|
|
33
|
+
engine: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
const EVENT_LOG_LIMIT = 20;
|
|
@@ -140,6 +142,7 @@ export function useReactiveDemo() {
|
|
|
140
142
|
parallelMs: Number(parallelMs.toFixed(1)),
|
|
141
143
|
sequentialMs: Number(sequentialMs.toFixed(1)),
|
|
142
144
|
ratio: Number((sequentialMs / Math.max(parallelMs, 0.01)).toFixed(2)),
|
|
145
|
+
engine: activeStorageLabel(),
|
|
143
146
|
};
|
|
144
147
|
} finally {
|
|
145
148
|
measuring.value = false;
|
|
@@ -156,6 +159,7 @@ export function useReactiveDemo() {
|
|
|
156
159
|
measuring,
|
|
157
160
|
readsPerRun: READS_PER_RUN,
|
|
158
161
|
isNative: Capacitor.isNativePlatform(),
|
|
162
|
+
engineName: activeStorageLabel(),
|
|
159
163
|
platform: Capacitor.getPlatform(),
|
|
160
164
|
seed,
|
|
161
165
|
advance,
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
<!-- Real Framework7 tabs, so the panes swipe rather than switching on a v-if. -->
|
|
11
11
|
<F7Toolbar tabbar top>
|
|
12
|
-
<
|
|
13
|
-
|
|
12
|
+
<F7ToolbarPane>
|
|
13
|
+
<F7Link tab-link="#demo-tab-data" tab-link-active>{{ t("demo.data") }}</F7Link>
|
|
14
|
+
<F7Link tab-link="#demo-tab-diagnostics">{{ t("demo.diagnostics") }}</F7Link>
|
|
15
|
+
</F7ToolbarPane>
|
|
14
16
|
</F7Toolbar>
|
|
15
17
|
|
|
16
18
|
<F7Tabs swipeable>
|
|
@@ -46,8 +48,11 @@
|
|
|
46
48
|
:reads-per-run="readsPerRun"
|
|
47
49
|
:is-native="isNative"
|
|
48
50
|
:platform="platform"
|
|
51
|
+
:engine-name="engineName"
|
|
49
52
|
@measure="measurePipelining"
|
|
50
53
|
/>
|
|
54
|
+
|
|
55
|
+
<DemoBenchmark />
|
|
51
56
|
</F7Tab>
|
|
52
57
|
</F7Tabs>
|
|
53
58
|
|
|
@@ -80,6 +85,7 @@ import type { OrderRow } from "@/domains/sales/sales.repository";
|
|
|
80
85
|
import DemoBusLog from "@/modules/demo/components/DemoBusLog.vue";
|
|
81
86
|
import DemoCreateOrderSheet from "@/modules/demo/components/DemoCreateOrderSheet.vue";
|
|
82
87
|
import DemoOrderList from "@/modules/demo/components/DemoOrderList.vue";
|
|
88
|
+
import DemoBenchmark from "@/modules/demo/components/DemoBenchmark.vue";
|
|
83
89
|
import DemoPipelineBenchmark from "@/modules/demo/components/DemoPipelineBenchmark.vue";
|
|
84
90
|
import DemoStatCards from "@/modules/demo/components/DemoStatCards.vue";
|
|
85
91
|
import { useReactiveDemo } from "@/modules/demo/composables/useReactiveDemo";
|
|
@@ -101,6 +107,7 @@ const {
|
|
|
101
107
|
readsPerRun,
|
|
102
108
|
isNative,
|
|
103
109
|
platform,
|
|
110
|
+
engineName,
|
|
104
111
|
seed,
|
|
105
112
|
advance,
|
|
106
113
|
remove,
|
|
@@ -38,6 +38,23 @@
|
|
|
38
38
|
</F7ListItem>
|
|
39
39
|
</F7List>
|
|
40
40
|
|
|
41
|
+
<F7BlockTitle>{{ t("settings.storage") }}</F7BlockTitle>
|
|
42
|
+
<F7List strong inset dividers class="rounded-2xl!">
|
|
43
|
+
<F7ListItem :title="t('settings.engine')" :after="storage">
|
|
44
|
+
<template #media><F7Icon f7="archivebox_fill" color="teal" /></template>
|
|
45
|
+
</F7ListItem>
|
|
46
|
+
<F7ListItem v-if="!durable" :title="t('settings.notDurable')">
|
|
47
|
+
<template #media><F7Icon f7="exclamationmark_triangle_fill" color="red" /></template>
|
|
48
|
+
<template #footer>{{ tradeoff }}</template>
|
|
49
|
+
</F7ListItem>
|
|
50
|
+
<F7ListItem v-for="skip in skipped" :key="skip.id" :title="skip.id" :footer="skip.detail">
|
|
51
|
+
<template #media><F7Icon f7="arrow_turn_down_right" color="gray" /></template>
|
|
52
|
+
</F7ListItem>
|
|
53
|
+
</F7List>
|
|
54
|
+
<F7Block class="mt-0!">
|
|
55
|
+
<p class="m-0 text-[13px] opacity-60">{{ t("settings.engineHint") }}</p>
|
|
56
|
+
</F7Block>
|
|
57
|
+
|
|
41
58
|
<F7BlockTitle>{{ t("settings.tryIt") }}</F7BlockTitle>
|
|
42
59
|
<F7List strong inset dividers class="rounded-2xl!">
|
|
43
60
|
<F7ListItem :title="t('settings.testToast')" link="#" @click="testToast">
|
|
@@ -62,6 +79,9 @@
|
|
|
62
79
|
<F7ListItem :title="t('settings.platform')" :after="platform">
|
|
63
80
|
<template #media><F7Icon f7="device_phone_portrait" color="purple" /></template>
|
|
64
81
|
</F7ListItem>
|
|
82
|
+
<F7ListItem :title="t('settings.webview')" :after="webview">
|
|
83
|
+
<template #media><F7Icon f7="globe" color="teal" /></template>
|
|
84
|
+
</F7ListItem>
|
|
65
85
|
<F7ListItem :title="t('settings.aboutUs')" link="#" @click="showAbout">
|
|
66
86
|
<template #media><F7Icon f7="info_circle_fill" color="blue" /></template>
|
|
67
87
|
</F7ListItem>
|
|
@@ -76,6 +96,8 @@
|
|
|
76
96
|
<script setup lang="ts">
|
|
77
97
|
import { Capacitor } from "@capacitor/core";
|
|
78
98
|
import { useAppTheme, type AppMode, type AppTheme } from "@/shared/composables/theme/useAppTheme";
|
|
99
|
+
import { activeStorage, activeStorageLabel, storageAttempts } from "@/shared/database/database";
|
|
100
|
+
import { webviewVersion } from "@/shared/database/storage";
|
|
79
101
|
|
|
80
102
|
const { t, locale, availableLocales } = useI18n();
|
|
81
103
|
const theme = useAppTheme();
|
|
@@ -85,6 +107,15 @@ const appName = __APP_NAME__;
|
|
|
85
107
|
const appVersion = __APP_VERSION__;
|
|
86
108
|
const platform = Capacitor.getPlatform();
|
|
87
109
|
|
|
110
|
+
const storage = activeStorageLabel();
|
|
111
|
+
const durable = activeStorage().durable;
|
|
112
|
+
const tradeoff = activeStorage().tradeoff;
|
|
113
|
+
// Which candidates the chain passed over, so a fallback is never something you discover later.
|
|
114
|
+
const skipped = storageAttempts().filter((attempt) => attempt.outcome !== "opened");
|
|
115
|
+
// The number that decides whether local storage works at all, and the OS version does not imply it.
|
|
116
|
+
const chromium = webviewVersion();
|
|
117
|
+
const webview = chromium === null ? "unknown" : `Chromium ${String(chromium)}`;
|
|
118
|
+
|
|
88
119
|
function onThemeChange(event: Event) {
|
|
89
120
|
appTheme.value.setTheme((event.target as HTMLSelectElement).value as AppTheme);
|
|
90
121
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MINIMUM_CHROMIUM_FOR_OPFS,
|
|
3
|
+
webviewLikelyTooOld,
|
|
4
|
+
webviewVersion,
|
|
5
|
+
} from "@/shared/database/storage";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The screen shown when the app cannot start, which in practice means the database did not open.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately framework-free. Mounting the app to report the failure would run screens that assume
|
|
11
|
+
* a working database, so a second failure would replace the first and the person would learn
|
|
12
|
+
* nothing. Plain DOM cannot fail for the same reason twice.
|
|
13
|
+
*
|
|
14
|
+
* It replaces a red sentence in a bare div, which is what shipped before and told a field user
|
|
15
|
+
* nothing they could act on.
|
|
16
|
+
*/
|
|
17
|
+
export function renderBootstrapError(error: unknown): void {
|
|
18
|
+
const root = document.getElementById("app");
|
|
19
|
+
if (!root) return;
|
|
20
|
+
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
23
|
+
const version = webviewVersion();
|
|
24
|
+
|
|
25
|
+
const advice = webviewLikelyTooOld()
|
|
26
|
+
? `This WebView is Chromium ${String(version)}. The app needs ${String(MINIMUM_CHROMIUM_FOR_OPFS)} or newer for local storage. Update Android System WebView in the Play Store - it updates separately from Android itself.`
|
|
27
|
+
: "Closing the app completely and opening it again usually clears this. Your data has not been deleted.";
|
|
28
|
+
|
|
29
|
+
root.replaceChildren();
|
|
30
|
+
root.insertAdjacentHTML(
|
|
31
|
+
"afterbegin",
|
|
32
|
+
`<div style="font:15px/1.55 system-ui,-apple-system,sans-serif;padding:28px 22px;max-width:38rem;margin:0 auto;color:#1c1c1e">
|
|
33
|
+
<div style="font-size:34px;line-height:1">⚠️</div>
|
|
34
|
+
<h1 style="font-size:19px;margin:14px 0 6px">The app could not start</h1>
|
|
35
|
+
<p style="margin:0 0 14px;color:#3a3a3c">${escapeHtml(message)}</p>
|
|
36
|
+
<p style="margin:0 0 20px;color:#3a3a3c">${escapeHtml(advice)}</p>
|
|
37
|
+
<button id="bootstrap-retry" style="appearance:none;border:0;border-radius:10px;background:#e2622a;color:#fff;font:600 15px system-ui;padding:12px 20px;width:100%">
|
|
38
|
+
Try again
|
|
39
|
+
</button>
|
|
40
|
+
<details style="margin-top:20px;color:#6c6c70">
|
|
41
|
+
<summary style="cursor:pointer;font-size:13px">Technical details</summary>
|
|
42
|
+
<p style="font-size:12px;margin:8px 0 0">WebView: Chromium ${version === null ? "unknown" : String(version)}</p>
|
|
43
|
+
<pre style="font-size:11px;white-space:pre-wrap;word-break:break-word;margin:8px 0 0">${escapeHtml(detail)}</pre>
|
|
44
|
+
</details>
|
|
45
|
+
</div>`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
document.getElementById("bootstrap-retry")?.addEventListener("click", () => {
|
|
49
|
+
window.location.reload();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The message can carry an engine's own text, and that text is not trusted markup. */
|
|
54
|
+
function escapeHtml(value: string): string {
|
|
55
|
+
return value
|
|
56
|
+
.replace(/&/g, "&")
|
|
57
|
+
.replace(/</g, "<")
|
|
58
|
+
.replace(/>/g, ">")
|
|
59
|
+
.replace(/"/g, """);
|
|
60
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { OpfsSQLiteDialect } from "@cavulsqa/mobile-db/opfs";
|
|
2
|
+
import OpfsWorker from "../opfs.worker?worker";
|
|
3
|
+
import { probeOpfsCapable } from "../storage";
|
|
4
|
+
import type { StorageCandidate } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Official `@sqlite.org/sqlite-wasm`, `opfs-sahpool` VFS, in a worker.
|
|
8
|
+
*
|
|
9
|
+
* The fastest thing measured on a real phone: reads 2.5-5.5x and batched writes ~9x against the
|
|
10
|
+
* Capacitor bridge, with much tighter worst cases. The SAH pool is chosen over the plain `opfs` VFS
|
|
11
|
+
* because it needs neither SharedArrayBuffer nor cross-origin isolation, and a Capacitor WebView
|
|
12
|
+
* cannot easily be given COOP/COEP headers.
|
|
13
|
+
*/
|
|
14
|
+
export const opfsSahPool: StorageCandidate = {
|
|
15
|
+
id: "sqlite-wasm-opfs-sahpool",
|
|
16
|
+
label: "SQLite WASM · OPFS sync access handle pool",
|
|
17
|
+
tradeoff:
|
|
18
|
+
"One connection per directory, taken exclusively - a stale handle blocks the open. The worker " +
|
|
19
|
+
"is serial, so a read waits behind an in-flight write.",
|
|
20
|
+
durable: true,
|
|
21
|
+
evidence: "measured",
|
|
22
|
+
probe: probeOpfsCapable,
|
|
23
|
+
createDialect: () =>
|
|
24
|
+
Promise.resolve(new OpfsSQLiteDialect({ worker: new OpfsWorker(), name: "app.sqlite3" })),
|
|
25
|
+
};
|