@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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 (116) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/wire-for-entity.ts +104 -77
  22. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  23. package/src/data-retention/resolve-for-tenant.ts +16 -4
  24. package/src/data-retention/run-retention-cleanup.ts +72 -10
  25. package/src/delivery/attempt-log.ts +22 -10
  26. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  27. package/src/folders/__tests__/folders.integration.test.ts +26 -0
  28. package/src/folders/feature.ts +4 -2
  29. package/src/folders/handlers/delete-folder.write.ts +40 -0
  30. package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
  31. package/src/folders/web/folder-manager.tsx +18 -13
  32. package/src/folders/web/i18n.ts +0 -2
  33. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  34. package/src/folders-user-data/hooks.ts +31 -8
  35. package/src/folders-user-data/index.ts +4 -4
  36. package/src/jobs/feature.ts +23 -21
  37. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  38. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  39. package/src/ledger/__tests__/reports.test.ts +20 -0
  40. package/src/ledger/entity.ts +1 -1
  41. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  42. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  43. package/src/ledger/recurring.ts +18 -9
  44. package/src/ledger/schemas.ts +4 -1
  45. package/src/ledger/web/index.ts +3 -2
  46. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  47. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
  48. package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
  49. package/src/personal-access-tokens/constants.ts +33 -0
  50. package/src/personal-access-tokens/feature.ts +78 -0
  51. package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
  52. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  53. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  54. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  55. package/src/personal-access-tokens/hash.ts +17 -0
  56. package/src/personal-access-tokens/index.ts +45 -0
  57. package/src/personal-access-tokens/resolver.ts +58 -0
  58. package/src/personal-access-tokens/roles.ts +29 -0
  59. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  60. package/src/personal-access-tokens/scopes.ts +43 -0
  61. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  62. package/src/personal-access-tokens/web/i18n.ts +69 -0
  63. package/src/personal-access-tokens/web/index.ts +7 -0
  64. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
  65. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  66. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  67. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  68. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  69. package/src/sessions/schema/user-session.ts +8 -2
  70. package/src/sessions/session-callbacks.ts +9 -3
  71. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  72. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  73. package/src/tags/handlers/assign-tag.write.ts +13 -3
  74. package/src/tags/schemas.ts +10 -4
  75. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  76. package/src/template-resolver/testing.ts +4 -3
  77. package/src/tier-engine/feature.ts +22 -10
  78. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  79. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  80. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  81. package/src/user/index.ts +4 -0
  82. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  85. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  86. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  87. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  88. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  89. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  90. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  91. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  92. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  93. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  95. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  96. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  97. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  98. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  99. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  100. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  101. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  102. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  103. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  104. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  105. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  106. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  107. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  108. package/src/user-data-rights/web/i18n.ts +0 -10
  109. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  110. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  111. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  112. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  113. package/src/user-profile/handlers/change-email.write.ts +11 -8
  114. package/src/user-profile/web/profile-screen.tsx +10 -8
  115. package/src/auth-email-password/stream-tenant.ts +0 -31
  116. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -2,8 +2,8 @@
2
2
  // `@cosmicdrift/kumiko-bundled-features/template-resolver/testing`.
3
3
  // No bun:test here — callers register cases with their own test runner.
4
4
 
5
- import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
6
5
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
7
7
  import type { ResolveRequest, TemplateResource } from "./api";
8
8
  import { TemplateNotFoundError } from "./api";
9
9
  import {
@@ -50,7 +50,7 @@ type SeedTemplateArgs = {
50
50
  };
51
51
 
52
52
  async function seedTemplate(db: DbConnection, args: SeedTemplateArgs): Promise<void> {
53
- await insertOne(db, templateResourcesTable, {
53
+ await seedRow(db, templateResourcesTable, {
54
54
  tenantId: args.tenantId,
55
55
  slug: args.slug,
56
56
  kind: args.kind,
@@ -74,10 +74,11 @@ export async function assertConsumerHandlesNotFound(
74
74
  const { tenantId } = opts;
75
75
  let rejected = false;
76
76
  let err: unknown;
77
+ const slug = `conformance-not-found-${crypto.randomUUID()}`;
77
78
  try {
78
79
  await consumer.resolve({
79
80
  tenantId,
80
- slug: "conformance-not-found-slug",
81
+ slug,
81
82
  kind: "mail-html",
82
83
  locale: "de",
83
84
  });
@@ -217,6 +217,7 @@ export function createTierEngineFeature<
217
217
  r.writeHandler(
218
218
  createSetTenantTierWrite({
219
219
  onAssigned: (tenantId, tier) => onTierAssigned.fn(tenantId, tier),
220
+ validTiers: opts.tierMap ? new Set(Object.keys(opts.tierMap)) : undefined,
220
221
  }),
221
222
  );
222
223
  r.queryHandler(getTenantTierQuery);
@@ -452,19 +453,30 @@ export function createTierEngineFeature<
452
453
  // tier-assignment-Row, die der seed-Pfad nicht anlegt) und prüft das
453
454
  // Fenster gegen die aktuelle Zeit. Nur auf dem disabled-Gate-Pfad
454
455
  // konsultiert. inserted_at ist immutable → pro Tenant einmal lesen.
455
- const startedMemo = new Map<TenantId, number>();
456
+ // Promise-memo (not a plain value cache): N concurrent requests for the
457
+ // same fresh tenant must await the SAME in-flight fetchOne, not each
458
+ // fire their own — a plain `Map<TenantId, number>` set AFTER the await
459
+ // lets every concurrent caller miss the cache and issue a redundant
460
+ // read (benign but wasteful on a signup burst).
461
+ const startedMemo = new Map<TenantId, Promise<number | undefined>>();
456
462
  const trialGate: TrialGate = async (tenantId, featureName) => {
457
463
  if (!trialFeatures.has(featureName)) return false;
458
- let startedMs = startedMemo.get(tenantId);
459
- if (startedMs === undefined) {
460
- const row = await fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
464
+ let startedMsPromise = startedMemo.get(tenantId);
465
+ if (startedMsPromise === undefined) {
466
+ startedMsPromise = fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
461
467
  id: tenantId,
462
- });
463
- // Tenant-Row noch nicht projiziert (Replay-Race) keinen Miss
464
- // memoizen, beim nächsten Request neu lesen.
465
- if (row?.insertedAt === undefined) return false;
466
- startedMs = row.insertedAt.epochMilliseconds;
467
- startedMemo.set(tenantId, startedMs);
468
+ }).then((row) => row?.insertedAt?.epochMilliseconds);
469
+ // Evict on rejection too a transient DB error must not poison
470
+ // every future call for this tenant with the same cached rejection.
471
+ startedMsPromise.catch(() => startedMemo.delete(tenantId));
472
+ startedMemo.set(tenantId, startedMsPromise);
473
+ }
474
+ const startedMs = await startedMsPromise;
475
+ // Tenant-Row noch nicht projiziert (Replay-Race) → keinen Miss
476
+ // memoizen, beim nächsten Request neu lesen.
477
+ if (startedMs === undefined) {
478
+ startedMemo.delete(tenantId);
479
+ return false;
468
480
  }
469
481
  return isTrialActive(startedMs, nowMs(), trial.durationHours);
470
482
  };
@@ -44,6 +44,12 @@ export type SetTenantTierOptions = {
44
44
  * Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
45
45
  * nicht). Ohne tierMap kein Resolver → no-op. */
46
46
  readonly onAssigned?: (tenantId: TenantId, tier: string) => void;
47
+ /** Tier-Namen aus der tierMap-Closure — ohne sie (storage-only-Mode) bleibt
48
+ * `tier` unvalidiert außer Length/Non-Empty. Mit ihr rejected der Handler
49
+ * einen unbekannten Tier-Namen statt ihn stillschweigend durchzuschreiben
50
+ * (featuresForTier gibt für unbekannte Namen defensiv ein leeres Set
51
+ * zurück — der Tenant verliert sonst lautlos alle Tier-Gates). */
52
+ readonly validTiers?: ReadonlySet<string>;
47
53
  };
48
54
 
49
55
  export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
@@ -51,7 +57,11 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
51
57
  name: "set-tenant-tier",
52
58
  schema: z.object({
53
59
  tenantId: z.string().min(1),
54
- tier: z.string().min(1).max(50),
60
+ tier: z
61
+ .string()
62
+ .min(1)
63
+ .max(50)
64
+ .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
55
65
  }),
56
66
  access: { roles: ["SystemAdmin"] },
57
67
  handler: async (event, ctx) => {
@@ -0,0 +1,186 @@
1
+ // #762 — backfillUserStreamTenants: pre-#497 user streams live on the
2
+ // creating tenant; post-#497 the executor addresses SYSTEM_TENANT_ID, so
3
+ // legacy users version-conflict on every write (password-reset collapses to
4
+ // invalid_token). The backfill retenants + renumbers per aggregate in global
5
+ // id order — including the split-stream case the raw #497-changeset SQL
6
+ // crashes on (legacy tenant v1..n AND post-#497 SYSTEM events for the same
7
+ // aggregate → events_aggregate_version_uq violation).
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
12
+ import {
13
+ createSystemUser,
14
+ SYSTEM_TENANT_ID,
15
+ type TenantId,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { append, createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
+ import {
19
+ createTestDb,
20
+ type TestDb,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { backfillUserStreamTenants } from "../db/queries/stream-tenant-backfill";
24
+ import { userEntity, userTable } from "../schema/user";
25
+
26
+ const T1 = "00000000-0000-4000-8000-000000000011" as TenantId;
27
+ const LEGACY_USER = "aaaaaaaa-0000-4000-8000-000000000001";
28
+ const SPLIT_USER = "aaaaaaaa-0000-4000-8000-000000000002";
29
+ const MODERN_USER = "aaaaaaaa-0000-4000-8000-000000000003";
30
+
31
+ let testDb: TestDb;
32
+
33
+ const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
34
+
35
+ beforeAll(async () => {
36
+ testDb = await createTestDb();
37
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
38
+ await createEventsTable(testDb.db);
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await testDb.cleanup();
43
+ });
44
+
45
+ beforeEach(async () => {
46
+ await asRawClient(testDb.db).unsafe(
47
+ `TRUNCATE kumiko_events, kumiko_snapshots, kumiko_archived_streams, read_users RESTART IDENTITY CASCADE`,
48
+ );
49
+ });
50
+
51
+ async function appendUserEvent(
52
+ aggregateId: string,
53
+ tenantId: TenantId,
54
+ expectedVersion: number,
55
+ type: string,
56
+ payload: Record<string, unknown>,
57
+ ): Promise<void> {
58
+ await append(testDb.db, {
59
+ aggregateId,
60
+ aggregateType: "user",
61
+ tenantId,
62
+ expectedVersion,
63
+ type,
64
+ payload,
65
+ metadata: { userId: "test-migrator" },
66
+ });
67
+ }
68
+
69
+ async function streamRows(
70
+ aggregateId: string,
71
+ ): Promise<ReadonlyArray<{ tenant_id: string; version: number; type: string }>> {
72
+ return (await asRawClient(testDb.db).unsafe(
73
+ `SELECT "tenant_id", "version", "type" FROM "kumiko_events"
74
+ WHERE "aggregate_id" = $1::uuid ORDER BY "id" ASC`,
75
+ [aggregateId],
76
+ )) as ReadonlyArray<{ tenant_id: string; version: number; type: string }>;
77
+ }
78
+
79
+ function seedUserRow(id: string, tenantId: TenantId, version: number) {
80
+ return asRawClient(testDb.db).unsafe(
81
+ `INSERT INTO "read_users" ("id", "tenant_id", "email", "display_name", "locale", "password_hash", "status", "version")
82
+ VALUES ($1::uuid, $2::uuid, $3, 'Legacy', 'de', 'x', 'active', $4)`,
83
+ [id, tenantId, `${id.slice(0, 8)}@example.com`, version],
84
+ );
85
+ }
86
+
87
+ describe("backfillUserStreamTenants (#762)", () => {
88
+ test("legacy stream: retenanted to SYSTEM, versions contiguous, executor write works again", async () => {
89
+ await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
90
+ await appendUserEvent(LEGACY_USER, T1, 1, "user.updated", { changes: { displayName: "L" } });
91
+ await seedUserRow(LEGACY_USER, T1, 2);
92
+
93
+ const result = await backfillUserStreamTenants(testDb.db);
94
+ expect(result.aggregatesMigrated).toBe(1);
95
+ expect(result.eventsMigrated).toBe(2);
96
+ expect(result.failed).toHaveLength(0);
97
+
98
+ const rows = await streamRows(LEGACY_USER);
99
+ expect(rows.map((r) => r.tenant_id)).toEqual([SYSTEM_TENANT_ID, SYSTEM_TENANT_ID]);
100
+ expect(rows.map((r) => r.version)).toEqual([1, 2]);
101
+
102
+ // The actual #762 symptom: an optimistic-locked executor update with the
103
+ // row version now targets a stream that HAS that version — no conflict.
104
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
105
+ const writeRes = await executor.update(
106
+ { id: LEGACY_USER, version: 2, changes: { displayName: "After" } },
107
+ createSystemUser(SYSTEM_TENANT_ID),
108
+ tdb,
109
+ );
110
+ expect(writeRes.isSuccess).toBe(true);
111
+ });
112
+
113
+ test("split stream (legacy + post-#497 SYSTEM events) merges in global id order", async () => {
114
+ // Legacy: created + updated on T1 (v1, v2). Then a post-#497 lifecycle
115
+ // write appended v1 on SYSTEM — the split the raw changeset-SQL trips on.
116
+ await appendUserEvent(SPLIT_USER, T1, 0, "user.created", { email: "s@example.com" });
117
+ await appendUserEvent(SPLIT_USER, T1, 1, "user.updated", { changes: { a: 1 } });
118
+ await appendUserEvent(SPLIT_USER, SYSTEM_TENANT_ID, 0, "user.updated", {
119
+ changes: { status: "restricted" },
120
+ });
121
+ await seedUserRow(SPLIT_USER, T1, 2);
122
+
123
+ const result = await backfillUserStreamTenants(testDb.db);
124
+ expect(result.aggregatesMigrated).toBe(1);
125
+ expect(result.eventsMigrated).toBe(3);
126
+ expect(result.failed).toHaveLength(0);
127
+
128
+ const rows = await streamRows(SPLIT_USER);
129
+ expect(rows.map((r) => r.tenant_id)).toEqual([
130
+ SYSTEM_TENANT_ID,
131
+ SYSTEM_TENANT_ID,
132
+ SYSTEM_TENANT_ID,
133
+ ]);
134
+ // Renumbered by global event id: created, updated(T1), updated(SYSTEM).
135
+ expect(rows.map((r) => r.version)).toEqual([1, 2, 3]);
136
+ expect(rows[0]?.type).toBe("user.created");
137
+ });
138
+
139
+ test("idempotent: second run finds nothing; modern SYSTEM streams untouched", async () => {
140
+ await appendUserEvent(MODERN_USER, SYSTEM_TENANT_ID, 0, "user.created", {
141
+ email: "m@example.com",
142
+ });
143
+ await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
144
+
145
+ const first = await backfillUserStreamTenants(testDb.db);
146
+ expect(first.aggregatesMigrated).toBe(1);
147
+
148
+ const second = await backfillUserStreamTenants(testDb.db);
149
+ expect(second.aggregatesMigrated).toBe(0);
150
+ expect(second.eventsMigrated).toBe(0);
151
+
152
+ const modern = await streamRows(MODERN_USER);
153
+ expect(modern).toHaveLength(1);
154
+ expect(modern[0]?.version).toBe(1);
155
+ });
156
+
157
+ test("stale snapshots dropped, archived-stream marker moves to SYSTEM", async () => {
158
+ await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
159
+ await asRawClient(testDb.db).unsafe(
160
+ `INSERT INTO "kumiko_snapshots" ("aggregate_id", "aggregate_type", "tenant_id", "version", "state")
161
+ VALUES ($1::uuid, 'user', $2::uuid, 1, '{}'::jsonb)`,
162
+ [LEGACY_USER, T1],
163
+ );
164
+ await asRawClient(testDb.db).unsafe(
165
+ `INSERT INTO "kumiko_archived_streams" ("tenant_id", "aggregate_id", "aggregate_type", "archived_by")
166
+ VALUES ($1::uuid, $2::uuid, 'user', 'test')`,
167
+ [T1, LEGACY_USER],
168
+ );
169
+
170
+ const result = await backfillUserStreamTenants(testDb.db);
171
+ expect(result.failed).toHaveLength(0);
172
+
173
+ const snapshots = (await asRawClient(testDb.db).unsafe(
174
+ `SELECT count(*)::int AS n FROM "kumiko_snapshots" WHERE "aggregate_id" = $1::uuid`,
175
+ [LEGACY_USER],
176
+ )) as ReadonlyArray<{ n: number }>;
177
+ expect(snapshots[0]?.n).toBe(0);
178
+
179
+ const archived = (await asRawClient(testDb.db).unsafe(
180
+ `SELECT "tenant_id" FROM "kumiko_archived_streams" WHERE "aggregate_id" = $1::uuid`,
181
+ [LEGACY_USER],
182
+ )) as ReadonlyArray<{ tenant_id: string }>;
183
+ expect(archived).toHaveLength(1);
184
+ expect(archived[0]?.tenant_id).toBe(SYSTEM_TENANT_ID);
185
+ });
186
+ });
@@ -0,0 +1,112 @@
1
+ // #762 — one-time migration tooling for pre-#497 user event streams.
2
+ //
3
+ // #497 made `user` a systemStream entity: every executor op addresses the
4
+ // stream on SYSTEM_TENANT_ID. Streams created before that live on whichever
5
+ // tenant created them ("scattered"), so post-#497 writes version-conflict
6
+ // against the empty SYSTEM stream. The raw UPDATE documented in the #497
7
+ // changeset breaks as soon as an aggregate has BOTH a legacy stream and
8
+ // post-#497 SYSTEM events (split stream — e.g. a skipOptimisticLock
9
+ // lifecycle write appended v1 on SYSTEM while v1..n live on the old
10
+ // tenant): retenanting then trips events_aggregate_version_uq.
11
+ //
12
+ // This tool merges per aggregate: all user events, ordered by global event
13
+ // id, are renumbered 1..n and retenanted to SYSTEM_TENANT_ID — two-phase
14
+ // version writes (negative interim values) dodge the unique index while the
15
+ // per-aggregate transaction is in flight. Snapshots of migrated aggregates
16
+ // are dropped (version numbering changed; the next snapshotting load
17
+ // recreates them); archived-stream markers move with the stream.
18
+ //
19
+ // Idempotent: the candidate query only matches aggregates that still have
20
+ // non-SYSTEM events. One failing aggregate does not abort the run — the
21
+ // rest of the estate migrates, failures are reported.
22
+ //
23
+ // After a run, rebuild the user projection so read_users.tenant_id reflects
24
+ // the stream move: rebuildProjection("user:projection:user-entity", ...) or
25
+ // the jobs:job:projection-rebuild job.
26
+
27
+ import { asRawClient, transaction } from "@cosmicdrift/kumiko-framework/bun-db";
28
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
29
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
30
+
31
+ export type UserStreamBackfillResult = {
32
+ readonly aggregatesMigrated: number;
33
+ readonly eventsMigrated: number;
34
+ readonly failed: ReadonlyArray<{ readonly aggregateId: string; readonly error: string }>;
35
+ };
36
+
37
+ export async function backfillUserStreamTenants(
38
+ db: DbConnection,
39
+ ): Promise<UserStreamBackfillResult> {
40
+ const candidates = (await asRawClient(db).unsafe(
41
+ `SELECT DISTINCT "aggregate_id" FROM "kumiko_events"
42
+ WHERE "aggregate_type" = 'user' AND "tenant_id" <> $1::uuid`,
43
+ [SYSTEM_TENANT_ID],
44
+ )) as ReadonlyArray<{ aggregate_id: string }>;
45
+
46
+ let aggregatesMigrated = 0;
47
+ let eventsMigrated = 0;
48
+ const failed: Array<{ aggregateId: string; error: string }> = [];
49
+
50
+ for (const { aggregate_id } of candidates) {
51
+ try {
52
+ eventsMigrated += await migrateAggregate(db, aggregate_id);
53
+ aggregatesMigrated++;
54
+ } catch (e) {
55
+ failed.push({
56
+ aggregateId: aggregate_id,
57
+ error: e instanceof Error ? e.message : String(e),
58
+ });
59
+ }
60
+ }
61
+ return { aggregatesMigrated, eventsMigrated, failed };
62
+ }
63
+
64
+ async function migrateAggregate(db: DbConnection, aggregateId: string): Promise<number> {
65
+ return transaction(db, async (tx) => {
66
+ const raw = asRawClient(tx);
67
+ // Global id order = commit-adjacent replay order; merges a split stream
68
+ // (legacy tenant + SYSTEM) into one consistent version sequence.
69
+ const events = (await raw.unsafe(
70
+ `SELECT "id" FROM "kumiko_events"
71
+ WHERE "aggregate_type" = 'user' AND "aggregate_id" = $1::uuid
72
+ ORDER BY "id" ASC
73
+ FOR UPDATE`,
74
+ [aggregateId],
75
+ )) as ReadonlyArray<{ id: bigint | string }>;
76
+
77
+ // Phase 1: negative interim versions — real versions are always >= 1, so
78
+ // nothing can collide with events_aggregate_version_uq mid-migration.
79
+ for (const [i, ev] of events.entries()) {
80
+ await raw.unsafe(
81
+ `UPDATE "kumiko_events" SET "tenant_id" = $1::uuid, "version" = $2 WHERE "id" = $3`,
82
+ [SYSTEM_TENANT_ID, -(i + 1), ev.id],
83
+ );
84
+ }
85
+ // Phase 2: final contiguous 1..n.
86
+ for (const [i, ev] of events.entries()) {
87
+ await raw.unsafe(`UPDATE "kumiko_events" SET "version" = $1 WHERE "id" = $2`, [i + 1, ev.id]);
88
+ }
89
+
90
+ // Version numbering changed → any snapshot of this aggregate is stale.
91
+ await raw.unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = $1::uuid`, [
92
+ aggregateId,
93
+ ]);
94
+
95
+ // Archived markers key on (tenant_id, aggregate_id) — move them with the
96
+ // stream, keeping an existing SYSTEM marker if both exist.
97
+ await raw.unsafe(
98
+ `INSERT INTO "kumiko_archived_streams" ("tenant_id", "aggregate_id", "aggregate_type", "archived_at", "archived_by", "reason")
99
+ SELECT $1::uuid, "aggregate_id", "aggregate_type", "archived_at", "archived_by", "reason"
100
+ FROM "kumiko_archived_streams"
101
+ WHERE "aggregate_id" = $2::uuid AND "tenant_id" <> $1::uuid
102
+ ON CONFLICT DO NOTHING`,
103
+ [SYSTEM_TENANT_ID, aggregateId],
104
+ );
105
+ await raw.unsafe(
106
+ `DELETE FROM "kumiko_archived_streams" WHERE "aggregate_id" = $1::uuid AND "tenant_id" <> $2::uuid`,
107
+ [aggregateId, SYSTEM_TENANT_ID],
108
+ );
109
+
110
+ return events.length;
111
+ });
112
+ }
package/src/user/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { UserCommandSchemas } from "./command-schemas";
2
2
  export { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "./constants";
3
+ export {
4
+ backfillUserStreamTenants,
5
+ type UserStreamBackfillResult,
6
+ } from "./db/queries/stream-tenant-backfill";
3
7
  export { createUserFeature } from "./feature";
4
8
  export type { UserStatus } from "./schema/user";
5
9
  export {
@@ -6,7 +6,7 @@
6
6
  // Beweist end-to-end via echte /api/write-Calls OHNE Auth (anonymousAccess).
7
7
 
8
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
- import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
10
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
11
  import {
12
12
  createTestUser,
@@ -15,7 +15,7 @@ import {
15
15
  testTenantId,
16
16
  unsafeCreateEntityTable,
17
17
  } from "@cosmicdrift/kumiko-framework/stack";
18
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
19
19
  import {
20
20
  createComplianceProfilesFeature,
21
21
  tenantComplianceProfileEntity,
@@ -77,7 +77,7 @@ beforeEach(async () => {
77
77
  });
78
78
 
79
79
  async function seedAlice(status: string = USER_STATUS.Active, email: string = ALICE_EMAIL) {
80
- await insertOne(stack.db, userTable, {
80
+ await seedRow(stack.db, userTable, {
81
81
  id: aliceUser.id,
82
82
  tenantId: tenantA,
83
83
  email,
@@ -343,7 +343,7 @@ describe("anonymous deletion flow — not configured (kein Secret)", () => {
343
343
  await unsafeCreateEntityTable(bareStack.db, userEntity);
344
344
  await unsafeCreateEntityTable(bareStack.db, tenantComplianceProfileEntity);
345
345
  await createEventsTable(bareStack.db);
346
- await insertOne(bareStack.db, userTable, {
346
+ await seedRow(bareStack.db, userTable, {
347
347
  id: aliceUser.id,
348
348
  tenantId: tenantA,
349
349
  email: ALICE_EMAIL,
@@ -1,7 +1,7 @@
1
1
  // S2.U7 — my-audit-log + invalid-attempt-audit + list-download-attempts.
2
2
 
3
3
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
4
- import { asRawClient, insertMany, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
5
5
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
6
6
  import {
7
7
  createTestUser,
@@ -10,7 +10,7 @@ import {
10
10
  testTenantId,
11
11
  unsafeCreateEntityTable,
12
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { resetTestTables, seedRow, seedRows } from "@cosmicdrift/kumiko-framework/testing";
14
14
  import {
15
15
  createComplianceProfilesFeature,
16
16
  tenantComplianceProfileEntity,
@@ -64,7 +64,7 @@ beforeEach(async () => {
64
64
  });
65
65
 
66
66
  async function seedUser(u: typeof alice, email: string): Promise<void> {
67
- await insertOne(stack.db, userTable, {
67
+ await seedRow(stack.db, userTable, {
68
68
  id: u.id,
69
69
  tenantId: u.tenantId,
70
70
  email,
@@ -180,7 +180,7 @@ describe("list-download-attempts (DPO operator-query)", () => {
180
180
  // Direct-INSERT in attempts (simuliert was die download-handler schreiben).
181
181
  const T = await import("@cosmicdrift/kumiko-framework/time");
182
182
  const now = T.getTemporal().Now.instant();
183
- await insertMany(stack.db, downloadAttemptsTable, [
183
+ await seedRows(stack.db, downloadAttemptsTable, [
184
184
  {
185
185
  id: "11111111-1111-4111-8111-111111111111",
186
186
  tenantId: tenantA,
@@ -13,7 +13,7 @@
13
13
  // Alices Forget; Bobs Daten landen NICHT in Alices Export-Bundle.
14
14
 
15
15
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
- import { asRawClient, deleteMany, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
17
  import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
18
18
  import {
19
19
  defineFeature,
@@ -28,7 +28,7 @@ import {
28
28
  unsafeCreateEntityTable,
29
29
  unsafePushTables,
30
30
  } from "@cosmicdrift/kumiko-framework/stack";
31
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
31
+ import { deleteRows, resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
32
32
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
33
33
  import {
34
34
  createComplianceProfilesFeature,
@@ -98,7 +98,7 @@ const exportNotes: UserDataExportHook = async (ctx) => {
98
98
  };
99
99
 
100
100
  const deleteNotes: UserDataDeleteHook = async (ctx, _strategy) => {
101
- await deleteMany(ctx.db, testNotesTable, {
101
+ await deleteRows(ctx.db, testNotesTable, {
102
102
  tenantId: ctx.tenantId,
103
103
  authorId: ctx.userId,
104
104
  });
@@ -182,7 +182,7 @@ async function seedUser(
182
182
  displayName?: string;
183
183
  } = {},
184
184
  ): Promise<void> {
185
- await insertOne(stack.db, userTable, {
185
+ await seedRow(stack.db, userTable, {
186
186
  id,
187
187
  tenantId: TENANT_SYSTEM,
188
188
  email: overrides.email ?? `user-${id}@example.com`,
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
11
  import { randomBytes } from "node:crypto";
12
- import { asRawClient, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
14
14
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
15
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -25,7 +25,7 @@ import {
25
25
  unsafeCreateEntityTable,
26
26
  unsafePushTables,
27
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
28
+ import { resetTestTables, updateRows } from "@cosmicdrift/kumiko-framework/testing";
29
29
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
30
30
  import {
31
31
  createComplianceProfilesFeature,
@@ -302,7 +302,7 @@ describe("download-by-token :: error paths", () => {
302
302
  const longAgo = getTemporal().Instant.fromEpochMilliseconds(
303
303
  Date.now() - 365 * 24 * 60 * 60 * 1000,
304
304
  );
305
- await updateMany(stack.db, exportDownloadTokensTable, { expiresAt: longAgo }, { jobId });
305
+ await updateRows(stack.db, exportDownloadTokensTable, { expiresAt: longAgo }, { jobId });
306
306
 
307
307
  const res = await stack.http.query(
308
308
  "user-data-rights:query:download-by-token",
@@ -316,7 +316,7 @@ describe("download-by-token :: error paths", () => {
316
316
 
317
317
  test("failed Job → 404 download.unavailable", async () => {
318
318
  const { jobId, plainToken } = await seedDoneJobWithToken();
319
- await updateMany(stack.db, exportJobsTable, { status: "failed" }, { id: jobId });
319
+ await updateRows(stack.db, exportJobsTable, { status: "failed" }, { id: jobId });
320
320
 
321
321
  const res = await stack.http.query(
322
322
  "user-data-rights:query:download-by-token",
@@ -330,7 +330,7 @@ describe("download-by-token :: error paths", () => {
330
330
 
331
331
  test("storage cleared → 404 download.expired", async () => {
332
332
  const { jobId, plainToken } = await seedDoneJobWithToken();
333
- await updateMany(stack.db, exportJobsTable, { downloadStorageKey: null }, { id: jobId });
333
+ await updateRows(stack.db, exportJobsTable, { downloadStorageKey: null }, { id: jobId });
334
334
 
335
335
  const res = await stack.http.query(
336
336
  "user-data-rights:query:download-by-token",
@@ -404,7 +404,7 @@ describe("download-by-job :: happy path", () => {
404
404
  // Symmetrisch zum token-Test: gleicher Code-Pfad muss auch im job-
405
405
  // handler 404 + unavailable raus, nicht 500.
406
406
  const { jobId } = await seedDoneJobWithToken();
407
- await updateMany(stack.db, exportJobsTable, { status: "failed" }, { id: jobId });
407
+ await updateRows(stack.db, exportJobsTable, { status: "failed" }, { id: jobId });
408
408
 
409
409
  const res = await stack.http.query(
410
410
  "user-data-rights:query:download-by-job",
@@ -418,7 +418,7 @@ describe("download-by-job :: happy path", () => {
418
418
 
419
419
  test("storage cleared (downloadStorageKey null) → 404 download.expired (job-Pfad)", async () => {
420
420
  const { jobId } = await seedDoneJobWithToken();
421
- await updateMany(stack.db, exportJobsTable, { downloadStorageKey: null }, { id: jobId });
421
+ await updateRows(stack.db, exportJobsTable, { downloadStorageKey: null }, { id: jobId });
422
422
 
423
423
  const res = await stack.http.query(
424
424
  "user-data-rights:query:download-by-job",
@@ -10,19 +10,14 @@
10
10
  // reale Postgres + Drizzle-customType-Codec-Path braucht.
11
11
 
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
- import {
14
- asRawClient,
15
- insertOne,
16
- selectMany,
17
- updateMany,
18
- } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
19
14
  import { extractPgError } from "@cosmicdrift/kumiko-framework/db";
20
15
  import {
21
16
  setupTestStack,
22
17
  type TestStack,
23
18
  unsafeCreateEntityTable,
24
19
  } from "@cosmicdrift/kumiko-framework/stack";
25
- import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
20
+ import { resetTestTables, seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
26
21
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
27
22
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
28
23
  import { createDataRetentionFeature } from "../../data-retention";
@@ -108,7 +103,7 @@ async function insertJob(
108
103
  if (overrides.bytesWritten !== undefined) {
109
104
  values["bytesWritten"] = overrides.bytesWritten;
110
105
  }
111
- await insertOne(stack.db, exportJobsTable, values);
106
+ await seedRow(stack.db, exportJobsTable, values);
112
107
  return id;
113
108
  }
114
109
 
@@ -178,7 +173,7 @@ describe("ExportJob :: Partial-UNIQUE-Index", () => {
178
173
  // Alice pending insert
179
174
  const aliceJobId = await insertJob(ALICE_ID, EXPORT_JOB_STATUS.Pending);
180
175
  // Worker pickt auf — Status flip
181
- await updateMany(
176
+ await updateRows(
182
177
  stack.db,
183
178
  exportJobsTable,
184
179
  { status: EXPORT_JOB_STATUS.Running },
@@ -193,7 +188,7 @@ describe("ExportJob :: Partial-UNIQUE-Index", () => {
193
188
  );
194
189
 
195
190
  // Aber wenn der running-Job fertig wird (done), darf User wieder pending starten
196
- await updateMany(
191
+ await updateRows(
197
192
  stack.db,
198
193
  exportJobsTable,
199
194
  { status: EXPORT_JOB_STATUS.Done },