@ultimat3/cli 20.2.1 → 22.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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -57,6 +57,12 @@ export interface ServiceWorkerInput {
57
57
  * this map does not name.
58
58
  */
59
59
  readonly documents?: ReadonlyMap<string, RenderedDocument>;
60
+ /**
61
+ * The page's framework scripts — realtime's page boot and sync worker (`pageSync(…).scripts`).
62
+ * Precached beside the island chunks for their reason: source-addressed and `immutable`, and an
63
+ * offline reload that cannot load the boot restores no record and shows the old count.
64
+ */
65
+ readonly scripts?: readonly { readonly url: string; readonly bytes: number }[];
60
66
  }
61
67
 
62
68
  /**
@@ -117,8 +123,12 @@ const pwaRoutes = (
117
123
  * Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
118
124
  * decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
119
125
  */
120
- const staticAssets = (islands: IslandBundle, styles: StyleBundle): readonly PrecacheAsset[] =>
121
- [...islands.chunks, ...styles.chunks]
126
+ const staticAssets = (
127
+ islands: IslandBundle,
128
+ styles: StyleBundle,
129
+ scripts: readonly { readonly url: string; readonly bytes: number }[],
130
+ ): readonly PrecacheAsset[] =>
131
+ [...islands.chunks, ...styles.chunks, ...scripts]
122
132
  .map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
123
133
  .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
124
134
 
@@ -200,7 +210,7 @@ export function serviceWorkerArtifacts(
200
210
  neverCache: pwa.offline.neverCache,
201
211
  },
202
212
  capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
203
- assets: staticAssets(input.islands, input.styles),
213
+ assets: staticAssets(input.islands, input.styles, input.scripts ?? []),
204
214
  },
205
215
  input.buildId,
206
216
  );
@@ -0,0 +1,31 @@
1
+ // Where a page's one socket dials — the framework's answer, so no app owns a `sync-url.ts`. Read
2
+ // once at boot from the deployment's env and handed to every document as `ultimate-sync`.
3
+
4
+ import { ConfigInvalidError } from '@ultimat3/core';
5
+
6
+ /**
7
+ * The sync node's own path (`createSyncNode`'s default in `@ultimat3/realtime`). Same origin by
8
+ * default because every rung already serves it there: `x dev` and a combined-role container mount
9
+ * the node on the web port, and `docker/helm`'s ingress routes `/_x/sync` to the `sync` service.
10
+ */
11
+ export const SYNC_PATH = '/_x/sync';
12
+
13
+ /**
14
+ * `SYNC_URL` verbatim when the deployment states one — the Compose rung publishes `sync` on its
15
+ * own port with no proxy in front, so only the deployment knows that URL — else `SYNC_PATH`,
16
+ * resolved by the browser against its own origin. Never derived from a port: behind any ingress a
17
+ * neighbouring port is a URL nothing publishes.
18
+ */
19
+ export function syncUrlFrom(env: Readonly<Record<string, string | undefined>>): string {
20
+ const declared = env['SYNC_URL']?.trim() ?? '';
21
+ if (declared === '') return SYNC_PATH;
22
+ const parsed = URL.parse(declared);
23
+ if (parsed === null || (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')) {
24
+ throw new ConfigInvalidError({
25
+ cause: 'SYNC_URL is set but is not a ws:// or wss:// URL, so no browser could dial it',
26
+ fix: 'export SYNC_URL="wss://sync.example.com/_x/sync" # or unset it to dial /_x/sync on the page origin',
27
+ meta: { key: 'SYNC_URL' },
28
+ });
29
+ }
30
+ return declared;
31
+ }
@@ -15,15 +15,19 @@ import { wrapImport } from './wrap';
15
15
  * (ai-maxxing's `fleet`, with `HostNotFoundError` and `SessionNotFoundError`) declares the errors
16
16
  * it has and not this one. Importing it anyway is a file that fails at import.
17
17
  */
18
- const missingLookup = (feature: NameSet): string =>
19
- ` // No lookup by id: ../errors declares no ${feature.pascal}NotFoundError, and a row that is not
18
+ const missingLookup = (feature: NameSet, entity: boolean): string =>
19
+ entity
20
+ ? ` // No lookup by id: ../errors declares no ${feature.pascal}NotFoundError, and a row that is not
20
21
  // there needs one to be thrown for it. Declare it there — the shape \`x g resource\` writes —
21
- // then read the row through ../repo and throw it when the read answers nothing.`;
22
+ // then read the row through ../repo and throw it when the read answers nothing.`
23
+ : ` // No lookup by id: this feature has no entity, and a generator writing into a slice never
24
+ // invents one. \`x g entity ${feature.kebab}\` declares the table; this body is yours until then.`;
22
25
 
23
26
  const actionSource = (
24
27
  name: NameSet,
25
28
  feature: NameSet,
26
29
  lookup: boolean,
30
+ entity: boolean,
27
31
  ): string => `// ${name.camel}: one mutation, server-authoritative. Input is validated before the handler runs
28
32
  // and the policy is the same object the MCP tool and the HTTP route evaluate.
29
33
  // \`t\` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package.
@@ -40,14 +44,15 @@ export const ${name.camel} = action({
40
44
  output: t.object({ id: t.uuid${lookup ? ', title: t.string' : ''} }),
41
45
  policy: can${feature.pascal}Write,
42
46
  cache: { invalidates: [${feature.camel}Tag] },
43
- mcp: { expose: true, description: '${name.raw} — edit this description' },
47
+ // No \`mcp\`: a tool's description is what an agent reads to decide to call it, and a placeholder
48
+ // there is worse than no tool. Write one, then \`mcp: { expose: true, description: '…' }\`.
44
49
  async handle({ input }) {
45
50
  ${
46
51
  lookup
47
52
  ? ` const row = await repo.byId(input.id);
48
53
  if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
49
54
  return { id: row.id, title: row.title };`
50
- : `${missingLookup(feature)}
55
+ : `${missingLookup(feature, entity)}
51
56
  return { id: input.id };`
52
57
  }
53
58
  },
@@ -58,6 +63,7 @@ const mutatorSource = (
58
63
  name: NameSet,
59
64
  feature: NameSet,
60
65
  lookup: boolean,
66
+ entity: boolean,
61
67
  ): string => `// ${name.camel}: an action with an optimistic local twin. The local half runs against the client
62
68
  // store immediately; the server half is authoritative and reconciles on conflict.
63
69
 
@@ -74,7 +80,7 @@ export const ${name.camel} = mutator({
74
80
  input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
75
81
  output: t.object({ id: t.uuid, title: t.string }),
76
82
  policy: can${feature.pascal}Write,
77
- mcp: { expose: true, description: '${name.raw} — edit this description' },
83
+ // No \`mcp\` until a real description is written: see \`x g action\`.
78
84
  // tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
79
85
  // augments LocalTables, and generated code cannot assume that has happened yet. The name is the
80
86
  // entity's snake_case table, so the local twin and the server row live under one key.
@@ -90,7 +96,7 @@ ${
90
96
  ? ` const row = await repo.byId(input.id);
91
97
  if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
92
98
  return { id: row.id, title: input.title };`
93
- : `${missingLookup(feature)}
99
+ : `${missingLookup(feature, entity)}
94
100
  return { id: input.id, title: input.title };`
95
101
  }
96
102
  },
@@ -190,11 +196,10 @@ contractTest('${name.camel} denies a foreign org', async () => {
190
196
  expect(denied).toBeUltimateError('X_FORBIDDEN');
191
197
  });
192
198
 
193
- contractTest('${name.camel} projects one tool and one operation', () => {
194
- // Same policy object on both surfaces — an MCP call cannot reach a different authz path.
195
- expect(target.tool().policy).toBe(target.policy);
196
- expect(target.tool().description).not.toBe('');
199
+ // No MCP tool until it is described: a placeholder description misleads the agent reading it.
200
+ contractTest('${name.camel} projects one operation and no tool', () => {
197
201
  expect(target.openapi().operationId).toBe('${name.camel}');
202
+ expect(target.mcp?.expose ?? false).toBe(false);
198
203
  });
199
204
  `;
200
205
 
@@ -207,6 +212,12 @@ export interface ActionOptions extends FeatureTarget {
207
212
  * `sliceExports` finds it there.
208
213
  */
209
214
  readonly sliceErrors?: string;
215
+ /**
216
+ * The slice's `entity.ts`, or absent when the feature has none. Absent, the action reads no row
217
+ * and the generator writes no entity — only `x g entity` and `x g resource` create a feature's
218
+ * data. `x g resource` passes the entity it is writing in the same run.
219
+ */
220
+ readonly sliceEntity?: string;
210
221
  }
211
222
 
212
223
  export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
@@ -214,19 +225,22 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
214
225
  const feature = names(target.feature);
215
226
  const dir = `${target.surfaceDir}/${target.feature}/actions`;
216
227
  const isMutator = target.mutator === true;
228
+ const entity = target.sliceEntity !== undefined;
217
229
  const lookup =
218
- target.sliceErrors === undefined ||
219
- sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`);
230
+ entity &&
231
+ (target.sliceErrors === undefined ||
232
+ sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`));
220
233
  return [
221
234
  // The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
222
235
  // (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
223
236
  // into a slice no `x g resource` had created emitted all three imports and wrote none of them.
224
- ...sliceFoundation(target, ['entity', 'policy', 'errors']),
237
+ // With no entity, only the policy: a lookup-free body imports neither ../errors nor ../repo.
238
+ ...sliceFoundation(target, entity ? ['entity', 'policy', 'errors'] : ['policy']),
225
239
  {
226
240
  path: `${dir}/${name.kebab}.ts`,
227
241
  contents: isMutator
228
- ? mutatorSource(name, feature, lookup)
229
- : actionSource(name, feature, lookup),
242
+ ? mutatorSource(name, feature, lookup, entity)
243
+ : actionSource(name, feature, lookup, entity),
230
244
  },
231
245
  // TWO test files, because the gate types a test by its FILENAME and this declaration owes two
232
246
  // suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
@@ -68,7 +68,7 @@ const repoSource = (name: NameSet, table: string): string => {
68
68
  const row = name.pascal;
69
69
  const byIdCall = wrapList(
70
70
  ' ',
71
- `const row = await db().one<${row}>(`,
71
+ 'const row = await db().one<Physical>(',
72
72
  [`sql\`select * from ${table} where id = \${id}\``],
73
73
  ');',
74
74
  );
@@ -90,32 +90,37 @@ const repoSource = (name: NameSet, table: string): string => {
90
90
  // join the caller's transaction without knowing one is open.
91
91
 
92
92
  import { db, sql } from '@ultimat3/db';
93
- import { dbDrift, newId } from '@ultimat3/entity';
94
- import type { ${name.pascal} } from './entity';
93
+ import { dbDrift, decodeRow, newId } from '@ultimat3/entity';
94
+ ${wrapImport([`type ${name.pascal}`, name.camel], './entity')}
95
+
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>>;
95
99
 
96
100
  export async function byId(id: string): Promise<${name.pascal} | undefined> {
97
101
  ${byIdCall}
98
- return row ?? undefined;
102
+ return row === null ? undefined : decodeRow(${name.camel}, row);
99
103
  }
100
104
 
101
105
  ${listSignature}
102
106
  // Ordered and bounded: an unordered page is a different page on every request.
103
- return db().query<${name.pascal}>(
107
+ const rows = await db().query<Physical>(
104
108
  sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
105
109
  );
110
+ return rows.map((row) => decodeRow(${name.camel}, row));
106
111
  }
107
112
 
108
113
  ${insertSignature}
109
114
  // Money is three physical columns — integer minor units, the ISO code, and the scale, never a
110
115
  // float. \`scale ?? null\`: an amount at the currency's own minor unit carries no scale at all,
111
116
  // and writing \`0\` for it would claim whole units — a 100x reinterpretation of the price.
112
- const created = await db().one<${name.pascal}>(sql\`
117
+ const created = await db().one<Physical>(sql\`
113
118
  insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
114
119
  values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
115
120
  \${row.price.scale ?? null})
116
121
  returning *\`);
117
122
  if (created === null) throw dbDrift('${table}', 'id');
118
- return created;
123
+ return decodeRow(${name.camel}, created);
119
124
  }
120
125
  `;
121
126
  };
@@ -45,7 +45,7 @@ export { entryFiles } from './scaffold-entries';
45
45
  export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
46
46
  // The nine guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
47
47
  export { scaffoldGuardFiles } from './scaffold-guards';
48
- export { i18nIndex } from './scaffold-i18n';
48
+ export { i18nIndex, localeEntry, localeImport } from './scaffold-i18n';
49
49
  export { repoFiles } from './scaffold-repo';
50
50
  export { shellFiles } from './scaffold-shell';
51
51
  export { siteFiles } from './scaffold-site';
@@ -3,11 +3,12 @@
3
3
  // both; the generated test pins them through a real driver, because a key that is not stable is a
4
4
  // job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
5
5
 
6
- import { stripComments } from '../ts-scan';
6
+ import { stripComments } from '@ultimat3/core';
7
7
  import type { FeatureTarget } from './entity';
8
8
  import type { GeneratedFile, NameSet } from './naming';
9
9
  import { names } from './naming';
10
10
  import { sliceExports, sliceFoundation } from './slice-foundation';
11
+ import { wrapList } from './wrap';
11
12
 
12
13
  const jobSource = (
13
14
  name: NameSet,
@@ -69,7 +70,8 @@ export const ${name.camel} = job({
69
70
  retry: { attempts: 5, backoff: 'exponential' },
70
71
  async run({ step }) {
71
72
  await step.run('process', async () => {
72
- // TODO: this job's own work.
73
+ // This job's own work. No table is read here: this feature has no entity, and a generator
74
+ // writing into a slice never invents one — \`x g entity\` declares it.
73
75
  });
74
76
  return { processed: true };
75
77
  },
@@ -117,7 +119,7 @@ export const ${name.camel} = task({
117
119
  tz: 'UTC',
118
120
  // No org in the payload: the job this enqueues declares \`tenant: 'none'\`, because this
119
121
  // feature's entity names no tenant column (or has none yet).
120
- enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
122
+ ${wrapList(' ', 'enqueue: () => [', [`[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]`], '],')}
121
123
  });
122
124
  `;
123
125
 
@@ -291,9 +293,10 @@ export interface JobOptions extends FeatureTarget {
291
293
  }
292
294
 
293
295
  /**
294
- * Whether `x g job`/`x g task` may assume the tenant-scoped shape: an `entity.ts` this feature
295
- * does not have yet is about to be scaffolded fresh by `sliceFoundation` below, tenant-scoped by
296
- * default — so absent counts as scoped. One that exists is trusted over that default: it declares
296
+ * Whether `x g job`/`x g task` may assume the tenant-scoped shape. A feature with no `entity.ts` is
297
+ * NOT: only `x g entity` and `x g resource` create a feature's data, and counting absent as scoped
298
+ * made `x g task nightly` lay down an `entity('nightlies', { title, price })` that the `drift` step
299
+ * then demanded a migration for. One that exists is trusted: it declares
297
300
  * a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`listByOrg` pair the
298
301
  * tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
299
302
  * an author trimmed `listByOrg` out of `repo.ts` (or never generated one) is not a slice this job
@@ -303,7 +306,7 @@ export function isTenantScopedSlice(
303
306
  sliceEntity: string | undefined,
304
307
  sliceRepo: string | undefined,
305
308
  ): boolean {
306
- if (sliceEntity === undefined) return true;
309
+ if (sliceEntity === undefined) return false;
307
310
  const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
308
311
  if (!declaresTenant) return false;
309
312
  if (sliceRepo === undefined) return true;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
6
  import { names } from './naming';
7
- import { wrapList } from './wrap';
7
+ import { wrapImport, wrapList } from './wrap';
8
8
 
9
9
  /** Biome would rewrap this itself, so the generator emits the already-formatted form. */
10
10
  const permissionSet = (feature: NameSet): string =>
@@ -65,6 +65,15 @@ export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
65
65
  '${feature.kebab}:write',
66
66
  ({ actor, input }) => actor !== null && actor.orgId === input.orgId,
67
67
  );
68
+
69
+ /**
70
+ * Create takes no org from its input — the row is written under the actor's own — so the tenancy
71
+ * rule is that the actor HAS one. Same grant as write.
72
+ */
73
+ export const can${feature.pascal}Create = can(
74
+ '${feature.kebab}:write',
75
+ ({ actor }) => actor !== null && typeof actor.orgId === 'string' && actor.orgId !== '',
76
+ );
68
77
  `;
69
78
 
70
79
  const policyTest = (
@@ -73,7 +82,7 @@ const policyTest = (
73
82
  // only read. A policy whose tests all pass is a policy nobody has tried to get past.
74
83
  import { testActor } from '@ultimat3/policy';
75
84
  import { expect, unitTest } from '@ultimat3/testing';
76
- import { can${feature.pascal}Read, can${feature.pascal}Write } from './policy';
85
+ ${wrapImport([`can${feature.pascal}Create`, `can${feature.pascal}Read`, `can${feature.pascal}Write`], './policy')}
77
86
 
78
87
  const org = '00000000-0000-4000-8000-000000000002';
79
88
  const otherOrg = '00000000-0000-4000-8000-000000000009';
@@ -108,9 +117,18 @@ unitTest('${feature.camel} write denies the read-only actor', async () => {
108
117
  await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input });
109
118
  });
110
119
 
120
+ unitTest('${feature.camel} create denies the read-only and the orgless actor', async () => {
121
+ const orgless = testActor('orgless', { permissions: [read, write] }).actor;
122
+ await expect(can${feature.pascal}Create).toDenyPolicy({ actor: null, input: {} });
123
+ await expect(can${feature.pascal}Create).toDenyPolicy({ actor: reader, input: {} });
124
+ await expect(can${feature.pascal}Create).toDenyPolicy({ actor: orgless, input: {} });
125
+ await expect(can${feature.pascal}Create).not.toDenyPolicy({ actor: writer, input: {} });
126
+ });
127
+
111
128
  unitTest('${feature.camel} rules name the permission they require', () => {
112
129
  expect(can${feature.pascal}Read.permissions).toEqual(['${feature.kebab}:read']);
113
130
  expect(can${feature.pascal}Write.permissions).toEqual(['${feature.kebab}:write']);
131
+ expect(can${feature.pascal}Create.permissions).toEqual(['${feature.kebab}:write']);
114
132
  });
115
133
  `;
116
134
 
@@ -0,0 +1,127 @@
1
+ // `x g resource`'s create action: a real insert. It used to be `x g action`'s generic body — read a
2
+ // row by id, throw NotFound — so the route the resource named `create` could never create, and its
3
+ // input was `{ id, orgId }` while the form posted `{ title }`. The input is the entity's own view of
4
+ // the columns a caller supplies; the org comes from the actor, never from the body.
5
+
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+ import { names } from './naming';
8
+ import { wrapImport, wrapList } from './wrap';
9
+
10
+ /** The columns a caller supplies: the resource entity's, minus `id`, `orgId` and `createdAt`. */
11
+ export const CREATE_COLUMNS = ['title', 'price'] as const;
12
+
13
+ const createSource = (
14
+ feature: NameSet,
15
+ ): string => `// create${feature.pascal}: inserts one ${feature.kebab} in the caller's org and returns it.
16
+ // The input is a view of the entity's own columns, so a column added there is one edit here —
17
+ // the list below — and never a second schema to keep in step.
18
+
19
+ import { action } from '@ultimat3/action';
20
+ import { tenancyActorOrgRequired } from '@ultimat3/entity';
21
+ ${wrapImport([`${feature.pascal}View`, feature.camel], '../entity')}
22
+ ${wrapImport([`can${feature.pascal}Create`, `${feature.camel}Tag`], '../policy')}
23
+ import * as service from '../service';
24
+
25
+ /** What a caller supplies. \`id\`, \`orgId\` and \`createdAt\` are the server's to write. */
26
+ ${wrapList(
27
+ '',
28
+ `export const Create${feature.pascal}Input = ${feature.camel}.$view([`,
29
+ CREATE_COLUMNS.map((column) => `'${column}'`),
30
+ ']);',
31
+ )}
32
+
33
+ export const create${feature.pascal} = action({
34
+ input: Create${feature.pascal}Input,
35
+ output: ${feature.pascal}View,
36
+ policy: can${feature.pascal}Create,
37
+ cache: { invalidates: [${feature.camel}Tag] },
38
+ // No \`mcp\`: an MCP tool's description IS what an agent reads to decide to call it, and a
39
+ // placeholder there is worse than no tool. Write one, then \`mcp: { expose: true, description }\`.
40
+ async handle({ input, ctx }) {
41
+ // The tenant is the actor's. The policy already refused an actor with none; this is the same
42
+ // fact for the type checker, and the framework's own refusal if a caller skips the policy.
43
+ const orgId = ctx.actor.orgId;
44
+ if (typeof orgId !== 'string' || orgId === '') {
45
+ throw tenancyActorOrgRequired({
46
+ entityName: '${feature.camel}',
47
+ operation: 'create',
48
+ actorId: ctx.actor.id,
49
+ actorKind: ctx.actor.kind,
50
+ });
51
+ }
52
+ return service.create({ ...input, orgId });
53
+ },
54
+ });
55
+ `;
56
+
57
+ const ORG = '00000000-0000-4000-8000-000000000002';
58
+
59
+ const createUnitTest = (
60
+ feature: NameSet,
61
+ ): string => `// create${feature.pascal}: its declared shape and the input it refuses — answered by the
62
+ // declaration alone, so they belong to the \`unit\` step.
63
+ import { expect, unitTest } from '@ultimat3/testing';
64
+ import { create${feature.pascal} } from './create-${feature.kebab}';
65
+
66
+ const target = create${feature.pascal}.named('create${feature.pascal}');
67
+ const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
68
+
69
+ unitTest('create${feature.pascal} is a declared action', () => {
70
+ expect(target.kind).toBe('action');
71
+ expect(target.describe().name).toBe('create${feature.pascal}');
72
+ });
73
+
74
+ unitTest('create${feature.pascal} takes the columns a caller supplies', async () => {
75
+ await expect(target.input).toAcceptInput(input);
76
+ await expect(target.input).toRejectInput({ price: input.price });
77
+ });
78
+ `;
79
+
80
+ const createContractTest = (
81
+ feature: NameSet,
82
+ ): string => `// create${feature.pascal}: the contract every action owes, and the orgless actor it denies before
83
+ // the handler runs — the org a row is written under is the actor's, so an actor with none is refused.
84
+ import { testActor } from '@ultimat3/policy';
85
+ import { contractTest, expect } from '@ultimat3/testing';
86
+ import { create${feature.pascal} } from './create-${feature.kebab}';
87
+
88
+ const target = create${feature.pascal}.named('create${feature.pascal}');
89
+ const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
90
+
91
+ // Holds the grant and belongs to no org: the denial is the predicate deciding, not the grant.
92
+ const orgless = testActor('orgless', { permissions: ['${feature.kebab}:write'] }).actor;
93
+ // And one that holds both, so the refusal above is not simply "every actor is refused".
94
+ const writer = testActor('writer', {
95
+ orgId: '${ORG}',
96
+ permissions: ['${feature.kebab}:write'],
97
+ }).actor;
98
+
99
+ contractTest('create${feature.pascal} passes the action contract', async () => {
100
+ for (const contract of target.contract()) await contract.run();
101
+ });
102
+
103
+ contractTest('create${feature.pascal} needs an actor with an org', async () => {
104
+ expect(await target.as(orgless, input).catch((error: unknown) => error)).toBeUltimateError(
105
+ 'X_FORBIDDEN',
106
+ );
107
+ await expect(target.policy).not.toDenyPolicy({ actor: writer, input });
108
+ });
109
+
110
+ contractTest('create${feature.pascal} projects one operation', () => {
111
+ expect(target.openapi().operationId).toBe('create${feature.pascal}');
112
+ });
113
+ `;
114
+
115
+ /** The create action and its two suites, under `<slice>/actions/create-<feature>`. */
116
+ export function resourceCreateFiles(rawName: string, sliceDir: string): readonly GeneratedFile[] {
117
+ const feature = names(rawName);
118
+ const dir = `${sliceDir}/actions`;
119
+ return [
120
+ { path: `${dir}/create-${feature.kebab}.ts`, contents: createSource(feature) },
121
+ { path: `${dir}/create-${feature.kebab}.test.ts`, contents: createUnitTest(feature) },
122
+ {
123
+ path: `${dir}/create-${feature.kebab}.contract.test.ts`,
124
+ contents: createContractTest(feature),
125
+ },
126
+ ];
127
+ }