@ultimat3/cli 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// The `--example` dashboard: the seeded `post` slice from above. Real rows through the slice's own
|
|
2
|
-
//
|
|
3
|
-
// a
|
|
2
|
+
// query — never its repo, which the `boundaries` step refuses a route (X_BOUNDARY_ROUTE_TO_DB) —
|
|
3
|
+
// aggregated by a pure view module this file also emits, so the numbers are testable without a
|
|
4
|
+
// database. The page's route declaration and its island come from `scaffold-dashboard-shared.ts`.
|
|
4
5
|
|
|
5
6
|
import { sortedImports } from './imports';
|
|
6
7
|
import type { GeneratedFile, NameSet } from './naming';
|
|
8
|
+
import { examplePageTest, exampleViewTest } from './scaffold-dashboard-example-tests';
|
|
7
9
|
import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffold-dashboard-shared';
|
|
8
10
|
|
|
9
11
|
// Plain strings for the framework lines, never template literals: the workspace-dependency scanner
|
|
@@ -12,20 +14,25 @@ import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffol
|
|
|
12
14
|
const examplePage = (
|
|
13
15
|
app: NameSet,
|
|
14
16
|
): string => `// The authed dashboard: what the seeded \`post\` slice looks like from above. Real rows, read
|
|
15
|
-
// through the slice's own
|
|
16
|
-
// are testable without a database.
|
|
17
|
+
// through the slice's own query and aggregated by \`dashboard-view.ts\`, which is pure so the
|
|
18
|
+
// numbers are testable without a database. Every chart is server markup — an svg and a hidden data table —
|
|
19
|
+
// so the page's only script is still the theme toggle.
|
|
17
20
|
//
|
|
18
21
|
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
19
22
|
${sortedImports([
|
|
20
23
|
`import { useT } from '@${app.kebab}/i18n';`,
|
|
21
24
|
"import { isUltimateError } from '@ultimat3/core';",
|
|
22
25
|
"import { currentLocale } from '@ultimat3/i18n';",
|
|
26
|
+
"import { formatMoney } from '@ultimat3/money';",
|
|
23
27
|
"import { defineRoute, island } from '@ultimat3/render';",
|
|
24
28
|
[
|
|
25
29
|
'import {',
|
|
26
|
-
'
|
|
30
|
+
' AreaChart,',
|
|
27
31
|
' DataTable,',
|
|
32
|
+
' DonutChart,',
|
|
33
|
+
' Gauge,',
|
|
28
34
|
' Grid,',
|
|
35
|
+
' InlineBar,',
|
|
29
36
|
' PageHeader,',
|
|
30
37
|
' RelativeTime,',
|
|
31
38
|
' Section,',
|
|
@@ -35,13 +42,15 @@ ${sortedImports([
|
|
|
35
42
|
].join('\n'),
|
|
36
43
|
])}
|
|
37
44
|
import { Shell } from '../../shared/shell';
|
|
38
|
-
import
|
|
45
|
+
import { postList } from '../post/live/post-list';
|
|
39
46
|
import {
|
|
40
47
|
bucketByDay,
|
|
41
48
|
CHART_DAYS,
|
|
42
49
|
formatCount,
|
|
43
50
|
type PostRow,
|
|
44
51
|
postStats,
|
|
52
|
+
priceCeilings,
|
|
53
|
+
priceMix,
|
|
45
54
|
toPostRow,
|
|
46
55
|
} from './dashboard-view';
|
|
47
56
|
import styles from './page.module.scss';
|
|
@@ -53,7 +62,7 @@ const ROW_LIMIT = 500;
|
|
|
53
62
|
const TABLE_ROWS = 10;
|
|
54
63
|
|
|
55
64
|
export interface DashboardData {
|
|
56
|
-
/** Newest first, as the
|
|
65
|
+
/** Newest first, as the query orders them. */
|
|
57
66
|
readonly rows: readonly PostRow[];
|
|
58
67
|
/** The instant the windows were cut at, so server and test agree on "the last 7 days". */
|
|
59
68
|
readonly now: string;
|
|
@@ -78,11 +87,13 @@ export async function dashboardData(
|
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
/**
|
|
81
|
-
* Whose posts: the ACTOR's org
|
|
82
|
-
*
|
|
90
|
+
* Whose posts: the ACTOR's org, and only for an actor \`post:read\` admits. Through the query, never
|
|
91
|
+
* the repo: \`dashboard:read\` lets a viewer onto this page and says nothing about posts, and the
|
|
92
|
+
* query is where the read's policy, order and bound live. The typed handle under it scopes every
|
|
93
|
+
* read to the request's actor, so the viewer \`auth/dev-actor.ts\` resolves sees the rows
|
|
83
94
|
* \`packages/db/src/seed.ts\` wrote, and a real session sees its own org's the day one exists.
|
|
84
95
|
*/
|
|
85
|
-
const load = (): Promise<DashboardData> => dashboardData(() =>
|
|
96
|
+
const load = (): Promise<DashboardData> => dashboardData(() => postList({ limit: ROW_LIMIT }));
|
|
86
97
|
|
|
87
98
|
${themeIsland}
|
|
88
99
|
|
|
@@ -98,7 +109,10 @@ export function Page(props: PageProps) {
|
|
|
98
109
|
const now = new Date(props.data.now);
|
|
99
110
|
const stats = postStats(props.data.rows, now);
|
|
100
111
|
const points = bucketByDay(props.data.rows, now, CHART_DAYS);
|
|
112
|
+
const mix = priceMix(props.data.rows);
|
|
101
113
|
const latest = props.data.rows.slice(0, TABLE_ROWS);
|
|
114
|
+
// Per currency: a bar is a share of the dearest post IN ITS CURRENCY, never of another's minor units.
|
|
115
|
+
const ceilings = priceCeilings(latest);
|
|
102
116
|
|
|
103
117
|
return (
|
|
104
118
|
<Shell
|
|
@@ -109,8 +123,8 @@ export function Page(props: PageProps) {
|
|
|
109
123
|
>
|
|
110
124
|
<div class={styles.page}>
|
|
111
125
|
<PageHeader title={t('app.dashboard.title')} description={t('app.dashboard.subtitle')} />
|
|
112
|
-
{/* 10rem, not the catalog's 16rem default:
|
|
113
|
-
instead of
|
|
126
|
+
{/* 10rem, not the catalog's 16rem default: two tiles sit side by side on a phone and four
|
|
127
|
+
across a monitor, instead of one alone on a row. */}
|
|
114
128
|
<Grid minColumn="10rem">
|
|
115
129
|
<StatTile
|
|
116
130
|
stat="posts-total"
|
|
@@ -131,10 +145,47 @@ export function Page(props: PageProps) {
|
|
|
131
145
|
value={formatCount(stats.today, locale)}
|
|
132
146
|
hint={t('app.dashboard.hintToday')}
|
|
133
147
|
/>
|
|
148
|
+
<StatTile
|
|
149
|
+
stat="posts-paid"
|
|
150
|
+
label={t('app.dashboard.statPaid')}
|
|
151
|
+
value={formatCount(mix.paid, locale)}
|
|
152
|
+
hint={t('app.dashboard.hintPaid')}
|
|
153
|
+
/>
|
|
134
154
|
</Grid>
|
|
135
|
-
<
|
|
136
|
-
<
|
|
137
|
-
|
|
155
|
+
<div class={styles.charts}>
|
|
156
|
+
<Section
|
|
157
|
+
title={t('app.dashboard.chartTitle')}
|
|
158
|
+
description={t('app.dashboard.chartRange')}
|
|
159
|
+
>
|
|
160
|
+
<AreaChart
|
|
161
|
+
label={t('app.dashboard.chartLabel')}
|
|
162
|
+
keys={points.map((point) => point.key)}
|
|
163
|
+
series={[
|
|
164
|
+
{ label: t('app.dashboard.chartSeries'), values: points.map((p) => p.value) },
|
|
165
|
+
]}
|
|
166
|
+
keyLabel={t('app.dashboard.chartKey')}
|
|
167
|
+
/>
|
|
168
|
+
</Section>
|
|
169
|
+
<Section title={t('app.dashboard.mixTitle')}>
|
|
170
|
+
<div class={styles.side}>
|
|
171
|
+
<DonutChart
|
|
172
|
+
label={t('app.dashboard.mixLabel')}
|
|
173
|
+
segments={[
|
|
174
|
+
{ label: t('app.dashboard.mixFree'), value: mix.free },
|
|
175
|
+
{ label: t('app.dashboard.mixPaid'), value: mix.paid },
|
|
176
|
+
]}
|
|
177
|
+
centreLabel={t('app.dashboard.mixTotal')}
|
|
178
|
+
keyLabel={t('app.dashboard.mixKey')}
|
|
179
|
+
valueLabel={t('app.dashboard.statTotal')}
|
|
180
|
+
/>
|
|
181
|
+
<Gauge
|
|
182
|
+
label={t('app.dashboard.gaugeLabel')}
|
|
183
|
+
value={stats.lastWeek}
|
|
184
|
+
max={stats.total}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
</Section>
|
|
188
|
+
</div>
|
|
138
189
|
<Section title={t('app.dashboard.tableTitle')}>
|
|
139
190
|
<DataTable
|
|
140
191
|
caption={t('app.dashboard.tableCaption')}
|
|
@@ -143,9 +194,24 @@ export function Page(props: PageProps) {
|
|
|
143
194
|
emptyTitle={t('app.dashboard.emptyTitle')}
|
|
144
195
|
columns={[
|
|
145
196
|
{ key: 'title', header: t('app.dashboard.columnTitle'), cell: (row) => row.title },
|
|
197
|
+
{
|
|
198
|
+
key: 'price',
|
|
199
|
+
header: t('app.dashboard.columnPrice'),
|
|
200
|
+
numeric: true,
|
|
201
|
+
cell: (row) => (
|
|
202
|
+
<InlineBar
|
|
203
|
+
value={row.price.minor}
|
|
204
|
+
max={ceilings.get(row.price.currency) ?? 0}
|
|
205
|
+
text={formatMoney(row.price, locale)}
|
|
206
|
+
/>
|
|
207
|
+
),
|
|
208
|
+
},
|
|
146
209
|
{
|
|
147
210
|
key: 'createdAt',
|
|
148
211
|
header: t('app.dashboard.columnCreated'),
|
|
212
|
+
// Below \`md\` the date folds into the row's "more" disclosure: the title and the
|
|
213
|
+
// price are what a phone has room for, and nothing is lost.
|
|
214
|
+
priority: 2,
|
|
149
215
|
cell: (row) => (
|
|
150
216
|
<RelativeTime value={row.createdAt} now={props.data.now} locale={locale} />
|
|
151
217
|
),
|
|
@@ -163,6 +229,7 @@ const exampleView =
|
|
|
163
229
|
(): string => `// The dashboard's numbers, apart from its markup: pure functions over the rows the page loaded,
|
|
164
230
|
// so the server render and a test cannot disagree about a figure. No I/O, no \`t()\`, no JSX.
|
|
165
231
|
|
|
232
|
+
import type { Money } from '@ultimat3/money';
|
|
166
233
|
import { type ChartPoint, deltaOf, type StatDelta } from '@ultimat3/ui';
|
|
167
234
|
|
|
168
235
|
/** Days of history the chart shows, oldest first. */
|
|
@@ -174,26 +241,54 @@ const DAY_MS = 86_400_000;
|
|
|
174
241
|
export interface PostRow {
|
|
175
242
|
readonly id: string;
|
|
176
243
|
readonly title: string;
|
|
244
|
+
/** Integer minor units plus an ISO code, never a float — the entity's \`money()\` column. */
|
|
245
|
+
readonly price: Money;
|
|
177
246
|
readonly createdAt: string;
|
|
178
247
|
}
|
|
179
248
|
|
|
180
249
|
/**
|
|
181
|
-
* Duck-typed rather than the entity's own row: the page only ever reads these
|
|
182
|
-
*
|
|
250
|
+
* Duck-typed rather than the entity's own row: the page only ever reads these four columns. The
|
|
251
|
+
* query reads through the typed handle, so \`createdAt\` arrives as the \`Date\` the entity declares.
|
|
183
252
|
*/
|
|
184
253
|
export function toPostRow(row: {
|
|
185
254
|
readonly id: string;
|
|
186
255
|
readonly title: string;
|
|
256
|
+
readonly price: Money;
|
|
187
257
|
readonly createdAt: Date | string;
|
|
188
258
|
}): PostRow {
|
|
189
259
|
const at = row.createdAt;
|
|
190
260
|
return {
|
|
191
261
|
id: row.id,
|
|
192
262
|
title: row.title,
|
|
263
|
+
price: row.price,
|
|
193
264
|
createdAt: at instanceof Date ? at.toISOString() : at,
|
|
194
265
|
};
|
|
195
266
|
}
|
|
196
267
|
|
|
268
|
+
/** Free (a zero price) against paid, over every row loaded — the two halves of the ring. */
|
|
269
|
+
export interface PriceMix {
|
|
270
|
+
readonly free: number;
|
|
271
|
+
readonly paid: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function priceMix(rows: readonly PostRow[]): PriceMix {
|
|
275
|
+
const paid = rows.filter((row) => row.price.minor > 0).length;
|
|
276
|
+
return { free: rows.length - paid, paid };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The dearest price per currency, in minor units — what each row's in-cell bar is a share of.
|
|
281
|
+
* Per currency because 1,900 cents and 1,900 yen are not the same height.
|
|
282
|
+
*/
|
|
283
|
+
export function priceCeilings(rows: readonly PostRow[]): ReadonlyMap<string, number> {
|
|
284
|
+
const ceilings = new Map<string, number>();
|
|
285
|
+
for (const row of rows) {
|
|
286
|
+
const { currency, minor } = row.price;
|
|
287
|
+
ceilings.set(currency, Math.max(ceilings.get(currency) ?? 0, minor));
|
|
288
|
+
}
|
|
289
|
+
return ceilings;
|
|
290
|
+
}
|
|
291
|
+
|
|
197
292
|
export interface PostStats {
|
|
198
293
|
readonly total: number;
|
|
199
294
|
/** Created in the seven days ending at \`now\`. */
|
|
@@ -255,161 +350,6 @@ export const formatCount = (value: number, locale: string): string =>
|
|
|
255
350
|
new Intl.NumberFormat(locale).format(value);
|
|
256
351
|
`;
|
|
257
352
|
|
|
258
|
-
const exampleViewTest =
|
|
259
|
-
(): string => `// The numbers the dashboard shows, pinned against a fixed clock. The interesting cases are the
|
|
260
|
-
// window edges: a post from eight days ago is in the prior week, not this one, and a day with no
|
|
261
|
-
// posts is still a bar.
|
|
262
|
-
import { expect, unitTest } from '@ultimat3/testing';
|
|
263
|
-
import { bucketByDay, formatCount, type PostRow, postStats, toPostRow } from './dashboard-view';
|
|
264
|
-
|
|
265
|
-
const NOW = new Date('2026-03-15T12:00:00.000Z');
|
|
266
|
-
const DAY_MS = 86_400_000;
|
|
267
|
-
|
|
268
|
-
const row = (id: string, daysAgo: number): PostRow => ({
|
|
269
|
-
id,
|
|
270
|
-
title: \`post \${id}\`,
|
|
271
|
-
createdAt: new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString(),
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const rows = [row('a', 1), row('b', 3), row('c', 8)];
|
|
275
|
-
|
|
276
|
-
unitTest('postStats counts the week, compares it with the prior one, and counts today', () => {
|
|
277
|
-
const stats = postStats(rows, NOW);
|
|
278
|
-
expect(stats.total).toBe(3);
|
|
279
|
-
expect(stats.lastWeek).toBe(2);
|
|
280
|
-
// Two this week against one the week before: +100%, and the chip says so.
|
|
281
|
-
expect(stats.delta).toEqual({ text: '+100%', trend: 'up' });
|
|
282
|
-
// 'a' is yesterday and nothing was created on 03-15 itself.
|
|
283
|
-
expect(stats.today).toBe(0);
|
|
284
|
-
expect(postStats([...rows, row('d', 0.25)], NOW).today).toBe(1);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
unitTest('a zero baseline yields no delta, and no rows count nothing', () => {
|
|
288
|
-
expect(postStats([row('a', 1)], NOW).delta).toBeUndefined();
|
|
289
|
-
expect(postStats([], NOW)).toEqual({ total: 0, lastWeek: 0, delta: undefined, today: 0 });
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
unitTest('bucketByDay is one bar per day, oldest first, quiet days included', () => {
|
|
293
|
-
const points = bucketByDay(rows, NOW, 14);
|
|
294
|
-
expect(points).toHaveLength(14);
|
|
295
|
-
expect(points.at(-1)?.key).toBe('03-15');
|
|
296
|
-
expect(points[0]?.key).toBe('03-02');
|
|
297
|
-
expect(points.reduce((sum, point) => sum + point.value, 0)).toBe(3);
|
|
298
|
-
// Eight days ago is 03-07: inside a 14-day window, so the bar is there.
|
|
299
|
-
expect(points.find((point) => point.key === '03-07')?.value).toBe(1);
|
|
300
|
-
expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
unitTest('toPostRow serialises a Date and passes ISO text through', () => {
|
|
304
|
-
const at = new Date('2026-01-02T03:04:05.000Z');
|
|
305
|
-
expect(toPostRow({ id: 'x', title: 't', createdAt: at }).createdAt).toBe(at.toISOString());
|
|
306
|
-
expect(toPostRow({ id: 'x', title: 't', createdAt: '2026-01-01' }).createdAt).toBe('2026-01-01');
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
unitTest('formatCount follows the locale', () => {
|
|
310
|
-
expect(formatCount(1234, 'en')).toBe('1,234');
|
|
311
|
-
expect(formatCount(1234, 'de')).toBe('1.234');
|
|
312
|
-
});
|
|
313
|
-
`;
|
|
314
|
-
|
|
315
|
-
const examplePageTest = (
|
|
316
|
-
app: NameSet,
|
|
317
|
-
): string => `// The dashboard, rendered as a request renders it: \`load\` reads the actor's org through the
|
|
318
|
-
// slice's repo — the in-memory driver under test — and the page turns the rows into tiles, a chart
|
|
319
|
-
// and a table. Losing the policy is the other regression worth a test: the page still renders, to
|
|
320
|
-
// anyone.
|
|
321
|
-
${sortedImports([
|
|
322
|
-
`import { driver } from '@${app.kebab}/db';`,
|
|
323
|
-
`import { useT } from '@${app.kebab}/i18n';`,
|
|
324
|
-
"import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
|
|
325
|
-
"import { testActor } from '@ultimat3/policy';",
|
|
326
|
-
"import { dbUnavailable } from '@ultimat3/db';",
|
|
327
|
-
"import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
|
|
328
|
-
])}
|
|
329
|
-
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
330
|
-
import * as repo from '../post/repo';
|
|
331
|
-
import * as page from './page';
|
|
332
|
-
|
|
333
|
-
const url = 'https://example.test/dashboard';
|
|
334
|
-
const viewer = testActor('viewer', { orgId: DEMO_ORG_ID }).actor;
|
|
335
|
-
const stranger = testActor('stranger', { orgId: '00000000-0000-4000-8000-000000000009' }).actor;
|
|
336
|
-
|
|
337
|
-
const DAY_MS = 86_400_000;
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* One post in the viewer's org, written the way the app writes one — \`daysAgo\` days before now.
|
|
341
|
-
* The request's clock is what stamps \`createdAt\`, so the test chooses the instant.
|
|
342
|
-
*/
|
|
343
|
-
const post = (title: string, daysAgo: number) => {
|
|
344
|
-
const clock = frozenClock(new Date(Date.now() - daysAgo * DAY_MS));
|
|
345
|
-
const draft = { orgId: DEMO_ORG_ID, title, price: { minor: 0, currency: 'USD' } };
|
|
346
|
-
return runWithContext(createContext({ actor: viewer, clock }), () => repo.insert(draft));
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
/** The figure a tile shows, read off the markup by the \`stat\` the page gave it. */
|
|
350
|
-
const stat = (html: string, name: string): string | undefined =>
|
|
351
|
-
html.match(new RegExp(\`data-stat="\${name}">([^<]*)<\`))?.[1];
|
|
352
|
-
|
|
353
|
-
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
354
|
-
afterEach(() => {
|
|
355
|
-
driver.reset?.();
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
|
|
359
|
-
const view = await renderRoute(page, { url, actor: viewer });
|
|
360
|
-
expect(page.config.render).toBe('ssr');
|
|
361
|
-
expect(page.config.policy?.permission).toBe('dashboard:read');
|
|
362
|
-
expect(page.config.offline).toBe('runtime');
|
|
363
|
-
expect(page.config.budget.js).toBe('60kb');
|
|
364
|
-
// What the render emitted, beside what the config declares: the theme toggle, and only it.
|
|
365
|
-
expect(view.islands.map((island) => [island.moduleId, island.strategy])).toEqual([
|
|
366
|
-
['shared-theme-toggle', 'visible'],
|
|
367
|
-
]);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
unitTest('it counts and lists the posts of the org that is looking', async () => {
|
|
371
|
-
await post('Last month', 30);
|
|
372
|
-
await post('This week', 3);
|
|
373
|
-
await post('Today', 0);
|
|
374
|
-
const view = await renderRoute(page, { url, actor: viewer });
|
|
375
|
-
// Newest first, as the repo orders them — and each tile counts its own window.
|
|
376
|
-
expect(view.data.rows.map((row) => row.title)).toEqual(['Today', 'This week', 'Last month']);
|
|
377
|
-
expect(stat(view.html, 'posts-total')).toBe('3');
|
|
378
|
-
expect(stat(view.html, 'posts-week')).toBe('2');
|
|
379
|
-
expect(stat(view.html, 'posts-today')).toBe('1');
|
|
380
|
-
// One table row per post, and one bar per day of the window whether or not it has posts.
|
|
381
|
-
expect(view.html.match(/<tr data-row=/g)).toHaveLength(3);
|
|
382
|
-
expect(view.html.match(/data-bar="true"/g)).toHaveLength(14);
|
|
383
|
-
expect(view.text).toContain('This week');
|
|
384
|
-
// The sidebar knows where it is.
|
|
385
|
-
expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
unitTest('another org sees none of them: the page says so instead of an empty table', async () => {
|
|
389
|
-
const t = useT();
|
|
390
|
-
await post('Not theirs', 0);
|
|
391
|
-
const view = await renderRoute(page, { url, actor: stranger });
|
|
392
|
-
expect(view.data.rows).toEqual([]);
|
|
393
|
-
expect(stat(view.html, 'posts-total')).toBe('0');
|
|
394
|
-
expect(view.html).not.toContain('<tr data-row=');
|
|
395
|
-
expect(view.text).toContain(t('app.dashboard.emptyTitle'));
|
|
396
|
-
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
397
|
-
expect(view.meta.description).toBe(t('app.dashboard.description'));
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
unitTest('the build measures the empty page: no database is not a failure there', async () => {
|
|
401
|
-
const measured = await page.dashboardData(() => Promise.reject(dbUnavailable('no database')));
|
|
402
|
-
// The clock is frozen under test, so the instant the windows are cut at is this one.
|
|
403
|
-
expect(measured).toEqual({ rows: [], now: new Date().toISOString() });
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
unitTest('any other failure of the read propagates', async () => {
|
|
407
|
-
const cause = new TypeError('the read broke');
|
|
408
|
-
const read = (): Promise<never> => Promise.reject(cause);
|
|
409
|
-
expect(await page.dashboardData(read).catch((error: unknown) => error)).toBe(cause);
|
|
410
|
-
});
|
|
411
|
-
`;
|
|
412
|
-
|
|
413
353
|
/** The example dashboard's page, view module and the tests beside both. */
|
|
414
354
|
export const exampleDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
415
355
|
{ path: `${DASHBOARD_DIR}/page.tsx`, contents: examplePage(app) },
|
|
@@ -22,7 +22,7 @@ export const routeConfig = (load: string): string => `export const config = defi
|
|
|
22
22
|
// toggle, provider, the ui runtime they share and the error registry are the rest. It was
|
|
23
23
|
// 60.9kb before the framework stopped shipping solid-js twice and the i18n catalog with it
|
|
24
24
|
// (issue #490), which is what put this at 64kb; 60kb is the figure the scaffold budgets held
|
|
25
|
-
// before that, kept rather than tightened so a Bun patch cannot red a first \`
|
|
25
|
+
// before that, kept rather than tightened so a Bun patch cannot red a first \`bun run check\`.
|
|
26
26
|
budget: { js: '60kb' },${load}
|
|
27
27
|
meta: ({ t }) => ({
|
|
28
28
|
title: t('app.dashboard.title'),
|
|
@@ -55,4 +55,65 @@ export const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as token
|
|
|
55
55
|
margin-inline: auto;
|
|
56
56
|
color: tokens.role('fg');
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
// The trend beside the ring and the dial: side by side once the main column is wide enough for the
|
|
60
|
+
// area chart to keep its 14 day labels legible, stacked before that.
|
|
61
|
+
.charts {
|
|
62
|
+
display: grid;
|
|
63
|
+
gap: tokens.space(6);
|
|
64
|
+
min-inline-size: 0;
|
|
65
|
+
|
|
66
|
+
@include tokens.respond-to('lg') {
|
|
67
|
+
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
|
68
|
+
align-items: start;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The ring and the dial share a column: two across on a tablet, where one alone would leave half
|
|
73
|
+
// the row empty, one above the other on a phone and in the narrow side column of a monitor.
|
|
74
|
+
.side {
|
|
75
|
+
display: grid;
|
|
76
|
+
gap: tokens.space(4);
|
|
77
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `apps/web/shared/theme.ts`: the app's brand, which the boot reads by this path and inlines in every
|
|
83
|
+
* document (`theme-brand.ts`). Emitted as the default theme — `defineTheme({})` renders no CSS, so
|
|
84
|
+
* no tag and no CSP entry — with the preset one edit away: the look is the app's choice (axiom 8).
|
|
85
|
+
*/
|
|
86
|
+
export const themeFile =
|
|
87
|
+
(): string => `// This app's brand: the boot reads \`brand\` from this file and inlines it in every document,
|
|
88
|
+
// after global.scss, with its hash in the CSP. \`defineTheme({})\` is @ultimat3/ui's default theme
|
|
89
|
+
// and renders nothing, so today no tag is emitted.
|
|
90
|
+
//
|
|
91
|
+
// \`defineTheme()\` is the one seam for restyling — colours, radii, fonts and shadows by role,
|
|
92
|
+
// validated at the call. The dashboard look (near-black panels, a cyan accent, glowing edges, its
|
|
93
|
+
// own eight chart series) is one edit:
|
|
94
|
+
//
|
|
95
|
+
// export const brand = defineTheme({ preset: 'scifi' });
|
|
96
|
+
//
|
|
97
|
+
// Every other slot layers onto the preset role by role:
|
|
98
|
+
// defineTheme({ preset: 'scifi', colors: { dark: { 'chart-1': '120 220 255' } } })
|
|
99
|
+
// A preset never forces a theme: to open dark whatever the OS says, set \`theme.defaultMode\` in
|
|
100
|
+
// app.config.ts.
|
|
101
|
+
import { defineTheme } from '@ultimat3/ui';
|
|
102
|
+
|
|
103
|
+
export const brand = defineTheme({});
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
/** Its test: what the brand renders is a fact the app pins, so opting in is a visible diff. */
|
|
107
|
+
export const themeTest =
|
|
108
|
+
(): string => `// The brand the boot inlines. The default theme renders no CSS — no \`<style>\`, no CSP entry —
|
|
109
|
+
// and the preset is a whole palette; either way \`defineTheme()\` validated it at import.
|
|
110
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
111
|
+
import { defineTheme } from '@ultimat3/ui';
|
|
112
|
+
import { brand } from './theme';
|
|
113
|
+
|
|
114
|
+
unitTest('the app ships the default theme until it opts into a preset', () => {
|
|
115
|
+
expect(brand.css).toBe('');
|
|
116
|
+
// The opt-in is one edit, and what it would ship is real CSS the boot inlines.
|
|
117
|
+
expect(defineTheme({ preset: 'scifi' }).css).toContain('--color-');
|
|
118
|
+
});
|
|
58
119
|
`;
|
|
@@ -2,25 +2,29 @@
|
|
|
2
2
|
// stopped being an `<h1>` in a panel. Two shapes, one per `x new` invocation, and both show only
|
|
3
3
|
// what is true of the app that was just written:
|
|
4
4
|
//
|
|
5
|
-
// --example the seeded `post` slice: a stat row,
|
|
6
|
-
//
|
|
7
|
-
// module this file
|
|
5
|
+
// --example the seeded `post` slice: a stat row, an area chart of posts per day, a ring of
|
|
6
|
+
// free against paid, a dial of this week's share, the latest posts in a table.
|
|
7
|
+
// Real rows from the app's own repo, aggregated by a pure view module this file
|
|
8
|
+
// also emits.
|
|
8
9
|
// --no-example no entity, so no rows to count and nothing honest to chart. Framework facts
|
|
9
10
|
// instead — routes, locales, roles, version — and the route table. A chart of
|
|
10
11
|
// invented numbers would teach the wrong thing to every app that starts here.
|
|
11
12
|
//
|
|
12
|
-
// One island on the page, the theme toggle (`scaffold-shell.ts`); the tiles,
|
|
13
|
+
// One island on the page, the theme toggle (`scaffold-shell.ts`); the tiles, charts and table are
|
|
13
14
|
// server markup, which is what keeps the route inside its 60kb budget.
|
|
14
15
|
|
|
15
16
|
import type { GeneratedFile, NameSet } from './naming';
|
|
16
17
|
import { bareDashboardFiles } from './scaffold-dashboard-bare';
|
|
17
18
|
import { exampleDashboardFiles } from './scaffold-dashboard-example';
|
|
18
|
-
import { DASHBOARD_DIR, dashboardStyle } from './scaffold-dashboard-shared';
|
|
19
|
+
import { DASHBOARD_DIR, dashboardStyle, themeFile, themeTest } from './scaffold-dashboard-shared';
|
|
19
20
|
|
|
20
21
|
/** `apps/web/app/dashboard/`, in the shape the invocation earns. */
|
|
21
22
|
export function dashboardFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
|
|
22
23
|
return [
|
|
23
24
|
...(example ? exampleDashboardFiles(app) : bareDashboardFiles(app)),
|
|
24
25
|
{ path: `${DASHBOARD_DIR}/page.module.scss`, contents: dashboardStyle() },
|
|
26
|
+
// Both shapes: the look is the app's to choose whether or not it has an entity yet.
|
|
27
|
+
{ path: 'apps/web/shared/theme.ts', contents: themeFile() },
|
|
28
|
+
{ path: 'apps/web/shared/theme.test.ts', contents: themeTest() },
|
|
25
29
|
];
|
|
26
30
|
}
|
|
@@ -74,7 +74,7 @@ export const dbClientSource = (
|
|
|
74
74
|
// same two lines: its import, and \`<tableName>: <entity>,\` in the set.
|
|
75
75
|
|
|
76
76
|
${sortedImports([
|
|
77
|
-
"import {
|
|
77
|
+
"import { storeMode } from '@ultimat3/core';",
|
|
78
78
|
"import { type Driver, database, memoryDriver, postgresDriver } from '@ultimat3/entity';",
|
|
79
79
|
...entries.map(handleImportLine),
|
|
80
80
|
])}
|
|
@@ -86,9 +86,11 @@ ${sortedImports([
|
|
|
86
86
|
* \`postgresDriver()\` takes no connection: it resolves \`@ultimat3/db\`'s process client, which is
|
|
87
87
|
* the embedded PGlite under \`x dev\` and the \`DATABASE_URL\` pool in a container. \`bun test\`
|
|
88
88
|
* installs no client, so a test gets the in-memory driver — the same contract, no database.
|
|
89
|
+
* \`storeMode\` is core's one answer to that question, shared with every other seam that has a
|
|
90
|
+
* memory and a database store.
|
|
89
91
|
*/
|
|
90
92
|
export const selectDriver = (env: Readonly<Record<string, string | undefined>>): Driver =>
|
|
91
|
-
|
|
93
|
+
storeMode(env) === 'memory' ? memoryDriver() : postgresDriver();
|
|
92
94
|
|
|
93
95
|
export const driver = selectDriver(Bun.env);
|
|
94
96
|
|
|
@@ -165,7 +165,7 @@ const dbSeedTest = (app: NameSet, example: boolean): string =>
|
|
|
165
165
|
// development viewer's tenant policy refuses, and the dashboard then counts zero.
|
|
166
166
|
${sortedImports([
|
|
167
167
|
`import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
|
|
168
|
-
"import {
|
|
168
|
+
"import { ctxOf, runWithContext } from '@ultimat3/core';",
|
|
169
169
|
"import { testActor } from '@ultimat3/policy';",
|
|
170
170
|
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
171
171
|
])}
|
|
@@ -174,7 +174,7 @@ import { ${app.camel}Seed } from './seed';
|
|
|
174
174
|
|
|
175
175
|
/** The development viewer's org: every read below runs as an actor inside it. */
|
|
176
176
|
const asViewer = <T>(run: () => Promise<T>): Promise<T> =>
|
|
177
|
-
runWithContext(
|
|
177
|
+
runWithContext(ctxOf({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
|
|
178
178
|
|
|
179
179
|
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
180
180
|
afterEach(() => {
|