@cosmicdrift/kumiko-bundled-features 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 +9 -9
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
- package/src/step-dispatcher/changes.json +7 -0
- package/src/step-dispatcher/feature.ts +48 -15
- package/src/step-dispatcher/index.ts +3 -1
- package/src/step-dispatcher/webhook-runner.ts +59 -19
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
|
@@ -20,8 +20,13 @@ import {
|
|
|
20
20
|
type EntityDefinition,
|
|
21
21
|
type TenantId,
|
|
22
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
AGGREGATE_TRANSFER_STREAM_TYPE,
|
|
25
|
+
AGGREGATE_TRANSFERRED_EVENT_TYPE,
|
|
26
|
+
loadAggregate,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
28
|
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
29
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
25
30
|
import {
|
|
26
31
|
createTestUser,
|
|
27
32
|
setupTestStack,
|
|
@@ -387,7 +392,7 @@ async function seedFileRef(
|
|
|
387
392
|
tenantId: TenantId,
|
|
388
393
|
entityType: string,
|
|
389
394
|
entityId: string,
|
|
390
|
-
): Promise<
|
|
395
|
+
): Promise<string> {
|
|
391
396
|
const user = createSystemUser(tenantId);
|
|
392
397
|
const db = createTenantDb(stack.db, tenantId, "system");
|
|
393
398
|
const result = await fileRefCrud.create(
|
|
@@ -404,6 +409,7 @@ async function seedFileRef(
|
|
|
404
409
|
db,
|
|
405
410
|
);
|
|
406
411
|
if (!result.isSuccess) throw new Error(`seedFileRef failed: ${result.error.message}`);
|
|
412
|
+
return String(result.data.id);
|
|
407
413
|
}
|
|
408
414
|
|
|
409
415
|
function grantFor(rowId: string): string {
|
|
@@ -429,8 +435,8 @@ describe("tenant-handover :: claim", () => {
|
|
|
429
435
|
test("claims the root, its parentRef-linked child, and both attached files into the caller's tenant, leaving an unrelated run of the same type untouched", async () => {
|
|
430
436
|
const runId = await seedRun(SOURCE_TENANT, "my run");
|
|
431
437
|
const photoId = await seedPhoto(SOURCE_TENANT, runId, "front");
|
|
432
|
-
await seedFileRef(SOURCE_TENANT, "run", runId);
|
|
433
|
-
await seedFileRef(SOURCE_TENANT, "photo", photoId);
|
|
438
|
+
const runFileRefId = await seedFileRef(SOURCE_TENANT, "run", runId);
|
|
439
|
+
const photoFileRefId = await seedFileRef(SOURCE_TENANT, "photo", photoId);
|
|
434
440
|
|
|
435
441
|
// A second, unrelated run of the SAME entity type, still in the source
|
|
436
442
|
// tenant — proves the claim moves only the identified run's rows, not
|
|
@@ -491,6 +497,58 @@ describe("tenant-handover :: claim", () => {
|
|
|
491
497
|
// The unrelated run (same entity type, different row) never moved.
|
|
492
498
|
expect(await readTenantId("handover_run", otherRunId)).toBe(SOURCE_TENANT);
|
|
493
499
|
expect(await readTenantId("handover_photo", otherPhotoId)).toBe(SOURCE_TENANT);
|
|
500
|
+
|
|
501
|
+
// The event store's own transfer primitive left its own audit trail too:
|
|
502
|
+
// exactly one aggregate.transferred system event per moved aggregate,
|
|
503
|
+
// landed in the destination tenant.
|
|
504
|
+
const transferEvents = await executeRawQuery<{ payload: Record<string, unknown> }>(
|
|
505
|
+
stack.db,
|
|
506
|
+
`SELECT payload FROM kumiko_events WHERE tenant_id = $1 AND aggregate_type = $2 AND type = $3`,
|
|
507
|
+
[dest.tenantId, AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE],
|
|
508
|
+
);
|
|
509
|
+
expect(transferEvents).toHaveLength(4);
|
|
510
|
+
const byMovedAggregateId = new Map(
|
|
511
|
+
transferEvents.map((row) => [row.payload["aggregateId"], row.payload]),
|
|
512
|
+
);
|
|
513
|
+
for (const [aggregateId, aggregateType] of [
|
|
514
|
+
[runId, "run"],
|
|
515
|
+
[photoId, "photo"],
|
|
516
|
+
[runFileRefId, "fileRef"],
|
|
517
|
+
[photoFileRefId, "fileRef"],
|
|
518
|
+
] as const) {
|
|
519
|
+
expect(byMovedAggregateId.get(aggregateId)).toMatchObject({
|
|
520
|
+
aggregateType,
|
|
521
|
+
aggregateId,
|
|
522
|
+
sourceTenantId: SOURCE_TENANT,
|
|
523
|
+
destinationTenantId: dest.tenantId,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// A projection rebuild of the root entity after the claim still resolves
|
|
528
|
+
// it under the destination tenant with the same content — the transfer
|
|
529
|
+
// didn't leave the projection and the event store out of sync.
|
|
530
|
+
await rebuildProjection("handover-fixtures:projection:run-entity", {
|
|
531
|
+
db: stack.db,
|
|
532
|
+
registry: stack.registry,
|
|
533
|
+
});
|
|
534
|
+
expect(await readTenantId("handover_run", runId)).toBe(dest.tenantId);
|
|
535
|
+
const rebuiltRow = await executeRawQuery<{ name: string }>(
|
|
536
|
+
stack.db,
|
|
537
|
+
`SELECT name FROM handover_run WHERE id = $1`,
|
|
538
|
+
[runId],
|
|
539
|
+
);
|
|
540
|
+
expect(rebuiltRow[0]?.name).toBe("my run");
|
|
541
|
+
|
|
542
|
+
// The event store's version bookkeeping survived the move: a normal
|
|
543
|
+
// write against the claimed root in its new tenant still succeeds.
|
|
544
|
+
const destDb = createTenantDb(stack.db, dest.tenantId, "system");
|
|
545
|
+
const destUser = createSystemUser(dest.tenantId);
|
|
546
|
+
const followUpWrite = await runCrud.update(
|
|
547
|
+
{ id: runId, version: 1, changes: { name: "renamed after claim" } },
|
|
548
|
+
destUser,
|
|
549
|
+
destDb,
|
|
550
|
+
);
|
|
551
|
+
expect(followUpWrite.isSuccess).toBe(true);
|
|
494
552
|
});
|
|
495
553
|
|
|
496
554
|
// kumiko-framework#3088: before this, resolveChildCandidates only knew
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.321.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Claim now moves event-store ownership through transferAggregateStreams instead of raw SQL",
|
|
6
|
+
"detail": "move-entity-graph.ts no longer runs its own `UPDATE kumiko_events` /\n`UPDATE kumiko_snapshots` against the event store — it calls the\nframework's `transferAggregateStreams` per aggregate type, threading the\nclaiming user's id as `transferredBy`. Archive markers now move with the\nstream (previously left behind under the source tenant). Each moved\naggregate gets its own `kumiko:system:aggregate.transferred` audit event\nin the destination tenant, in addition to the existing\n`tenant-handover:event:claimed` summary event.",
|
|
7
|
+
"migration": "No action needed — the claim API and its response shape are unchanged."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.313.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -82,6 +82,7 @@ export function createClaimTenantHandoverHandler(opts: ClaimTenantHandoverOption
|
|
|
82
82
|
rootRowId: subject,
|
|
83
83
|
sourceTenantId: expectedAnchor,
|
|
84
84
|
destinationTenantId,
|
|
85
|
+
transferredBy: event.user.id,
|
|
85
86
|
});
|
|
86
87
|
|
|
87
88
|
// Own aggregate, decoupled from the transferred root's stream (see
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// The actual ownership change (kumiko-framework#3035): raw SQL against the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// read-model tables, because moving a row across the tenant boundary is
|
|
3
|
+
// exactly the kind of write the framework's entity write map cannot express
|
|
4
|
+
// (it is single-tenant-scoped by design — that boundary is the whole point
|
|
5
|
+
// of `assertTenantMatch`). This is the framework's OWN declared operation,
|
|
6
|
+
// not a consumer's `acknowledgeCrossTenant` escape. The event-store side of
|
|
7
|
+
// the move (kumiko_events/kumiko_snapshots/kumiko_archived_streams) is the
|
|
8
|
+
// framework's own `transferAggregateStreams` primitive
|
|
9
|
+
// — see event-store/transfer.ts for why that boundary is owned there instead.
|
|
7
10
|
//
|
|
8
11
|
// Every statement here runs inside the calling write handler's own
|
|
9
12
|
// transaction (HandlerContext.db is transaction-scoped for the handler's
|
|
@@ -28,12 +31,11 @@ import {
|
|
|
28
31
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
29
32
|
import { MAX_TRANSFER_DEPTH, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
30
33
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
34
|
+
import { transferAggregateStreams } from "@cosmicdrift/kumiko-framework/event-store";
|
|
31
35
|
import { transferTenantStorageUsage } from "@cosmicdrift/kumiko-framework/files";
|
|
32
36
|
import { resolveTransferAdjacency, type TransferEdge } from "./transfer-graph";
|
|
33
37
|
|
|
34
38
|
const FILE_REFS_TABLE = "file_refs";
|
|
35
|
-
const EVENTS_TABLE = "kumiko_events";
|
|
36
|
-
const SNAPSHOTS_TABLE = "kumiko_snapshots";
|
|
37
39
|
|
|
38
40
|
// The commitAnchor statement itself: spends the anchor (WHERE tenant_id =
|
|
39
41
|
// expected) AND performs the ownership write (SET tenant_id = destination)
|
|
@@ -57,34 +59,6 @@ export async function moveRootRow(args: {
|
|
|
57
59
|
return rows.length === 1;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
async function moveEventHistory(args: {
|
|
61
|
-
readonly db: DbRunner;
|
|
62
|
-
readonly aggregateType: string;
|
|
63
|
-
readonly aggregateIds: readonly string[];
|
|
64
|
-
readonly sourceTenantId: string;
|
|
65
|
-
readonly destinationTenantId: string;
|
|
66
|
-
}): Promise<void> {
|
|
67
|
-
// skip: an empty id list has nothing to move — both callers already
|
|
68
|
-
// filter to a non-empty list before calling, this only guards a future
|
|
69
|
-
// caller that forgets to.
|
|
70
|
-
if (args.aggregateIds.length === 0) return;
|
|
71
|
-
await executeRawQuery(
|
|
72
|
-
args.db,
|
|
73
|
-
`UPDATE ${EVENTS_TABLE} SET tenant_id = $1 ` +
|
|
74
|
-
`WHERE tenant_id = $2 AND aggregate_type = $3 AND aggregate_id = ANY($4)`,
|
|
75
|
-
[args.destinationTenantId, args.sourceTenantId, args.aggregateType, args.aggregateIds],
|
|
76
|
-
);
|
|
77
|
-
// Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
|
|
78
|
-
// dropped rather than rewritten, forcing a full replay from the just-moved
|
|
79
|
-
// events on next read instead of carrying the snapshot's own generation
|
|
80
|
-
// bookkeeping across the tenant boundary.
|
|
81
|
-
await executeRawQuery(
|
|
82
|
-
args.db,
|
|
83
|
-
`DELETE FROM ${SNAPSHOTS_TABLE} WHERE tenant_id = $1 AND aggregate_id = ANY($2)`,
|
|
84
|
-
[args.sourceTenantId, args.aggregateIds],
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
62
|
// Moves the file_refs row itself PLUS the fileRef aggregate's own event
|
|
89
63
|
// history + storage-usage counters — fileRef is a standard ES entity (see
|
|
90
64
|
// files-storage-tracking.md), so without those two follow-ups a later write
|
|
@@ -98,6 +72,7 @@ async function moveFileRefs(args: {
|
|
|
98
72
|
readonly entityIds: readonly string[];
|
|
99
73
|
readonly sourceTenantId: string;
|
|
100
74
|
readonly destinationTenantId: string;
|
|
75
|
+
readonly transferredBy: string;
|
|
101
76
|
}): Promise<number> {
|
|
102
77
|
if (args.entityIds.length === 0) return 0;
|
|
103
78
|
const rows = await executeRawQuery<{ id: string }>(
|
|
@@ -109,8 +84,8 @@ async function moveFileRefs(args: {
|
|
|
109
84
|
const movedIds = rows.map((row) => row.id);
|
|
110
85
|
if (movedIds.length === 0) return 0;
|
|
111
86
|
|
|
112
|
-
// Must run before
|
|
113
|
-
// transferTenantStorageUsage's own comment for why.
|
|
87
|
+
// Must run before transferAggregateStreams rewrites these events' tenant_id —
|
|
88
|
+
// see transferTenantStorageUsage's own comment for why.
|
|
114
89
|
await transferTenantStorageUsage({
|
|
115
90
|
db: args.db,
|
|
116
91
|
registry: args.registry,
|
|
@@ -118,12 +93,12 @@ async function moveFileRefs(args: {
|
|
|
118
93
|
sourceTenantId: args.sourceTenantId,
|
|
119
94
|
destinationTenantId: args.destinationTenantId,
|
|
120
95
|
});
|
|
121
|
-
await
|
|
122
|
-
db: args.db,
|
|
96
|
+
await transferAggregateStreams(args.db, {
|
|
123
97
|
aggregateType: "fileRef",
|
|
124
98
|
aggregateIds: movedIds,
|
|
125
99
|
sourceTenantId: args.sourceTenantId,
|
|
126
100
|
destinationTenantId: args.destinationTenantId,
|
|
101
|
+
transferredBy: args.transferredBy,
|
|
127
102
|
});
|
|
128
103
|
return movedIds.length;
|
|
129
104
|
}
|
|
@@ -237,8 +212,17 @@ export async function moveTransferGraph(args: {
|
|
|
237
212
|
readonly rootRowId: string;
|
|
238
213
|
readonly sourceTenantId: string;
|
|
239
214
|
readonly destinationTenantId: string;
|
|
215
|
+
readonly transferredBy: string;
|
|
240
216
|
}): Promise<Readonly<Record<string, number>>> {
|
|
241
|
-
const {
|
|
217
|
+
const {
|
|
218
|
+
db,
|
|
219
|
+
registry,
|
|
220
|
+
rootEntityName,
|
|
221
|
+
rootRowId,
|
|
222
|
+
sourceTenantId,
|
|
223
|
+
destinationTenantId,
|
|
224
|
+
transferredBy,
|
|
225
|
+
} = args;
|
|
242
226
|
const movedCounts: Record<string, number> = { [rootEntityName]: 1 };
|
|
243
227
|
const trackFileMove = (count: number) => {
|
|
244
228
|
// skip: nothing moved (this entity had no attached files) — the audit
|
|
@@ -248,12 +232,12 @@ export async function moveTransferGraph(args: {
|
|
|
248
232
|
movedCounts["fileRef"] = (movedCounts["fileRef"] ?? 0) + count;
|
|
249
233
|
};
|
|
250
234
|
|
|
251
|
-
await
|
|
252
|
-
db,
|
|
235
|
+
await transferAggregateStreams(db, {
|
|
253
236
|
aggregateType: rootEntityName,
|
|
254
237
|
aggregateIds: [rootRowId],
|
|
255
238
|
sourceTenantId,
|
|
256
239
|
destinationTenantId,
|
|
240
|
+
transferredBy,
|
|
257
241
|
});
|
|
258
242
|
trackFileMove(
|
|
259
243
|
await moveFileRefs({
|
|
@@ -263,6 +247,7 @@ export async function moveTransferGraph(args: {
|
|
|
263
247
|
entityIds: [rootRowId],
|
|
264
248
|
sourceTenantId,
|
|
265
249
|
destinationTenantId,
|
|
250
|
+
transferredBy,
|
|
266
251
|
}),
|
|
267
252
|
);
|
|
268
253
|
|
|
@@ -317,12 +302,12 @@ export async function moveTransferGraph(args: {
|
|
|
317
302
|
movedCounts[edge.entityName] = (movedCounts[edge.entityName] ?? 0) + childIds.length;
|
|
318
303
|
discovered.push({ entityName: edge.entityName, rowIds: childIds });
|
|
319
304
|
|
|
320
|
-
await
|
|
321
|
-
db,
|
|
305
|
+
await transferAggregateStreams(db, {
|
|
322
306
|
aggregateType: edge.entityName,
|
|
323
307
|
aggregateIds: childIds,
|
|
324
308
|
sourceTenantId,
|
|
325
309
|
destinationTenantId,
|
|
310
|
+
transferredBy,
|
|
326
311
|
});
|
|
327
312
|
trackFileMove(
|
|
328
313
|
await moveFileRefs({
|
|
@@ -332,6 +317,7 @@ export async function moveTransferGraph(args: {
|
|
|
332
317
|
entityIds: childIds,
|
|
333
318
|
sourceTenantId,
|
|
334
319
|
destinationTenantId,
|
|
320
|
+
transferredBy,
|
|
335
321
|
}),
|
|
336
322
|
);
|
|
337
323
|
}
|