@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.108.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 (115) 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 +204 -0
  48. package/src/personal-access-tokens/constants.ts +33 -0
  49. package/src/personal-access-tokens/feature.ts +78 -0
  50. package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
  51. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  52. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  53. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  54. package/src/personal-access-tokens/hash.ts +17 -0
  55. package/src/personal-access-tokens/index.ts +45 -0
  56. package/src/personal-access-tokens/resolver.ts +58 -0
  57. package/src/personal-access-tokens/roles.ts +29 -0
  58. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  59. package/src/personal-access-tokens/scopes.ts +22 -0
  60. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  61. package/src/personal-access-tokens/web/i18n.ts +45 -0
  62. package/src/personal-access-tokens/web/index.ts +7 -0
  63. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
  64. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  65. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  66. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  67. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  68. package/src/sessions/schema/user-session.ts +8 -2
  69. package/src/sessions/session-callbacks.ts +9 -3
  70. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  71. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  72. package/src/tags/handlers/assign-tag.write.ts +13 -3
  73. package/src/tags/schemas.ts +10 -4
  74. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  75. package/src/template-resolver/testing.ts +4 -3
  76. package/src/tier-engine/feature.ts +22 -10
  77. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  78. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  79. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  80. package/src/user/index.ts +4 -0
  81. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  82. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  85. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  86. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  87. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  88. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  89. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  90. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  91. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  92. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  93. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  95. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  96. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  97. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  98. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  99. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  100. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  101. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  102. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  103. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  104. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  105. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  106. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  107. package/src/user-data-rights/web/i18n.ts +0 -10
  108. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  109. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  110. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  111. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  112. package/src/user-profile/handlers/change-email.write.ts +11 -8
  113. package/src/user-profile/web/profile-screen.tsx +10 -8
  114. package/src/auth-email-password/stream-tenant.ts +0 -31
  115. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -12,15 +12,13 @@ import { DELIVERY_ATTEMPT_EVENT } from "./constants";
12
12
  import { deliveryAttemptSchema } from "./events";
13
13
  import type { DeliveryLogEntry } from "./types";
14
14
 
15
- // Append one delivery-attempt event to `attemptId`'s stream and run the inline
16
- // `delivery-log` projection in the same write (low-level append() does not
17
- // auto-fire projections — only the dispatcher/executor paths do). getStreamVersion
18
- // returns 0 for a fresh stream, so the same call serves both the first event
19
- // (queued, or a single-shot terminal) and follow-ups (queued → sent/failed).
20
- export async function appendAttemptEvent(
15
+ // Shared append + inline-projection write (low-level append() does not
16
+ // auto-fire projections only the dispatcher/executor paths do).
17
+ async function writeAttemptEvent(
21
18
  db: DbConnection,
22
19
  registry: Registry,
23
20
  attemptId: string,
21
+ expectedVersion: number,
24
22
  entry: DeliveryLogEntry,
25
23
  ): Promise<void> {
26
24
  const { tenantId, ...rest } = entry;
@@ -28,7 +26,6 @@ export async function appendAttemptEvent(
28
26
  // service/job + feature-registration fails loudly here instead of landing on
29
27
  // the events-table and crashing a consumer later.
30
28
  const payload = deliveryAttemptSchema.parse(rest);
31
- const expectedVersion = await getStreamVersion(db, attemptId, tenantId);
32
29
  const stored = await append(db, {
33
30
  aggregateId: attemptId,
34
31
  aggregateType: "deliveryAttempt",
@@ -41,14 +38,29 @@ export async function appendAttemptEvent(
41
38
  await runProjectionsForEvent(stored, registry, db);
42
39
  }
43
40
 
44
- // Single-shot terminal log (inline channels, skips, idempotency dups): fresh
45
- // aggregate id, one event.
41
+ // Append one delivery-attempt event to `attemptId`'s stream for the
42
+ // MULTI-EVENT path (queued → sent/failed follow-ups), where the stream may
43
+ // already carry the "queued" event and the real current version must be
44
+ // looked up.
45
+ export async function appendAttemptEvent(
46
+ db: DbConnection,
47
+ registry: Registry,
48
+ attemptId: string,
49
+ entry: DeliveryLogEntry,
50
+ ): Promise<void> {
51
+ const expectedVersion = await getStreamVersion(db, attemptId, entry.tenantId);
52
+ await writeAttemptEvent(db, registry, attemptId, expectedVersion, entry);
53
+ }
54
+
55
+ // Single-shot terminal log (inline channels, skips, idempotency dups): a
56
+ // FRESH aggregate id is guaranteed version 0 — skip the getStreamVersion
57
+ // round-trip that appendAttemptEvent needs for its follow-up-event case.
46
58
  export async function logAttempt(
47
59
  db: DbConnection,
48
60
  registry: Registry,
49
61
  entry: DeliveryLogEntry,
50
62
  ): Promise<string> {
51
63
  const attemptId = generateId();
52
- await appendAttemptEvent(db, registry, attemptId, entry);
64
+ await writeAttemptEvent(db, registry, attemptId, 0, entry);
53
65
  return attemptId;
54
66
  }
@@ -1,5 +1,5 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
- import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import {
4
4
  buildEntityTable,
5
5
  createEventStoreExecutor,
@@ -25,7 +25,7 @@ import {
25
25
  unsafeCreateEntityTable,
26
26
  unsafePushTables,
27
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
- import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
28
+ import { createLateBoundHolder, seedRow } from "@cosmicdrift/kumiko-framework/testing";
29
29
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
30
30
  import { Temporal } from "temporal-polyfill";
31
31
  import { z } from "zod";
@@ -93,7 +93,7 @@ function widgetAuditFeature(): FeatureDefinition {
93
93
  if (!ctx.db) return;
94
94
  const name = result.changes!["name"] as string | undefined;
95
95
  if (!name) return;
96
- await insertOne(ctx.db, widgetAuditTable, {
96
+ await seedRow(ctx.db, widgetAuditTable, {
97
97
  id: generateId(),
98
98
  widgetName: name,
99
99
  version: 1,
@@ -253,7 +253,7 @@ describe("feature-toggles runtime cache", () => {
253
253
  });
254
254
 
255
255
  test("refresh() re-reads the DB snapshot", async () => {
256
- await insertOne(stack.db, globalFeatureStateTable, {
256
+ await seedRow(stack.db, globalFeatureStateTable, {
257
257
  featureName: "widget",
258
258
  enabled: false,
259
259
  version: 1,
@@ -192,6 +192,32 @@ describe("folders integration — single-membership set / move / clear", () => {
192
192
  });
193
193
  });
194
194
 
195
+ describe("folders integration — delete blocks on live assignments (658/1)", () => {
196
+ test("deleting a folder that still holds an assignment is rejected, row survives", async () => {
197
+ const f = await createFolder("occupied");
198
+ await setFolder(f, "credit-occupied");
199
+
200
+ const err = await stack.http.writeErr(FoldersHandlers.deleteFolder, { id: f }, admin);
201
+ expect(err.httpStatus).toBe(422);
202
+ expect((await listFolders()).some((row) => row["id"] === f)).toBe(true);
203
+ });
204
+
205
+ test("deleting an empty folder succeeds", async () => {
206
+ const f = await createFolder("empty");
207
+ await stack.http.writeOk(FoldersHandlers.deleteFolder, { id: f }, admin);
208
+ expect((await listFolders()).some((row) => row["id"] === f)).toBe(false);
209
+ });
210
+
211
+ test("clearing the assignment first unblocks the delete", async () => {
212
+ const f = await createFolder("occupied-then-cleared");
213
+ await setFolder(f, "credit-occupied-2");
214
+ await clearFolder("credit-occupied-2");
215
+
216
+ await stack.http.writeOk(FoldersHandlers.deleteFolder, { id: f }, admin);
217
+ expect((await listFolders()).some((row) => row["id"] === f)).toBe(false);
218
+ });
219
+ });
220
+
195
221
  describe("folders integration — rename via folder:update", () => {
196
222
  test("update renames, optimistic-locked, bumps version", async () => {
197
223
  const id = await createFolder("Alt");
@@ -18,7 +18,6 @@
18
18
  import {
19
19
  type AccessRule,
20
20
  defineEntityCreateHandler,
21
- defineEntityDeleteHandler,
22
21
  defineEntityDetailHandler,
23
22
  defineEntityListHandler,
24
23
  defineEntityUpdateHandler,
@@ -28,6 +27,7 @@ import {
28
27
  import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants";
29
28
  import { folderAssignmentEntity, folderEntity } from "./entity";
30
29
  import { createClearFolderHandler } from "./handlers/clear-folder.write";
30
+ import { createDeleteFolderHandler } from "./handlers/delete-folder.write";
31
31
  import { createSetFolderHandler } from "./handlers/set-folder.write";
32
32
 
33
33
  // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
@@ -60,7 +60,9 @@ function registerFolders(
60
60
  // later stage, reparent: it accepts changes.parentId, optimistic-locked).
61
61
  r.writeHandler(defineEntityCreateHandler("folder", folderEntity, { access }));
62
62
  r.writeHandler(defineEntityUpdateHandler("folder", folderEntity, { access }));
63
- r.writeHandler(defineEntityDeleteHandler("folder", folderEntity, { access }));
63
+ // Custom, not defineEntityDeleteHandler: blocks the delete when folder-
64
+ // assignments still point at this folder (658/1) — see delete-folder.write.ts.
65
+ r.writeHandler(createDeleteFolderHandler(access));
64
66
  r.queryHandler(defineEntityListHandler("folder", folderEntity, { access }));
65
67
  r.queryHandler(defineEntityDetailHandler("folder", folderEntity, { access }));
66
68
 
@@ -0,0 +1,40 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { z } from "zod";
4
+ import { DEFAULT_FOLDER_ACCESS } from "../constants";
5
+ import { folderAssignmentExecutor, folderExecutor } from "../executor";
6
+
7
+ const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
8
+
9
+ // delete-folder — the UI's folder-manager only blocks deleting a folder that
10
+ // still has CHILD folders; it never checks folder-assignments. A leaf folder
11
+ // holding entities could be deleted, leaving its folder-assignment rows
12
+ // pointing at a folderId that no longer exists (no cascade, no cleanup path —
13
+ // see 658/1). Block server-side instead: this is the same "referential
14
+ // integrity has no FK here" reasoning set-folder.write.ts already applies to
15
+ // folderId on assignment-creation.
16
+ export function createDeleteFolderHandler(
17
+ access: AccessRule = DEFAULT_FOLDER_ACCESS,
18
+ ): WriteHandlerDef {
19
+ return {
20
+ name: "folder:delete",
21
+ schema: deleteFolderPayloadSchema,
22
+ access,
23
+ handler: async (event, ctx) => {
24
+ const payload = event.payload as { id: string }; // @cast-boundary engine-payload
25
+ const assigned = await folderAssignmentExecutor.list(
26
+ { filter: { field: "folderId", op: "eq", value: payload.id }, limit: 1 },
27
+ event.user,
28
+ ctx.db,
29
+ );
30
+ if (assigned.rows.length > 0) {
31
+ return writeFailure(
32
+ new UnprocessableError("folder_has_assignments", { details: { folderId: payload.id } }),
33
+ );
34
+ }
35
+ return folderExecutor.delete({ id: payload.id }, event.user, ctx.db);
36
+ },
37
+ };
38
+ }
39
+
40
+ export const deleteFolderHandler: WriteHandlerDef = createDeleteFolderHandler();
@@ -248,6 +248,30 @@ describe("FolderManager filing mode", () => {
248
248
  expect(row.className).not.toContain("ring-primary/40");
249
249
  });
250
250
 
251
+ test("a second drop while a reassign is still in flight is ignored (busy guard)", async () => {
252
+ folderRows = [
253
+ { id: "f1", name: "A", parentId: null, version: 1 },
254
+ { id: "f2", name: "B", parentId: null, version: 1 },
255
+ ];
256
+ let resolveWrite: (v: { isSuccess: true; data: undefined }) => void = () => {};
257
+ dispatchSpy.mockImplementationOnce(
258
+ () =>
259
+ new Promise((resolve) => {
260
+ resolveWrite = resolve;
261
+ }),
262
+ );
263
+ render(
264
+ <Wrapper>
265
+ <FolderManager filing={filingWith(() => {})} />
266
+ </Wrapper>,
267
+ );
268
+ dropLeaf("folder-node-f2", "c-1");
269
+ // Second drop lands WHILE the first write is still pending.
270
+ dropLeaf("folder-node-f2", "c-1");
271
+ resolveWrite({ isSuccess: true, data: undefined });
272
+ await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
273
+ });
274
+
251
275
  test("the unfiled bucket stays a drop target even when nothing is currently unfiled", () => {
252
276
  folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
253
277
  const allFiled: FolderFiling = {
@@ -238,21 +238,26 @@ export function FolderManager({
238
238
  // No-op if it already lives there (both handlers are idempotent, but a re-set
239
239
  // would burn a redundant event).
240
240
  const reassign = async (entityId: string, folderId: string | null): Promise<void> => {
241
- if (filing === undefined) return;
241
+ if (filing === undefined || busy) return;
242
242
  if ((currentFolderByEntity.get(entityId) ?? null) === folderId) return;
243
+ setBusy(true);
243
244
  setErrorKey(null);
244
- const entityType = typeByEntity.get(entityId) ?? filing.entityType;
245
- const ok =
246
- folderId === null
247
- ? await writeOk(FoldersHandlers.clearFolder, { entityType, entityId })
248
- : await writeOk(FoldersHandlers.setFolder, {
249
- folderId,
250
- entityType,
251
- entityId,
252
- });
253
- if (ok) {
254
- await catalog.refetch();
255
- await filing.onReassigned();
245
+ try {
246
+ const entityType = typeByEntity.get(entityId) ?? filing.entityType;
247
+ const ok =
248
+ folderId === null
249
+ ? await writeOk(FoldersHandlers.clearFolder, { entityType, entityId })
250
+ : await writeOk(FoldersHandlers.setFolder, {
251
+ folderId,
252
+ entityType,
253
+ entityId,
254
+ });
255
+ if (ok) {
256
+ await catalog.refetch();
257
+ await filing.onReassigned();
258
+ }
259
+ } finally {
260
+ setBusy(false);
256
261
  }
257
262
  };
258
263
 
@@ -18,7 +18,6 @@ export const defaultTranslations: TranslationsByLocale = {
18
18
  "folders.section.working": "Speichert…",
19
19
 
20
20
  "folders.manager.loading": "Lädt…",
21
- "folders.manager.empty": "Noch keine Ordner.",
22
21
  "folders.manager.newRoot": "Neuer Ordner",
23
22
  "folders.manager.add": "Anlegen",
24
23
  "folders.manager.addChild": "Unterordner",
@@ -44,7 +43,6 @@ export const defaultTranslations: TranslationsByLocale = {
44
43
  "folders.section.working": "Saving…",
45
44
 
46
45
  "folders.manager.loading": "Loading…",
47
- "folders.manager.empty": "No folders yet.",
48
46
  "folders.manager.newRoot": "New folder",
49
47
  "folders.manager.add": "Create",
50
48
  "folders.manager.addChild": "Subfolder",
@@ -0,0 +1,69 @@
1
+ // folderAssignmentExportHook — GDPR export must not surface cleared
2
+ // (soft-deleted) folder-assignment rows. Regression (658/3): the hook read
3
+ // via selectMany without an isDeleted filter — folderAssignmentEntity is
4
+ // softDelete: true, so a cleared assignment (clear-folder) still had a row
5
+ // in read_folder_assignments and rode along in the export.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
+ import {
10
+ createTestUser,
11
+ setupTestStack,
12
+ type TestStack,
13
+ unsafeCreateEntityTable,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { createFoldersFeature, FoldersHandlers } from "../../folders";
16
+ import { folderAssignmentEntity, folderEntity } from "../../folders/entity";
17
+ import { folderAssignmentExportHook } from "../hooks";
18
+
19
+ let stack: TestStack;
20
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
21
+
22
+ beforeAll(async () => {
23
+ stack = await setupTestStack({ features: [createFoldersFeature()] });
24
+ await unsafeCreateEntityTable(stack.db, folderEntity);
25
+ await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
26
+ await createEventsTable(stack.db);
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await stack.cleanup();
31
+ });
32
+
33
+ describe("folderAssignmentExportHook", () => {
34
+ test("excludes a cleared (soft-deleted) assignment from the export", async () => {
35
+ const folder = await stack.http.writeOk<{ id: string }>(
36
+ FoldersHandlers.createFolder,
37
+ { name: "Active loans" },
38
+ admin,
39
+ );
40
+
41
+ await stack.http.writeOk(
42
+ FoldersHandlers.setFolder,
43
+ { folderId: folder.id, entityType: "credit", entityId: "credit-kept" },
44
+ admin,
45
+ );
46
+ await stack.http.writeOk(
47
+ FoldersHandlers.setFolder,
48
+ { folderId: folder.id, entityType: "credit", entityId: "credit-cleared" },
49
+ admin,
50
+ );
51
+ // clear-folder soft-deletes the assignment row (isDeleted: true).
52
+ await stack.http.writeOk(
53
+ FoldersHandlers.clearFolder,
54
+ { entityType: "credit", entityId: "credit-cleared" },
55
+ admin,
56
+ );
57
+
58
+ const snippet = await folderAssignmentExportHook({
59
+ db: stack.db,
60
+ tenantId: admin.tenantId,
61
+ userId: admin.id,
62
+ });
63
+
64
+ expect(snippet).not.toBeNull();
65
+ const entityIds = (snippet?.rows ?? []).map((r) => r["entityId"]);
66
+ expect(entityIds).toContain("credit-kept");
67
+ expect(entityIds).not.toContain("credit-cleared");
68
+ });
69
+ });
@@ -4,16 +4,21 @@
4
4
  // Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
5
5
  // (a folder name is tenant data, not per-user PII).
6
6
 
7
- import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
8
9
  import {
9
10
  createEntityExecutor,
11
+ createSystemUser,
10
12
  type UserDataDeleteHook,
11
13
  type UserDataExportHook,
12
14
  } from "@cosmicdrift/kumiko-framework/engine";
13
15
  import { folderAssignmentEntity, folderEntity } from "../folders";
14
16
 
15
- const { table: folderTable } = createEntityExecutor("folder", folderEntity);
16
- const { table: folderAssignmentTable } = createEntityExecutor(
17
+ const { table: folderTable, executor: folderExecutor } = createEntityExecutor(
18
+ "folder",
19
+ folderEntity,
20
+ );
21
+ const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = createEntityExecutor(
17
22
  "folder-assignment",
18
23
  folderAssignmentEntity,
19
24
  );
@@ -30,8 +35,12 @@ export const folderExportHook: UserDataExportHook = async (ctx) => {
30
35
  };
31
36
 
32
37
  export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
38
+ // folderAssignmentEntity is softDelete: true — a cleared assignment
39
+ // (isDeleted: true) is a removed folder membership, not something the GDPR
40
+ // export should still surface as current data.
33
41
  const rows = await selectMany<Record<string, unknown>>(ctx.db, folderAssignmentTable, {
34
42
  tenantId: ctx.tenantId,
43
+ isDeleted: false,
35
44
  });
36
45
  if (rows.length === 0) return null;
37
46
  return { entity: "folder-assignment", rows };
@@ -43,16 +52,30 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
43
52
  // tenant would destroy co-members' folders. anonymize is also a no-op — folder
44
53
  // rows carry no person-link to strip (name is tenant data, not PII), so a
45
54
  // retention hold simply keeps them.
46
- function tenantScopedDelete(table: typeof folderTable): UserDataDeleteHook {
55
+ function tenantScopedDelete(
56
+ table: typeof folderTable,
57
+ executor: EventStoreExecutor,
58
+ ): UserDataDeleteHook {
47
59
  return async (ctx, strategy) => {
48
60
  // skip: multi-user tenant — a tenant-wide delete would destroy co-members' folders
49
61
  if (ctx.tenantModel !== "single-user") return;
50
62
  // skip: anonymize is a no-op — folder rows carry no per-user PII to strip
51
63
  if (strategy === "anonymize") return;
52
- await deleteMany(ctx.db, table, { tenantId: ctx.tenantId });
64
+ // Per-row via the executor (event -> rebuild-safe): a bulk deleteMany is
65
+ // eventless, so a projection rebuild resurrects the rows. Bounded — forget
66
+ // only fires for single-user tenants.
67
+ const systemUser = createSystemUser(ctx.tenantId);
68
+ // The executor needs a TenantDb (loadById → db.fetchOne), not the raw ctx.db.
69
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
70
+ const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
71
+ for (const row of rows) {
72
+ await executor.delete({ id: row.id }, systemUser, tdb);
73
+ }
53
74
  };
54
75
  }
55
76
 
56
- export const folderDeleteHook: UserDataDeleteHook = tenantScopedDelete(folderTable);
57
- export const folderAssignmentDeleteHook: UserDataDeleteHook =
58
- tenantScopedDelete(folderAssignmentTable);
77
+ export const folderDeleteHook: UserDataDeleteHook = tenantScopedDelete(folderTable, folderExecutor);
78
+ export const folderAssignmentDeleteHook: UserDataDeleteHook = tenantScopedDelete(
79
+ folderAssignmentTable,
80
+ folderAssignmentExecutor,
81
+ );
@@ -16,10 +16,10 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
16
16
  r.describe(
17
17
  "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `folders` feature's `folder` + `folder-assignment` entities. Mounts the EXT_USER_DATA export + delete hooks so a tenant's folder tree and its entity-to-folder assignments are included in the user-data export bundle and erased on a tenant-scoped forget (single-user tenants only; multi-user + anonymize are no-ops since folder rows carry no per-user PII). Kept separate from `folders` so folder consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `folders`.",
18
18
  );
19
- // user-data-rights ist die harte Abhängigkeit (EXT_USER_DATA-Host). `folders` ist
20
- // OPTIONAL: ist es toggleable(default=false) gemountet (z.B. per-Tenant via Tier),
21
- // würde ein hartes r.requires eine effectively disabled"-Boot-Warnung werfen,
22
- // obwohl die folder-Entities existieren und die Hooks korrekt greifen.
19
+ // user-data-rights is the hard dependency (EXT_USER_DATA host). `folders` is
20
+ // OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
21
+ // tier), a hard r.requires would throw an "effectively disabled" boot
22
+ // warning even though the folder entities exist and the hooks work fine.
23
23
  r.requires("user-data-rights");
24
24
  r.optionalRequires("folders");
25
25
  r.useExtension(EXT_USER_DATA, "folder", {
@@ -57,9 +57,9 @@ export function createJobsFeature(): FeatureDefinition {
57
57
  table: jobRunsTable,
58
58
  apply: {
59
59
  [JOB_RUN_STARTED_EVENT]: defineApply<z.infer<typeof runStartedSchema>>(
60
- async (event, tx) => {
60
+ async (event, tx, table) => {
61
61
  const p = event.payload;
62
- await insertOne(tx, jobRunsTable, {
62
+ await insertOne(tx, table, {
63
63
  id: event.aggregateId,
64
64
  tenantId: event.tenantId,
65
65
  version: event.version,
@@ -76,11 +76,11 @@ export function createJobsFeature(): FeatureDefinition {
76
76
  },
77
77
  ),
78
78
  [JOB_RUN_COMPLETED_EVENT]: defineApply<z.infer<typeof runCompletedSchema>>(
79
- async (event, tx) => {
79
+ async (event, tx, table) => {
80
80
  const p = event.payload;
81
81
  await updateMany(
82
82
  tx,
83
- jobRunsTable,
83
+ table,
84
84
  {
85
85
  status: "completed",
86
86
  duration: p.duration,
@@ -93,23 +93,25 @@ export function createJobsFeature(): FeatureDefinition {
93
93
  );
94
94
  },
95
95
  ),
96
- [JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(async (event, tx) => {
97
- const p = event.payload;
98
- await updateMany(
99
- tx,
100
- jobRunsTable,
101
- {
102
- status: "failed",
103
- error: p.error,
104
- duration: p.duration,
105
- finishedAt: Temporal.Instant.from(p.finishedAt),
106
- version: event.version,
107
- modifiedAt: event.createdAt,
108
- modifiedById: event.metadata?.userId ?? "system",
109
- },
110
- { id: event.aggregateId },
111
- );
112
- }),
96
+ [JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(
97
+ async (event, tx, table) => {
98
+ const p = event.payload;
99
+ await updateMany(
100
+ tx,
101
+ table,
102
+ {
103
+ status: "failed",
104
+ error: p.error,
105
+ duration: p.duration,
106
+ finishedAt: Temporal.Instant.from(p.finishedAt),
107
+ version: event.version,
108
+ modifiedAt: event.createdAt,
109
+ modifiedById: event.metadata?.userId ?? "system",
110
+ },
111
+ { id: event.aggregateId },
112
+ );
113
+ },
114
+ ),
113
115
  },
114
116
  });
115
117
 
@@ -6,10 +6,15 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
7
7
  import { z } from "zod";
8
8
 
9
- export const projectionRebuildPayloadSchema = z.object({ projection: z.string().min(1) });
9
+ export const projectionRebuildPayloadSchema = z.object({
10
+ projection: z.string().min(1),
11
+ // Quarantine mode (#760): skip + dead-letter poison events instead of
12
+ // failing the whole rebuild. Operator opt-in per run.
13
+ skipApplyErrors: z.boolean().optional(),
14
+ });
10
15
 
11
16
  export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
12
- const { projection } = projectionRebuildPayloadSchema.parse(rawPayload);
17
+ const { projection, skipApplyErrors } = projectionRebuildPayloadSchema.parse(rawPayload);
13
18
  if (!ctx.db) {
14
19
  throw new InternalError({
15
20
  message:
@@ -23,8 +28,13 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
23
28
  });
24
29
  }
25
30
  const db = ctx.db as DbConnection; // @cast-boundary db-operator
26
- const result = await rebuildProjection(projection, { db, registry: ctx.registry });
31
+ const result = await rebuildProjection(projection, {
32
+ db,
33
+ registry: ctx.registry,
34
+ ...(skipApplyErrors === true && { errorPolicy: { skipApplyErrors: true } }),
35
+ });
27
36
  ctx.log?.info?.(
28
- `[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events in ${result.durationMs}ms`,
37
+ `[jobs:projection-rebuild] rebuilt ${projection}: ${result.eventsProcessed} events` +
38
+ `${result.eventsSkipped > 0 ? ` (${result.eventsSkipped} quarantined)` : ""} in ${result.durationMs}ms`,
29
39
  );
30
40
  };
@@ -188,6 +188,50 @@ describe("ledger integration — reverse-transaction (Storno)", () => {
188
188
  // Original + Storno cancel → books net to zero.
189
189
  expect(trialBalance(rows)).toBe(0);
190
190
  });
191
+
192
+ test("reversing a draft transaction is rejected (only posted entries count)", async () => {
193
+ const bank = await createAccount("Bank Draft", "asset");
194
+ const rent = await createAccount("Draft Income", "income");
195
+ const draft = await stack.http.writeOk<{ id: string }>(
196
+ LedgerHandlers.createTransaction,
197
+ {
198
+ date: "2026-01-15",
199
+ description: "Draft entry",
200
+ status: "draft",
201
+ lines: [
202
+ { accountId: bank, amount: 5000 },
203
+ { accountId: rent, amount: -5000 },
204
+ ],
205
+ },
206
+ admin,
207
+ );
208
+
209
+ const err = await stack.http.writeErr(
210
+ LedgerHandlers.reverseTransaction,
211
+ { id: draft.id },
212
+ admin,
213
+ );
214
+ expect(err.code).toBe("unprocessable");
215
+
216
+ // No Storno booked — the draft is the only row.
217
+ expect(await listTransactions()).toHaveLength(1);
218
+ });
219
+
220
+ test("reversing the same transaction twice is rejected on the second call", async () => {
221
+ const bank = await createAccount("Bank Dup", "asset");
222
+ const rent = await createAccount("Dup Income", "income");
223
+ const tx = await createTransaction([
224
+ { accountId: bank, amount: 20000 },
225
+ { accountId: rent, amount: -20000 },
226
+ ]);
227
+
228
+ await stack.http.writeOk(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
229
+ const err = await stack.http.writeErr(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
230
+ expect(err.code).toBe("conflict");
231
+
232
+ // Original + exactly ONE Storno — a second reverse must not book again.
233
+ expect(await listTransactions()).toHaveLength(2);
234
+ });
191
235
  });
192
236
 
193
237
  describe("ledger integration — trial balance (golden invariant)", () => {
@@ -5,6 +5,7 @@ import {
5
5
  incomeStatement,
6
6
  type LedgerAccount,
7
7
  type LedgerEntry,
8
+ normalizeLines,
8
9
  } from "../reports";
9
10
 
10
11
  // Pure report math — no DB. A small but complete set of books:
@@ -50,6 +51,25 @@ function balanceOf(report: ReturnType<typeof accountBalances>, id: string): numb
50
51
  return report.accounts.find((a) => a.id === id)?.balance ?? Number.NaN;
51
52
  }
52
53
 
54
+ describe("normalizeLines — jsonb array-or-string driver surfacing", () => {
55
+ test("passes an already-parsed array through unchanged", () => {
56
+ const lines = [{ accountId: "bank", amount: 100 }];
57
+ expect(normalizeLines(lines)).toEqual(lines);
58
+ });
59
+
60
+ test("parses a JSON-string-encoded array (the other driver path)", () => {
61
+ const lines = [{ accountId: "bank", amount: 100 }];
62
+ expect(normalizeLines(JSON.stringify(lines))).toEqual(lines);
63
+ });
64
+
65
+ test("falls back to [] for malformed JSON or a non-array value", () => {
66
+ expect(normalizeLines("not json")).toEqual([]);
67
+ expect(normalizeLines(null)).toEqual([]);
68
+ expect(normalizeLines(undefined)).toEqual([]);
69
+ expect(normalizeLines(42)).toEqual([]);
70
+ });
71
+ });
72
+
53
73
  describe("accountBalances — natural balances + trial balance", () => {
54
74
  test("natural balances are sign-corrected by account type", () => {
55
75
  const r = accountBalances(accounts, posted);
@@ -73,7 +73,7 @@ export const scheduleEntity = createEntity({
73
73
  // Absent → open-ended (projects to the window's end).
74
74
  endDate: createDateField(),
75
75
  interval: createSelectField({ options: SCHEDULE_INTERVALS, required: true }),
76
- amount: createNumberField({ required: true }),
76
+ amount: createNumberField({ required: true, min: 1, integer: true }),
77
77
  debitAccountId: createTextField({ required: true, maxLength: 64 }),
78
78
  creditAccountId: createTextField({ required: true, maxLength: 64 }),
79
79
  },