@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `x g admin:page <name>` — a screen the admin derives from nothing: a reconciliation fixer, a
|
|
2
|
+
// proxy health board, a deploy button. What the template has to get right is what it does NOT
|
|
3
|
+
// emit: no `defineRoute`, because `pages:` is the one thing that puts a page in the admin's route
|
|
4
|
+
// table and `guardedPage()` is the one thing that decides it. A scaffold that wrote a route
|
|
5
|
+
// declaration here would hand back the unguarded second way in that seam exists to close.
|
|
6
|
+
|
|
7
|
+
import { catalogJson } from './catalog-json';
|
|
8
|
+
import { catalogPath, resolveLocales } from './locales';
|
|
9
|
+
import type { GeneratedFile } from './naming';
|
|
10
|
+
import { camel, kebab, pascal } from './naming';
|
|
11
|
+
|
|
12
|
+
/** Where an admin lives when the caller does not say. `x new` scaffolds this layout. */
|
|
13
|
+
export const DEFAULT_ADMIN_PAGE_DIR = 'apps/admin/src/pages';
|
|
14
|
+
|
|
15
|
+
export interface AdminPageOptions {
|
|
16
|
+
/** The permission the page's own work needs. `admin:read` is composed in front of it. */
|
|
17
|
+
readonly permission: string;
|
|
18
|
+
/**
|
|
19
|
+
* Directory the page lands in, app-root-relative and POSIX — the same `--at` `x g island` takes,
|
|
20
|
+
* and for the same reason: an app's admin is wherever its `defineAdmin` is, which no generator
|
|
21
|
+
* can derive. `apps/admin/app/admin/` is as real a layout as the scaffold's, and a hardcoded
|
|
22
|
+
* destination means every such app moves the two files by hand after every run.
|
|
23
|
+
*/
|
|
24
|
+
readonly dir?: string;
|
|
25
|
+
readonly locales?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const titleKeyFor = (name: string): string => `admin.${name}.title`;
|
|
29
|
+
|
|
30
|
+
const pageSource = (name: string, permission: string, dir: string): string => {
|
|
31
|
+
const Name = pascal(name);
|
|
32
|
+
const declaration = camel(name);
|
|
33
|
+
return `// Admin page: /${name}. An ORDINARY component — there is no \`defineRoute\` here, deliberately:
|
|
34
|
+
// \`pages:\` is what puts this in the admin's route table and \`guardedPage()\` is what decides it,
|
|
35
|
+
// so a route declaration in this file would be a second, unguarded way in.
|
|
36
|
+
//
|
|
37
|
+
// Wire it in once, and add \`navGroup\` to link it in the sidebar — this file is ${dir}/${name}.tsx,
|
|
38
|
+
// so the specifier is relative to wherever \`defineAdmin\` lives:
|
|
39
|
+
// import { ${declaration}Page } from './${name}';
|
|
40
|
+
// defineAdmin({ …, pages: […, ${declaration}Page] })
|
|
41
|
+
|
|
42
|
+
import type { AdminCustomPage, AdminPageProps } from '@ultimat3/admin';
|
|
43
|
+
import { t } from '@ultimat3/i18n';
|
|
44
|
+
|
|
45
|
+
export function ${Name}Page(props: AdminPageProps) {
|
|
46
|
+
return (
|
|
47
|
+
<section>
|
|
48
|
+
<h1>{t('${titleKeyFor(name)}')}</h1>
|
|
49
|
+
<p>{props.url}</p>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const ${declaration}Page: AdminCustomPage = {
|
|
55
|
+
path: '/${name}',
|
|
56
|
+
titleKey: '${titleKeyFor(name)}',
|
|
57
|
+
// At least one, never empty: an empty list is X_ADMIN_PAGE_UNGUARDED at declaration time, which
|
|
58
|
+
// is the whole reason the permission is a required field and not an optional one.
|
|
59
|
+
permissions: ['${permission}'],
|
|
60
|
+
component: ${Name}Page,
|
|
61
|
+
};
|
|
62
|
+
`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const pageTest = (name: string, permission: string): string => {
|
|
66
|
+
const declaration = camel(name);
|
|
67
|
+
return `// The ${name} admin page is guarded and owns no route of its own — the two facts that separate an
|
|
68
|
+
// admin screen from a page, and the two an edit here is most likely to break.
|
|
69
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
70
|
+
import { ${declaration}Page } from './${name}';
|
|
71
|
+
|
|
72
|
+
// Both facts the frame reads off the declaration, so both are decidable without a request: a path
|
|
73
|
+
// that is not rooted is X_ADMIN_PAGE_PATH_INVALID, and no permission at all is
|
|
74
|
+
// X_ADMIN_PAGE_UNGUARDED — a page that would render for anyone who can open the admin.
|
|
75
|
+
unitTest('the ${name} admin page is rooted and guarded', () => {
|
|
76
|
+
expect(${declaration}Page.path.startsWith('/')).toBe(true);
|
|
77
|
+
expect(${declaration}Page.permissions).toContain('${permission}');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
unitTest('the ${name} admin page declares no route of its own', () => {
|
|
81
|
+
// \`pages:\` is the only way in. A \`config\` export here would be a route the frame never guards.
|
|
82
|
+
expect('config' in ${declaration}Page).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function adminPageFiles(
|
|
88
|
+
rawName: string,
|
|
89
|
+
options: AdminPageOptions,
|
|
90
|
+
): readonly GeneratedFile[] {
|
|
91
|
+
const name = kebab(rawName);
|
|
92
|
+
// Trailing slashes trimmed exactly as `islandFiles` does — one `--at`, one normalization.
|
|
93
|
+
const dir = (options.dir ?? DEFAULT_ADMIN_PAGE_DIR).replace(/\/+$/, '');
|
|
94
|
+
return [
|
|
95
|
+
{ path: `${dir}/${name}.tsx`, contents: pageSource(name, options.permission, dir) },
|
|
96
|
+
{ path: `${dir}/${name}.test.ts`, contents: pageTest(name, options.permission) },
|
|
97
|
+
...resolveLocales(options.locales).map((locale) => ({
|
|
98
|
+
path: catalogPath(locale),
|
|
99
|
+
contents: catalogJson({ [titleKeyFor(name)]: pascal(name) }),
|
|
100
|
+
merge: 'json' as const,
|
|
101
|
+
})),
|
|
102
|
+
];
|
|
103
|
+
}
|
package/src/templates/admin.ts
CHANGED
|
@@ -11,8 +11,8 @@ import { names } from './naming';
|
|
|
11
11
|
const resourceSource = (
|
|
12
12
|
feature: NameSet,
|
|
13
13
|
): string => `// Admin override for ${feature.pluralKebab}. Everything not set here — fields, operations,
|
|
14
|
-
// detail layout — is derived from the entity. Wire
|
|
15
|
-
//
|
|
14
|
+
// detail layout — is derived from the entity. Wire it in once, importing ${feature.camel}AdminResource
|
|
15
|
+
// through this app's own tsconfig path alias for apps/web/app/${feature.kebab}/admin/resource:
|
|
16
16
|
// defineAdmin({ entities: [..., ${feature.camel}], resources: { ${feature.table}: ${feature.camel}AdminResource } })
|
|
17
17
|
|
|
18
18
|
import type { AdminResourceOptions, AdminRow } from '@ultimat3/admin';
|
|
@@ -26,13 +26,17 @@ export const ${feature.camel}AdminResource: AdminResourceOptions<AdminRow> = {
|
|
|
26
26
|
|
|
27
27
|
const resourceTest = (
|
|
28
28
|
feature: NameSet,
|
|
29
|
-
): string =>
|
|
29
|
+
): string => `// The ${feature.kebab} admin override says what the entity cannot derive: a title key, the list
|
|
30
|
+
// columns, a bounded page size. Everything unset here is derived, and needs no test.
|
|
31
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
30
32
|
import { ${feature.camel}AdminResource } from './resource';
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
expect(
|
|
34
|
+
const resource = ${feature.camel}AdminResource;
|
|
35
|
+
|
|
36
|
+
unitTest('${feature.camel}AdminResource sets a title key and list fields', () => {
|
|
37
|
+
expect(resource.titleKey).toBe('admin.${feature.kebab}.title');
|
|
38
|
+
expect(resource.listFields?.length).toBeGreaterThan(0);
|
|
39
|
+
expect(resource.pageSize).toBeGreaterThan(0);
|
|
36
40
|
});
|
|
37
41
|
`;
|
|
38
42
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// `x g backfill` — a one-pass table sweep. The backfill is a factory over job(), not a ninth
|
|
2
|
+
// primitive, so it inherits .enqueue(), the retry policy, the cancellation and the manifest row.
|
|
3
|
+
// One live run per name: a second enqueue while the pass is going is the same pass.
|
|
4
|
+
|
|
5
|
+
import type { FeatureTarget } from './entity';
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { names } from './naming';
|
|
8
|
+
import { sliceFoundation } from './slice-foundation';
|
|
9
|
+
import { wrapImport } from './wrap';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the entity's value export is called inside the generated file. `x g backfill invoice
|
|
13
|
+
* --feature invoice` is a legal invocation and it emitted `import { invoice } from '../entity'`
|
|
14
|
+
* beside `export const invoice = backfill(...)` — one name, two declarations, which is
|
|
15
|
+
* `lint/suspicious/noRedeclare` in the app's own gate and a genuinely ambiguous reference in TS.
|
|
16
|
+
* Aliased only when it would collide, because every other backfill reads better without one.
|
|
17
|
+
*/
|
|
18
|
+
const entityRef = (name: NameSet, feature: NameSet): string =>
|
|
19
|
+
name.camel === feature.camel ? `${feature.camel}Entity` : feature.camel;
|
|
20
|
+
|
|
21
|
+
const entityImport = (name: NameSet, feature: NameSet): string => {
|
|
22
|
+
const local = entityRef(name, feature);
|
|
23
|
+
return local === feature.camel ? feature.camel : `${feature.camel} as ${local}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The chain accessor, wrapped the way Biome would wrap it. Emitted pre-formatted rather than
|
|
28
|
+
* always-wrapped because the formatter joins an arrow body back onto one line when it fits — so a
|
|
29
|
+
* fixed shape is wrong for one name length or the other. Same reason `policy.ts` measures its
|
|
30
|
+
* `definePermissions` line.
|
|
31
|
+
*/
|
|
32
|
+
const tableLine = (name: NameSet, feature: NameSet): string => {
|
|
33
|
+
const ref = entityRef(name, feature);
|
|
34
|
+
const head = `const ${feature.camel}Table = () =>`;
|
|
35
|
+
const body = `tableFor(${ref}, postgresRepo(${ref}));`;
|
|
36
|
+
return `${head} ${body}`.length <= 100 ? `${head} ${body}` : `${head}\n ${body}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Working source, never a stub: a generated `throw new Error(…)` carries no `X_*` code and a
|
|
41
|
+
* generated no-op handler checkpoints a page it never wrote, which reports swept rows nobody
|
|
42
|
+
* touched. The row projection is the one line an author replaces, and it is exported so the
|
|
43
|
+
* generated test asserts the WORK rather than only the declaration around it.
|
|
44
|
+
*/
|
|
45
|
+
const backfillSource = (
|
|
46
|
+
name: NameSet,
|
|
47
|
+
feature: NameSet,
|
|
48
|
+
): string => `// ${name.camel}: one pass over a chain of rows. The backfill is a job factory, not a ninth
|
|
49
|
+
// primitive, so it inherits .enqueue(), retry, cancellation and the manifest row.
|
|
50
|
+
// \`BackfillBatch\` comes from @ultimat3/jobs, not @ultimat3/schema: a backfill file imports one package.
|
|
51
|
+
|
|
52
|
+
import type { Ctx } from '@ultimat3/core';
|
|
53
|
+
import { assert, hasScope } from '@ultimat3/core';
|
|
54
|
+
import type { ReadBuilder } from '@ultimat3/entity';
|
|
55
|
+
import { CROSS_TENANT_SCOPE, postgresRepo, tableFor } from '@ultimat3/entity';
|
|
56
|
+
import type { BackfillBatch } from '@ultimat3/jobs';
|
|
57
|
+
import { backfill } from '@ultimat3/jobs';
|
|
58
|
+
import type { ${feature.pascal} } from '../entity';
|
|
59
|
+
import { ${entityImport(name, feature)} } from '../entity';
|
|
60
|
+
|
|
61
|
+
/** The row this sweep visits, aliased once: every signature below then reads at one width. */
|
|
62
|
+
type Row = ${feature.pascal};
|
|
63
|
+
|
|
64
|
+
/** The table as a chain — the seam \`database()\` hands an app, so this sweep reads what a query reads. */
|
|
65
|
+
${tableLine(name, feature)}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The rows this pass visits. A one-pass sweep has no single org, so it declares \`tenant: 'none'\`
|
|
69
|
+
* below — which STRIPS the org from the run rather than inheriting the worker's. That makes
|
|
70
|
+
* spanning tenants a capability instead of an accident: the actor this worker runs as has to carry
|
|
71
|
+
* \`tenancy:cross\`, and this is where that is said, before a page is read rather than inside the
|
|
72
|
+
* plan builder. A single-org sweep is the other shape — declare \`tenant: () => '<org id>'\` and put
|
|
73
|
+
* \`.where({ orgId: ctx.actor.orgId })\` back.
|
|
74
|
+
*/
|
|
75
|
+
const ${name.camel}Scope = (ctx: Ctx): ReadBuilder<Row> => {
|
|
76
|
+
assert(
|
|
77
|
+
hasScope(ctx.actor, CROSS_TENANT_SCOPE),
|
|
78
|
+
'${name.kebab}: this pass spans every tenant and its actor holds no tenancy:cross',
|
|
79
|
+
// A generated \`fix:\` is copied and run verbatim, so it names a command this build SHIPS.
|
|
80
|
+
'x db backfill ${name.kebab} --write --json',
|
|
81
|
+
);
|
|
82
|
+
return ${feature.camel}Table();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the sweep writes for one row. Replace the projection with the change this pass exists to
|
|
87
|
+
* make, and keep it IDEMPOTENT: a page replays whole when an attempt is cancelled between the last
|
|
88
|
+
* row and its checkpoint, so the second run of this function must produce the first run's row.
|
|
89
|
+
*/
|
|
90
|
+
export const ${name.camel}Row = (row: Row): Row => ({
|
|
91
|
+
...row,
|
|
92
|
+
title: row.title.trim(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const ${name.camel} = backfill({
|
|
96
|
+
name: '${name.kebab}',
|
|
97
|
+
// A sweep over a table belongs to no one org, so it declares none — and \`'none'\` STRIPS the org
|
|
98
|
+
// rather than inheriting the worker's, so a tenant-scoped read inside the pass fails closed
|
|
99
|
+
// (X_TENANCY_ACTOR_ORG_REQUIRED) instead of reading somebody's rows by accident. A sweep that
|
|
100
|
+
// genuinely spans tenants says so out loud: its work runs inside \`crossTenant(reason, fn)\`, and
|
|
101
|
+
// the reason IS the mechanism. A per-org sweep declares its org instead: \`tenant: () => orgId\`,
|
|
102
|
+
// one enqueue per org.
|
|
103
|
+
tenant: 'none',
|
|
104
|
+
source: ({ ctx }): ReadBuilder<Row> => ${name.camel}Scope(ctx),
|
|
105
|
+
handle: async ({ rows, signal }: BackfillBatch<Row>) => {
|
|
106
|
+
// One page, in its own durable step, at least once. Write through upsertAll, updateWhere or an
|
|
107
|
+
// idempotent statement; never count + 1. The signal is the run cancellation composed with this
|
|
108
|
+
// batch's ceiling, so a cancelled pass stops here instead of writing past its lease.
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
const next = rows.map(${name.camel}Row);
|
|
111
|
+
await ${feature.camel}Table().upsertAll(next, { onConflict: ['id'] });
|
|
112
|
+
},
|
|
113
|
+
// How many rows still NEED the change — never how many the sweep visits. Declare it once
|
|
114
|
+
// \`source\` narrows to the rows that are actually behind (\`.andWhere('publishedAt', 'is', null)\`
|
|
115
|
+
// and the like): then a dry run cannot lie, and a pass that exhausts its source while this still
|
|
116
|
+
// answers above zero fails as X_BACKFILL_STALLED instead of writing a completed row nobody can
|
|
117
|
+
// trust. Left out here because this scaffold re-normalises every row it visits, so a count of
|
|
118
|
+
// the same chain would never reach zero.
|
|
119
|
+
// count: ({ ctx }) => ${name.camel}Scope(ctx).andWhere('publishedAt', 'is', null).count(),
|
|
120
|
+
// batch: 1_000, // rows per step, default. Adjust to balance statement size and retry scope.
|
|
121
|
+
// rate: 5, // batches per second, default. Raise to sweep faster; there is no unthrottled mode.
|
|
122
|
+
// retry: { attempts: 5, backoff: 'exponential' },
|
|
123
|
+
// requires: '20260814120000_add_publish_at', // the migration x db backfill checks first
|
|
124
|
+
// environments: ['staging', 'production'], // omit for every environment — never implied
|
|
125
|
+
});
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const backfillTest = (
|
|
129
|
+
name: NameSet,
|
|
130
|
+
feature: NameSet,
|
|
131
|
+
): string => `// ${name.camel} sweeps rows a user never asked for, so the two facts worth failing on are its
|
|
132
|
+
// durable identity — one live run per name, retried under the same key — and that the row
|
|
133
|
+
// projection it applies is idempotent, because a cancelled attempt replays its page whole.
|
|
134
|
+
|
|
135
|
+
import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
136
|
+
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
137
|
+
import type { ${feature.pascal} } from '../entity';
|
|
138
|
+
${wrapImport([name.camel, `${name.camel}Row`], `./${name.kebab}`)}
|
|
139
|
+
|
|
140
|
+
// The driver is process-global, so it is installed and released around this file rather than
|
|
141
|
+
// left behind for whichever test happens to run next.
|
|
142
|
+
beforeAll(() => {
|
|
143
|
+
setJobDriver(createMemoryDriver());
|
|
144
|
+
});
|
|
145
|
+
afterAll(resetJobDriver);
|
|
146
|
+
|
|
147
|
+
// The durable name this sweep runs under, spelled once — so the assertion below carries the
|
|
148
|
+
// backfill's own name and still fits the formatter width the app's \`lint\` step enforces.
|
|
149
|
+
const expectedKey = '${name.kebab}';
|
|
150
|
+
|
|
151
|
+
const row = (over: Partial<${feature.pascal}> = {}): ${feature.pascal} => ({
|
|
152
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
153
|
+
orgId: '00000000-0000-4000-8000-000000000002',
|
|
154
|
+
title: ' needs normalising ',
|
|
155
|
+
price: { minor: 1000, currency: 'USD' },
|
|
156
|
+
createdAt: new Date(0),
|
|
157
|
+
...over,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
jobTest('${name.camel} declares a durable name and retry policy', () => {
|
|
161
|
+
expect(${name.camel}.kind).toBe('job');
|
|
162
|
+
expect(${name.camel}.idempotencyKeyFor({})).toBe(expectedKey);
|
|
163
|
+
expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
jobTest('${name.camel} uses one key across attempts', () => {
|
|
167
|
+
const key = ${name.camel}.idempotencyKeyFor({});
|
|
168
|
+
expect(${name.camel}.idempotencyKeyFor({})).toBe(key);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
jobTest('${name.camel} projects itself into the manifest', () => {
|
|
172
|
+
const described = ${name.camel}.describe();
|
|
173
|
+
expect(described.queue).toBe('default');
|
|
174
|
+
expect(described.retry.attempts).toBeGreaterThan(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
jobTest('${name.camel} actually rewrites the row it is handed', () => {
|
|
178
|
+
// The declaration alone cannot fail this: a handler that returned without writing would still
|
|
179
|
+
// enqueue, still checkpoint and still report the page as swept.
|
|
180
|
+
expect(${name.camel}Row(row()).title).toBe('needs normalising');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
jobTest('${name.camel} replays a page idempotently', () => {
|
|
184
|
+
// At least once is the contract: an attempt cancelled between the last row and its checkpoint
|
|
185
|
+
// hands this page to the next attempt. Twice through must equal once through.
|
|
186
|
+
const once = ${name.camel}Row(row());
|
|
187
|
+
expect(${name.camel}Row(once)).toEqual(once);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
191
|
+
// One live run per name, forced or not: a second enqueue while the pass is going is the same pass.
|
|
192
|
+
// \`.enqueue()\` is the backfill path — the declared job, queued with no scheduler involved.
|
|
193
|
+
const first = await ${name.camel}.enqueue({});
|
|
194
|
+
expect(first.deduped).toBe(false);
|
|
195
|
+
const again = await ${name.camel}.enqueue({});
|
|
196
|
+
expect(again.deduped).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
`;
|
|
199
|
+
|
|
200
|
+
export function backfillFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
|
|
201
|
+
const name = names(rawName);
|
|
202
|
+
const feature = names(target.feature);
|
|
203
|
+
const dir = `${target.surfaceDir}/${target.feature}/backfills`;
|
|
204
|
+
return [
|
|
205
|
+
// A sweep is a chain over the entity's own table (`tableFor(entity, postgresRepo(entity))`), so
|
|
206
|
+
// the entity is what it reads and what its generated test builds rows of. No repo call, but
|
|
207
|
+
// `repo.ts` rides along with `entity.ts`: it is that file's only reader.
|
|
208
|
+
...sliceFoundation(target, ['entity']),
|
|
209
|
+
{ path: `${dir}/${name.kebab}.ts`, contents: backfillSource(name, feature) },
|
|
210
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: backfillTest(name, feature) },
|
|
211
|
+
];
|
|
212
|
+
}
|
package/src/templates/entity.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { names } from './naming';
|
|
7
|
+
import { wrapImport, wrapList } from './wrap';
|
|
8
|
+
|
|
9
|
+
/** The columns that leave the server. One list, read by the declaration and by its test. */
|
|
10
|
+
const VIEW_KEYS: readonly string[] = ["'id'", "'title'", "'price'", "'createdAt'"];
|
|
7
11
|
|
|
8
12
|
export interface FeatureTarget {
|
|
9
13
|
/** `apps/web/app` or `apps/web/site` — the surface the feature lives in. */
|
|
@@ -28,16 +32,18 @@ export const ${name.camel} = entity('${table}', {
|
|
|
28
32
|
id: uuid().primaryKey(),
|
|
29
33
|
orgId: uuid(),
|
|
30
34
|
title: text({ max: 200 }),
|
|
31
|
-
// One property,
|
|
32
|
-
// Money is integer minor units plus an ISO code, never a
|
|
35
|
+
// One property, three physical columns: price_minor bigint + price_currency char(3) +
|
|
36
|
+
// the nullable price_scale integer. Money is integer minor units plus an ISO code, never a
|
|
37
|
+
// float; the scale is what lets an amount name a sub-cent value, and NULL is not 0.
|
|
33
38
|
price: money(),
|
|
34
39
|
// Always timestamptz. Stored UTC; formatted at the edge with an explicit IANA time zone.
|
|
35
40
|
createdAt: timestamp().defaultNow(),
|
|
36
41
|
},
|
|
37
42
|
// Each rule runs in the app on write AND as a Postgres CHECK — one declaration, both sides.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
invariant('${snake}
|
|
43
|
+
// \`c\` is typed from the columns above: \`c.titel\` is a compile error that names \`title\`.
|
|
44
|
+
invariants: (c) => [
|
|
45
|
+
invariant('${snake}_title_not_blank', c.title.trimmed().minLength(1)),
|
|
46
|
+
invariant('${snake}_price_non_negative', c.price.minor.atLeast(0)),
|
|
41
47
|
],
|
|
42
48
|
indexes: [{ on: ['orgId', 'createdAt'] }],
|
|
43
49
|
});
|
|
@@ -47,14 +53,31 @@ export type ${name.pascal} = typeof ${name.camel}.$row;
|
|
|
47
53
|
// What leaves the server: an action writes \`output: ${name.pascal}View\` and the shape is the
|
|
48
54
|
// columns', never a second declaration to keep in sync. The tenant column is not in it — an org
|
|
49
55
|
// id is the caller's context, not the client's data.
|
|
50
|
-
export const ${name.pascal}View = ${name.camel}.$view([
|
|
56
|
+
${wrapList('', `export const ${name.pascal}View = ${name.camel}.$view([`, VIEW_KEYS, ']);')}
|
|
51
57
|
export type ${name.pascal}View = typeof ${name.pascal}View.$row;
|
|
52
58
|
`;
|
|
53
59
|
|
|
54
|
-
const repoSource = (
|
|
55
|
-
name
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
const repoSource = (name: NameSet, table: string): string => {
|
|
61
|
+
const row = name.pascal;
|
|
62
|
+
const byIdCall = wrapList(
|
|
63
|
+
' ',
|
|
64
|
+
`const row = await db().one<${row}>(`,
|
|
65
|
+
[`sql\`select * from ${table} where id = \${id}\``],
|
|
66
|
+
');',
|
|
67
|
+
);
|
|
68
|
+
const listSignature = wrapList(
|
|
69
|
+
'',
|
|
70
|
+
'export async function listByOrg(',
|
|
71
|
+
['orgId: string', 'limit = 50'],
|
|
72
|
+
`): Promise<readonly ${row}[]> {`,
|
|
73
|
+
);
|
|
74
|
+
const insertSignature = wrapList(
|
|
75
|
+
'',
|
|
76
|
+
'export async function insert(',
|
|
77
|
+
[`row: Omit<${row}, 'id' | 'createdAt'>`],
|
|
78
|
+
`): Promise<${row}> {`,
|
|
79
|
+
);
|
|
80
|
+
return `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
|
|
58
81
|
// queries; actions call services; services call this.
|
|
59
82
|
// \`db()\` is the ambient handle: inside a transaction it IS the transaction, so these functions
|
|
60
83
|
// join the caller's transaction without knowing one is open.
|
|
@@ -64,43 +87,53 @@ import { dbDrift, newId } from '@ultimat3/entity';
|
|
|
64
87
|
import type { ${name.pascal} } from './entity';
|
|
65
88
|
|
|
66
89
|
export async function byId(id: string): Promise<${name.pascal} | undefined> {
|
|
67
|
-
|
|
90
|
+
${byIdCall}
|
|
68
91
|
return row ?? undefined;
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
|
|
94
|
+
${listSignature}
|
|
72
95
|
// Ordered and bounded: an unordered page is a different page on every request.
|
|
73
96
|
return db().query<${name.pascal}>(
|
|
74
97
|
sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
|
|
75
98
|
);
|
|
76
99
|
}
|
|
77
100
|
|
|
78
|
-
|
|
79
|
-
// Money is
|
|
101
|
+
${insertSignature}
|
|
102
|
+
// Money is three physical columns — integer minor units, the ISO code, and the scale, never a
|
|
103
|
+
// float. \`scale ?? null\`: an amount at the currency's own minor unit carries no scale at all,
|
|
104
|
+
// and writing \`0\` for it would claim whole units — a 100x reinterpretation of the price.
|
|
80
105
|
const created = await db().one<${name.pascal}>(sql\`
|
|
81
|
-
insert into ${table} (id, org_id, title, price_minor, price_currency)
|
|
82
|
-
values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency}
|
|
106
|
+
insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
|
|
107
|
+
values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
|
|
108
|
+
\${row.price.scale ?? null})
|
|
83
109
|
returning *\`);
|
|
84
110
|
if (created === null) throw dbDrift('${table}', 'id');
|
|
85
111
|
return created;
|
|
86
112
|
}
|
|
87
113
|
`;
|
|
114
|
+
};
|
|
88
115
|
|
|
89
116
|
const entityTest = (
|
|
90
117
|
name: NameSet,
|
|
91
118
|
snake: string,
|
|
92
119
|
table: string,
|
|
93
|
-
): string =>
|
|
120
|
+
): string => `// The ${name.kebab} entity's declaration: the table it maps to and the invariants it names. Both
|
|
121
|
+
// are what the migration generator and every query read, so both are worth pinning.
|
|
122
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
94
123
|
import type { ${name.pascal} } from './entity';
|
|
95
|
-
|
|
124
|
+
${wrapImport([`${name.pascal}View`, name.camel], './entity')}
|
|
125
|
+
|
|
126
|
+
type Over = Partial<${name.pascal}>;
|
|
96
127
|
|
|
97
|
-
const row = (over:
|
|
128
|
+
const row = (over: Over = {}): ${name.pascal} => ({
|
|
98
129
|
id: '00000000-0000-4000-8000-000000000001',
|
|
99
130
|
orgId: '00000000-0000-4000-8000-000000000002',
|
|
100
131
|
title: 'valid title',
|
|
101
|
-
// \`money()\` puts \`MoneyValue\` on the row
|
|
102
|
-
//
|
|
103
|
-
|
|
132
|
+
// \`money()\` puts \`MoneyValue\` on the row — the same type \`@ultimat3/money\`'s \`Money\` is,
|
|
133
|
+
// so this value goes straight to \`add()\`, \`formatMoney()\` and \`<Money>\` with no conversion.
|
|
134
|
+
// Integer minor units, never a float; the column is a Postgres bigint and a stored value past
|
|
135
|
+
// ±2^53 is refused when it is read rather than rounded into the row.
|
|
136
|
+
price: { minor: 1000, currency: 'USD' },
|
|
104
137
|
createdAt: new Date(0),
|
|
105
138
|
...over,
|
|
106
139
|
});
|
|
@@ -117,24 +150,32 @@ unitTest('${name.camel} describes itself for the manifest', () => {
|
|
|
117
150
|
// a column added below reaches every one of them without a second declaration.
|
|
118
151
|
const described = ${name.camel}.$describe();
|
|
119
152
|
expect(described.orgScoped).toBe(true);
|
|
120
|
-
// One \`price\` property,
|
|
121
|
-
// description is where that shows, and it is what fails here if money ever
|
|
153
|
+
// One \`price\` property, three physical columns: integer minor units, the ISO code, and the
|
|
154
|
+
// nullable scale. The description is where that shows, and it is what fails here if money ever
|
|
155
|
+
// becomes a float — or if a migration forgets the scale column and unscales every sub-cent row.
|
|
122
156
|
expect(described.columns.map((column) => column.column)).toContain('price_minor');
|
|
123
157
|
expect(described.columns.map((column) => column.column)).toContain('price_currency');
|
|
124
|
-
expect(described.
|
|
158
|
+
expect(described.columns.map((column) => column.column)).toContain('price_scale');
|
|
159
|
+
// Named first: the assertion line carries the entity's own name and stays under the app's
|
|
160
|
+
// formatter width whatever that name is.
|
|
161
|
+
const rules = described.invariants.map((rule) => rule.name);
|
|
162
|
+
expect(rules).toContain('${snake}_price_non_negative');
|
|
125
163
|
});
|
|
126
164
|
|
|
127
|
-
unitTest('${name.pascal}View projects the row an action returns
|
|
128
|
-
|
|
165
|
+
unitTest('${name.pascal}View projects the row an action returns', () => {
|
|
166
|
+
const keys = ${name.pascal}View.$keys;
|
|
167
|
+
expect(keys).toEqual([${VIEW_KEYS.join(', ')}]);
|
|
129
168
|
// The org id is the caller's context, never the client's data: a view that leaked it would
|
|
130
169
|
// let a response carry a tenant boundary the policy already decided.
|
|
131
|
-
expect(
|
|
170
|
+
expect(keys).not.toContain('orgId');
|
|
132
171
|
});
|
|
133
172
|
|
|
134
|
-
unitTest('${name.camel} invariants reject
|
|
173
|
+
unitTest('${name.camel} invariants reject blank and negative', () => {
|
|
174
|
+
const blank = row({ title: ' ' });
|
|
175
|
+
const negative = row({ price: { minor: -1, currency: 'USD' } });
|
|
135
176
|
expect(() => ${name.camel}.$assert(row())).not.toThrow();
|
|
136
|
-
expect(() => ${name.camel}.$assert(
|
|
137
|
-
expect(() => ${name.camel}.$assert(
|
|
177
|
+
expect(() => ${name.camel}.$assert(blank)).toThrow();
|
|
178
|
+
expect(() => ${name.camel}.$assert(negative)).toThrow();
|
|
138
179
|
});
|
|
139
180
|
|
|
140
181
|
unitTest('${name.camel} parses a row through its own columns', () => {
|