@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.
Files changed (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +14 -8
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. 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
+ }
@@ -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 this in once:
15
- // import { ${feature.camel}AdminResource } from '${'@'}app/web/app/${feature.kebab}/admin/resource';
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 => `import { expect, unitTest } from '@ultimat3/testing';
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
- unitTest('${feature.camel}AdminResource sets a title key and bounded list fields', () => {
33
- expect(${feature.camel}AdminResource.titleKey).toBe('admin.${feature.kebab}.title');
34
- expect(${feature.camel}AdminResource.listFields?.length).toBeGreaterThan(0);
35
- expect(${feature.camel}AdminResource.pageSize).toBeGreaterThan(0);
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
+ }
@@ -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, two physical columns: price_minor bigint + price_currency char(3).
32
- // Money is integer minor units plus an ISO code, never a float.
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
- invariants: [
39
- invariant('${snake}_title_not_blank', (c) => c.title.trimmed().minLength(1)),
40
- invariant('${snake}_price_non_negative', (c) => c.price.minor.atLeast(0)),
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(['id', 'title', 'price', 'createdAt']);
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: NameSet,
56
- table: string,
57
- ): string => `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
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
- const row = await db().one<${name.pascal}>(sql\`select * from ${table} where id = \${id}\`);
90
+ ${byIdCall}
68
91
  return row ?? undefined;
69
92
  }
70
93
 
71
- export async function listByOrg(orgId: string, limit = 50): Promise<readonly ${name.pascal}[]> {
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
- export async function insert(row: Omit<${name.pascal}, 'id' | 'createdAt'>): Promise<${name.pascal}> {
79
- // Money is two physical columns — integer minor units plus the ISO code, never a float.
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 => `import { expect, unitTest } from '@ultimat3/testing';
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
- import { ${name.pascal}View, ${name.camel} } from './entity';
124
+ ${wrapImport([`${name.pascal}View`, name.camel], './entity')}
125
+
126
+ type Over = Partial<${name.pascal}>;
96
127
 
97
- const row = (over: Partial<${name.pascal}> = {}): ${name.pascal} => ({
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, whose minor units are bigint — the column is a
102
- // Postgres bigint, and a JS number would silently lose precision above 2^53 minor units.
103
- price: { minor: 1000n, currency: 'USD' },
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, two physical columns: integer minor units plus the ISO code. The
121
- // description is where that shows, and it is what fails here if money ever becomes a float.
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.invariants.map((rule) => rule.name)).toContain('${snake}_price_non_negative');
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, without the tenant', () => {
128
- expect(${name.pascal}View.$keys).toEqual(['id', 'title', 'price', 'createdAt']);
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(${name.pascal}View.$keys).not.toContain('orgId');
170
+ expect(keys).not.toContain('orgId');
132
171
  });
133
172
 
134
- unitTest('${name.camel} invariants reject a blank title and a negative price', () => {
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(row({ title: ' ' }))).toThrow();
137
- expect(() => ${name.camel}.$assert(row({ price: { minor: -1n, currency: 'USD' } }))).toThrow();
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', () => {