@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.
- package/package.json +3 -3
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +105 -0
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +12 -5
- package/src/db/event-store-executor-read.ts +125 -37
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +71 -1
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +68 -3
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +74 -22
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +36 -6
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-filter-ops.ts +10 -2
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- 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
|
|
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
|
|
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
|
|
152
|
+
const objArg = nthArg(args, 2);
|
|
144
153
|
if (objectHasValidOwnTenantId(objArg)) {
|
|
145
|
-
const rest = [args
|
|
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)
|
|
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;
|
package/src/ui-types/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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";
|