@ultimat3/cli 22.15.0 → 23.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 +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
|
@@ -33,7 +33,7 @@ ${themeIsland}
|
|
|
33
33
|
|
|
34
34
|
${routeConfig('')}
|
|
35
35
|
|
|
36
|
-
export function
|
|
36
|
+
export function Page() {
|
|
37
37
|
const t = useT();
|
|
38
38
|
const locale = currentLocale();
|
|
39
39
|
// Read at render, not at import: the registries are filled by the boot scan, after this module.
|
|
@@ -174,9 +174,71 @@ unitTest('formatCount follows the locale', () => {
|
|
|
174
174
|
});
|
|
175
175
|
`;
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
const barePageTest = (
|
|
178
|
+
app: NameSet,
|
|
179
|
+
): string => `// The dashboard, rendered as a request renders it. Its facts are the framework's registries, so
|
|
180
|
+
// the test registers the one route it renders — the boot scan's job in a running app — and reads
|
|
181
|
+
// the tiles and the route table back off the markup.
|
|
182
|
+
${sortedImports([
|
|
183
|
+
`import { catalogs, useT } from '@${app.kebab}/i18n';`,
|
|
184
|
+
"import { frameworkVersion } from '@ultimat3/core';",
|
|
185
|
+
"import { clearRoutes, registerRoute } from '@ultimat3/render';",
|
|
186
|
+
"import { afterAll, beforeAll, expect, renderRoute, unitTest } from '@ultimat3/testing';",
|
|
187
|
+
])}
|
|
188
|
+
import { roles } from '../../shared/roles';
|
|
189
|
+
import * as page from './page';
|
|
190
|
+
|
|
191
|
+
const url = 'https://example.test/dashboard';
|
|
192
|
+
|
|
193
|
+
/** The figure a tile shows, read off the markup by the \`stat\` the page gave it. */
|
|
194
|
+
const stat = (html: string, name: string): string | undefined =>
|
|
195
|
+
html.match(new RegExp(\`data-stat="\${name}">([^<]*)<\`))?.[1];
|
|
196
|
+
|
|
197
|
+
// The route table is process-global and a test process starts with none: this file registers the
|
|
198
|
+
// page it renders, and takes it back out so no later file inherits a route it never declared.
|
|
199
|
+
beforeAll(() => {
|
|
200
|
+
registerRoute({ file: 'apps/web/app/dashboard/page.tsx', config: page.config });
|
|
201
|
+
});
|
|
202
|
+
afterAll(clearRoutes);
|
|
203
|
+
|
|
204
|
+
unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
|
|
205
|
+
const view = await renderRoute(page, { url });
|
|
206
|
+
expect(page.config.render).toBe('ssr');
|
|
207
|
+
expect(page.config.policy?.permission).toBe('dashboard:read');
|
|
208
|
+
expect(page.config.offline).toBe('runtime');
|
|
209
|
+
expect(page.config.budget.js).toBe('60kb');
|
|
210
|
+
// What the render emitted, beside what the config declares: the theme toggle, and only it.
|
|
211
|
+
expect(view.islands.map((island) => [island.moduleId, island.strategy])).toEqual([
|
|
212
|
+
['shared-theme-toggle', 'visible'],
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
unitTest('the tiles are the framework registries, counted', async () => {
|
|
217
|
+
const view = await renderRoute(page, { url });
|
|
218
|
+
expect(stat(view.html, 'routes')).toBe('1');
|
|
219
|
+
expect(stat(view.html, 'locales')).toBe(String(catalogs.locales.length));
|
|
220
|
+
expect(stat(view.html, 'roles')).toBe(String(Object.keys(roles).length));
|
|
221
|
+
expect(stat(view.html, 'version')).toBe(frameworkVersion());
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
unitTest('the table is the route table: path, surface and render mode per route', async () => {
|
|
225
|
+
const t = useT();
|
|
226
|
+
const view = await renderRoute(page, { url });
|
|
227
|
+
expect(view.html.match(/<tr data-row=/g)).toHaveLength(1);
|
|
228
|
+
expect(view.html).toMatch(
|
|
229
|
+
/<tr data-row="\\/dashboard"><td>\\/dashboard<\\/td><td>app<\\/td><td>ssr<\\/td>/,
|
|
230
|
+
);
|
|
231
|
+
expect(view.text).toContain(t('app.dashboard.routesCaption'));
|
|
232
|
+
expect(view.meta.title).toBe(t('app.dashboard.title'));
|
|
233
|
+
// The sidebar knows where it is.
|
|
234
|
+
expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
|
|
235
|
+
});
|
|
236
|
+
`;
|
|
237
|
+
|
|
238
|
+
/** The bare dashboard's page, view module and the tests beside both. */
|
|
178
239
|
export const bareDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
179
240
|
{ path: `${DASHBOARD_DIR}/page.tsx`, contents: barePage(app) },
|
|
180
241
|
{ path: `${DASHBOARD_DIR}/dashboard-view.ts`, contents: bareView() },
|
|
181
242
|
{ path: `${DASHBOARD_DIR}/dashboard-view.test.ts`, contents: bareViewTest() },
|
|
243
|
+
{ path: `${DASHBOARD_DIR}/page.test.ts`, contents: barePageTest(app) },
|
|
182
244
|
];
|
|
@@ -34,7 +34,6 @@ ${sortedImports([
|
|
|
34
34
|
"} from '@ultimat3/ui';",
|
|
35
35
|
].join('\n'),
|
|
36
36
|
])}
|
|
37
|
-
import { DEMO_ORG_ID } from '../../shared/demo-org';
|
|
38
37
|
import { Shell } from '../../shared/shell';
|
|
39
38
|
import * as repo from '../post/repo';
|
|
40
39
|
import {
|
|
@@ -47,14 +46,6 @@ import {
|
|
|
47
46
|
} from './dashboard-view';
|
|
48
47
|
import styles from './page.module.scss';
|
|
49
48
|
|
|
50
|
-
/**
|
|
51
|
-
* Whose posts. The route's policy decides who may open this page; which org's rows it aggregates
|
|
52
|
-
* is a decision the load makes, and until this app issues sessions the only org with rows is the
|
|
53
|
-
* one \`packages/db/src/seed.ts\` writes. The day sessions exist, this becomes the actor's org and
|
|
54
|
-
* the read becomes \`postList.as(actor, …)\` — the query already declares the tenancy rule.
|
|
55
|
-
*/
|
|
56
|
-
const DEMO_ORG = DEMO_ORG_ID;
|
|
57
|
-
|
|
58
49
|
/** The stat row counts what it can see. Past this many posts, write an aggregate query. */
|
|
59
50
|
const ROW_LIMIT = 500;
|
|
60
51
|
|
|
@@ -68,29 +59,40 @@ export interface DashboardData {
|
|
|
68
59
|
readonly now: string;
|
|
69
60
|
}
|
|
70
61
|
|
|
71
|
-
|
|
62
|
+
/**
|
|
63
|
+
* What \`load\` answers, with the read as a parameter. The one branch a running app never takes is
|
|
64
|
+
* the build's: \`x build --target static\` renders every route once to measure its JS budget with
|
|
65
|
+
* no database wired, and that render gets the real empty branches — which weigh the same markup —
|
|
66
|
+
* instead of a route the budget step cannot measure. Any other failure still propagates.
|
|
67
|
+
*/
|
|
68
|
+
export async function dashboardData(
|
|
69
|
+
read: () => Promise<readonly Parameters<typeof toPostRow>[0][]>,
|
|
70
|
+
): Promise<DashboardData> {
|
|
72
71
|
const now = new Date().toISOString();
|
|
73
72
|
try {
|
|
74
|
-
|
|
75
|
-
return { rows: rows.map(toPostRow), now };
|
|
73
|
+
return { rows: (await read()).map(toPostRow), now };
|
|
76
74
|
} catch (error) {
|
|
77
|
-
// \`x build --target static\` renders every route once to measure its JS budget, with no
|
|
78
|
-
// database wired — so this is that measurement pass, not a request. Empty rows render the
|
|
79
|
-
// real empty branches, which measure the same markup. Any other failure still propagates.
|
|
80
75
|
if (isUltimateError(error) && error.code === 'X_DB_UNAVAILABLE') return { rows: [], now };
|
|
81
76
|
throw error;
|
|
82
77
|
}
|
|
83
78
|
}
|
|
84
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Whose posts: the ACTOR's org. The repo names none — the typed handle scopes every read to the
|
|
82
|
+
* request's actor — so the viewer \`auth/dev-actor.ts\` resolves sees the rows
|
|
83
|
+
* \`packages/db/src/seed.ts\` wrote, and a real session sees its own org's the day one exists.
|
|
84
|
+
*/
|
|
85
|
+
const load = (): Promise<DashboardData> => dashboardData(() => repo.list(ROW_LIMIT));
|
|
86
|
+
|
|
85
87
|
${themeIsland}
|
|
86
88
|
|
|
87
89
|
${routeConfig('\n load,')}
|
|
88
90
|
|
|
89
|
-
export interface
|
|
91
|
+
export interface PageProps {
|
|
90
92
|
readonly data: DashboardData;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
export function
|
|
95
|
+
export function Page(props: PageProps) {
|
|
94
96
|
const t = useT();
|
|
95
97
|
const locale = currentLocale();
|
|
96
98
|
const now = new Date(props.data.now);
|
|
@@ -176,20 +178,15 @@ export interface PostRow {
|
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
/**
|
|
179
|
-
* Duck-typed rather than the entity's own row: the page only ever reads these three columns.
|
|
180
|
-
*
|
|
181
|
-
* BOTH spellings of the timestamp, and that is a measured fact rather than caution: the repo's
|
|
182
|
-
* \`select *\` hands back column names as Postgres has them (\`created_at\`) while its row type
|
|
183
|
-
* says \`createdAt\`, so the typed property is \`undefined\` on a real row. Until the repo aliases
|
|
184
|
-
* its columns — dz-showcase's does — the page reads whichever one arrived.
|
|
181
|
+
* Duck-typed rather than the entity's own row: the page only ever reads these three columns. The
|
|
182
|
+
* repo reads through the typed handle, so \`createdAt\` arrives as the \`Date\` the entity declares.
|
|
185
183
|
*/
|
|
186
184
|
export function toPostRow(row: {
|
|
187
185
|
readonly id: string;
|
|
188
186
|
readonly title: string;
|
|
189
|
-
readonly createdAt
|
|
190
|
-
readonly created_at?: Date | string | undefined;
|
|
187
|
+
readonly createdAt: Date | string;
|
|
191
188
|
}): PostRow {
|
|
192
|
-
const at = row.createdAt
|
|
189
|
+
const at = row.createdAt;
|
|
193
190
|
return {
|
|
194
191
|
id: row.id,
|
|
195
192
|
title: row.title,
|
|
@@ -303,13 +300,10 @@ unitTest('bucketByDay is one bar per day, oldest first, quiet days included', ()
|
|
|
303
300
|
expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
|
|
304
301
|
});
|
|
305
302
|
|
|
306
|
-
unitTest('toPostRow serialises a Date
|
|
303
|
+
unitTest('toPostRow serialises a Date and passes ISO text through', () => {
|
|
307
304
|
const at = new Date('2026-01-02T03:04:05.000Z');
|
|
308
305
|
expect(toPostRow({ id: 'x', title: 't', createdAt: at }).createdAt).toBe(at.toISOString());
|
|
309
306
|
expect(toPostRow({ id: 'x', title: 't', createdAt: '2026-01-01' }).createdAt).toBe('2026-01-01');
|
|
310
|
-
// What \`select *\` really returns: the snake_case column, and no \`createdAt\` at all.
|
|
311
|
-
expect(toPostRow({ id: 'x', title: 't', created_at: at }).createdAt).toBe(at.toISOString());
|
|
312
|
-
expect(toPostRow({ id: 'x', title: 't' }).createdAt).toBe('');
|
|
313
307
|
});
|
|
314
308
|
|
|
315
309
|
unitTest('formatCount follows the locale', () => {
|
|
@@ -318,9 +312,108 @@ unitTest('formatCount follows the locale', () => {
|
|
|
318
312
|
});
|
|
319
313
|
`;
|
|
320
314
|
|
|
321
|
-
|
|
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
|
+
/** The example dashboard's page, view module and the tests beside both. */
|
|
322
414
|
export const exampleDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
|
|
323
415
|
{ path: `${DASHBOARD_DIR}/page.tsx`, contents: examplePage(app) },
|
|
324
416
|
{ path: `${DASHBOARD_DIR}/dashboard-view.ts`, contents: exampleView() },
|
|
325
417
|
{ path: `${DASHBOARD_DIR}/dashboard-view.test.ts`, contents: exampleViewTest() },
|
|
418
|
+
{ path: `${DASHBOARD_DIR}/page.test.ts`, contents: examplePageTest(app) },
|
|
326
419
|
];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What both dashboards share — `scaffold-dashboard.ts` picks one of `scaffold-dashboard-example.ts`
|
|
2
2
|
// and `scaffold-dashboard-bare.ts` per invocation, and everything the two must agree on lives here:
|
|
3
|
-
// the route declaration, the theme island
|
|
3
|
+
// the route declaration, the theme island and the stylesheet.
|
|
4
4
|
|
|
5
5
|
export const DASHBOARD_DIR = 'apps/web/app/dashboard';
|
|
6
6
|
|
|
@@ -56,22 +56,3 @@ export const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as token
|
|
|
56
56
|
color: tokens.role('fg');
|
|
57
57
|
}
|
|
58
58
|
`;
|
|
59
|
-
|
|
60
|
-
export const dashboardPageTest =
|
|
61
|
-
(): string => `// The dashboard renders per request, is gated by a policy, hydrates its one island, and stays
|
|
62
|
-
// under its budget. Losing the policy is the interesting regression: the page still renders, to
|
|
63
|
-
// anyone.
|
|
64
|
-
import { expect, unitTest } from '@ultimat3/testing';
|
|
65
|
-
import { config } from './page';
|
|
66
|
-
|
|
67
|
-
unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
|
|
68
|
-
expect(config.render).toBe('ssr');
|
|
69
|
-
expect(config.policy?.permission).toBe('dashboard:read');
|
|
70
|
-
expect(config.offline).toBe('runtime');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
unitTest('the dashboard hydrates its one island inside a stated budget', () => {
|
|
74
|
-
expect(config.hydrate).toBe('visible');
|
|
75
|
-
expect(config.budget.js).toBe('60kb');
|
|
76
|
-
});
|
|
77
|
-
`;
|
|
@@ -15,13 +15,12 @@
|
|
|
15
15
|
import type { GeneratedFile, NameSet } from './naming';
|
|
16
16
|
import { bareDashboardFiles } from './scaffold-dashboard-bare';
|
|
17
17
|
import { exampleDashboardFiles } from './scaffold-dashboard-example';
|
|
18
|
-
import { DASHBOARD_DIR,
|
|
18
|
+
import { DASHBOARD_DIR, dashboardStyle } from './scaffold-dashboard-shared';
|
|
19
19
|
|
|
20
20
|
/** `apps/web/app/dashboard/`, in the shape the invocation earns. */
|
|
21
21
|
export function dashboardFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
|
|
22
22
|
return [
|
|
23
23
|
...(example ? exampleDashboardFiles(app) : bareDashboardFiles(app)),
|
|
24
24
|
{ path: `${DASHBOARD_DIR}/page.module.scss`, contents: dashboardStyle() },
|
|
25
|
-
{ path: `${DASHBOARD_DIR}/page.test.ts`, contents: dashboardPageTest() },
|
|
26
25
|
];
|
|
27
26
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The generated app's `packages/db/src/client.ts`: the ONE typed database handle, and the entity
|
|
2
|
+
// set `x g entity` / `x g resource` add to. Its own module rather than a section of
|
|
3
|
+
// `scaffold-db-package.ts` because `handle-registration.ts` writes the same file into an app that
|
|
4
|
+
// predates it — one template, two writers.
|
|
5
|
+
|
|
6
|
+
import { sortedImports } from './imports';
|
|
7
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
8
|
+
import { names } from './naming';
|
|
9
|
+
|
|
10
|
+
/** Where the handle lives, relative to the app root. The generators' one anchor. */
|
|
11
|
+
export const HANDLE_FILE = 'packages/db/src/client.ts';
|
|
12
|
+
|
|
13
|
+
/** The scope a pure call that names no app writes — `x g` and `x new` always name theirs. */
|
|
14
|
+
export const PLACEHOLDER_DB_MODULE = '@app/db';
|
|
15
|
+
|
|
16
|
+
/** One entity in the handle's set. */
|
|
17
|
+
export interface HandleEntry {
|
|
18
|
+
/** The key on the handle — the table's name, camelCase: `db.posts`. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
/** The entity's exported binding: `post`. */
|
|
21
|
+
readonly binding: string;
|
|
22
|
+
/** Where the binding is imported from: `@myapp/web/app/post/entity`. */
|
|
23
|
+
readonly specifier: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The line an entity takes in the set. Spelled once: the template and the registrar both write it. */
|
|
27
|
+
export const handleEntryLine = (entry: HandleEntry): string => ` ${entry.key}: ${entry.binding},`;
|
|
28
|
+
|
|
29
|
+
/** The import that line needs. */
|
|
30
|
+
export const handleImportLine = (entry: HandleEntry): string =>
|
|
31
|
+
`import { ${entry.binding} } from '${entry.specifier}';`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The web workspace's package name, from the db package's: both carry the app's scope, which is
|
|
35
|
+
* how `x new` names them and how the root tsconfig's `paths` resolves them.
|
|
36
|
+
*/
|
|
37
|
+
export const webModuleOf = (dbModule: string): string =>
|
|
38
|
+
`${dbModule.slice(0, dbModule.lastIndexOf('/'))}/web`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The entry for the entity `x g entity <rawName>` writes into `<surfaceDir>/<feature>/entity.ts`.
|
|
42
|
+
* The key is `NameSet.plural` — the same word `repo.ts` reads the table under.
|
|
43
|
+
*/
|
|
44
|
+
export function handleEntryFor(
|
|
45
|
+
rawName: string,
|
|
46
|
+
target: { readonly surfaceDir: string; readonly feature: string; readonly dbModule: string },
|
|
47
|
+
): HandleEntry {
|
|
48
|
+
const name = names(rawName);
|
|
49
|
+
// `apps/web/app` → `app`: the web workspace's own root is what its package name stands for.
|
|
50
|
+
const inWorkspace = target.surfaceDir.split('/').slice(2).join('/');
|
|
51
|
+
return {
|
|
52
|
+
key: name.plural,
|
|
53
|
+
binding: name.camel,
|
|
54
|
+
specifier: `${webModuleOf(target.dbModule)}/${inWorkspace}/${target.feature}/entity`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const entitySet = (entries: readonly HandleEntry[]): string =>
|
|
59
|
+
entries.length === 0
|
|
60
|
+
? 'const entities = {};'
|
|
61
|
+
: `const entities = {\n${entries.map(handleEntryLine).join('\n')}\n};`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The handle, over `entries`. The set is a named, one-entry-per-line object on purpose: it is the
|
|
65
|
+
* anchor `x g entity` inserts a line into, and an expanded object literal is a shape Biome leaves
|
|
66
|
+
* alone at any length — an inline `database({ a, b }, …)` re-wraps as it grows.
|
|
67
|
+
*/
|
|
68
|
+
export const dbClientSource = (
|
|
69
|
+
entries: readonly HandleEntry[],
|
|
70
|
+
): string => `// The one typed database handle. \`db.<table>\` exists because the entity is in the set below —
|
|
71
|
+
// nobody writes a repository class per entity, and nobody can reach a table that is not listed.
|
|
72
|
+
//
|
|
73
|
+
// \`x g entity\` and \`x g resource\` add their entity to \`entities\`. One written by hand is the
|
|
74
|
+
// same two lines: its import, and \`<tableName>: <entity>,\` in the set.
|
|
75
|
+
|
|
76
|
+
${sortedImports([
|
|
77
|
+
"import { resolveEnvironment } from '@ultimat3/core';",
|
|
78
|
+
"import { type Driver, database, memoryDriver, postgresDriver } from '@ultimat3/entity';",
|
|
79
|
+
...entries.map(handleImportLine),
|
|
80
|
+
])}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One driver, NAMED rather than defaulted, and exported so a test resets the same object the app
|
|
84
|
+
* reads through (\`driver.reset?.()\`).
|
|
85
|
+
*
|
|
86
|
+
* \`postgresDriver()\` takes no connection: it resolves \`@ultimat3/db\`'s process client, which is
|
|
87
|
+
* the embedded PGlite under \`x dev\` and the \`DATABASE_URL\` pool in a container. \`bun test\`
|
|
88
|
+
* installs no client, so a test gets the in-memory driver — the same contract, no database.
|
|
89
|
+
*/
|
|
90
|
+
export const selectDriver = (env: Readonly<Record<string, string | undefined>>): Driver =>
|
|
91
|
+
resolveEnvironment({ env }) === 'test' ? memoryDriver() : postgresDriver();
|
|
92
|
+
|
|
93
|
+
export const driver = selectDriver(Bun.env);
|
|
94
|
+
|
|
95
|
+
/** Every entity the handle serves, keyed by the name its table takes on \`db\`. */
|
|
96
|
+
${entitySet(entries)}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Only a feature's \`repo.ts\`, a \`query\`'s \`sql\` or a seed may use this. A route importing it is
|
|
100
|
+
* \`X_BOUNDARY_ROUTE_TO_DB\`.
|
|
101
|
+
*/
|
|
102
|
+
export const db = database(entities, { driver });
|
|
103
|
+
|
|
104
|
+
export type Db = typeof db;
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
const clientTest = (
|
|
108
|
+
app: NameSet,
|
|
109
|
+
): string => `// Which store the handle reads: memory under \`bun test\`, the process's Postgres client
|
|
110
|
+
// everywhere else. A handle that silently took the memory driver in a container would serve an
|
|
111
|
+
// empty database with a green gate.
|
|
112
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
113
|
+
import { db, driver, selectDriver } from './client';
|
|
114
|
+
|
|
115
|
+
unitTest('${app.kebab} reads memory under test and Postgres everywhere else', () => {
|
|
116
|
+
// Only the in-memory driver can empty itself — a Postgres driver that could truncate an app's
|
|
117
|
+
// rows from a test eventually would — so \`reset\` is what tells the two apart.
|
|
118
|
+
expect(selectDriver({ NODE_ENV: 'test' }).reset).toBeDefined();
|
|
119
|
+
expect(selectDriver({ NODE_ENV: 'production' }).reset).toBeUndefined();
|
|
120
|
+
expect(selectDriver({ ULTIMATE_ENV: 'development' }).reset).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
unitTest('the handle is built over the driver this module exports', () => {
|
|
124
|
+
expect(driver.reset).toBeDefined();
|
|
125
|
+
expect(typeof db).toBe('object');
|
|
126
|
+
});
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `packages/db/src/client.ts` and its test. With the example slice the set holds `post`; under
|
|
131
|
+
* `--no-example` it is empty and the first `x g entity` fills it.
|
|
132
|
+
*/
|
|
133
|
+
export const dbClientFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => {
|
|
134
|
+
const dbModule = `@${app.kebab}/db`;
|
|
135
|
+
const entries = example
|
|
136
|
+
? [handleEntryFor('post', { surfaceDir: 'apps/web/app', feature: 'post', dbModule })]
|
|
137
|
+
: [];
|
|
138
|
+
return [
|
|
139
|
+
{ path: HANDLE_FILE, contents: dbClientSource(entries) },
|
|
140
|
+
{ path: 'packages/db/src/client.test.ts', contents: clientTest(app) },
|
|
141
|
+
];
|
|
142
|
+
};
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// and the deterministic seed. No business logic — that is the package's own stated boundary, and it
|
|
3
3
|
// is why `example` reaches only the two files describing the slice's table.
|
|
4
4
|
//
|
|
5
|
-
// No migration. `x db gen` is the ONE writer of `packages/db/migrations
|
|
6
|
-
// wrote `0000_initial.sql` was a second one: it
|
|
7
|
-
// diffed, so the first `x db gen` saw a schema
|
|
8
|
-
// about what "initial" meant. `x db gen
|
|
9
|
-
// the `.sql`, the `.snapshot.json
|
|
5
|
+
// No migration and no schema dump. `x db gen` is the ONE writer of `packages/db/migrations` and
|
|
6
|
+
// of `packages/db/schema`, and a scaffold that hand-wrote `0000_initial.sql` was a second one: it
|
|
7
|
+
// declared a `posts` table the generator had never diffed, so the first `x db gen` saw a schema
|
|
8
|
+
// the ledger already claimed and the two disagreed about what "initial" meant. `x db gen
|
|
9
|
+
// "initial"` is the app's first command instead — it writes the `.sql`, the `.snapshot.json`, the
|
|
10
|
+
// `.hash` and the dump together, which no hand-written file can.
|
|
10
11
|
|
|
12
|
+
import { sortedImports } from './imports';
|
|
11
13
|
import type { GeneratedFile, NameSet } from './naming';
|
|
14
|
+
import { dbClientFiles } from './scaffold-db-client';
|
|
12
15
|
import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
|
|
13
16
|
|
|
14
17
|
const DESCRIPTION = 'Entity re-exports and SQL migrations, no business logic';
|
|
@@ -45,10 +48,14 @@ const dbPackage = (app: NameSet, example: boolean): string =>
|
|
|
45
48
|
: workspacePackageJson(app, 'db', DESCRIPTION);
|
|
46
49
|
|
|
47
50
|
const dbIndex =
|
|
48
|
-
(): string => `// Schema and
|
|
49
|
-
//
|
|
51
|
+
(): string => `// Schema, migrations and the typed handle — no business logic lives in this package.
|
|
52
|
+
// \`db\` is the handle \`./client\` builds over the app's entities: \`db.<table>\`, which is what a
|
|
53
|
+
// feature's repo.ts reads through. \`sql\` and \`withTransaction\` are @ultimat3/db's own, and the
|
|
54
|
+
// connection pool behind all three is one, sized by ROLE.
|
|
50
55
|
export type { DbClient, SqlFragment } from '@ultimat3/db';
|
|
51
|
-
export {
|
|
56
|
+
export { sql, withTransaction } from '@ultimat3/db';
|
|
57
|
+
export type { Db } from './client';
|
|
58
|
+
export { db, driver, selectDriver } from './client';
|
|
52
59
|
export * as schema from './schema';
|
|
53
60
|
`;
|
|
54
61
|
|
|
@@ -151,11 +158,86 @@ export const ${app.camel}Seed = defineSeed('${app.kebab}', async () => {
|
|
|
151
158
|
});
|
|
152
159
|
`;
|
|
153
160
|
|
|
161
|
+
const dbSeedTest = (app: NameSet, example: boolean): string =>
|
|
162
|
+
example
|
|
163
|
+
? `// The seed, run against the in-memory driver: the rows it writes, whose they are, and that a
|
|
164
|
+
// second run writes nothing. The org is the point — a row seeded under any other id is one the
|
|
165
|
+
// development viewer's tenant policy refuses, and the dashboard then counts zero.
|
|
166
|
+
${sortedImports([
|
|
167
|
+
`import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
|
|
168
|
+
"import { createContext, runWithContext } from '@ultimat3/core';",
|
|
169
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
170
|
+
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
171
|
+
])}
|
|
172
|
+
import { db, driver } from './client';
|
|
173
|
+
import { ${app.camel}Seed } from './seed';
|
|
174
|
+
|
|
175
|
+
/** The development viewer's org: every read below runs as an actor inside it. */
|
|
176
|
+
const asViewer = <T>(run: () => Promise<T>): Promise<T> =>
|
|
177
|
+
runWithContext(createContext({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
|
|
178
|
+
|
|
179
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
driver.reset?.();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
unitTest('the seed writes its posts under the demo org', async () => {
|
|
185
|
+
const run = await ${app.camel}Seed.run({ driver });
|
|
186
|
+
expect(run.metrics).toEqual({ inserted: 2, updated: 0, skipped: 0 });
|
|
187
|
+
const rows = await asViewer(() => db.posts.where({ orgId: DEMO_ORG_ID }).orderBy('title').all());
|
|
188
|
+
expect(rows.map((row) => row.title)).toEqual(['Hello ${app.pascal}', 'Second post']);
|
|
189
|
+
expect(rows.map((row) => row.price.minor)).toEqual([0, 1900]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
unitTest('a replay writes nothing: its ids are derived from labels, never generated', async () => {
|
|
193
|
+
await ${app.camel}Seed.run({ driver });
|
|
194
|
+
const replay = await ${app.camel}Seed.run({ driver });
|
|
195
|
+
expect(replay.metrics).toEqual({ inserted: 0, updated: 0, skipped: 2 });
|
|
196
|
+
});
|
|
197
|
+
`
|
|
198
|
+
: `// The seed as \`x db seed\` finds it: a declared \`defineSeed()\` under this app's name, which
|
|
199
|
+
// writes nothing until an entity exists. The day it inserts a row, the last assertion says so.
|
|
200
|
+
import { isSeed } from '@ultimat3/entity';
|
|
201
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
202
|
+
import { driver } from './client';
|
|
203
|
+
import { ${app.camel}Seed } from './seed';
|
|
204
|
+
|
|
205
|
+
unitTest('the seed is one x db seed discovers, in the dev tier', () => {
|
|
206
|
+
expect(isSeed(${app.camel}Seed)).toBe(true);
|
|
207
|
+
expect(${app.camel}Seed.name).toBe('${app.kebab}');
|
|
208
|
+
// \`dev\`: fixture data, never loaded into production. \`reference\` is the other tier.
|
|
209
|
+
expect(${app.camel}Seed.tier).toBe('dev');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
unitTest('it writes nothing yet', async () => {
|
|
213
|
+
const run = await ${app.camel}Seed.run({ driver });
|
|
214
|
+
expect(run.metrics).toEqual({ inserted: 0, updated: 0, skipped: 0 });
|
|
215
|
+
});
|
|
216
|
+
`;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `packages/db/schema/` is written by `x db gen` and held by the `drift` step, so two things about
|
|
220
|
+
* it are git's to know. `linguist-generated` collapses it in a review — the migration is what a
|
|
221
|
+
* human reads; the dump is its projection. `text eol=lf` is not cosmetic: the gate compares bytes,
|
|
222
|
+
* and a checkout that rewrote line endings would be `X_SCHEMA_DUMP_DRIFT` on a correct tree.
|
|
223
|
+
*
|
|
224
|
+
* The attributes ship; the directory does not. `x new` scaffolds no migration, so there is nothing
|
|
225
|
+
* to dump until the first `x db gen` — which creates it.
|
|
226
|
+
*/
|
|
227
|
+
export const DB_GITATTRIBUTES = `# Generated by \`x db gen\` from the migrations and held equal to them by \`x verify\`'s drift
|
|
228
|
+
# step (X_SCHEMA_DUMP_DRIFT). Review the migration, not this projection of it.
|
|
229
|
+
schema/** linguist-generated=true text eol=lf
|
|
230
|
+
`;
|
|
231
|
+
|
|
154
232
|
/** Every file the `packages/db` workspace ships, in the order `x new` writes them. */
|
|
155
233
|
export const dbPackageFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => [
|
|
156
234
|
{ path: 'packages/db/package.json', contents: dbPackage(app, example) },
|
|
235
|
+
{ path: 'packages/db/.gitattributes', contents: DB_GITATTRIBUTES },
|
|
157
236
|
...packageShapeFiles(app, 'db', DESCRIPTION),
|
|
158
237
|
{ path: 'packages/db/src/index.ts', contents: dbIndex() },
|
|
238
|
+
// The typed handle and its test — `scaffold-db-client.ts`, shared with `x g entity`.
|
|
239
|
+
...dbClientFiles(app, example),
|
|
159
240
|
{ path: 'packages/db/src/schema.ts', contents: dbSchema(app, example) },
|
|
160
241
|
{ path: 'packages/db/src/seed.ts', contents: dbSeed(app, example) },
|
|
242
|
+
{ path: 'packages/db/src/seed.test.ts', contents: dbSeedTest(app, example) },
|
|
161
243
|
];
|