@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.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 (125) hide show
  1. package/package.json +6 -8
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
@@ -1,71 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import type { AppContext, SaveContext } from "@cosmicdrift/kumiko-framework/engine";
3
- import { bindAutoRevokeFromFeature, createSessionsFeature } from "../feature";
4
-
5
- // The postSave hook is registered unconditionally; the revoker arrives either
6
- // as the explicit constructor option or late-bound by run{Prod,Dev}App via
7
- // bindAutoRevokeOnPasswordChange. These tests pin the binding + precedence
8
- // semantics at the hook level — the full DB sweep is covered by
9
- // password-auto-revoke.integration.test.ts.
10
-
11
- const passwordChange: SaveContext = {
12
- kind: "save",
13
- id: "user-1",
14
- data: {},
15
- changes: { passwordHash: "new-hash" },
16
- previous: {},
17
- isNew: false,
18
- };
19
-
20
- // @cast-boundary test fixture — the hook never touches AppContext
21
- const appContext = {} as unknown as AppContext;
22
-
23
- function userPostSaveHook(feature: ReturnType<typeof createSessionsFeature>) {
24
- const hook = feature.entityHooks?.postSave?.["user"]?.[0];
25
- if (!hook) throw new Error("sessions feature did not register the user postSave hook");
26
- return (ctx: SaveContext) => hook.fn(ctx, appContext);
27
- }
28
-
29
- describe("sessions auto-revoke binding", () => {
30
- test("late-bound revoker fires on password change", async () => {
31
- const revoker = mock(async (_userId: string) => 1);
32
- const feature = createSessionsFeature();
33
-
34
- const bind = bindAutoRevokeFromFeature(feature);
35
- expect(bind).toBeDefined();
36
- bind?.(revoker);
37
-
38
- await userPostSaveHook(feature)(passwordChange);
39
- expect(revoker).toHaveBeenCalledTimes(1);
40
- expect(revoker).toHaveBeenCalledWith("user-1");
41
- });
42
-
43
- test("unbound hook is a silent no-op", async () => {
44
- const feature = createSessionsFeature();
45
- // must resolve without throwing — stateless-JWT deployments have no revoker
46
- const result = await userPostSaveHook(feature)(passwordChange);
47
- expect(result).toBeUndefined();
48
- });
49
-
50
- test("skips isNew and non-passwordHash changes", async () => {
51
- const revoker = mock(async (_userId: string) => 1);
52
- const feature = createSessionsFeature();
53
- bindAutoRevokeFromFeature(feature)?.(revoker);
54
-
55
- await userPostSaveHook(feature)({ ...passwordChange, isNew: true });
56
- await userPostSaveHook(feature)({ ...passwordChange, changes: { displayName: "x" } });
57
- expect(revoker).not.toHaveBeenCalled();
58
- });
59
-
60
- test("explicit constructor option wins over the runtime binding", async () => {
61
- const explicit = mock(async (_userId: string) => 1);
62
- const lateBound = mock(async (_userId: string) => 1);
63
- const feature = createSessionsFeature({ autoRevokeOnPasswordChange: explicit });
64
-
65
- bindAutoRevokeFromFeature(feature)?.(lateBound);
66
- await userPostSaveHook(feature)(passwordChange);
67
-
68
- expect(explicit).toHaveBeenCalledWith("user-1");
69
- expect(lateBound).not.toHaveBeenCalled();
70
- });
71
- });
@@ -1,89 +0,0 @@
1
- // Shared loop for chunked, idempotent row migrations (secrets KEK rotation,
2
- // config re-encrypt, future entity-field re-encrypts). Owns the operational
3
- // mechanics — abort signal, deadline, failure circuit-breaker, batch
4
- // accounting — while the caller owns the domain: what a batch is and how a
5
- // single row migrates.
6
- //
7
- // Contract notes:
8
- // - nextBatch() returning an empty array ends the run ("done"). Callers
9
- // whose batch query re-evaluates (WHERE kek_version != current) converge
10
- // naturally; full-scan callers return their one batch, then [].
11
- // - migrateRow returns "migrated" | "skipped" (already current, lost a
12
- // version_conflict race) | "failed" (counted against maxFailures);
13
- // throws count as "failed" too and go through onRowError.
14
- // - Rows that keep failing MAY be re-served by a re-evaluating nextBatch —
15
- // the circuit-breaker (maxFailures) is what terminates that loop, same
16
- // semantics the secrets rotate job always had.
17
-
18
- export type MigrationRowOutcome = "migrated" | "skipped" | "failed";
19
-
20
- export type ChunkedMigrationStopReason = "done" | "timeout" | "signal" | "too_many_failures";
21
-
22
- export type ChunkedMigrationResult = {
23
- readonly migrated: number;
24
- readonly skipped: number;
25
- readonly failed: number;
26
- readonly batchesProcessed: number;
27
- readonly stoppedReason: ChunkedMigrationStopReason;
28
- };
29
-
30
- export type ChunkedMigrationOptions<Row> = {
31
- readonly nextBatch: () => Promise<readonly Row[]>;
32
- readonly migrateRow: (row: Row) => Promise<MigrationRowOutcome>;
33
- readonly maxFailures: number;
34
- // Epoch ms; Number.POSITIVE_INFINITY for no time bound.
35
- readonly deadlineAt: number;
36
- readonly signal?: AbortSignal | undefined;
37
- readonly onRowError?: (row: Row, err: unknown) => void;
38
- };
39
-
40
- export async function runChunkedMigration<Row>(
41
- opts: ChunkedMigrationOptions<Row>,
42
- ): Promise<ChunkedMigrationResult> {
43
- let migrated = 0;
44
- let skipped = 0;
45
- let failed = 0;
46
- let batchesProcessed = 0;
47
- let stoppedReason: ChunkedMigrationStopReason = "done";
48
-
49
- outer: while (true) {
50
- if (opts.signal?.aborted) {
51
- stoppedReason = "signal";
52
- break;
53
- }
54
- if (Date.now() >= opts.deadlineAt) {
55
- stoppedReason = "timeout";
56
- break;
57
- }
58
-
59
- const batch = await opts.nextBatch();
60
- if (batch.length === 0) break;
61
-
62
- batchesProcessed++;
63
-
64
- if (failed >= opts.maxFailures) {
65
- stoppedReason = "too_many_failures";
66
- break;
67
- }
68
-
69
- for (const row of batch) {
70
- if (failed >= opts.maxFailures) {
71
- stoppedReason = "too_many_failures";
72
- break outer;
73
- }
74
- let outcome: MigrationRowOutcome;
75
- try {
76
- outcome = await opts.migrateRow(row);
77
- } catch (err) {
78
- opts.onRowError?.(row, err);
79
- failed++;
80
- continue;
81
- }
82
- if (outcome === "migrated") migrated++;
83
- else if (outcome === "skipped") skipped++;
84
- else failed++;
85
- }
86
- }
87
-
88
- return { migrated, skipped, failed, batchesProcessed, stoppedReason };
89
- }
@@ -1,7 +0,0 @@
1
- export {
2
- type ChunkedMigrationOptions,
3
- type ChunkedMigrationResult,
4
- type ChunkedMigrationStopReason,
5
- type MigrationRowOutcome,
6
- runChunkedMigration,
7
- } from "./chunked-entity-migration";
@@ -1,34 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { assignTagPayloadSchema } from "../schemas";
3
-
4
- // 456/3: tagAssignmentAggregateId joins tenantId/tagId/entityType/entityId
5
- // with "|" to derive the stream id — a literal "|" in entityType/entityId
6
- // could shift tuple boundaries and collide with an unrelated combination.
7
- describe("assignTagPayloadSchema — no pipe in entityType/entityId", () => {
8
- test("accepts normal values", () => {
9
- const parsed = assignTagPayloadSchema.safeParse({
10
- tagId: "tag-1",
11
- entityType: "credit",
12
- entityId: "entity-1",
13
- });
14
- expect(parsed.success).toBe(true);
15
- });
16
-
17
- test("rejects a pipe in entityType", () => {
18
- const parsed = assignTagPayloadSchema.safeParse({
19
- tagId: "tag-1",
20
- entityType: "credit|forged",
21
- entityId: "entity-1",
22
- });
23
- expect(parsed.success).toBe(false);
24
- });
25
-
26
- test("rejects a pipe in entityId", () => {
27
- const parsed = assignTagPayloadSchema.safeParse({
28
- tagId: "tag-1",
29
- entityType: "credit",
30
- entityId: "entity|forged",
31
- });
32
- expect(parsed.success).toBe(false);
33
- });
34
- });
@@ -1,64 +0,0 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
2
- import {
3
- createStaticLocaleResolver,
4
- LocaleProvider,
5
- PrimitivesProvider,
6
- } from "@cosmicdrift/kumiko-renderer";
7
- import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
- import { fireEvent, render, screen } from "@testing-library/react";
9
- import type { ReactNode } from "react";
10
- import { defaultTranslations } from "../i18n";
11
- import { TagManager } from "../tag-manager";
12
-
13
- type TagRow = { id: string; name: string; color?: string; scope?: string; version: number };
14
-
15
- let catalogRows: readonly TagRow[] = [];
16
-
17
- beforeEach(() => {
18
- catalogRows = [{ id: "t1", name: "urgent", color: "#ef4444", version: 1 }];
19
- });
20
-
21
- const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
22
-
23
- const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
24
- mock.module("@cosmicdrift/kumiko-renderer", () => ({
25
- ...actual_renderer,
26
- useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
27
- useQuery: mock(() => ({
28
- data: { rows: catalogRows },
29
- loading: false,
30
- error: null,
31
- refetch: mock(),
32
- })),
33
- }));
34
-
35
- function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
36
- return (
37
- <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
38
- <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
39
- </LocaleProvider>
40
- );
41
- }
42
-
43
- // 695/4: saveEdit silently no-ops on an empty trimmed name — the Save button
44
- // must reflect that in its disabled state, not just in the click handler.
45
- describe("TagManager — edit Save button", () => {
46
- test("Save is disabled once the name is cleared to empty/whitespace", () => {
47
- render(
48
- <Wrapper>
49
- <TagManager />
50
- </Wrapper>,
51
- );
52
- fireEvent.click(screen.getByTestId("tag-manager-edit-btn-t1"));
53
-
54
- const save = screen.getByTestId("tag-manager-save-t1") as HTMLButtonElement;
55
- expect(save.disabled).toBe(false);
56
-
57
- const nameInput = document.getElementById("tag-edit-name-t1") as HTMLInputElement;
58
- fireEvent.change(nameInput, { target: { value: " " } });
59
- expect(save.disabled).toBe(true);
60
-
61
- fireEvent.change(nameInput, { target: { value: "renamed" } });
62
- expect(save.disabled).toBe(false);
63
- });
64
- });
@@ -1,186 +0,0 @@
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
- });
@@ -1,112 +0,0 @@
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
- }