@cosmicdrift/kumiko-framework 0.320.0 → 0.321.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 +4 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/client-ip.test.ts +25 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +42 -0
- package/src/api/client-ip.ts +27 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +2 -0
- package/src/api/server.ts +3 -1
- package/src/changes.json +48 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/schema-cli.ts +5 -1
- package/src/upgrade-cli.ts +58 -18
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Syntactic cross-file resolution for feature-ast identifier extractors.
|
|
2
|
+
// Deliberately avoids ts-morph's symbol/type-checker APIs (getSymbol,
|
|
3
|
+
// getDefinitionNodes, getModuleSpecifierSourceFile, ...) — those build a full
|
|
4
|
+
// TypeScript program on first use, which against this repo's workspace
|
|
5
|
+
// packages pulls in ~1000 source files and costs seconds instead of
|
|
6
|
+
// milliseconds.
|
|
7
|
+
import type {
|
|
8
|
+
Node,
|
|
9
|
+
ParameterDeclaration,
|
|
10
|
+
SourceFile,
|
|
11
|
+
Statement,
|
|
12
|
+
VariableDeclaration,
|
|
13
|
+
} from "ts-morph";
|
|
14
|
+
import { SyntaxKind, ts } from "ts-morph";
|
|
15
|
+
|
|
16
|
+
const MODULE_RESOLUTION_OPTIONS: ts.CompilerOptions = {
|
|
17
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
18
|
+
module: ts.ModuleKind.ESNext,
|
|
19
|
+
target: ts.ScriptTarget.ESNext,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const MAX_RESOLUTION_DEPTH = 24;
|
|
23
|
+
|
|
24
|
+
export function resolveModuleFile(fromFile: SourceFile, specifier: string): SourceFile | undefined {
|
|
25
|
+
const project = fromFile.getProject();
|
|
26
|
+
// The project's own host, not ts.sys: it honours an in-memory file system
|
|
27
|
+
// (Designer) and exists outside Node.
|
|
28
|
+
const resolved = ts.resolveModuleName(
|
|
29
|
+
specifier,
|
|
30
|
+
fromFile.getFilePath(),
|
|
31
|
+
MODULE_RESOLUTION_OPTIONS,
|
|
32
|
+
project.getModuleResolutionHost(),
|
|
33
|
+
);
|
|
34
|
+
const resolvedFileName = resolved.resolvedModule?.resolvedFileName;
|
|
35
|
+
if (!resolvedFileName) return undefined;
|
|
36
|
+
// A declaration file has no initializers to read. Installed packages that
|
|
37
|
+
// ship .ts sources (the @cosmicdrift/* packages in a consumer repo) stay
|
|
38
|
+
// resolvable, as they were via the type checker.
|
|
39
|
+
if (resolvedFileName.endsWith(".d.ts")) return undefined;
|
|
40
|
+
return fromFile.getProject().addSourceFileAtPathIfExists(resolvedFileName);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findImportBindingForLocalName(
|
|
44
|
+
sourceFile: SourceFile,
|
|
45
|
+
localName: string,
|
|
46
|
+
): { readonly moduleSpecifier: string; readonly importedName: string } | undefined {
|
|
47
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
48
|
+
const named = importDecl
|
|
49
|
+
.getNamedImports()
|
|
50
|
+
.find((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === localName);
|
|
51
|
+
if (!named) continue;
|
|
52
|
+
return {
|
|
53
|
+
moduleSpecifier: importDecl.getModuleSpecifierValue(),
|
|
54
|
+
importedName: named.getName(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `visited` keys on `${filePath}#${name}`, not path alone — aliasing can
|
|
62
|
+
* legally re-export a different name from the same file — and doubles as
|
|
63
|
+
* the cycle guard; `depth` backstops pathological non-cyclic chains.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveExportedVariable(
|
|
66
|
+
file: SourceFile,
|
|
67
|
+
exportedName: string,
|
|
68
|
+
visited: Set<string> = new Set(),
|
|
69
|
+
depth = 0,
|
|
70
|
+
): VariableDeclaration | undefined {
|
|
71
|
+
if (depth > MAX_RESOLUTION_DEPTH) return undefined;
|
|
72
|
+
const key = `${file.getFilePath()}#${exportedName}`;
|
|
73
|
+
if (visited.has(key)) return undefined;
|
|
74
|
+
visited.add(key);
|
|
75
|
+
|
|
76
|
+
const directDecl = file.getVariableDeclaration(exportedName);
|
|
77
|
+
if (directDecl?.getVariableStatement()?.hasExportKeyword()) return directDecl;
|
|
78
|
+
|
|
79
|
+
const namedExportMatch = resolveViaNamedExportSpecifier(file, exportedName, visited, depth);
|
|
80
|
+
if (namedExportMatch) return namedExportMatch;
|
|
81
|
+
|
|
82
|
+
return resolveViaStarExport(file, exportedName, visited, depth);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveViaNamedExportSpecifier(
|
|
86
|
+
file: SourceFile,
|
|
87
|
+
exportedName: string,
|
|
88
|
+
visited: Set<string>,
|
|
89
|
+
depth: number,
|
|
90
|
+
): VariableDeclaration | undefined {
|
|
91
|
+
for (const exportDecl of file.getExportDeclarations()) {
|
|
92
|
+
const specifier = exportDecl
|
|
93
|
+
.getNamedExports()
|
|
94
|
+
.find((spec) => (spec.getAliasNode()?.getText() ?? spec.getName()) === exportedName);
|
|
95
|
+
if (!specifier) continue;
|
|
96
|
+
const originalName = specifier.getName();
|
|
97
|
+
|
|
98
|
+
if (!exportDecl.hasModuleSpecifier()) {
|
|
99
|
+
const resolved = resolveLocalExportBinding(file, originalName, visited, depth);
|
|
100
|
+
if (resolved) return resolved;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const moduleSpecifierValue = exportDecl.getModuleSpecifierValue();
|
|
105
|
+
if (!moduleSpecifierValue) continue;
|
|
106
|
+
const target = resolveModuleFile(file, moduleSpecifierValue);
|
|
107
|
+
if (!target) continue;
|
|
108
|
+
const resolved = resolveExportedVariable(target, originalName, visited, depth + 1);
|
|
109
|
+
if (resolved) return resolved;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveLocalExportBinding(
|
|
115
|
+
file: SourceFile,
|
|
116
|
+
originalName: string,
|
|
117
|
+
visited: Set<string>,
|
|
118
|
+
depth: number,
|
|
119
|
+
): VariableDeclaration | undefined {
|
|
120
|
+
const localDecl = file.getVariableDeclaration(originalName);
|
|
121
|
+
if (localDecl) return localDecl;
|
|
122
|
+
const binding = findImportBindingForLocalName(file, originalName);
|
|
123
|
+
if (!binding) return undefined;
|
|
124
|
+
const target = resolveModuleFile(file, binding.moduleSpecifier);
|
|
125
|
+
if (!target) return undefined;
|
|
126
|
+
return resolveExportedVariable(target, binding.importedName, visited, depth + 1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveViaStarExport(
|
|
130
|
+
file: SourceFile,
|
|
131
|
+
exportedName: string,
|
|
132
|
+
visited: Set<string>,
|
|
133
|
+
depth: number,
|
|
134
|
+
): VariableDeclaration | undefined {
|
|
135
|
+
for (const exportDecl of file.getExportDeclarations()) {
|
|
136
|
+
if (!exportDecl.hasModuleSpecifier()) continue;
|
|
137
|
+
if (exportDecl.getNamedExports().length > 0) continue;
|
|
138
|
+
if (exportDecl.getNamespaceExport()) continue; // `export * as ns from "./m"` is not a re-export target.
|
|
139
|
+
const moduleSpecifierValue = exportDecl.getModuleSpecifierValue();
|
|
140
|
+
if (!moduleSpecifierValue) continue;
|
|
141
|
+
const target = resolveModuleFile(file, moduleSpecifierValue);
|
|
142
|
+
if (!target) continue;
|
|
143
|
+
const resolved = resolveExportedVariable(target, exportedName, visited, depth + 1);
|
|
144
|
+
if (resolved) return resolved;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A function parameter of the same name shadows and stops the search — it has no statically readable initializer. */
|
|
150
|
+
export function findScopedVariableDeclaration(
|
|
151
|
+
identifier: Node,
|
|
152
|
+
name: string,
|
|
153
|
+
): VariableDeclaration | undefined {
|
|
154
|
+
for (const ancestor of [identifier, ...identifier.getAncestors()]) {
|
|
155
|
+
const params = getShadowingParameters(ancestor);
|
|
156
|
+
if (params?.some((p) => p.getName() === name)) return undefined;
|
|
157
|
+
|
|
158
|
+
const statements = getDirectStatements(ancestor);
|
|
159
|
+
if (!statements) continue;
|
|
160
|
+
for (const stmt of statements) {
|
|
161
|
+
const varStmt = stmt.asKind(SyntaxKind.VariableStatement);
|
|
162
|
+
if (!varStmt) continue;
|
|
163
|
+
const decl = varStmt
|
|
164
|
+
.getDeclarationList()
|
|
165
|
+
.getDeclarations()
|
|
166
|
+
.find((d) => d.getName() === name);
|
|
167
|
+
if (decl) return decl;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getShadowingParameters(node: Node): readonly ParameterDeclaration[] | undefined {
|
|
174
|
+
const fn =
|
|
175
|
+
node.asKind(SyntaxKind.ArrowFunction) ??
|
|
176
|
+
node.asKind(SyntaxKind.FunctionExpression) ??
|
|
177
|
+
node.asKind(SyntaxKind.FunctionDeclaration);
|
|
178
|
+
return fn?.getParameters();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getDirectStatements(node: Node): readonly Statement[] | undefined {
|
|
182
|
+
const block = node.asKind(SyntaxKind.Block);
|
|
183
|
+
if (block) return block.getStatements();
|
|
184
|
+
const sourceFile = node.asKind(SyntaxKind.SourceFile);
|
|
185
|
+
if (sourceFile) return sourceFile.getStatements();
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
@@ -2,6 +2,12 @@ import type { CallExpression, Node, ObjectLiteralExpression } from "ts-morph";
|
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
3
|
import { isPlainObject } from "../../../utils/is-plain-object";
|
|
4
4
|
import type { ParseError } from "../parse";
|
|
5
|
+
import {
|
|
6
|
+
findImportBindingForLocalName,
|
|
7
|
+
findScopedVariableDeclaration,
|
|
8
|
+
resolveExportedVariable,
|
|
9
|
+
resolveModuleFile,
|
|
10
|
+
} from "./resolve-import";
|
|
5
11
|
|
|
6
12
|
export type ExtractOutput<TPattern> =
|
|
7
13
|
| { readonly kind: "pattern"; readonly pattern: TPattern }
|
|
@@ -200,28 +206,31 @@ function unwrapLiteralInitializer(node: Node): string | undefined {
|
|
|
200
206
|
/**
|
|
201
207
|
* Resolves a bare Identifier to the string value of its declaration's
|
|
202
208
|
* initializer (`export const EXT_TENANT_DATA = "tenant-data" as const`),
|
|
203
|
-
* following imports
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* function/class/type definition or an unresolvable import
|
|
207
|
-
*
|
|
209
|
+
* following imports syntactically across files and packages against a
|
|
210
|
+
* real-filesystem Project without ever building a TypeScript program (see
|
|
211
|
+
* resolve-import.ts). Only descends into VariableDeclaration initializers; a
|
|
212
|
+
* function/class/type definition or an unresolvable import yields undefined,
|
|
213
|
+
* never a throw.
|
|
208
214
|
*/
|
|
209
215
|
function resolveIdentifierToStringLiteral(identifier: Node): string | undefined {
|
|
210
216
|
const id = identifier.asKind(SyntaxKind.Identifier);
|
|
211
217
|
if (!id) return undefined;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
for (const def of defs) {
|
|
219
|
-
const init = def.asKind(SyntaxKind.VariableDeclaration)?.getInitializer();
|
|
220
|
-
if (!init) continue;
|
|
221
|
-
const value = unwrapLiteralInitializer(init);
|
|
218
|
+
const name = id.getText();
|
|
219
|
+
|
|
220
|
+
const scoped = findScopedVariableDeclaration(id, name);
|
|
221
|
+
if (scoped) {
|
|
222
|
+
const init = scoped.getInitializer();
|
|
223
|
+
const value = init ? unwrapLiteralInitializer(init) : undefined;
|
|
222
224
|
if (value !== undefined) return value;
|
|
223
225
|
}
|
|
224
|
-
|
|
226
|
+
|
|
227
|
+
const binding = findImportBindingForLocalName(id.getSourceFile(), name);
|
|
228
|
+
if (!binding) return undefined;
|
|
229
|
+
const targetFile = resolveModuleFile(id.getSourceFile(), binding.moduleSpecifier);
|
|
230
|
+
if (!targetFile) return undefined;
|
|
231
|
+
const decl = resolveExportedVariable(targetFile, binding.importedName);
|
|
232
|
+
const init = decl?.getInitializer();
|
|
233
|
+
return init ? unwrapLiteralInitializer(init) : undefined;
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
/**
|
|
@@ -279,9 +288,7 @@ export function resolveSameFileObjectLiteral(node: Node): ObjectLiteralExpressio
|
|
|
279
288
|
if (direct) return direct;
|
|
280
289
|
const identifier = node.asKind(SyntaxKind.Identifier);
|
|
281
290
|
if (!identifier) return undefined;
|
|
282
|
-
const
|
|
283
|
-
const fromSymbol = valueDecl?.asKind(SyntaxKind.VariableDeclaration);
|
|
284
|
-
const varDecl = fromSymbol ?? node.getSourceFile().getVariableDeclaration(identifier.getText());
|
|
291
|
+
const varDecl = findScopedVariableDeclaration(identifier, identifier.getText());
|
|
285
292
|
return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
286
293
|
}
|
|
287
294
|
|
|
@@ -73,7 +73,9 @@ import {
|
|
|
73
73
|
extractWorkspace,
|
|
74
74
|
extractWriteHandler,
|
|
75
75
|
findFunctionLiteral,
|
|
76
|
+
findImportBindingForLocalName,
|
|
76
77
|
readObjectPropertyInitializer,
|
|
78
|
+
resolveModuleFile,
|
|
77
79
|
resolveSameFileObjectLiteral,
|
|
78
80
|
} from "./extractors";
|
|
79
81
|
import type { FeaturePattern, UnknownPattern } from "./patterns";
|
|
@@ -296,13 +298,13 @@ function walkSetupCallback(
|
|
|
296
298
|
/**
|
|
297
299
|
* Resolves a bare call like `registerShowPonyScreens(r)` to the callee's
|
|
298
300
|
* body + its own parameter name for the registrar slot. The callee may be
|
|
299
|
-
* declared in the same file
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
301
|
+
* declared in the same file, or imported by name from another file —
|
|
302
|
+
* resolved syntactically via resolveModuleFile, which only works against a
|
|
303
|
+
* real-filesystem Project; the in-memory Designer Project has no files to
|
|
304
|
+
* resolve against and returns undefined there, same as any other
|
|
305
|
+
* unresolvable reference. Does not follow re-exports in the target file — a
|
|
306
|
+
* wrapper function is expected to be declared directly in the module it's
|
|
307
|
+
* imported from.
|
|
306
308
|
*/
|
|
307
309
|
function resolveRegistrarWrapperCall(
|
|
308
310
|
call: CallExpression,
|
|
@@ -328,21 +330,12 @@ function resolveRegistrarWrapperCall(
|
|
|
328
330
|
const local = resolveWrapperDeclarationInFile(callSourceFile, name, argIndex);
|
|
329
331
|
if (local) return { ...local, sourceFile: callSourceFile };
|
|
330
332
|
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
d.getNamedImports().some((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name),
|
|
335
|
-
);
|
|
336
|
-
if (!importDecl) return undefined;
|
|
337
|
-
const exportedName =
|
|
338
|
-
importDecl
|
|
339
|
-
.getNamedImports()
|
|
340
|
-
.find((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name)
|
|
341
|
-
?.getName() ?? name;
|
|
342
|
-
const targetFile = importDecl.getModuleSpecifierSourceFile();
|
|
333
|
+
const binding = findImportBindingForLocalName(callSourceFile, name);
|
|
334
|
+
if (!binding) return undefined;
|
|
335
|
+
const targetFile = resolveModuleFile(callSourceFile, binding.moduleSpecifier);
|
|
343
336
|
if (!targetFile) return undefined; // Unresolvable: in-memory project, external package, or missing file.
|
|
344
337
|
|
|
345
|
-
const imported = resolveWrapperDeclarationInFile(targetFile,
|
|
338
|
+
const imported = resolveWrapperDeclarationInFile(targetFile, binding.importedName, argIndex);
|
|
346
339
|
return imported ? { ...imported, sourceFile: targetFile } : undefined;
|
|
347
340
|
}
|
|
348
341
|
|
|
@@ -25,9 +25,11 @@ export {
|
|
|
25
25
|
|
|
26
26
|
type WebhookHttpMethod = "POST" | "PUT" | "PATCH";
|
|
27
27
|
|
|
28
|
+
// Name inside the tenant-owned secrets namespace
|
|
29
|
+
// `step-dispatcher:webhook-auth.<secret>` (secrets feature).
|
|
28
30
|
type WebhookAuth =
|
|
29
|
-
| { readonly kind: "bearer"; readonly
|
|
30
|
-
| { readonly kind: "header"; readonly name: string; readonly
|
|
31
|
+
| { readonly kind: "bearer"; readonly secret: string }
|
|
32
|
+
| { readonly kind: "header"; readonly name: string; readonly secret: string };
|
|
31
33
|
|
|
32
34
|
type WebhookSendArgs = {
|
|
33
35
|
readonly url: StepResolver<string>;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// transferAggregateStreams — the event store's own tenant-transfer primitive.
|
|
2
|
+
// Pins: events, snapshots and the archive marker all move together; a system
|
|
3
|
+
// aggregate.transferred event lands per moved aggregate; an empty id list is
|
|
4
|
+
// a no-op; an id with no events under the given tenant/aggregateType is left
|
|
5
|
+
// untouched; an aggregate belonging to an unrelated third tenant is never
|
|
6
|
+
// touched by another tenant's move; an archive marker for a DIFFERENT
|
|
7
|
+
// aggregate type sharing the same aggregate id stays behind.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
11
|
+
import { asRawClient } from "../../db/query";
|
|
12
|
+
import type { TenantId } from "../../engine/types";
|
|
13
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
14
|
+
import { generateId as uuid } from "../../utils";
|
|
15
|
+
import {
|
|
16
|
+
AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
17
|
+
AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
18
|
+
append,
|
|
19
|
+
archiveStream,
|
|
20
|
+
isStreamArchived,
|
|
21
|
+
loadAggregate,
|
|
22
|
+
loadLatestSnapshot,
|
|
23
|
+
saveSnapshot,
|
|
24
|
+
transferAggregateStreams,
|
|
25
|
+
} from "../index";
|
|
26
|
+
|
|
27
|
+
let bun: BunTestDb;
|
|
28
|
+
const sourceTenant = uuid() as TenantId;
|
|
29
|
+
const destinationTenant = uuid() as TenantId;
|
|
30
|
+
const otherTenant = uuid() as TenantId;
|
|
31
|
+
const userId = uuid();
|
|
32
|
+
const transferredBy = uuid();
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
await ensureTemporalPolyfill();
|
|
36
|
+
bun = await createTestDb();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await bun.cleanup();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
await asRawClient(bun.db).unsafe(
|
|
45
|
+
`TRUNCATE kumiko_events, kumiko_snapshots, kumiko_archived_streams RESTART IDENTITY`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function seedAggregate(tenantId: TenantId, aggregateType = "counter"): Promise<string> {
|
|
50
|
+
const aggregateId = uuid();
|
|
51
|
+
await append(bun.db, {
|
|
52
|
+
aggregateId,
|
|
53
|
+
aggregateType,
|
|
54
|
+
tenantId,
|
|
55
|
+
expectedVersion: 0,
|
|
56
|
+
type: "counter.incremented",
|
|
57
|
+
payload: { by: 1 },
|
|
58
|
+
metadata: { userId },
|
|
59
|
+
});
|
|
60
|
+
return aggregateId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Each `aggregate.transferred` event lives on its OWN fresh stream (see
|
|
64
|
+
// transfer.ts) — the row's own `aggregate_id` column is that stream's id,
|
|
65
|
+
// not the moved aggregate's. The moved aggregate's id is in the payload.
|
|
66
|
+
async function transferredEvents(tenantId: TenantId): Promise<readonly Record<string, unknown>[]> {
|
|
67
|
+
const rows = (await asRawClient(bun.db).unsafe(
|
|
68
|
+
`SELECT payload FROM kumiko_events WHERE tenant_id = $1 AND aggregate_type = $2 AND type = $3`,
|
|
69
|
+
[tenantId, AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE],
|
|
70
|
+
)) as ReadonlyArray<{ payload: Record<string, unknown> }>;
|
|
71
|
+
return rows.map((row) => row.payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("transferAggregateStreams", () => {
|
|
75
|
+
test("moves events, drops the snapshot, carries the archive marker, and emits one aggregate.transferred event per moved aggregate", async () => {
|
|
76
|
+
const first = await seedAggregate(sourceTenant);
|
|
77
|
+
const second = await seedAggregate(sourceTenant);
|
|
78
|
+
await saveSnapshot(bun.db, {
|
|
79
|
+
aggregateId: first,
|
|
80
|
+
tenantId: sourceTenant,
|
|
81
|
+
aggregateType: "counter",
|
|
82
|
+
version: 1,
|
|
83
|
+
state: { count: 1 },
|
|
84
|
+
});
|
|
85
|
+
await archiveStream(bun.db, {
|
|
86
|
+
tenantId: sourceTenant,
|
|
87
|
+
aggregateId: first,
|
|
88
|
+
aggregateType: "counter",
|
|
89
|
+
archivedBy: "ops",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
93
|
+
sourceTenantId: sourceTenant,
|
|
94
|
+
destinationTenantId: destinationTenant,
|
|
95
|
+
aggregateType: "counter",
|
|
96
|
+
aggregateIds: [first, second],
|
|
97
|
+
transferredBy,
|
|
98
|
+
});
|
|
99
|
+
expect(movedCount).toBe(2);
|
|
100
|
+
|
|
101
|
+
// Events: gone from the source tenant, present under the destination.
|
|
102
|
+
expect(
|
|
103
|
+
await loadAggregate(bun.db, first, sourceTenant, { includeArchived: true }),
|
|
104
|
+
).toHaveLength(0);
|
|
105
|
+
expect(await loadAggregate(bun.db, second, sourceTenant)).toHaveLength(0);
|
|
106
|
+
const firstEvents = await loadAggregate(bun.db, first, destinationTenant, {
|
|
107
|
+
includeArchived: true,
|
|
108
|
+
});
|
|
109
|
+
expect(firstEvents.some((e) => e.type === "counter.incremented")).toBe(true);
|
|
110
|
+
const secondEvents = await loadAggregate(bun.db, second, destinationTenant);
|
|
111
|
+
expect(secondEvents.some((e) => e.type === "counter.incremented")).toBe(true);
|
|
112
|
+
|
|
113
|
+
// Snapshot dropped from the source tenant, not recreated anywhere.
|
|
114
|
+
expect(await loadLatestSnapshot(bun.db, first, sourceTenant)).toBeNull();
|
|
115
|
+
expect(await loadLatestSnapshot(bun.db, first, destinationTenant)).toBeNull();
|
|
116
|
+
|
|
117
|
+
// Archive marker moved with the stream.
|
|
118
|
+
expect(await isStreamArchived(bun.db, sourceTenant, first)).toBe(false);
|
|
119
|
+
expect(await isStreamArchived(bun.db, destinationTenant, first)).toBe(true);
|
|
120
|
+
expect(await isStreamArchived(bun.db, destinationTenant, second)).toBe(false);
|
|
121
|
+
|
|
122
|
+
// One system event per moved aggregate, in the destination tenant.
|
|
123
|
+
const events = await transferredEvents(destinationTenant);
|
|
124
|
+
expect(events).toHaveLength(2);
|
|
125
|
+
const byAggregateId = new Map(events.map((payload) => [payload["aggregateId"], payload]));
|
|
126
|
+
expect(byAggregateId.get(first)).toMatchObject({
|
|
127
|
+
aggregateType: "counter",
|
|
128
|
+
aggregateId: first,
|
|
129
|
+
sourceTenantId: sourceTenant,
|
|
130
|
+
destinationTenantId: destinationTenant,
|
|
131
|
+
});
|
|
132
|
+
expect(byAggregateId.get(second)).toMatchObject({
|
|
133
|
+
aggregateType: "counter",
|
|
134
|
+
aggregateId: second,
|
|
135
|
+
sourceTenantId: sourceTenant,
|
|
136
|
+
destinationTenantId: destinationTenant,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an empty id list is a no-op", async () => {
|
|
141
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
142
|
+
sourceTenantId: sourceTenant,
|
|
143
|
+
destinationTenantId: destinationTenant,
|
|
144
|
+
aggregateType: "counter",
|
|
145
|
+
aggregateIds: [],
|
|
146
|
+
transferredBy,
|
|
147
|
+
});
|
|
148
|
+
expect(movedCount).toBe(0);
|
|
149
|
+
expect(await transferredEvents(destinationTenant)).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an id with no events under this tenant/aggregateType is left untouched and gets no event", async () => {
|
|
153
|
+
const untouched = uuid();
|
|
154
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
155
|
+
sourceTenantId: sourceTenant,
|
|
156
|
+
destinationTenantId: destinationTenant,
|
|
157
|
+
aggregateType: "counter",
|
|
158
|
+
aggregateIds: [untouched],
|
|
159
|
+
transferredBy,
|
|
160
|
+
});
|
|
161
|
+
expect(movedCount).toBe(0);
|
|
162
|
+
expect(await loadAggregate(bun.db, untouched, sourceTenant)).toHaveLength(0);
|
|
163
|
+
expect(await loadAggregate(bun.db, untouched, destinationTenant)).toHaveLength(0);
|
|
164
|
+
expect(await transferredEvents(destinationTenant)).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("an aggregate under an unrelated third tenant with the same id is untouched", async () => {
|
|
168
|
+
const sharedId = uuid();
|
|
169
|
+
await append(bun.db, {
|
|
170
|
+
aggregateId: sharedId,
|
|
171
|
+
aggregateType: "counter",
|
|
172
|
+
tenantId: otherTenant,
|
|
173
|
+
expectedVersion: 0,
|
|
174
|
+
type: "counter.incremented",
|
|
175
|
+
payload: { by: 1 },
|
|
176
|
+
metadata: { userId },
|
|
177
|
+
});
|
|
178
|
+
await append(bun.db, {
|
|
179
|
+
aggregateId: sharedId,
|
|
180
|
+
aggregateType: "counter",
|
|
181
|
+
tenantId: sourceTenant,
|
|
182
|
+
expectedVersion: 0,
|
|
183
|
+
type: "counter.incremented",
|
|
184
|
+
payload: { by: 1 },
|
|
185
|
+
metadata: { userId },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await transferAggregateStreams(bun.db, {
|
|
189
|
+
sourceTenantId: sourceTenant,
|
|
190
|
+
destinationTenantId: destinationTenant,
|
|
191
|
+
aggregateType: "counter",
|
|
192
|
+
aggregateIds: [sharedId],
|
|
193
|
+
transferredBy,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const otherTenantEvents = await loadAggregate(bun.db, sharedId, otherTenant);
|
|
197
|
+
expect(otherTenantEvents).toHaveLength(1);
|
|
198
|
+
expect(await loadAggregate(bun.db, sharedId, sourceTenant)).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("an archived marker for a different aggregate type sharing the moved aggregate's id stays in the source tenant", async () => {
|
|
202
|
+
const sharedId = uuid();
|
|
203
|
+
// `append()` shares its version-conflict check across the plain
|
|
204
|
+
// aggregateId, so a real "widget" stream can't coexist with a real
|
|
205
|
+
// "counter" stream under the same id/tenant — fabricate the archived
|
|
206
|
+
// marker directly (the primary verifier of the event-store primitive,
|
|
207
|
+
// same reasoning as guard-event-store-writes' own __tests__ exclusion)
|
|
208
|
+
// to prove the WHERE clause's aggregate_type filter, not the stream's
|
|
209
|
+
// version-numbering.
|
|
210
|
+
await asRawClient(bun.db).unsafe(
|
|
211
|
+
`INSERT INTO "kumiko_archived_streams" ("tenant_id", "aggregate_id", "aggregate_type", "archived_by") VALUES ($1, $2, $3, $4)`,
|
|
212
|
+
[sourceTenant, sharedId, "widget", "ops"],
|
|
213
|
+
);
|
|
214
|
+
await append(bun.db, {
|
|
215
|
+
aggregateId: sharedId,
|
|
216
|
+
aggregateType: "counter",
|
|
217
|
+
tenantId: sourceTenant,
|
|
218
|
+
expectedVersion: 0,
|
|
219
|
+
type: "counter.incremented",
|
|
220
|
+
payload: { by: 1 },
|
|
221
|
+
metadata: { userId },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const movedCount = await transferAggregateStreams(bun.db, {
|
|
225
|
+
sourceTenantId: sourceTenant,
|
|
226
|
+
destinationTenantId: destinationTenant,
|
|
227
|
+
aggregateType: "counter",
|
|
228
|
+
aggregateIds: [sharedId],
|
|
229
|
+
transferredBy,
|
|
230
|
+
});
|
|
231
|
+
expect(movedCount).toBe(1);
|
|
232
|
+
|
|
233
|
+
expect(await isStreamArchived(bun.db, sourceTenant, sharedId)).toBe(true);
|
|
234
|
+
expect(await isStreamArchived(bun.db, destinationTenant, sharedId)).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/event-store/index.ts
CHANGED
|
@@ -53,6 +53,12 @@ export {
|
|
|
53
53
|
saveSnapshot,
|
|
54
54
|
snapshotsTable,
|
|
55
55
|
} from "./snapshot";
|
|
56
|
+
export {
|
|
57
|
+
AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
58
|
+
AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
59
|
+
type TransferAggregateStreamsArgs,
|
|
60
|
+
transferAggregateStreams,
|
|
61
|
+
} from "./transfer";
|
|
56
62
|
export {
|
|
57
63
|
type EventUpcasters,
|
|
58
64
|
makeUpcastCtx,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The event store's own tenant-transfer primitive: the ONE place allowed to
|
|
2
|
+
// move a stream across the tenant boundary (enforced by
|
|
3
|
+
// guard-event-store-writes' allowlist). Each move leaves an audit event in
|
|
4
|
+
// the destination tenant.
|
|
5
|
+
//
|
|
6
|
+
// Runs entirely on the caller-supplied DbRunner, so the move rides whatever
|
|
7
|
+
// transaction the caller is already inside (tenant-handover's claim handler
|
|
8
|
+
// commits it together with the root row's ownership write — see
|
|
9
|
+
// move-entity-graph.ts's file header for why that matters).
|
|
10
|
+
|
|
11
|
+
import type { DbRunner } from "../db";
|
|
12
|
+
import {
|
|
13
|
+
deleteSnapshotsForAggregates,
|
|
14
|
+
transferArchivedStreamRows,
|
|
15
|
+
transferEventRows,
|
|
16
|
+
} from "../db/queries/event-store-transfer";
|
|
17
|
+
import type { TenantId } from "../engine/types";
|
|
18
|
+
import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
|
|
19
|
+
import { generateId } from "../utils/ids";
|
|
20
|
+
import { append } from "./event-store";
|
|
21
|
+
|
|
22
|
+
export const AGGREGATE_TRANSFER_STREAM_TYPE = "aggregate-transfer";
|
|
23
|
+
export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
|
|
24
|
+
|
|
25
|
+
export type TransferAggregateStreamsArgs = {
|
|
26
|
+
readonly sourceTenantId: TenantId;
|
|
27
|
+
readonly destinationTenantId: TenantId;
|
|
28
|
+
readonly aggregateType: string;
|
|
29
|
+
readonly aggregateIds: readonly string[];
|
|
30
|
+
readonly transferredBy: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Moves one aggregate type's event history, drops its cached snapshots, and
|
|
34
|
+
// carries any archive marker across the tenant boundary. Returns the number
|
|
35
|
+
// of aggregates that actually had events (and therefore moved) — an id in
|
|
36
|
+
// `aggregateIds` with no events under `sourceTenantId`/`aggregateType`
|
|
37
|
+
// contributes nothing and gets no follow-up event.
|
|
38
|
+
export async function transferAggregateStreams(
|
|
39
|
+
db: DbRunner,
|
|
40
|
+
args: TransferAggregateStreamsArgs,
|
|
41
|
+
): Promise<number> {
|
|
42
|
+
if (args.aggregateIds.length === 0) return 0;
|
|
43
|
+
|
|
44
|
+
const movedAggregateIds = await transferEventRows(db, {
|
|
45
|
+
sourceTenantId: args.sourceTenantId,
|
|
46
|
+
destinationTenantId: args.destinationTenantId,
|
|
47
|
+
aggregateType: args.aggregateType,
|
|
48
|
+
aggregateIds: args.aggregateIds,
|
|
49
|
+
});
|
|
50
|
+
if (movedAggregateIds.length === 0) return 0;
|
|
51
|
+
|
|
52
|
+
await deleteSnapshotsForAggregates(db, {
|
|
53
|
+
tenantId: args.sourceTenantId,
|
|
54
|
+
aggregateIds: movedAggregateIds,
|
|
55
|
+
});
|
|
56
|
+
await transferArchivedStreamRows(db, {
|
|
57
|
+
sourceTenantId: args.sourceTenantId,
|
|
58
|
+
destinationTenantId: args.destinationTenantId,
|
|
59
|
+
aggregateType: args.aggregateType,
|
|
60
|
+
aggregateIds: movedAggregateIds,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const aggregateId of movedAggregateIds) {
|
|
64
|
+
// A fresh, own stream — not an append onto the moved aggregate's own
|
|
65
|
+
// stream — so the marker never bumps the moved aggregate's version or
|
|
66
|
+
// shows up when a projection replays that aggregate's history.
|
|
67
|
+
await append(db, {
|
|
68
|
+
aggregateId: generateId(),
|
|
69
|
+
aggregateType: AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
70
|
+
tenantId: args.destinationTenantId,
|
|
71
|
+
expectedVersion: 0,
|
|
72
|
+
type: AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
73
|
+
payload: {
|
|
74
|
+
aggregateType: args.aggregateType,
|
|
75
|
+
aggregateId,
|
|
76
|
+
sourceTenantId: args.sourceTenantId,
|
|
77
|
+
destinationTenantId: args.destinationTenantId,
|
|
78
|
+
},
|
|
79
|
+
metadata: { userId: args.transferredBy },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return movedAggregateIds.length;
|
|
84
|
+
}
|