@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
@@ -45,7 +45,7 @@ ${permissionSet(feature)}
45
45
  /** What a write invalidates and a read depends on — one tag, both directions. */
46
46
  export const ${feature.camel}Tag = tag('${feature.kebab}');
47
47
 
48
- /** What every ${feature.kebab} rule needs to decide. Actions and queries both accept it. */
48
+ /** What a ${feature.kebab} write decides on: the org of the row an action names. */
49
49
  export interface ${feature.pascal}Scope {
50
50
  readonly orgId: string;
51
51
  }
@@ -54,13 +54,17 @@ export interface ${feature.pascal}Scope {
54
54
  // never do this" from "you may, but not in that org" — an agent can act on the difference.
55
55
  // The predicates below add tenancy only; the grant is never re-checked by hand.
56
56
 
57
- /** Read is org-scoped: an actor sees rows in their own org and nothing else. */
58
- export const can${feature.pascal}Read = can<${feature.pascal}Scope>(
57
+ /**
58
+ * Read takes no org from its input: the repo's typed handle scopes every read to the actor's own
59
+ * org, so the tenancy rule here is that the actor HAS one. A second org check on an input field
60
+ * would be a second source of tenancy, and the one a caller could set.
61
+ */
62
+ export const can${feature.pascal}Read = can(
59
63
  '${feature.kebab}:read',
60
- ({ actor, input }) => actor !== null && actor.orgId === input.orgId,
64
+ ({ actor }) => actor !== null && typeof actor.orgId === 'string' && actor.orgId !== '',
61
65
  );
62
66
 
63
- /** Write is the same tenancy rule on a second permission — grant the two separately in roles. */
67
+ /** Write compares the org the action's input names with the actor's — grant it apart from read. */
64
68
  export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
65
69
  '${feature.kebab}:write',
66
70
  ({ actor, input }) => actor !== null && actor.orgId === input.orgId,
@@ -103,10 +107,13 @@ const reader = testActor('reader', { orgId: org, permissions: [read] }).actor;
103
107
  const writer = testActor('writer', { orgId: org, permissions: [read, write] }).actor;
104
108
  const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read, write] }).actor;
105
109
 
106
- unitTest('${feature.camel} read denies anonymous and cross-org actors', async () => {
107
- await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input });
108
- await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input });
109
- await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input });
110
+ unitTest('${feature.camel} read denies anonymous and orgless actors', async () => {
111
+ // Which org's rows a reader gets is the handle's, not this rule's: the slice's query test reads
112
+ // as an actor in another org and gets none of these.
113
+ const orgless = testActor('orgless', { permissions: [read] }).actor;
114
+ await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input: {} });
115
+ await expect(can${feature.pascal}Read).toDenyPolicy({ actor: orgless, input: {} });
116
+ await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input: {} });
110
117
  });
111
118
 
112
119
  unitTest('${feature.camel} write denies the read-only actor', async () => {
@@ -3,9 +3,11 @@
3
3
  // an unbounded live query is a memory leak that only shows up under load.
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';
8
- import { sliceFoundation } from './slice-foundation';
9
+ import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
10
+ import { sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
9
11
  import { wrapImport } from './wrap';
10
12
 
11
13
  /** A live read always fans out fresh, so a TTL on it would only ever be dead configuration. */
@@ -28,18 +30,23 @@ ${wrapImport(
28
30
  )}
29
31
  import * as repo from '../repo';
30
32
 
33
+ // No \`orgId\` in the input, and none in the policy: \`repo.list\` reads through the typed handle,
34
+ // which scopes every read to the ACTOR's org, and a live window is keyed by the subscriber's
35
+ // tenant. One source of tenancy — a parameter would be a second one a caller could set.
31
36
  export const ${name.camel} = query({
32
- input: t.object({ orgId: t.uuid, limit: t.number.default(50) }),
37
+ input: t.object({ limit: t.number.default(50) }),
33
38
  policy: can${feature.pascal}Read,
34
39
  live: ${String(live)},${cacheLine(feature, live)}
35
40
  // Opt-in, unlike an action's tool: a read hands rows to an agent, so silence exposes nothing.
36
41
  mcp: { expose: true, description: '${name.raw} — edit this description' },
37
- sql: ({ orgId, limit }) =>
42
+ sql: ({ limit }) =>
38
43
  // \`feature.table\`, not the kebab plural: \`from()\` quotes the identifier into the SQL text,
39
44
  // and the entity created the table as snake_case.
40
- from<${feature.pascal}>('${feature.table}', () => repo.listByOrg(orgId, limit))
41
- .where({ orgId })
42
- .orderBy('createdAt')
45
+ from<${feature.pascal}>('${feature.table}', () => repo.list(limit))
46
+ // Newest first — \`repo.list\`'s own order. These two lines are ONE read spelled twice: the
47
+ // thunk is what the in-memory driver answers and this chain is the SQL Postgres runs, so a
48
+ // direction that differs here is a bounded page holding different rows on each.
49
+ .orderBy('createdAt', 'desc')
43
50
  // The primary key last is what makes the order TOTAL: \`createdAt\` alone ties, and two
44
51
  // rows that tie can swap between evaluations — a bounded read then drops one and repeats
45
52
  // the other, and a live subscription patches a row it never sent.
@@ -48,29 +55,42 @@ export const ${name.camel} = query({
48
55
  });
49
56
  `;
50
57
 
51
- const queryTest = (name: NameSet, feature: NameSet, live: boolean): string => {
58
+ const queryTest = (name: NameSet, feature: NameSet, shape: ReadShape): string => {
59
+ const { live } = shape;
52
60
  const wrapper = live ? 'liveTest' : 'unitTest';
61
+ // A one-shot query's file carries the read cases too, so it imports what they need; a live
62
+ // query's declaration suite reads nothing and imports only its own.
63
+ const subscribes = live && shape.storesRow;
64
+ const imports = subscribes
65
+ ? [
66
+ "import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
67
+ `import { afterEach, expect, ${wrapper} } from '@ultimat3/testing';`,
68
+ `import { driver } from '${shape.dbModule}';`,
69
+ ]
70
+ : live
71
+ ? [`import { expect, ${wrapper} } from '@ultimat3/testing';`]
72
+ : readImports(shape, ['expect', wrapper]);
73
+ const repo = shape.storesRow
74
+ ? `${subscribes ? `\nimport type { ${feature.pascal} } from '../entity';` : ''}\nimport * as repo from '../repo';`
75
+ : '';
53
76
  return `// ${name.camel}: the shape it reads, the policy it asserts, and the actor it refuses. The read is
54
77
  // declarative, so what a test can get wrong is the authz, and that is what this pins.
55
- import { testActor } from '@ultimat3/policy';
56
- import { sourceFor } from '@ultimat3/query';
57
- import { expect, ${wrapper} } from '@ultimat3/testing';
78
+ ${sortedImports([
79
+ ...imports,
80
+ "import { testActor } from '@ultimat3/policy';",
81
+ subscribes
82
+ ? "import { registerQuery, sourceFor } from '@ultimat3/query';"
83
+ : "import { sourceFor } from '@ultimat3/query';",
84
+ ])}${repo}
58
85
  import { ${name.camel} } from './${name.kebab}';
59
-
60
- // A real v4 uuid: the read parses its input the way a request does, so a placeholder that only
61
- // looks like a uuid would fail before it ever built any SQL.
62
- const orgId = '00000000-0000-4000-8000-000000000002';
63
-
86
+ ${live && !shape.storesRow ? '' : ORG_LINE}
64
87
  // Named here because every projection needs a stable name and this file does not boot the app.
65
88
  // At boot \`registerQueries(await import('./${live ? 'live' : 'queries'}'))\` stamps the same name
66
89
  // onto the same object.
67
90
  const target = ${name.camel}.named('${name.camel}');
68
91
 
69
- // Holds the grant, wrong org — so a denial here is the predicate deciding, not the grant.
70
- const outsider = testActor('outsider', {
71
- orgId: '00000000-0000-4000-8000-000000000009',
72
- permissions: ['${feature.kebab}:read'],
73
- }).actor;
92
+ // Holds the grant and no org — so a denial here is the predicate deciding, not the grant.
93
+ const orgless = testActor('orgless', { permissions: ['${feature.kebab}:read'] }).actor;
74
94
 
75
95
  ${wrapper}('${name.camel} is a declared ${live ? 'live ' : ''}query', () => {
76
96
  expect(target.kind).toBe('query');
@@ -85,7 +105,7 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
85
105
  // written reason, never a boolean, because a boolean reads exactly like forgetting the policy.
86
106
  const source = await sourceFor(
87
107
  target,
88
- { orgId, limit: 50 },
108
+ { limit: 50 },
89
109
  {
90
110
  actor: null,
91
111
  unenforced: 'a scaffolded test asserts the SQL text; the policy is asserted separately',
@@ -93,17 +113,18 @@ ${wrapper}('${name.camel} is bounded and TOTALLY ordered', async () => {
93
113
  );
94
114
  const { sql } = source.toSQL();
95
115
  const text = sql.toLowerCase();
96
- expect(text).toContain('order by');
116
+ // Newest first, as \`repo.list\` — the in-memory half of this same read — returns them.
117
+ expect(text).toContain('order by "createdat" desc');
97
118
  expect(text).toContain('limit');
98
119
  // "ordered" is not enough. Dropping the id tiebreak still leaves an ORDER BY, so asserting on
99
120
  // its presence alone would keep passing while the read went non-deterministic under ties.
100
121
  expect(text.slice(text.lastIndexOf('order by'))).toContain('id');
101
122
  });
102
123
 
103
- ${wrapper}('${name.camel} denies a foreign org before it reads a row', async () => {
124
+ ${wrapper}('${name.camel} denies an orgless actor before a read', async () => {
104
125
  // \`.as()\` is the one read path with the actor swapped: validate, authorize, then read. The
105
126
  // denial lands before any SQL executes, which is why this needs no database.
106
- const denied = await target.as(outsider, { orgId, limit: 50 }).catch((error: unknown) => error);
127
+ const denied = await target.as(orgless, { limit: 50 }).catch((error: unknown) => error);
107
128
  expect(denied).toBeUltimateError('X_FORBIDDEN');
108
129
  });
109
130
 
@@ -112,11 +133,195 @@ ${wrapper}('${name.camel} exposes one MCP tool that reads, and never writes', ()
112
133
  expect(target.tool().policy).toBe(target.policy);
113
134
  expect(target.tool().mutates).toBe(false);
114
135
  });
136
+ ${subscribes ? subscribeTest(name, feature) : ''}`;
137
+ };
138
+
139
+ /**
140
+ * The REAL subscribe path, with two orgs: a sync node in this process (the `subscribe` fixture),
141
+ * never `.as(actor)`. `.as()` installs a request context of its own, so a live source that
142
+ * leaves its tenant to the acting actor — which is what the query above does, through
143
+ * `repo.list` — read fine there while every real subscriber was answered `X_TENANCY_UNSCOPED`.
144
+ * Two orgs, a snapshot and a write: the window a subscriber is served from is its own org's.
145
+ */
146
+ const subscribeTest = (name: NameSet, feature: NameSet): string => `
147
+ // The module's own export under its own name — what \`defineApi\` does at boot — so a subscribe
148
+ // frame naming it reaches this declaration.
149
+ const live = registerQuery('${name.camel}', ${name.camel});
150
+
151
+ type Row = ${feature.pascal};
152
+
153
+ const otherOrg = '${OTHER_ORG}';
154
+
155
+ const readerOf = (org: string) =>
156
+ testActor('reader', { orgId: org, permissions: ['${feature.kebab}:read'] }).actor;
157
+
158
+ // The request clock, so \`createdAt\` is an instant this file chose and "newest" is decidable.
159
+ const clock = frozenClock('2026-01-01T00:00:00.000Z');
160
+
161
+ const store = (org: string, title: string) => {
162
+ const draft = { orgId: org, title, price: { minor: 0, currency: 'USD' } };
163
+ const writer = createContext({ actor: testActor('writer', { orgId: org }).actor, clock });
164
+ return runWithContext(writer, () => repo.insert(draft));
165
+ };
166
+
167
+ // One store per process: without this, one test's rows are the next test's fixtures.
168
+ afterEach(() => {
169
+ driver.reset?.();
170
+ });
171
+
172
+ liveTest('two orgs subscribe: each is sent its own rows and writes', async ({ subscribe }) => {
173
+ await store(orgId, 'ours');
174
+ await store(otherOrg, 'theirs');
175
+ // The REAL subscribe path — a sync node in this process — and never \`.as(actor)\`: that
176
+ // installs a request context of its own, so a source read with no tenant passes there and is
177
+ // X_TENANCY_UNSCOPED for every real subscriber.
178
+ // ONE input for both: which org a window holds is the subscriber's, never a parameter.
179
+ const input = { limit: 50 };
180
+ const ours = await subscribe<Row>(live, input, readerOf(orgId));
181
+ const theirs = await subscribe<Row>(live, input, readerOf(otherOrg));
182
+ expect(ours.rows().map((row) => row.title)).toEqual(['ours']);
183
+ expect(theirs.rows().map((row) => row.title)).toEqual(['theirs']);
184
+
185
+ // A write after the snapshot reaches its own org's subscriber as a patch — placed first, the
186
+ // order the declaration names — and nobody else's.
187
+ clock.advance(1000);
188
+ await store(orgId, 'ours too');
189
+ await ours.settled();
190
+ await theirs.settled();
191
+ expect(ours.rows().map((row) => row.title)).toEqual(['ours too', 'ours']);
192
+ expect(theirs.rows().map((row) => row.title)).toEqual(['theirs']);
193
+ expect(theirs.patches()).toEqual([]);
194
+ });
195
+ `;
196
+
197
+ const ORG = '00000000-0000-4000-8000-000000000002';
198
+
199
+ /** The org the reader and its rows belong to — an actor's, never the read's input. */
200
+ const ORG_LINE = `
201
+ // The org the reader and the stored rows belong to: the ACTOR's, never a field of the input.
202
+ const orgId = '${ORG}';
115
203
  `;
204
+ const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
205
+
206
+ /** What the read test may do with the slice the query reads. */
207
+ interface ReadShape {
208
+ readonly live: boolean;
209
+ /** The slice still takes the row `x g entity` scaffolds, so the test may store some. */
210
+ readonly storesRow: boolean;
211
+ readonly dbModule: string;
212
+ }
213
+
214
+ /**
215
+ * Rows, stored and read back: the case that runs the thunk AND the order the declaration puts on
216
+ * it. Three rows a second apart, a page of two — so an order that is not newest-first, a missing
217
+ * bound and another org's row each fail it on their own.
218
+ */
219
+ const storedRowsTest = (): string => `
220
+ const otherOrg = '${OTHER_ORG}';
221
+
222
+ // The request clock, so \`createdAt\` is an instant this file chose and "newest" is decidable.
223
+ const clock = frozenClock('2026-01-01T00:00:00.000Z');
224
+
225
+ const store = (owner: string, title: string) => {
226
+ const draft = { orgId: owner, title, price: { minor: 0, currency: 'USD' } };
227
+ const writer = createContext({ actor: testActor('writer', { orgId: owner }).actor, clock });
228
+ return runWithContext(writer, () => repo.insert(draft));
116
229
  };
117
230
 
231
+ // One store per process: without this, one test's rows are the next test's fixtures.
232
+ afterEach(() => {
233
+ driver.reset?.();
234
+ });
235
+
236
+ unitTest('stored rows read back newest first, bounded, and never another org', async () => {
237
+ for (const title of ['first', 'second', 'third']) {
238
+ await store(orgId, title);
239
+ clock.advance(1000);
240
+ }
241
+ await store(otherOrg, 'theirs');
242
+ // The declaration's order over the repo's rows: what the SQL names on Postgres, read here
243
+ // from the in-memory driver. A page of two is the two NEWEST, on both.
244
+ const page = await target.as(member, { limit: 2 });
245
+ expect(page.map((row) => row.title)).toEqual(['third', 'second']);
246
+ // The same input, an actor in the other org: its own row, and nothing of this org's.
247
+ const outsider = testActor('outsider', { orgId: otherOrg, permissions: [read] }).actor;
248
+ const theirs = await target.as(outsider, { limit: 50 });
249
+ expect(theirs.map((row) => row.title)).toEqual(['theirs']);
250
+ });
251
+ `;
252
+
253
+ /**
254
+ * The READ, executed — the half of a query a declaration test never runs. `.as()` is the one read
255
+ * path: parse, authorize, then read through the repo, which under `bun test` is the in-memory
256
+ * driver. For a live query this is its own file, because the `.live.test.ts` beside it belongs to
257
+ * the `live` step and an app's coverage floor counts the unit suite alone.
258
+ *
259
+ * Rows are stored only in a slice whose row this generator can spell (`sliceTakesScaffoldRow`):
260
+ * one an author reshaped has its own columns, and the refusal and the empty read need none.
261
+ *
262
+ * Every actor is `testActor(…).actor` — the one fixture builder, and an `Actor`, so the stored-row
263
+ * case builds its context from it directly.
264
+ */
265
+ const readCases = (
266
+ feature: NameSet,
267
+ shape: ReadShape,
268
+ ): string => `// The read grant, named once so every line below fits the formatter's width at any feature name.
269
+ const read = '${feature.kebab}:read';
270
+
271
+ // A member of the org, holding the read grant and nothing else.
272
+ const member = testActor('member', { orgId, permissions: [read] }).actor;
273
+
274
+ unitTest('a caller with no grant is refused before the read', async () => {
275
+ const stranger = testActor('stranger', { orgId }).actor;
276
+ const refused = await target.as(stranger, { limit: 50 }).catch((error: unknown) => error);
277
+ expect(refused).toBeUltimateError('X_FORBIDDEN');
278
+ });
279
+
280
+ unitTest('the read runs as its caller: an org with no rows reads empty', async () => {
281
+ // Through the policy, the source and the repo — every line of the declaration, against the
282
+ // in-memory driver.
283
+ expect(await target.as(member, { limit: 50 })).toEqual([]);
284
+ });
285
+ ${shape.storesRow ? storedRowsTest() : ''}`;
286
+
287
+ /** The imports the read cases need, over whatever the file they land in already imports. */
288
+ const readImports = (shape: ReadShape, testing: readonly string[]): readonly string[] => [
289
+ ...(shape.storesRow
290
+ ? ["import { createContext, frozenClock, runWithContext } from '@ultimat3/core';"]
291
+ : []),
292
+ `import { ${[...(shape.storesRow ? ['afterEach'] : []), ...testing].join(', ')} } from '@ultimat3/testing';`,
293
+ ...(shape.storesRow ? [`import { driver } from '${shape.dbModule}';`] : []),
294
+ ];
295
+
296
+ /** A live query's unit half, as its own file: the suite beside it runs on the `live` step. */
297
+ const liveReadTest = (
298
+ name: NameSet,
299
+ feature: NameSet,
300
+ shape: ReadShape,
301
+ ): string => `// ${name.camel}, READ: what a caller gets back, through the repo and the in-memory driver. The
302
+ // subscription — the snapshot and the patches a write produces — is the live suite's, next door.
303
+ ${sortedImports([...readImports(shape, ['expect', 'unitTest']), "import { testActor } from '@ultimat3/policy';"])}${shape.storesRow ? "\nimport * as repo from '../repo';" : ''}
304
+ import { ${name.camel} } from './${name.kebab}';
305
+
306
+ const orgId = '${ORG}';
307
+
308
+ // Named here because every projection needs a stable name and this file does not boot the app.
309
+ const target = ${name.camel}.named('${name.camel}');
310
+
311
+ ${readCases(feature, shape)}`;
312
+
118
313
  export interface QueryOptions extends FeatureTarget {
119
314
  readonly live?: boolean;
315
+ /** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
316
+ readonly locales?: readonly string[];
317
+ /**
318
+ * The slice's `entity.ts`, or absent when the feature has none yet. Decides one thing: whether
319
+ * the unit test may STORE rows (`sliceTakesScaffoldRow`). `x g resource` passes the entity it is
320
+ * writing in the same run; `x g query` reads the one on disk.
321
+ */
322
+ readonly sliceEntity?: string;
323
+ /** The slice's `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
324
+ readonly sliceRepo?: string;
120
325
  }
121
326
 
122
327
  export function queryFiles(rawName: string, target: QueryOptions): readonly GeneratedFile[] {
@@ -124,17 +329,28 @@ export function queryFiles(rawName: string, target: QueryOptions): readonly Gene
124
329
  const feature = names(target.feature);
125
330
  const live = target.live === true;
126
331
  const dir = `${target.surfaceDir}/${target.feature}/${live ? 'live' : 'queries'}`;
332
+ const shape: ReadShape = {
333
+ live,
334
+ storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
335
+ dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
336
+ };
127
337
  return [
128
338
  // A read declares the row type it returns (`../entity`), the rule that admits it (`../policy`)
129
339
  // and the one module allowed to query the table (`../repo`) — all three are the slice's, and
130
340
  // `--live` changes only which directory this file lands in.
131
- ...sliceFoundation(target, ['entity', 'policy']),
341
+ ...sliceFoundation(target, ['entity', 'policy'], target.locales),
132
342
  { path: `${dir}/${name.kebab}.ts`, contents: querySource(name, feature, live) },
133
343
  // The suffix follows the WRAPPER, which follows `--live`: a `liveTest` in a plain
134
344
  // `<name>.test.ts` runs under `unit`, so `x test live` had no files in an app full of them.
135
345
  {
136
346
  path: `${dir}/${name.kebab}${live ? '.live' : ''}.test.ts`,
137
- contents: queryTest(name, feature, live),
347
+ contents: live
348
+ ? queryTest(name, feature, shape)
349
+ : `${queryTest(name, feature, shape)}\n${readCases(feature, shape)}`,
138
350
  },
351
+ // A live read's unit half, in its own file: the suite above runs on the `live` step.
352
+ ...(live
353
+ ? [{ path: `${dir}/${name.kebab}.test.ts`, contents: liveReadTest(name, feature, shape) }]
354
+ : []),
139
355
  ];
140
356
  }
@@ -3,6 +3,7 @@
3
3
  // input was `{ id, orgId }` while the form posted `{ title }`. The input is the entity's own view of
4
4
  // the columns a caller supplies; the org comes from the actor, never from the body.
5
5
 
6
+ import { sortedImports } from './imports';
6
7
  import type { GeneratedFile, NameSet } from './naming';
7
8
  import { names } from './naming';
8
9
  import { wrapImport, wrapList } from './wrap';
@@ -17,6 +18,7 @@ const createSource = (
17
18
  // the list below — and never a second schema to keep in step.
18
19
 
19
20
  import { action } from '@ultimat3/action';
21
+ import type { Actor } from '@ultimat3/core';
20
22
  import { tenancyActorOrgRequired } from '@ultimat3/entity';
21
23
  ${wrapImport([`${feature.pascal}View`, feature.camel], '../entity')}
22
24
  ${wrapImport([`can${feature.pascal}Create`, `${feature.camel}Tag`], '../policy')}
@@ -30,6 +32,21 @@ ${wrapList(
30
32
  ']);',
31
33
  )}
32
34
 
35
+ /**
36
+ * The org a row is written under: the actor's, never the body's. The policy already refused an
37
+ * actor with none, so inside \`handle\` this is that fact for the type checker — and the
38
+ * framework's own refusal if a caller ever reaches the service around the policy.
39
+ */
40
+ export const orgOf = (actor: Actor): string => {
41
+ if (typeof actor.orgId === 'string' && actor.orgId !== '') return actor.orgId;
42
+ throw tenancyActorOrgRequired({
43
+ entityName: '${feature.camel}',
44
+ operation: 'create',
45
+ actorId: actor.id,
46
+ actorKind: actor.kind,
47
+ });
48
+ };
49
+
33
50
  export const create${feature.pascal} = action({
34
51
  input: Create${feature.pascal}Input,
35
52
  output: ${feature.pascal}View,
@@ -38,18 +55,7 @@ export const create${feature.pascal} = action({
38
55
  // No \`mcp\`: an MCP tool's description IS what an agent reads to decide to call it, and a
39
56
  // placeholder there is worse than no tool. Write one, then \`mcp: { expose: true, description }\`.
40
57
  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 });
58
+ return service.create({ ...input, orgId: orgOf(ctx.actor) });
53
59
  },
54
60
  });
55
61
  `;
@@ -58,13 +64,29 @@ const ORG = '00000000-0000-4000-8000-000000000002';
58
64
 
59
65
  const createUnitTest = (
60
66
  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}';
67
+ dbModule: string,
68
+ ): string => `// create${feature.pascal}: its declared shape, the input it refuses, and the row its handler
69
+ // writes — against the in-memory driver, so all three belong to the \`unit\` step.
70
+ ${sortedImports([
71
+ "import { testActor } from '@ultimat3/policy';",
72
+ "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
73
+ `import { driver } from '${dbModule}';`,
74
+ ])}
75
+ import { create${feature.pascal}, orgOf } from './create-${feature.kebab}';
65
76
 
66
77
  const target = create${feature.pascal}.named('create${feature.pascal}');
67
78
  const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
79
+ const orgId = '${ORG}';
80
+
81
+ // Holds the grant and an org: past the policy, so what answers is the handler.
82
+ const grant = ['${feature.kebab}:write'];
83
+ const writer = testActor('writer', { orgId, permissions: grant }).actor;
84
+ const orgless = testActor('orgless', { permissions: grant }).actor;
85
+
86
+ // One store per process: without this, one test's rows are the next test's fixtures.
87
+ afterEach(() => {
88
+ driver.reset?.();
89
+ });
68
90
 
69
91
  unitTest('create${feature.pascal} is a declared action', () => {
70
92
  expect(target.kind).toBe('action');
@@ -75,6 +97,24 @@ unitTest('create${feature.pascal} takes the columns a caller supplies', async ()
75
97
  await expect(target.input).toAcceptInput(input);
76
98
  await expect(target.input).toRejectInput({ price: input.price });
77
99
  });
100
+
101
+ unitTest('the handler writes the row and answers its view', async () => {
102
+ const created = await target.as(writer, input);
103
+ expect(created).toMatchObject({ title: input.title, price: input.price });
104
+ // The view, not the row: the tenant is the caller's context, never the client's data.
105
+ expect(created).not.toHaveProperty('orgId');
106
+ });
107
+
108
+ unitTest('orgOf answers the org of the actor, and refuses an actor with none', () => {
109
+ expect(orgOf(writer)).toBe(orgId);
110
+ let refused: unknown;
111
+ try {
112
+ orgOf(orgless);
113
+ } catch (error) {
114
+ refused = error;
115
+ }
116
+ expect(refused).toBeUltimateError('X_TENANCY_ACTOR_ORG_REQUIRED');
117
+ });
78
118
  `;
79
119
 
80
120
  const createContractTest = (
@@ -113,12 +153,16 @@ contractTest('create${feature.pascal} projects one operation', () => {
113
153
  `;
114
154
 
115
155
  /** The create action and its two suites, under `<slice>/actions/create-<feature>`. */
116
- export function resourceCreateFiles(rawName: string, sliceDir: string): readonly GeneratedFile[] {
156
+ export function resourceCreateFiles(
157
+ rawName: string,
158
+ sliceDir: string,
159
+ dbModule: string,
160
+ ): readonly GeneratedFile[] {
117
161
  const feature = names(rawName);
118
162
  const dir = `${sliceDir}/actions`;
119
163
  return [
120
164
  { path: `${dir}/create-${feature.kebab}.ts`, contents: createSource(feature) },
121
- { path: `${dir}/create-${feature.kebab}.test.ts`, contents: createUnitTest(feature) },
165
+ { path: `${dir}/create-${feature.kebab}.test.ts`, contents: createUnitTest(feature, dbModule) },
122
166
  {
123
167
  path: `${dir}/create-${feature.kebab}.contract.test.ts`,
124
168
  contents: createContractTest(feature),