@ultimat3/cli 22.14.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.
Files changed (174) hide show
  1. package/CLAUDE.md +25 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +35 -3
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
@@ -3,39 +3,13 @@
3
3
  // One live run per name: a second enqueue while the pass is going is the same pass.
4
4
 
5
5
  import type { FeatureTarget } from './entity';
6
+ import { sortedImports } from './imports';
6
7
  import type { GeneratedFile, NameSet } from './naming';
7
8
  import { names } from './naming';
9
+ import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
8
10
  import { sliceFoundation } from './slice-foundation';
9
11
  import { wrapImport } from './wrap';
10
12
 
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
13
  /**
40
14
  * Working source, never a stub: a generated `throw new Error(…)` carries no `X_*` code and a
41
15
  * generated no-op handler checkpoints a page it never wrote, which reports swept rows nobody
@@ -45,32 +19,29 @@ const tableLine = (name: NameSet, feature: NameSet): string => {
45
19
  const backfillSource = (
46
20
  name: NameSet,
47
21
  feature: NameSet,
22
+ dbModule: string,
48
23
  ): string => `// ${name.camel}: one pass over a chain of rows. The backfill is a job factory, not a ninth
49
24
  // primitive, so it inherits .enqueue(), retry, cancellation and the manifest row.
50
25
  // \`BackfillBatch\` comes from @ultimat3/jobs, not @ultimat3/schema: a backfill file imports one package.
51
26
 
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';
27
+ ${sortedImports([
28
+ "import { assert, type Ctx, hasScope } from '@ultimat3/core';",
29
+ "import { CROSS_TENANT_SCOPE, type ReadBuilder } from '@ultimat3/entity';",
30
+ "import { type BackfillBatch, backfill } from '@ultimat3/jobs';",
31
+ `import { db } from '${dbModule}';`,
32
+ ])}
58
33
  import type { ${feature.pascal} } from '../entity';
59
- import { ${entityImport(name, feature)} } from '../entity';
60
34
 
61
35
  /** The row this sweep visits, aliased once: every signature below then reads at one width. */
62
36
  type Row = ${feature.pascal};
63
37
 
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
38
  /**
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.
39
+ * The rows this pass visits, read through the app's typed handle — the table a query reads. A
40
+ * one-pass sweep has no single org, so it declares \`tenant: 'none'\` below — which STRIPS the org
41
+ * from the run rather than inheriting the worker's. That makes spanning tenants a capability
42
+ * instead of an accident: the actor this worker runs as has to carry \`tenancy:cross\`, and this is
43
+ * where that is said, before a page is read rather than inside the plan builder. A single-org
44
+ * sweep is the other shape — declare \`tenant: () => '<org id>'\`, and the handle scopes the read.
74
45
  */
75
46
  const ${name.camel}Scope = (ctx: Ctx): ReadBuilder<Row> => {
76
47
  assert(
@@ -79,7 +50,7 @@ const ${name.camel}Scope = (ctx: Ctx): ReadBuilder<Row> => {
79
50
  // A generated \`fix:\` is copied and run verbatim, so it names a command this build SHIPS.
80
51
  'x db backfill ${name.kebab} --write --json',
81
52
  );
82
- return ${feature.camel}Table();
53
+ return db.${feature.plural};
83
54
  };
84
55
 
85
56
  /**
@@ -103,12 +74,18 @@ export const ${name.camel} = backfill({
103
74
  tenant: 'none',
104
75
  source: ({ ctx }): ReadBuilder<Row> => ${name.camel}Scope(ctx),
105
76
  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.
77
+ // One page, in its own durable step, at least once. The signal is the run cancellation
78
+ // composed with this batch's ceiling, so a cancelled pass stops here instead of writing past
79
+ // its lease.
109
80
  signal.throwIfAborted();
110
- const next = rows.map(${name.camel}Row);
111
- await ${feature.camel}Table().upsertAll(next, { onConflict: ['id'] });
81
+ // One \`update\` per row, by primary key, and only the column this pass owns — never count + 1.
82
+ // Not \`upsertAll(rows, { onConflict: ['id'] })\`: on a tenant-scoped table a collision judged
83
+ // on \`id\` alone could land on another tenant's row, so the handle refuses it
84
+ // (X_TENANCY_UNSCOPED) and the pass would retry its first page for ever.
85
+ for (const row of rows) {
86
+ const next = ${name.camel}Row(row);
87
+ await db.${feature.plural}.update(row.id, { title: next.title });
88
+ }
112
89
  },
113
90
  // How many rows still NEED the change — never how many the sweep visits. Declare it once
114
91
  // \`source\` narrows to the rows that are actually behind (\`.andWhere('publishedAt', 'is', null)\`
@@ -127,15 +104,12 @@ export const ${name.camel} = backfill({
127
104
 
128
105
  const backfillTest = (
129
106
  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.
107
+ ): string => `// ${name.camel}'s durable identity: one live run per name, retried under the same key. The work
108
+ // itself — the pass over real rows and the projection it applies — is the unit suite's, next door.
134
109
 
135
110
  import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
136
111
  import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
137
- import type { ${feature.pascal} } from '../entity';
138
- ${wrapImport([name.camel, `${name.camel}Row`], `./${name.kebab}`)}
112
+ import { ${name.camel} } from './${name.kebab}';
139
113
 
140
114
  // The driver is process-global, so it is installed and released around this file rather than
141
115
  // left behind for whichever test happens to run next.
@@ -148,15 +122,6 @@ afterAll(resetJobDriver);
148
122
  // backfill's own name and still fits the formatter width the app's \`lint\` step enforces.
149
123
  const expectedKey = '${name.kebab}';
150
124
 
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
125
  jobTest('${name.camel} declares a durable name and retry policy', () => {
161
126
  expect(${name.camel}.kind).toBe('job');
162
127
  expect(${name.camel}.idempotencyKeyFor({})).toBe(expectedKey);
@@ -174,19 +139,6 @@ jobTest('${name.camel} projects itself into the manifest', () => {
174
139
  expect(described.retry.attempts).toBeGreaterThan(0);
175
140
  });
176
141
 
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
142
  jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
191
143
  // One live run per name, forced or not: a second enqueue while the pass is going is the same pass.
192
144
  // \`.enqueue()\` is the backfill path — the declared job, queued with no scheduler involved.
@@ -197,18 +149,100 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
197
149
  });
198
150
  `;
199
151
 
200
- export function backfillFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
152
+ /**
153
+ * The WORK, on the `unit` step: one whole pass, run by an in-process worker over rows in the
154
+ * in-memory driver, and the projection it applies. An app's coverage floor counts the unit suite
155
+ * alone — and a sweep whose handler nothing ever ran is how a body that could not complete shipped.
156
+ */
157
+ const backfillUnitTest = (
158
+ name: NameSet,
159
+ feature: NameSet,
160
+ dbModule: string,
161
+ ): string => `// ${name.camel}, SWEPT: one whole pass run by a worker in this process, over rows in the in-memory
162
+ // driver. A sweep rewrites rows no user asked it to, so what is worth failing on is that it reaches
163
+ // every tenant's rows, writes the projection and nothing else, and that a replay changes nothing.
164
+ ${sortedImports([
165
+ "import { createContext, runWithContext } from '@ultimat3/core';",
166
+ "import { testActor } from '@ultimat3/policy';",
167
+ "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
168
+ `import { db, driver } from '${dbModule}';`,
169
+ ])}
170
+ import type { ${feature.pascal} } from '../entity';
171
+ ${wrapImport([name.camel, `${name.camel}Row`], `./${name.kebab}`)}
172
+
173
+ const orgA = '00000000-0000-4000-8000-000000000002';
174
+ const orgB = '00000000-0000-4000-8000-000000000009';
175
+
176
+ /** What a request is to the handle: an actor, whose org every read and write runs under. */
177
+ const inOrg = <T>(orgId: string, run: () => Promise<T>): Promise<T> =>
178
+ runWithContext(createContext({ actor: testActor('member', { orgId }).actor }), run);
179
+
180
+ const store = (orgId: string, title: string): Promise<${feature.pascal}> => {
181
+ const draft = { orgId, title, price: { minor: 1000, currency: 'USD' } };
182
+ return inOrg(orgId, () => db.${feature.plural}.insert(draft));
183
+ };
184
+
185
+ const titlesOf = (orgId: string): Promise<readonly string[]> =>
186
+ inOrg(orgId, async () => (await db.${feature.plural}.all()).map((row) => row.title));
187
+
188
+ // One store per process: without this, one test's rows are the next test's fixtures.
189
+ afterEach(() => {
190
+ driver.reset?.();
191
+ });
192
+
193
+ unitTest('one pass rewrites the rows of every tenant, and completes', async ({ runJobs }) => {
194
+ await store(orgA, ' needs normalising ');
195
+ await store(orgB, ' so does this ');
196
+ const trace = await runJobs(${name.camel}, {});
197
+ // \`completed\`, not retried: a handler the table refuses retries its first page for ever.
198
+ expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
199
+ expect(await titlesOf(orgA)).toEqual(['needs normalising']);
200
+ expect(await titlesOf(orgB)).toEqual(['so does this']);
201
+ });
202
+
203
+ unitTest('the pass writes the projection and nothing else', async ({ runJobs }) => {
204
+ const stored = await store(orgA, ' padded ');
205
+ await runJobs(${name.camel}, {});
206
+ const [swept] = await inOrg(orgA, () => db.${feature.plural}.all());
207
+ expect(swept).toEqualRow({ ...stored, title: 'padded' });
208
+ });
209
+
210
+ unitTest('the projection is idempotent: a replayed page changes nothing', () => {
211
+ // At least once is the contract: an attempt cancelled between the last row and its checkpoint
212
+ // hands this page to the next attempt. Twice through must equal once through.
213
+ const row: ${feature.pascal} = {
214
+ id: '00000000-0000-4000-8000-000000000001',
215
+ orgId: orgA,
216
+ title: ' needs normalising ',
217
+ price: { minor: 1000, currency: 'USD' },
218
+ createdAt: new Date(0),
219
+ };
220
+ const once = ${name.camel}Row(row);
221
+ expect(once.title).toBe('needs normalising');
222
+ expect(${name.camel}Row(once)).toEqual(once);
223
+ });
224
+ `;
225
+
226
+ export interface BackfillOptions extends FeatureTarget {
227
+ /** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
228
+ readonly locales?: readonly string[];
229
+ }
230
+
231
+ export function backfillFiles(rawName: string, target: BackfillOptions): readonly GeneratedFile[] {
201
232
  const name = names(rawName);
202
233
  const feature = names(target.feature);
203
234
  const dir = `${target.surfaceDir}/${target.feature}/backfills`;
235
+ const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE;
204
236
  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
- // A sweep IS a job (`backfill()` is a job factory), its test is a `jobTest`, and the gate
211
- // types a test by its filename — so `<name>.test.ts` put it in the `unit` step forever.
212
- { path: `${dir}/${name.kebab}.job.test.ts`, contents: backfillTest(name, feature) },
237
+ // A sweep is a chain over the entity's own table, reached through the app's typed handle
238
+ // (`db.<table>`), so the entity is what it reads and what its generated test stores rows of.
239
+ // No repo call, but `repo.ts` rides along with `entity.ts`: it is that file's only reader.
240
+ ...sliceFoundation(target, ['entity'], target.locales),
241
+ { path: `${dir}/${name.kebab}.ts`, contents: backfillSource(name, feature, dbModule) },
242
+ // A sweep IS a job (`backfill()` is a job factory): its durable identity is a `jobTest`, and
243
+ // the gate types a test by its filename.
244
+ { path: `${dir}/${name.kebab}.job.test.ts`, contents: backfillTest(name) },
245
+ // And the pass itself on the `unit` step, against the in-memory driver.
246
+ { path: `${dir}/${name.kebab}.test.ts`, contents: backfillUnitTest(name, feature, dbModule) },
213
247
  ];
214
248
  }
@@ -1,10 +1,13 @@
1
1
  // `x g entity <name>` — a table, its domain type and its invariants, plus the repo that owns the
2
- // only DB access for the feature. Emitted as strings rather than copied fixture files so the
3
- // generator output is typed, diffable and testable from a unit test.
2
+ // only DB access for the feature and reads through the app's typed handle. Emitted as strings
3
+ // rather than copied fixture files so the generator output is typed, diffable and testable from a
4
+ // unit test.
4
5
 
6
+ import { sortedImports } from './imports';
5
7
  import type { GeneratedFile, NameSet } from './naming';
6
8
  import { names } from './naming';
7
- import { wrapImport, wrapList } from './wrap';
9
+ import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
10
+ import { LINE_WIDTH, wrapImport, wrapList } from './wrap';
8
11
 
9
12
  /** The columns that leave the server. One list, read by the declaration and by its test. */
10
13
  const VIEW_KEYS: readonly string[] = ["'id'", "'title'", "'price'", "'createdAt'"];
@@ -13,6 +16,12 @@ export interface FeatureTarget {
13
16
  /** `apps/web/app` or `apps/web/site` — the surface the feature lives in. */
14
17
  readonly surfaceDir: string;
15
18
  readonly feature: string;
19
+ /**
20
+ * The app's own db package, `@<app>/db` — where the generated `repo.ts` imports the typed handle
21
+ * from. Supplied by `x g` (read off `packages/db/package.json`) and by `x new`; absent only in a
22
+ * pure call that names no app, which gets `PLACEHOLDER_DB_MODULE`.
23
+ */
24
+ readonly dbModule?: string;
16
25
  }
17
26
 
18
27
  const entitySource = (
@@ -30,10 +39,10 @@ export const ${name.camel} = entity('${table}', {
30
39
  //
31
40
  // SINGLE-TENANT APP? There is no flag: \`x g action\`, \`x g query\` and \`x g policy\` decide on
32
41
  // \`orgId\` too, so the edit is per slice and it is this: delete \`tenant: 'orgId'\` and the
33
- // \`orgId: uuid()\` column below, drop \`'orgId'\` from \`indexes\`, and in repo.ts turn
34
- // \`listByOrg(orgId, limit)\` into \`list(limit)\` with no \`org_id\` predicate and no \`org_id\` in
35
- // the insert. entity.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false, and its
36
- // \`row()\` fixture loses \`orgId\`. Nothing else reads the column.
42
+ // \`orgId: uuid()\` column below and drop \`'orgId'\` from \`indexes\`. repo.ts needs no edit —
43
+ // it names no org. entity.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false,
44
+ // its \`row()\` fixture and repo.test.ts's \`draft()\` lose \`orgId\`, and repo.test.ts loses its
45
+ // last test. Nothing else reads the column.
37
46
  tenant: 'orgId',
38
47
  columns: {
39
48
  id: uuid().primaryKey(),
@@ -64,67 +73,152 @@ ${wrapList('', `export const ${name.pascal}View = ${name.camel}.$view([`, VIEW_K
64
73
  export type ${name.pascal}View = typeof ${name.pascal}View.$row;
65
74
  `;
66
75
 
67
- const repoSource = (name: NameSet, table: string): string => {
76
+ /**
77
+ * A fluent read the way Biome prints it: on one line while it fits, one call per line when it does
78
+ * not. The name decides which — `db.creditNoteAttachments` breaks a chain `db.posts` does not — so
79
+ * a fixed shape is a red `lint` over code nobody typed.
80
+ */
81
+ const chain = (indent: string, head: string, calls: readonly string[]): string => {
82
+ const joined = `${indent}${head}${calls.join('')};`;
83
+ if (joined.length <= LINE_WIDTH) return joined;
84
+ return `${indent}${head}\n${calls.map((call) => `${indent} ${call}`).join('\n')};`;
85
+ };
86
+
87
+ const repoSource = (name: NameSet, dbModule: string): string => {
68
88
  const row = name.pascal;
69
- const byIdCall = wrapList(
70
- ' ',
71
- 'const row = await db().one<Physical>(',
72
- [`sql\`select * from ${table} where id = \${id}\``],
73
- ');',
74
- );
89
+ const table = `db.${name.plural}`;
75
90
  const listSignature = wrapList(
76
91
  '',
77
- 'export async function listByOrg(',
78
- ['orgId: string', 'limit = 50'],
92
+ 'export async function list(',
93
+ ['limit = 50'],
79
94
  `): Promise<readonly ${row}[]> {`,
80
95
  );
96
+ const byIdSignature = wrapList(
97
+ '',
98
+ 'export async function byId(',
99
+ ['id: string'],
100
+ `): Promise<${row} | undefined> {`,
101
+ );
102
+ const byIdBody = ` return (await ${table}.where({ id }).one()) ?? undefined;`;
81
103
  const insertSignature = wrapList(
82
104
  '',
83
105
  'export async function insert(',
84
106
  [`row: Omit<${row}, 'id' | 'createdAt'>`],
85
107
  `): Promise<${row}> {`,
86
108
  );
109
+ const listChain = chain(' ', `return ${table}`, [
110
+ ".orderBy('createdAt', 'desc')",
111
+ '.limit(limit)',
112
+ '.all()',
113
+ ]);
87
114
  return `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
88
115
  // queries; actions call services; services call this.
89
- // \`db()\` is the ambient handle: inside a transaction it IS the transaction, so these functions
90
- // join the caller's transaction without knowing one is open.
91
-
92
- import { db, sql } from '@ultimat3/db';
93
- import { dbDrift, decodeRow, newId } from '@ultimat3/entity';
94
- ${wrapImport([`type ${name.pascal}`, name.camel], './entity')}
116
+ //
117
+ // Every statement goes through the typed handle (\`db.<table>\`), so a renamed column is a compile
118
+ // error here and there is no SQL text to keep in step with the entity. The handle owns the rest:
119
+ // tenancy (every read runs under the ACTOR's org, so no function here takes or names one), the
120
+ // codecs (money is one property; a sealed column is opened on read), keyset paging (\`.limit()\`,
121
+ // \`.after(cursor)\`) and the ambient transaction.
122
+ //
123
+ // Raw SQL has two homes and neither is this file: a \`query\`'s source and a migration. Reach for
124
+ // it here only for a statement the handle cannot express — a join, a window, a CTE.
95
125
 
96
- // What \`select *\` answers: snake_case columns, and money as three of them. Never cast to the row
97
- // type — \`decodeRow\` is the entity's own reading of it, so \`price\` is a Money again.
98
- type Physical = Readonly<Record<string, unknown>>;
126
+ import { db } from '${dbModule}';
127
+ import type { ${row} } from './entity';
99
128
 
100
- export async function byId(id: string): Promise<${name.pascal} | undefined> {
101
- ${byIdCall}
102
- return row === null ? undefined : decodeRow(${name.camel}, row);
129
+ ${byIdSignature}
130
+ ${byIdBody}
103
131
  }
104
132
 
105
133
  ${listSignature}
106
- // Ordered and bounded: an unordered page is a different page on every request.
107
- const rows = await db().query<Physical>(
108
- sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
109
- );
110
- return rows.map((row) => decodeRow(${name.camel}, row));
134
+ // The actor's org and nobody else's: the handle scopes the read, so there is no org to pass.
135
+ // Ordered and bounded — an unordered page is a different page on every request — and the handle
136
+ // adds the primary key as the last sort key, so the order is total without \`id\` spelled here.
137
+ ${listChain}
111
138
  }
112
139
 
113
140
  ${insertSignature}
114
- // Money is three physical columns — integer minor units, the ISO code, and the scale, never a
115
- // float. \`scale ?? null\`: an amount at the currency's own minor unit carries no scale at all,
116
- // and writing \`0\` for it would claim whole units — a 100x reinterpretation of the price.
117
- const created = await db().one<Physical>(sql\`
118
- insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
119
- values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
120
- \${row.price.scale ?? null})
121
- returning *\`);
122
- if (created === null) throw dbDrift('${table}', 'id');
123
- return decodeRow(${name.camel}, created);
141
+ // \`id\` and \`createdAt\` are the declaration's defaults, filled by the handle. The row names its
142
+ // org, and one that is not the actor's is refused (X_TENANCY_ACTOR_MISMATCH).
143
+ return ${table}.insert(row);
124
144
  }
125
145
  `;
126
146
  };
127
147
 
148
+ /**
149
+ * The repo's own test, emitted beside it: a generated file with no test is uncovered source in an
150
+ * app whose gate holds a coverage floor. Runs against the in-memory driver — the same contract
151
+ * Postgres serves — so it needs no database.
152
+ */
153
+ const repoTest = (
154
+ name: NameSet,
155
+ dbModule: string,
156
+ ): string => `// The ${name.kebab} repo against the in-memory driver: the contract Postgres serves, with no
157
+ // database. What it pins is what the repo cannot show by being read — whose rows a call reaches.
158
+ ${sortedImports([
159
+ "import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
160
+ "import { testActor } from '@ultimat3/policy';",
161
+ "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
162
+ `import { driver } from '${dbModule}';`,
163
+ ])}
164
+ import * as repo from './repo';
165
+
166
+ const orgId = '00000000-0000-4000-8000-000000000002';
167
+ const otherOrg = '00000000-0000-4000-8000-000000000009';
168
+
169
+ // The request clock, so \`createdAt\` is an instant this file chose and "newest" is decidable.
170
+ const clock = frozenClock('2026-01-01T00:00:00.000Z');
171
+
172
+ /** What a request is to the handle: an actor, whose org every read and write runs under. */
173
+ const inOrg = <T>(org: string, run: () => Promise<T>): Promise<T> =>
174
+ runWithContext(createContext({ actor: testActor('member', { orgId: org }).actor, clock }), run);
175
+
176
+ const draft = (title: string) => ({ orgId, title, price: { minor: 1200, currency: 'USD' } });
177
+
178
+ // One store per process: without this, one test's rows are the next test's fixtures.
179
+ afterEach(() => {
180
+ driver.reset?.();
181
+ });
182
+
183
+ unitTest('insert stores the row and byId reads it back', async () => {
184
+ const stored = await inOrg(orgId, () => repo.insert(draft('first')));
185
+ // Neither was supplied: the entity's own defaults filled both.
186
+ expect(stored.id).toHaveLength(36);
187
+ expect(stored.createdAt).toEqual(clock.now());
188
+ expect(stored.price).toEqual({ minor: 1200, currency: 'USD' });
189
+ // \`toEqualRow\`, never \`toEqual\`: a \`.sealed()\` column is a non-enumerable property of a row,
190
+ // so \`toEqual\` would call two rows equal that hold different secrets.
191
+ expect(await inOrg(orgId, () => repo.byId(stored.id))).toEqualRow(stored);
192
+ });
193
+
194
+ unitTest('byId answers undefined for an id nothing holds', async () => {
195
+ const absent = '00000000-0000-4000-8000-0000000000ff';
196
+ expect(await inOrg(orgId, () => repo.byId(absent))).toBeUndefined();
197
+ });
198
+
199
+ unitTest('list is newest first and bounded', async () => {
200
+ for (const title of ['first', 'second', 'third']) {
201
+ await inOrg(orgId, () => repo.insert(draft(title)));
202
+ clock.advance(1000);
203
+ }
204
+ const page = await inOrg(orgId, () => repo.list(2));
205
+ expect(page.map((row) => row.title)).toEqual(['third', 'second']);
206
+ });
207
+
208
+ unitTest('an actor in another org never reads the row, and no call names an org', async () => {
209
+ const stored = await inOrg(orgId, () => repo.insert(draft('private')));
210
+ // Neither read takes an org: the handle scopes both to the actor it runs under.
211
+ expect(await inOrg(otherOrg, () => repo.byId(stored.id))).toBeUndefined();
212
+ expect(await inOrg(otherOrg, () => repo.list())).toEqual([]);
213
+ expect(await inOrg(orgId, () => repo.list())).toHaveLength(1);
214
+ });
215
+
216
+ unitTest('a read with no actor is refused rather than answered for every org', async () => {
217
+ const refused = await repo.list().catch((error: unknown) => error);
218
+ expect(refused).toBeUltimateError('X_TENANCY_UNSCOPED');
219
+ });
220
+ `;
221
+
128
222
  const entityTest = (
129
223
  name: NameSet,
130
224
  snake: string,
@@ -202,9 +296,11 @@ unitTest('${name.camel} parses a row through its own columns', () => {
202
296
  export function entityFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
203
297
  const name = names(rawName);
204
298
  const dir = `${target.surfaceDir}/${target.feature}`;
299
+ const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE;
205
300
  return [
206
301
  { path: `${dir}/entity.ts`, contents: entitySource(name, name.snake, name.table) },
207
302
  { path: `${dir}/entity.test.ts`, contents: entityTest(name, name.snake, name.table) },
208
- { path: `${dir}/repo.ts`, contents: repoSource(name, name.table) },
303
+ { path: `${dir}/repo.ts`, contents: repoSource(name, dbModule) },
304
+ { path: `${dir}/repo.test.ts`, contents: repoTest(name, dbModule) },
209
305
  ];
210
306
  }
@@ -185,17 +185,10 @@ function layoutFinding(path: string, at: string, property: string, where: string
185
185
 
186
186
  export const guard: Guard = {
187
187
  summary: 'an animation moves transform and opacity, never the layout',
188
- async check(root) {
189
- const files: StyleFile[] = [];
190
- for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
191
- cwd: root,
192
- absolute: false,
193
- })) {
194
- const path = entry.split('\\\\').join('/');
195
- if (path.includes('node_modules/')) continue;
196
- files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
197
- }
198
- return animatedLayoutProperties(files);
188
+ async check(_root, sources) {
189
+ // The run's ONE read of the stylesheets: every guard asking for this glob shares the walk.
190
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
191
+ return animatedLayoutProperties(sheets.map((file) => ({ path: file.path, scss: file.text })));
199
192
  },
200
193
  };
201
194
  `;
@@ -67,19 +67,15 @@ export function bareThrows(files: readonly SourceFile[]): readonly Finding[] {
67
67
 
68
68
  export const guard: Guard = {
69
69
  summary: 'a failure carries a code, a cause and an executable fix — never a bare Error',
70
- async check(root) {
71
- const files: SourceFile[] = [];
72
- for await (const entry of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scan({
73
- cwd: root,
74
- absolute: false,
75
- })) {
76
- const path = entry.split('\\\\').join('/');
77
- // A test states its verdict with \`expect.unreachable()\`, which the suite reports on its own
78
- // terms; \`node_modules\` is not this app's source.
79
- if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
80
- files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
81
- }
82
- return bareThrows(files);
70
+ async check(_root, sources) {
71
+ // A test states its verdict with \`expect.unreachable()\`, which the suite reports on its own
72
+ // terms. The run's ONE read of the source: every guard asking for this glob shares the walk.
73
+ const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
74
+ return bareThrows(
75
+ code
76
+ .filter((file) => !/\\.(?:test|d)\\.tsx?$/.test(file.path))
77
+ .map((file) => ({ path: file.path, source: file.text })),
78
+ );
83
79
  },
84
80
  };
85
81
  `;
@@ -161,17 +161,10 @@ export function unreplacedFocusRings(files: readonly StyleFile[]): readonly Find
161
161
 
162
162
  export const guard: Guard = {
163
163
  summary: 'a stylesheet replaces the focus ring, never only removes it',
164
- async check(root) {
165
- const files: StyleFile[] = [];
166
- for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
167
- cwd: root,
168
- absolute: false,
169
- })) {
170
- const path = entry.split('\\\\').join('/');
171
- if (path.includes('node_modules/')) continue;
172
- files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
173
- }
174
- return unreplacedFocusRings(files);
164
+ async check(_root, sources) {
165
+ // The run's ONE read of the stylesheets: every guard asking for this glob shares the walk.
166
+ const sheets = await sources.files('{apps,packages}/**/*.scss');
167
+ return unreplacedFocusRings(sheets.map((file) => ({ path: file.path, scss: file.text })));
175
168
  },
176
169
  };
177
170
  `;
@@ -141,19 +141,19 @@ export function unsizedImages(files: readonly SourceFile[]): readonly Finding[]
141
141
 
142
142
  export const guard: Guard = {
143
143
  summary: 'an image declares its box, and the priority one is never lazy',
144
- async check(root) {
144
+ async check(_root, sources) {
145
145
  const files: SourceFile[] = [];
146
146
  // TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
147
147
  // 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
148
148
  // \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
149
149
  // matches 17 — so folding these into one line silently turns the guard off, which is worse than
150
- // the hole it closes. A LEADING group is fine and four guards here rely on it:
150
+ // the hole it closes. A LEADING group is fine and the stylesheet guards here rely on it:
151
151
  // \`{apps,packages}/**/*.scss\` matches all 15.
152
152
  for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
153
- for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
154
- const path = entry.split('\\\\').join('/');
155
- if (path.includes('node_modules/') || /\\.test\\.tsx?$/.test(path)) continue;
156
- files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
153
+ // The run's ONE read: every guard asking for these two globs shares the walk.
154
+ for (const file of await sources.files(pattern)) {
155
+ if (/\\.test\\.tsx?$/.test(file.path)) continue;
156
+ files.push({ path: file.path, source: file.text });
157
157
  }
158
158
  }
159
159
  return unsizedImages(files);