@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 (111) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env bun
2
+ // migrate-cross-tenant — rewrites `crossTenant: true` on entity-convention
3
+ // handlers to `escapeHatch: { reason }` where the handler name and verb can
4
+ // be derived from the call, and reports every other site for review (fw#2915).
5
+ //
6
+ // Usage:
7
+ // bun scripts/codemod/migrate-cross-tenant.ts [--dry-run] <path...>
8
+
9
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import {
12
+ type CallExpression,
13
+ Node,
14
+ type ObjectLiteralExpression,
15
+ Project,
16
+ type PropertyAssignment,
17
+ SyntaxKind,
18
+ } from "ts-morph";
19
+
20
+ export interface Rewrite {
21
+ readonly file: string;
22
+ readonly line: number;
23
+ readonly before: string;
24
+ readonly after: string;
25
+ }
26
+
27
+ export interface ManualSite {
28
+ readonly file: string;
29
+ readonly line: number;
30
+ readonly why: string;
31
+ readonly handlerName?: string;
32
+ }
33
+
34
+ export interface MigrateCrossTenantResult {
35
+ readonly output: string;
36
+ readonly rewrites: Rewrite[];
37
+ readonly manual: ManualSite[];
38
+ }
39
+
40
+ const VERB_IN_NAME_CALLEES: Record<string, string> = {
41
+ defineEntityCreateHandler: "create",
42
+ defineEntityUpdateHandler: "update",
43
+ defineEntityDeleteHandler: "delete",
44
+ defineEntityRestoreHandler: "restore",
45
+ defineEntityListHandler: "list",
46
+ defineEntityDetailHandler: "detail",
47
+ };
48
+
49
+ const VERB_IN_ARG_CALLEES = new Set(["defineEntityWriteHandler", "defineEntityQueryHandler"]);
50
+
51
+ const READ_VERBS = new Set(["list", "detail"]);
52
+
53
+ function isCrossTenantAssignment(node: Node): node is PropertyAssignment {
54
+ return Node.isPropertyAssignment(node) && node.getName() === "crossTenant";
55
+ }
56
+
57
+ function getCalleeName(call: CallExpression): string | undefined {
58
+ const expr = call.getExpression();
59
+ if (Node.isIdentifier(expr)) return expr.getText();
60
+ if (Node.isPropertyAccessExpression(expr)) return expr.getName();
61
+ return undefined;
62
+ }
63
+
64
+ function hasEscapeHatchProperty(obj: ObjectLiteralExpression): boolean {
65
+ return obj.getProperty("escapeHatch") !== undefined;
66
+ }
67
+
68
+ interface AutoRewriteTarget {
69
+ readonly handlerName: string;
70
+ readonly entityName: string;
71
+ readonly verb: string;
72
+ }
73
+
74
+ function deriveAutoRewriteTarget(
75
+ objectLiteral: ObjectLiteralExpression,
76
+ ): AutoRewriteTarget | undefined {
77
+ const call = objectLiteral.getParentIfKind(SyntaxKind.CallExpression);
78
+ if (!call) return undefined;
79
+ if (!call.getArguments().includes(objectLiteral)) return undefined;
80
+
81
+ const calleeName = getCalleeName(call);
82
+ if (!calleeName) return undefined;
83
+
84
+ const firstArg = call.getArguments()[0];
85
+ if (!firstArg || !Node.isStringLiteral(firstArg)) return undefined;
86
+ const firstArgValue = firstArg.getLiteralValue();
87
+
88
+ const verbFromName = VERB_IN_NAME_CALLEES[calleeName];
89
+ if (verbFromName) {
90
+ return {
91
+ handlerName: `${firstArgValue}:${verbFromName}`,
92
+ entityName: firstArgValue,
93
+ verb: verbFromName,
94
+ };
95
+ }
96
+
97
+ if (VERB_IN_ARG_CALLEES.has(calleeName)) {
98
+ const sep = firstArgValue.indexOf(":");
99
+ if (sep === -1) return undefined;
100
+ const entityName = firstArgValue.slice(0, sep);
101
+ const verb = firstArgValue.slice(sep + 1);
102
+ if (!entityName || !verb) return undefined;
103
+ return { handlerName: firstArgValue, entityName, verb };
104
+ }
105
+
106
+ return undefined;
107
+ }
108
+
109
+ function deriveEntityCrudContext(objectLiteral: ObjectLiteralExpression): string | undefined {
110
+ const verbGroupAssignment = objectLiteral.getParent();
111
+ if (!Node.isPropertyAssignment(verbGroupAssignment)) return undefined;
112
+ const verbGroup = verbGroupAssignment.getName();
113
+ if (verbGroup !== "write" && verbGroup !== "read") return undefined;
114
+
115
+ const optionsObject = verbGroupAssignment.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
116
+ const call = optionsObject?.getParentIfKind(SyntaxKind.CallExpression);
117
+ if (!call || !optionsObject || !call.getArguments().includes(optionsObject)) return undefined;
118
+
119
+ const calleeName = getCalleeName(call);
120
+ if (calleeName !== "registerEntityCrud" && calleeName !== "crud") return undefined;
121
+
122
+ const args = call.getArguments();
123
+ const entityArg = calleeName === "crud" ? args[0] : args[1];
124
+ const entityName =
125
+ entityArg && Node.isStringLiteral(entityArg) ? entityArg.getLiteralValue() : undefined;
126
+ return `under ${calleeName}(${entityName ? `"${entityName}", ` : ""}...) → ${verbGroup}: multiple verbs share this crossTenant — set escapeHatch per verb manually, one reason each`;
127
+ }
128
+
129
+ function classifyManualReason(objectLiteral: ObjectLiteralExpression): string {
130
+ const entityCrudWhy = deriveEntityCrudContext(objectLiteral);
131
+ if (entityCrudWhy) return entityCrudWhy;
132
+
133
+ const call = objectLiteral.getParentIfKind(SyntaxKind.CallExpression);
134
+ if (!call?.getArguments().includes(objectLiteral)) {
135
+ return "crossTenant is not set directly in a handler call (e.g. a spread/shared object) — give each consuming handler its own escapeHatch reason";
136
+ }
137
+
138
+ const calleeName = getCalleeName(call);
139
+ if (!calleeName || (!VERB_IN_NAME_CALLEES[calleeName] && !VERB_IN_ARG_CALLEES.has(calleeName))) {
140
+ return `call \`${calleeName ?? "<unknown>"}\` is not a recognized entity-convention handler factory — add escapeHatch manually`;
141
+ }
142
+
143
+ return "handler name/verb could not be derived from the call's first argument — add escapeHatch manually";
144
+ }
145
+
146
+ function deriveBestEffortHandlerName(assignment: PropertyAssignment): string | undefined {
147
+ const call = assignment.getFirstAncestorByKind(SyntaxKind.CallExpression);
148
+ const firstArg = call?.getArguments()[0];
149
+ return firstArg && Node.isStringLiteral(firstArg) ? firstArg.getLiteralValue() : undefined;
150
+ }
151
+
152
+ function buildEscapeHatchReason(target: AutoRewriteTarget): string {
153
+ const verbKind = READ_VERBS.has(target.verb) ? "reads" : "writes";
154
+ return `${target.handlerName} ${verbKind} ${target.entityName} rows across every tenant (migrated from crossTenant: true; state the operator use case here)`;
155
+ }
156
+
157
+ export function migrateCrossTenantSource(
158
+ source: string,
159
+ fileName: string,
160
+ ): MigrateCrossTenantResult {
161
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
162
+ const sourceFile = project.createSourceFile(fileName, source);
163
+
164
+ const targets = sourceFile
165
+ .getDescendantsOfKind(SyntaxKind.PropertyAssignment)
166
+ .filter(isCrossTenantAssignment);
167
+
168
+ const rewrites: Rewrite[] = [];
169
+ const manual: ManualSite[] = [];
170
+
171
+ for (const assignment of targets) {
172
+ if (assignment.wasForgotten()) continue;
173
+ const line = assignment.getStartLineNumber();
174
+ const init = assignment.getInitializer();
175
+
176
+ if (init?.getKind() === SyntaxKind.FalseKeyword) continue;
177
+
178
+ if (init?.getKind() !== SyntaxKind.TrueKeyword) {
179
+ manual.push({
180
+ file: fileName,
181
+ line,
182
+ why: `crossTenant is not a literal boolean (\`${init?.getText() ?? "<missing>"}\`) — replace manually with escapeHatch: { reason }`,
183
+ ...(deriveBestEffortHandlerName(assignment) !== undefined && {
184
+ handlerName: deriveBestEffortHandlerName(assignment),
185
+ }),
186
+ });
187
+ continue;
188
+ }
189
+
190
+ const objectLiteral = assignment.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
191
+ if (!objectLiteral) continue;
192
+
193
+ if (hasEscapeHatchProperty(objectLiteral)) {
194
+ manual.push({
195
+ file: fileName,
196
+ line,
197
+ why: "this object already declares escapeHatch — merge the two manually and remove crossTenant",
198
+ ...(deriveBestEffortHandlerName(assignment) !== undefined && {
199
+ handlerName: deriveBestEffortHandlerName(assignment),
200
+ }),
201
+ });
202
+ continue;
203
+ }
204
+
205
+ const target = deriveAutoRewriteTarget(objectLiteral);
206
+ if (!target) {
207
+ manual.push({
208
+ file: fileName,
209
+ line,
210
+ why: classifyManualReason(objectLiteral),
211
+ ...(deriveBestEffortHandlerName(assignment) !== undefined && {
212
+ handlerName: deriveBestEffortHandlerName(assignment),
213
+ }),
214
+ });
215
+ continue;
216
+ }
217
+
218
+ const before = assignment.getText();
219
+ const after = `escapeHatch: { reason: ${JSON.stringify(buildEscapeHatchReason(target))} }`;
220
+ assignment.replaceWithText(after);
221
+ rewrites.push({ file: fileName, line, before, after });
222
+ }
223
+
224
+ return { output: sourceFile.getFullText(), rewrites, manual };
225
+ }
226
+
227
+ class CliError extends Error {}
228
+
229
+ interface CliOptions {
230
+ readonly dryRun: boolean;
231
+ readonly paths: string[];
232
+ }
233
+
234
+ function parseArgs(argv: string[]): CliOptions {
235
+ let dryRun = false;
236
+ const paths: string[] = [];
237
+
238
+ for (const arg of argv) {
239
+ if (arg === "--dry-run") {
240
+ dryRun = true;
241
+ continue;
242
+ }
243
+ if (arg.startsWith("--")) throw new CliError(`unknown flag: ${arg}`);
244
+ paths.push(arg);
245
+ }
246
+
247
+ if (paths.length === 0) throw new CliError("at least one path is required");
248
+ return { dryRun, paths };
249
+ }
250
+
251
+ function isMigratableFile(name: string): boolean {
252
+ return /\.(ts|tsx)$/.test(name);
253
+ }
254
+
255
+ function walkDir(dir: string, out: string[]): void {
256
+ for (const name of readdirSync(dir)) {
257
+ if (name === "node_modules") continue;
258
+ const full = join(dir, name);
259
+ const stat = statSync(full);
260
+ if (stat.isDirectory()) {
261
+ walkDir(full, out);
262
+ continue;
263
+ }
264
+ if (isMigratableFile(name)) out.push(full);
265
+ }
266
+ }
267
+
268
+ function collectFiles(paths: string[]): string[] {
269
+ const files: string[] = [];
270
+ for (const p of paths) {
271
+ const abs = resolve(p);
272
+ const stat = statSync(abs);
273
+ if (stat.isDirectory()) walkDir(abs, files);
274
+ else if (isMigratableFile(abs)) files.push(abs);
275
+ }
276
+ return files;
277
+ }
278
+
279
+ async function main(): Promise<void> {
280
+ let options: CliOptions;
281
+ try {
282
+ options = parseArgs(process.argv.slice(2));
283
+ } catch (err) {
284
+ if (err instanceof CliError) {
285
+ console.error(`[migrate-cross-tenant] ${err.message}`);
286
+ process.exit(2);
287
+ }
288
+ throw err;
289
+ }
290
+
291
+ const files = collectFiles(options.paths);
292
+ const allManual: ManualSite[] = [];
293
+ let filesChanged = 0;
294
+ let totalRewrites = 0;
295
+
296
+ for (const file of files) {
297
+ const before = readFileSync(file, "utf8");
298
+ if (!before.includes("crossTenant")) continue;
299
+ const result = migrateCrossTenantSource(before, file);
300
+ allManual.push(...result.manual);
301
+
302
+ if (result.rewrites.length === 0) continue;
303
+ filesChanged += 1;
304
+ totalRewrites += result.rewrites.length;
305
+
306
+ if (options.dryRun) {
307
+ console.log(`\n=== ${file} (${result.rewrites.length} rewrite(s)) ===`);
308
+ for (const r of result.rewrites) {
309
+ console.log(`${file}:${r.line}`);
310
+ console.log(`- ${r.before}`);
311
+ console.log(`+ ${r.after}`);
312
+ }
313
+ } else {
314
+ writeFileSync(file, result.output);
315
+ }
316
+ }
317
+
318
+ console.log(
319
+ `\n[migrate-cross-tenant] ${options.dryRun ? "would change" : "changed"} ${filesChanged} file(s); ` +
320
+ `rewrites: ${totalRewrites}`,
321
+ );
322
+
323
+ if (allManual.length > 0) {
324
+ console.log(`\n[migrate-cross-tenant] manual review needed (${allManual.length}):`);
325
+ for (const m of allManual) {
326
+ console.log(`${m.file}:${m.line} ${m.handlerName ?? "<unknown>"} ${m.why}`);
327
+ }
328
+ }
329
+
330
+ process.exit(0);
331
+ }
332
+
333
+ if (import.meta.main) {
334
+ await main();
335
+ }
@@ -109,6 +109,15 @@ interface PendingRewrite {
109
109
  readonly line: number;
110
110
  }
111
111
 
112
+ // The caller checks `args.length` immediately before every use — the index
113
+ // is always in bounds; this replaces a non-null assertion with an explicit
114
+ // invariant instead of silencing the compiler.
115
+ function nthArg(args: Node[], index: number): Node {
116
+ const arg = args[index];
117
+ if (!arg) throw new Error(`planRewrite: expected an argument at index ${index}`);
118
+ return arg;
119
+ }
120
+
112
121
  function planRewrite(
113
122
  callExpr: Node,
114
123
  globalTables: ReadonlySet<string>,
@@ -118,14 +127,14 @@ function planRewrite(
118
127
  if (!Node.isIdentifier(calleeExpr)) return undefined;
119
128
  const fnName = calleeExpr.getText();
120
129
  const args = callExpr.getArguments();
121
- if (args.length === 0 || !isRawOnCtxDb(args[0]!)) return undefined;
130
+ if (args.length === 0 || !isRawOnCtxDb(nthArg(args, 0))) return undefined;
122
131
 
123
132
  const typeArgs = callExpr.getTypeArguments();
124
133
  const typeArgsText =
125
134
  typeArgs.length > 0 ? `<${typeArgs.map((t) => t.getText()).join(", ")}>` : "";
126
135
 
127
136
  if (GLOBAL_TABLE_FN_NAMES.has(fnName) && args.length >= 2) {
128
- const tableArg = args[1]!;
137
+ const tableArg = nthArg(args, 1);
129
138
  if (Node.isIdentifier(tableArg) && globalTables.has(tableArg.getText())) {
130
139
  const rest = args.slice(2).map((a) => a.getText());
131
140
  const after = `ctx.db.global(${tableArg.getText()}).${fnName}${typeArgsText}(${rest.join(", ")})`;
@@ -140,9 +149,9 @@ function planRewrite(
140
149
  }
141
150
 
142
151
  if (OWN_TENANT_FN_NAMES.has(fnName) && args.length >= 3) {
143
- const objArg = args[2]!;
152
+ const objArg = nthArg(args, 2);
144
153
  if (objectHasValidOwnTenantId(objArg)) {
145
- const rest = [args[1]!, objArg, ...args.slice(3)].map((a) => a.getText());
154
+ const rest = [nthArg(args, 1), objArg, ...args.slice(3)].map((a) => a.getText());
146
155
  const after = `ctx.db.${fnName}${typeArgsText}(${rest.join(", ")})`;
147
156
  return {
148
157
  callExpr,
@@ -174,7 +183,10 @@ function isTenantDbRawAccess(pae: Node): boolean {
174
183
  function enclosingName(node: Node): string {
175
184
  let current: Node | undefined = node.getParent();
176
185
  while (current) {
177
- if (Node.isFunctionDeclaration(current) && current.getName()) return current.getName()!;
186
+ if (Node.isFunctionDeclaration(current)) {
187
+ const name = current.getName();
188
+ if (name) return name;
189
+ }
178
190
  if (Node.isMethodDeclaration(current)) return current.getName();
179
191
  if (Node.isPropertyAssignment(current)) return current.getName();
180
192
  if (Node.isVariableDeclaration(current)) return current.getName();
@@ -283,7 +295,8 @@ function parseArgs(argv: string[]): CliOptions {
283
295
  const paths: string[] = [];
284
296
 
285
297
  for (let i = 0; i < argv.length; i++) {
286
- const arg = argv[i]!;
298
+ const arg = argv[i];
299
+ if (!arg) break;
287
300
  if (arg === "--dry-run") {
288
301
  dryRun = true;
289
302
  continue;
@@ -140,7 +140,8 @@ function parseArgs(argv: string[]): CliOptions {
140
140
  const paths: string[] = [];
141
141
 
142
142
  for (let i = 0; i < argv.length; i++) {
143
- const arg = argv[i]!;
143
+ const arg = argv[i];
144
+ if (!arg) break;
144
145
  if (arg === "--dry-run") {
145
146
  dryRun = true;
146
147
  continue;
@@ -27,12 +27,14 @@ export type { ParsedRefTarget } from "../engine/parse-ref-target";
27
27
  export { parseRefTarget } from "../engine/parse-ref-target";
28
28
  export {
29
29
  evalFieldCondition,
30
+ explicitListScreenId,
30
31
  isExtensionEditSection,
31
32
  isFieldsEditSection,
32
33
  isFormatSpec,
33
34
  isWriteFormEditSection,
34
35
  normalizeEditField,
35
36
  normalizeListColumn,
37
+ resolveNavParentScreen,
36
38
  } from "../engine/screen-helpers";
37
39
  // Entity + field types. EntityDefinition is the canonical shape that
38
40
  // view-model builders iterate; FieldDefinition is the per-field union
@@ -99,6 +101,7 @@ export type {
99
101
  PlatformComponent,
100
102
  ProjectionDetailScreenDefinition,
101
103
  ProjectionListScreenDefinition,
104
+ RelatedListToolbarAction,
102
105
  RowAction,
103
106
  RowActionDrawer,
104
107
  RowActionNavigate,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { generateId } from "../ids";
2
+ import { generateDeterministicId, generateId } from "../ids";
3
3
 
4
4
  // generateId is the row/stream/correlation ID source. The callers rely on
5
5
  // it being a UUIDv7 (time-sortable → dense B-Tree indexes), not a v4 — a
@@ -22,3 +22,31 @@ describe("generateId", () => {
22
22
  expect(new Set(ids).size).toBe(ids.length);
23
23
  });
24
24
  });
25
+
26
+ describe("generateDeterministicId", () => {
27
+ test("returns a well-formed lowercase UUID", () => {
28
+ expect(generateDeterministicId("ledger:schedule", "contract-1")).toMatch(UUID_RX);
29
+ });
30
+
31
+ test("is UUID version 5 (content-addressed, not v7)", () => {
32
+ expect(generateDeterministicId("ledger:schedule", "contract-1")[14]).toBe("5");
33
+ });
34
+
35
+ test("is deterministic: same namespace + key always yields the same id", () => {
36
+ const a = generateDeterministicId("ledger:schedule", "contract-1");
37
+ const b = generateDeterministicId("ledger:schedule", "contract-1");
38
+ expect(a).toBe(b);
39
+ });
40
+
41
+ test("differs when the key changes", () => {
42
+ const a = generateDeterministicId("ledger:schedule", "contract-1");
43
+ const b = generateDeterministicId("ledger:schedule", "contract-2");
44
+ expect(a).not.toBe(b);
45
+ });
46
+
47
+ test("differs when the namespace changes for the same key", () => {
48
+ const a = generateDeterministicId("ledger:schedule", "contract-1");
49
+ const b = generateDeterministicId("ledger:transaction", "contract-1");
50
+ expect(a).not.toBe(b);
51
+ });
52
+ });
package/src/utils/ids.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { v7 } from "uuid";
1
+ import { v5, v7 } from "uuid";
2
2
 
3
3
  // Non-secret identifiers for DB rows, event streams, correlation/request
4
4
  // IDs, SSE connections, distributed locks. UUIDv7: first 48 bits are a
@@ -15,3 +15,12 @@ import { v7 } from "uuid";
15
15
  export function generateId(): string {
16
16
  return v7();
17
17
  }
18
+
19
+ // Derived from a DNS name (RFC 4122 §4.3) so the namespace is reproducible, not a hand-picked constant.
20
+ const DETERMINISTIC_ID_NAMESPACE = v5("kumiko.rocks", v5.DNS);
21
+
22
+ // Same (namespace, key) → same id, so a redelivered event hits the same row. Ids are
23
+ // unique per table, not per tenant: fold the tenant id into `key` or tenants collide.
24
+ export function generateDeterministicId(namespace: string, key: string): string {
25
+ return v5(`${namespace}:${key}`, DETERMINISTIC_ID_NAMESPACE);
26
+ }
@@ -2,7 +2,7 @@ export { assertUnreachable } from "./assert";
2
2
  export { toSnakeCase } from "./case";
3
3
  export { compareByCodepoint } from "./compare";
4
4
  export { readPositiveIntEnv } from "./env-parse";
5
- export { generateId } from "./ids";
5
+ export { generateDeterministicId, generateId } from "./ids";
6
6
  export { isPlainObject } from "./is-plain-object";
7
7
  export { parseStringArrayJson } from "./parse-string-array-json";
8
8
  export { parseJsonOrThrow, parseJsonSafe, stringifyJson } from "./safe-json";