@cosmicdrift/kumiko-bundled-features 0.192.0 → 0.193.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 (24) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +43 -1
  3. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +237 -0
  4. package/src/file-derivatives/feature.ts +142 -21
  5. package/src/file-derivatives/handlers/public-variant.query.ts +122 -0
  6. package/src/file-derivatives/index.ts +3 -2
  7. package/src/file-derivatives/presets.ts +7 -0
  8. package/src/form-draft/__tests__/cleanup.integration.test.ts +30 -0
  9. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +26 -1
  10. package/src/form-draft/__tests__/feature-optional-config.integration.test.ts +90 -0
  11. package/src/form-draft/db/queries/cleanup.ts +15 -14
  12. package/src/form-draft/db/queries/owned-file-refs.ts +13 -2
  13. package/src/form-draft/feature.ts +7 -1
  14. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +55 -0
  15. package/src/form-draft/handlers/cleanup.job.ts +57 -11
  16. package/src/form-draft/handlers/discard.write.ts +1 -0
  17. package/src/form-draft/lookup.ts +2 -0
  18. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +60 -0
  19. package/src/form-draft-user-data/hooks.ts +12 -1
  20. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  21. package/src/template-resolver/handlers/collection-shared.ts +2 -0
  22. package/src/template-resolver/web/__tests__/editor-read-only.test.tsx +25 -0
  23. package/src/template-resolver/web/client-plugin.tsx +22 -0
  24. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +21 -6
@@ -120,6 +120,14 @@ async function backdate(draftKey: string, daysAgo: number): Promise<void> {
120
120
  );
121
121
  }
122
122
 
123
+ async function backdateFileRef(storageKey: string, daysAgo: number): Promise<void> {
124
+ await asRawClient(stack.db).unsafe(
125
+ `UPDATE "file_refs" SET "inserted_at" = now() - ($1::int * interval '1 day')
126
+ WHERE "storage_key" = $2`,
127
+ [daysAgo, storageKey],
128
+ );
129
+ }
130
+
123
131
  async function draftExists(draftKey: string): Promise<boolean> {
124
132
  const rows = await asRawClient(stack.db).unsafe(
125
133
  "SELECT 1 FROM read_form_drafts WHERE draft_key = $1",
@@ -251,4 +259,26 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
251
259
  });
252
260
  expect(providerA.keys()).toContain(forgedKey);
253
261
  });
262
+
263
+ test("does NOT release a storageKey whose file_refs row predates the draft (entity-prefilled edit-mode value, fw-review #5)", async () => {
264
+ const key = "tenant-a/vehicle/prefilled.jpg";
265
+ await providerA.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
266
+ await seedFileRef(key);
267
+ // The photo was uploaded when the domain entity was originally created —
268
+ // long before this editing session's draft. backdate() (below) also
269
+ // ages the draft row to 31 days, so the file must be aged further still
270
+ // to model "predates the draft" rather than "same moment".
271
+ await backdateFileRef(key, 60);
272
+
273
+ await saveDraft("wizard:stale-with-prefilled-photo", { photo: fileRefPointer(key) });
274
+ await backdate("wizard:stale-with-prefilled-photo", 31);
275
+
276
+ await dispatchCleanup();
277
+
278
+ await waitFor(async () => {
279
+ expect(await draftExists("wizard:stale-with-prefilled-photo")).toBe(false);
280
+ });
281
+ // The live domain entity this draft was editing still references `key`.
282
+ expect(providerA.keys()).toContain(key);
283
+ });
254
284
  });
@@ -14,6 +14,17 @@
14
14
  // file_refs row exists, a forged { storageKey: "<victim's key>" } would
15
15
  // reach the storage provider's delete() unchecked (see
16
16
  // db/queries/owned-file-refs.ts).
17
+ //
18
+ // Ordering matters for release-eligibility (fw-review #5): a storageKey is
19
+ // only releasable if its file_refs row was inserted AFTER the draft row —
20
+ // i.e. uploaded during THIS draft's own lifetime. Every "should release"
21
+ // fixture below therefore creates the draft FIRST (an empty first save,
22
+ // same as a wizard's initial autosave) and only then seeds the file_refs
23
+ // row and saves the pointer into it — mirroring the real upload-during-an-
24
+ // open-wizard order. A file_refs row seeded BEFORE the draft ever existed
25
+ // models a domain entity's pre-existing photo pulled into an edit-mode
26
+ // draft's initial values, which must NOT be releasable (see
27
+ // db/queries/owned-file-refs.ts and handlers/cleanup.job.ts).
17
28
 
18
29
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
30
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -91,9 +102,9 @@ async function seedFileRef(storageKey: string, ownerUser = owner): Promise<void>
91
102
  describe("form-draft discard — FileRef release", () => {
92
103
  test("discarding with releaseFiles: true and a single-file field releases its storage binary", async () => {
93
104
  const key = "tenant/vehicle/photo/one.jpg";
105
+ await saveDraft("wizard:one-photo", {}); // initial autosave, draft row created first
94
106
  await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
95
107
  await seedFileRef(key);
96
-
97
108
  await saveDraft("wizard:one-photo", { photo: fileRefPointer(key) });
98
109
  expect(provider.keys()).toContain(key);
99
110
 
@@ -106,6 +117,7 @@ describe("form-draft discard — FileRef release", () => {
106
117
  test("discarding with releaseFiles: true and a multi-file field releases every storage binary", async () => {
107
118
  const keyA = "tenant/vehicle/photos/a.jpg";
108
119
  const keyB = "tenant/vehicle/photos/b.jpg";
120
+ await saveDraft("wizard:many-photos", {}); // initial autosave, draft row created first
109
121
  await provider.write(keyA, new Uint8Array([1]), "image/jpeg");
110
122
  await provider.write(keyB, new Uint8Array([2]), "image/jpeg");
111
123
  await seedFileRef(keyA);
@@ -121,6 +133,19 @@ describe("form-draft discard — FileRef release", () => {
121
133
  expect(provider.keys()).not.toContain(keyB);
122
134
  });
123
135
 
136
+ test("discarding with releaseFiles: true does NOT release a storageKey whose file_refs row predates the draft (entity-prefilled edit-mode value)", async () => {
137
+ const key = "tenant/vehicle/photo/prefilled.jpg";
138
+ await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
139
+ await seedFileRef(key); // uploaded long before this editing session's draft existed
140
+ await saveDraft("wizard:edit-prefilled", { photo: fileRefPointer(key) });
141
+
142
+ await discardDraft("wizard:edit-prefilled", true);
143
+
144
+ // The live domain entity this draft was editing still points at `key` —
145
+ // releasing it here would orphan the entity's photo.
146
+ expect(provider.keys()).toContain(key);
147
+ });
148
+
124
149
  test("discarding without releaseFiles leaves the storage binary intact (successful-submit default)", async () => {
125
150
  const key = "tenant/vehicle/photo/kept.jpg";
126
151
  await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
@@ -0,0 +1,90 @@
1
+ // fw-review #6 — form-draft declared a hard r.requires("config"), so any
2
+ // app mounting form-draft without the config feature failed at boot with
3
+ // `Feature "form-draft" requires feature "config" which is not registered`
4
+ // (registry-validate.ts). config is now r.optionalRequires: form-draft must
5
+ // boot standalone, and its cleanup job must still run — falling back to
6
+ // FORM_DRAFT_DEFAULT_RETENTION_DAYS since there's no configResolver to read
7
+ // the `form-draft:config:retention-days` override from (see
8
+ // handlers/cleanup.job.ts).
9
+
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
12
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
+ import {
14
+ createTestUser,
15
+ setupTestStack,
16
+ type TestStack,
17
+ unsafeCreateEntityTable,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
19
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
20
+ import { FormDraftHandlers } from "../constants";
21
+ import { formDraftEntity } from "../entity";
22
+ import { formDraftFeature } from "../feature";
23
+
24
+ let stack: TestStack;
25
+ const owner = createTestUser({ id: 1, roles: ["TenantMember"] });
26
+
27
+ beforeAll(async () => {
28
+ // No createConfigFeature() here — the whole point of this suite.
29
+ stack = await setupTestStack({
30
+ features: [formDraftFeature],
31
+ jobs: { consumerLane: "worker", queueNamePrefix: `kumiko-form-draft-no-config-${Date.now()}` },
32
+ });
33
+ await unsafeCreateEntityTable(stack.db, formDraftEntity);
34
+ await createEventsTable(stack.db);
35
+ });
36
+
37
+ afterAll(async () => {
38
+ await stack.cleanup();
39
+ });
40
+
41
+ beforeEach(async () => {
42
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
43
+ await asRawClient(stack.db).unsafe("DELETE FROM read_form_drafts");
44
+ });
45
+
46
+ async function draftExists(draftKey: string): Promise<boolean> {
47
+ const rows = await asRawClient(stack.db).unsafe(
48
+ "SELECT 1 FROM read_form_drafts WHERE draft_key = $1",
49
+ [draftKey],
50
+ );
51
+ return (rows as unknown[]).length > 0;
52
+ }
53
+
54
+ async function backdate(draftKey: string, daysAgo: number): Promise<void> {
55
+ await asRawClient(stack.db).unsafe(
56
+ `UPDATE "read_form_drafts"
57
+ SET "inserted_at" = now() - ($1::int * interval '1 day'),
58
+ "modified_at" = now() - ($1::int * interval '1 day')
59
+ WHERE "draft_key" = $2`,
60
+ [daysAgo, draftKey],
61
+ );
62
+ }
63
+
64
+ describe("form-draft mounted without the config feature", () => {
65
+ test("boots successfully (setupTestStack does not throw)", () => {
66
+ expect(stack).toBeDefined();
67
+ });
68
+
69
+ test("cleanup job still deletes drafts older than the 30-day default retention", async () => {
70
+ await stack.http.writeOk(
71
+ FormDraftHandlers.save,
72
+ { draftKey: "wizard:old-no-config", values: {}, stepIndex: 0 },
73
+ owner,
74
+ );
75
+ await backdate("wizard:old-no-config", 31);
76
+ await stack.http.writeOk(
77
+ FormDraftHandlers.save,
78
+ { draftKey: "wizard:fresh-no-config", values: {}, stepIndex: 0 },
79
+ owner,
80
+ );
81
+
82
+ if (!stack.jobRunner) throw new Error("jobRunner not wired");
83
+ await stack.jobRunner.dispatch("form-draft:job:cleanup", {});
84
+
85
+ await waitFor(async () => {
86
+ expect(await draftExists("wizard:old-no-config")).toBe(false);
87
+ });
88
+ expect(await draftExists("wizard:fresh-no-config")).toBe(true);
89
+ });
90
+ });
@@ -1,6 +1,7 @@
1
1
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { Temporal } from "temporal-polyfill";
4
5
  import type { FormDraftBlob } from "../../schemas";
5
6
 
6
7
  export type StaleDraftRow = {
@@ -8,6 +9,7 @@ export type StaleDraftRow = {
8
9
  readonly tenantId: TenantId;
9
10
  readonly ownerId: string;
10
11
  readonly draft: FormDraftBlob;
12
+ readonly insertedAt: Temporal.Instant;
11
13
  };
12
14
 
13
15
  // COALESCE(modified_at, inserted_at) — a draft's "last touched" timestamp is
@@ -17,34 +19,33 @@ export type StaleDraftRow = {
17
19
  //
18
20
  // Reads the blob + tenantId BEFORE anything is deleted (issue #1915) — the
19
21
  // caller (cleanup.job.ts) walks each row's blob for FileRefs and releases
20
- // them per-tenant via ctx._fileProviderResolver, then deletes the batch by
21
- // id via deleteDraftsByIds below. Ordered oldest-first so a batch is a
22
- // stable, deterministic slice across the two queries.
22
+ // them per-tenant via ctx._fileProviderResolver, then deletes the batch
23
+ // per-row via formDraftExecutor.delete (event-sourced, not raw SQL — a
24
+ // rebuild must not resurrect a "deleted" draft). Ordered oldest-first so a
25
+ // batch is a stable, deterministic slice across the two queries.
23
26
  export async function selectStaleDraftsBatch(
24
27
  db: DbConnection,
25
28
  olderThanDays: number,
26
29
  batchSize: number,
27
30
  ): Promise<readonly StaleDraftRow[]> {
28
31
  const rows = (await asRawClient(db).unsafe(
29
- `SELECT "id", "tenant_id", "owner_id", "draft" FROM "read_form_drafts"
32
+ `SELECT "id", "tenant_id", "owner_id", "draft", "inserted_at" FROM "read_form_drafts"
30
33
  WHERE COALESCE("modified_at", "inserted_at") < now() - ($1::int * interval '1 day')
31
34
  ORDER BY COALESCE("modified_at", "inserted_at") ASC
32
35
  LIMIT $2`,
33
36
  [olderThanDays, batchSize],
34
- )) as readonly { id: string; tenant_id: string; owner_id: string; draft: FormDraftBlob }[];
37
+ )) as readonly {
38
+ id: string;
39
+ tenant_id: string;
40
+ owner_id: string;
41
+ draft: FormDraftBlob;
42
+ inserted_at: Date;
43
+ }[];
35
44
  return rows.map((row) => ({
36
45
  id: row.id,
37
46
  tenantId: row.tenant_id as TenantId, // @cast-boundary db-row
38
47
  ownerId: row.owner_id,
39
48
  draft: row.draft,
49
+ insertedAt: Temporal.Instant.fromEpochMilliseconds(+row.inserted_at), // @cast-boundary db-row
40
50
  }));
41
51
  }
42
-
43
- export async function deleteDraftsByIds(db: DbConnection, ids: readonly string[]): Promise<number> {
44
- if (ids.length === 0) return 0;
45
- const rows = (await asRawClient(db).unsafe(
46
- `DELETE FROM "read_form_drafts" WHERE "id" = ANY($1::uuid[]) RETURNING "id"`,
47
- [ids],
48
- )) as readonly { id: string }[];
49
- return rows.length;
50
- }
@@ -1,6 +1,7 @@
1
1
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { Temporal } from "temporal-polyfill";
4
5
 
5
6
  // Narrows a draft blob's raw FileRef-shaped storageKeys (collectDraftFileRefKeys
6
7
  // output — extracted from free-form, client-supplied JSON, issue #1889) down to
@@ -9,18 +10,28 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
10
  // { storageKey: "<someone-else's-real-key>" } would reach the storage
10
11
  // provider's delete() unchecked — the same ownership boundary file-routes.ts
11
12
  // enforces (loadFileForTenant + FileAccessGuard) before every read/delete.
13
+ //
14
+ // `draftInsertedAt` additionally excludes FileRefs that existed BEFORE the
15
+ // draft row was created. In edit-mode the draft blob carries the full form
16
+ // values, including FileRef pointers the domain entity pre-filled — those
17
+ // storageKeys are owned by the caller (uploaded by them, on an earlier
18
+ // entity) but must survive a discard/cleanup sweep because the live entity
19
+ // still references them. Only uploads that happened during THIS draft's
20
+ // lifetime (inserted after the draft row) are release-eligible.
12
21
  export async function filterOwnedStorageKeys(
13
22
  db: DbRunner,
14
23
  tenantId: TenantId,
15
24
  ownerId: string,
16
25
  candidateKeys: readonly string[],
26
+ draftInsertedAt: Temporal.Instant,
17
27
  ): Promise<readonly string[]> {
18
28
  if (candidateKeys.length === 0) return [];
19
29
  const rows = (await asRawClient(db).unsafe(
20
30
  `SELECT "storage_key" FROM "file_refs"
21
31
  WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
22
- AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false`,
23
- [tenantId, ownerId, candidateKeys],
32
+ AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
33
+ AND "inserted_at" > $4::timestamptz`,
34
+ [tenantId, ownerId, candidateKeys, draftInsertedAt.toString()],
24
35
  )) as readonly { storage_key: string }[];
25
36
  return rows.map((row) => row.storage_key);
26
37
  }
@@ -25,7 +25,13 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
25
25
  recommended: false,
26
26
  });
27
27
 
28
- r.requires("config");
28
+ // config is optional: the retention-days key only resolves when the
29
+ // config feature is mounted (ctx.configResolver present) — the cleanup
30
+ // job already falls back to FORM_DRAFT_DEFAULT_RETENTION_DAYS otherwise
31
+ // (see handlers/cleanup.job.ts). A hard r.requires("config") would break
32
+ // every app that mounts form-draft without config, which is a breaking
33
+ // change no minor/patch release should introduce silently.
34
+ r.optionalRequires("config");
29
35
  r.entity("form-draft", formDraftEntity);
30
36
 
31
37
  r.writeHandler(saveDraftWrite);
@@ -0,0 +1,55 @@
1
+ // groupStaleDraftIdsByTenant — the raw-SQL cross-tenant DELETE it replaced
2
+ // (deleteDraftsByIds) never distinguished tenants; formDraftExecutor.delete
3
+ // needs a tenant-scoped db + a per-tenant SessionUser, so this grouping is
4
+ // the load-bearing step between "batch of stale rows across N tenants" and
5
+ // "N per-tenant delete loops". Pure logic, no DB — DB-gated coverage of the
6
+ // actual delete path lives in __tests__/cleanup.integration.test.ts.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { testTenantId } from "@cosmicdrift/kumiko-framework/stack";
10
+ import { Temporal } from "temporal-polyfill";
11
+ import type { StaleDraftRow } from "../../db/queries/cleanup";
12
+ import { groupStaleDraftIdsByTenant } from "../cleanup.job";
13
+
14
+ const TENANT_A = testTenantId(101);
15
+ const TENANT_B = testTenantId(102);
16
+
17
+ function row(id: string, tenantId = TENANT_A): StaleDraftRow {
18
+ return {
19
+ id,
20
+ tenantId,
21
+ ownerId: "owner-1",
22
+ draft: { values: {}, stepIndex: 0, savedAt: "" },
23
+ insertedAt: Temporal.Instant.fromEpochMilliseconds(0),
24
+ };
25
+ }
26
+
27
+ describe("groupStaleDraftIdsByTenant", () => {
28
+ test("groups ids under their own tenant, preserves per-tenant order", () => {
29
+ const batch = [
30
+ row("id-a1", TENANT_A),
31
+ row("id-b1", TENANT_B),
32
+ row("id-a2", TENANT_A),
33
+ row("id-b2", TENANT_B),
34
+ ];
35
+
36
+ const grouped = groupStaleDraftIdsByTenant(batch);
37
+
38
+ expect(grouped.size).toBe(2);
39
+ expect(grouped.get(TENANT_A)).toEqual(["id-a1", "id-a2"]);
40
+ expect(grouped.get(TENANT_B)).toEqual(["id-b1", "id-b2"]);
41
+ });
42
+
43
+ test("a single-tenant batch produces exactly one group", () => {
44
+ const batch = [row("id-1"), row("id-2"), row("id-3")];
45
+
46
+ const grouped = groupStaleDraftIdsByTenant(batch);
47
+
48
+ expect(grouped.size).toBe(1);
49
+ expect(grouped.get(TENANT_A)).toEqual(["id-1", "id-2", "id-3"]);
50
+ });
51
+
52
+ test("an empty batch produces an empty map", () => {
53
+ expect(groupStaleDraftIdsByTenant([]).size).toBe(0);
54
+ });
55
+ });
@@ -6,23 +6,22 @@
6
6
  // Chunked DELETE (default 1000/batch) keeps lock durations bounded, mirror
7
7
  // of sessions/handlers/cleanup.job.ts.
8
8
 
9
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
+ import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
10
  import {
11
11
  type AppContext,
12
12
  access,
13
13
  type ConfigKeyDefinition,
14
14
  createSystemConfig,
15
+ createSystemUser,
15
16
  type JobHandlerFn,
16
17
  SYSTEM_TENANT_ID,
17
18
  SYSTEM_USER_ID,
19
+ type TenantId,
18
20
  } from "@cosmicdrift/kumiko-framework/engine";
19
21
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
20
- import {
21
- deleteDraftsByIds,
22
- type StaleDraftRow,
23
- selectStaleDraftsBatch,
24
- } from "../db/queries/cleanup";
22
+ import { type StaleDraftRow, selectStaleDraftsBatch } from "../db/queries/cleanup";
25
23
  import { filterOwnedStorageKeys } from "../db/queries/owned-file-refs";
24
+ import { formDraftExecutor } from "../executor";
26
25
  import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
27
26
 
28
27
  export const FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY = "form-draft:config:retention-days";
@@ -63,7 +62,13 @@ async function releaseRowFileRefs(
63
62
  // draft owner are releasable — `draft.values` is free-form JSON the
64
63
  // owning user controls, so an unverified key could target someone
65
64
  // else's file (see db/queries/owned-file-refs.ts).
66
- const ownedKeys = await filterOwnedStorageKeys(db, row.tenantId, row.ownerId, keys);
65
+ const ownedKeys = await filterOwnedStorageKeys(
66
+ db,
67
+ row.tenantId,
68
+ row.ownerId,
69
+ keys,
70
+ row.insertedAt,
71
+ );
67
72
  const provider = await fileProviderResolver(row.tenantId);
68
73
  await releaseDraftFileRefs(ownedKeys, (key) => provider.delete(key), log);
69
74
  } catch (err) {
@@ -73,6 +78,50 @@ async function releaseRowFileRefs(
73
78
  }
74
79
  }
75
80
 
81
+ // A stale-drafts batch spans arbitrary tenants (this job sweeps the whole
82
+ // table under SYSTEM_TENANT_ID, not per-tenant) — group by tenant so each
83
+ // tenant's rows get deleted through a tenant-scoped db + system user, one
84
+ // event-store delete per row rather than a raw cross-tenant DELETE.
85
+ export function groupStaleDraftIdsByTenant(
86
+ batch: readonly StaleDraftRow[],
87
+ ): ReadonlyMap<TenantId, readonly string[]> {
88
+ const grouped = new Map<TenantId, string[]>();
89
+ for (const row of batch) {
90
+ const ids = grouped.get(row.tenantId);
91
+ if (ids) ids.push(row.id);
92
+ else grouped.set(row.tenantId, [row.id]);
93
+ }
94
+ return grouped;
95
+ }
96
+
97
+ // Raw `DELETE FROM read_form_drafts` would bypass the event store entirely
98
+ // — a projection rebuild/replay has no `deleted` event to apply and the row
99
+ // resurrects, PII included. formDraftExecutor.delete() is the event-sourced
100
+ // path; it needs a tenant-scoped db + a SessionUser, so rows are grouped by
101
+ // tenant first (see groupStaleDraftIdsByTenant above).
102
+ async function deleteStaleDraftsBatch(
103
+ batch: readonly StaleDraftRow[],
104
+ db: DbConnection,
105
+ log: AppContext["log"],
106
+ ): Promise<number> {
107
+ let deleted = 0;
108
+ for (const [tenantId, ids] of groupStaleDraftIdsByTenant(batch)) {
109
+ const systemUser = createSystemUser(tenantId);
110
+ const tenantDb = createTenantDb(db, tenantId, "system");
111
+ for (const id of ids) {
112
+ const result = await formDraftExecutor.delete({ id }, systemUser, tenantDb);
113
+ if (result.isSuccess) {
114
+ deleted++;
115
+ } else {
116
+ log?.warn?.(
117
+ `[form-draft:cleanup] failed to delete stale draft id=${id} tenant=${tenantId}: ${result.error.message}`,
118
+ );
119
+ }
120
+ }
121
+ }
122
+ return deleted;
123
+ }
124
+
76
125
  export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
77
126
  if (!ctx.db || !ctx.registry) {
78
127
  throw new InternalError({
@@ -109,10 +158,7 @@ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
109
158
  await releaseRowFileRefs(row, db, fileProviderResolver, ctx.log);
110
159
  }
111
160
 
112
- const batchDeleted = await deleteDraftsByIds(
113
- db,
114
- batch.map((row) => row.id),
115
- );
161
+ const batchDeleted = await deleteStaleDraftsBatch(batch, db, ctx.log);
116
162
  deleted += batchDeleted;
117
163
  if (batchDeleted === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
118
164
  }
@@ -47,6 +47,7 @@ export const discardDraftWrite = defineWriteHandler({
47
47
  event.user.tenantId,
48
48
  ownerId,
49
49
  candidateKeys,
50
+ existing.insertedAt,
50
51
  );
51
52
  await releaseDraftFileRefs(ownedKeys, (key) => files.ref(key).delete(), ctx.log);
52
53
  }
@@ -1,6 +1,7 @@
1
1
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { Temporal } from "temporal-polyfill";
4
5
  import { formDraftTable } from "./executor";
5
6
  import type { FormDraftBlob } from "./schemas";
6
7
 
@@ -8,6 +9,7 @@ export type FormDraftRow = {
8
9
  readonly id: string;
9
10
  readonly version: number;
10
11
  readonly draft: FormDraftBlob;
12
+ readonly insertedAt: Temporal.Instant;
11
13
  };
12
14
 
13
15
  // Shared by save (upsert lookup + race re-lookup), discard, and get — always
@@ -0,0 +1,60 @@
1
+ // formDraftDeleteHook must not silently succeed when a forget run deletes
2
+ // every draft row for a user — an Art.17 forget run that reports success
3
+ // while PII is still there is a compliance bug, not a display bug.
4
+ // The partial-failure case (one delete among several fails) is covered
5
+ // mock-free in run-forget-cleanup.integration.test.ts:877; a mock-free way
6
+ // to trigger it here would need an optimistic-lock race too fragile to rely on.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createTestUser,
13
+ setupTestStack,
14
+ type TestStack,
15
+ unsafeCreateEntityTable,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { createConfigFeature } from "../../config";
18
+ import { FormDraftHandlers, formDraftEntity, formDraftFeature } from "../../form-draft";
19
+ import { formDraftDeleteHook } from "../hooks";
20
+
21
+ async function countDraftRows(stack: TestStack, ownerId: string): Promise<number> {
22
+ const rows = await asRawClient(stack.db).unsafe(
23
+ "SELECT count(*)::int AS n FROM read_form_drafts WHERE owner_id = $1",
24
+ [ownerId],
25
+ );
26
+ return (rows as Array<{ n: number }>)[0]?.n ?? 0;
27
+ }
28
+
29
+ let stack: TestStack;
30
+ const alice = createTestUser({ id: 31, roles: ["TenantMember"] });
31
+
32
+ beforeAll(async () => {
33
+ stack = await setupTestStack({ features: [formDraftFeature, createConfigFeature()] });
34
+ await unsafeCreateEntityTable(stack.db, formDraftEntity);
35
+ await createEventsTable(stack.db);
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await stack.cleanup();
40
+ });
41
+
42
+ describe("formDraftDeleteHook — delete failure handling (#review-batch-fw-1)", () => {
43
+ test("does not throw when every delete succeeds", async () => {
44
+ const bob = createTestUser({ id: 32, roles: ["TenantMember"], tenantId: alice.tenantId });
45
+ await stack.http.writeOk(
46
+ FormDraftHandlers.save,
47
+ { draftKey: "wizard:bob-ok", values: {}, stepIndex: 0 },
48
+ bob,
49
+ );
50
+
51
+ await expect(
52
+ formDraftDeleteHook(
53
+ { db: stack.db, registry: stack.registry, tenantId: bob.tenantId, userId: bob.id },
54
+ "delete",
55
+ ),
56
+ ).resolves.toBeUndefined();
57
+
58
+ expect(await countDraftRows(stack, String(bob.id))).toBe(0);
59
+ });
60
+ });
@@ -43,7 +43,18 @@ export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
43
43
  });
44
44
  const systemUser = createSystemUser(ctx.tenantId);
45
45
  const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
46
+ const failures: string[] = [];
46
47
  for (const row of rows) {
47
- await formDraftExecutor.delete({ id: row.id }, systemUser, tdb);
48
+ const result = await formDraftExecutor.delete({ id: row.id }, systemUser, tdb);
49
+ if (!result.isSuccess) {
50
+ failures.push(`${row.id}: ${result.error.message}`);
51
+ }
52
+ }
53
+ // A silently-ignored delete failure would let the forget pipeline flip the
54
+ // user to Deleted (Art. 17 "done") while the row — and its PII — is still
55
+ // there. Throwing rolls back this user's sub-tx (runForgetCleanup) so the
56
+ // status stays DeletionRequested and the next run retries.
57
+ if (failures.length > 0) {
58
+ throw new Error(`[form-draft-user-data] failed to delete draft(s): ${failures.join(", ")}`);
48
59
  }
49
60
  };
@@ -50,6 +50,7 @@ export const bySlugQuery = defineQueryHandler({
50
50
  locale: row.locale,
51
51
  title: row.title,
52
52
  content: row.content,
53
+ contentFormat: row.contentFormat,
53
54
  folder: row.folder,
54
55
  updatedAt: row.updatedAt,
55
56
  };
@@ -34,6 +34,7 @@ export type CollectionEntryRow = {
34
34
  readonly locale: string;
35
35
  readonly title: string | null;
36
36
  readonly content: string | null;
37
+ readonly contentFormat: string;
37
38
  readonly folder: string | null;
38
39
  readonly updatedAt: Date;
39
40
  };
@@ -81,6 +82,7 @@ export function toCollectionEntry(row: CollectionEntryRow) {
81
82
  locale: row.locale,
82
83
  title: row.title,
83
84
  content: row.content,
85
+ contentFormat: row.contentFormat,
84
86
  folder: row.folder,
85
87
  updatedAt: row.updatedAt,
86
88
  };
@@ -28,6 +28,7 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
28
28
  locale: "de",
29
29
  title: "Impressum",
30
30
  content: "Inhalt",
31
+ contentFormat: "plain",
31
32
  folder: "legal",
32
33
  },
33
34
  loading: false,
@@ -160,4 +161,28 @@ describe("TextContentEditor — handleSave", () => {
160
161
  const [, payload] = write.mock.calls[0] as unknown as [unknown, { folder: string | null }];
161
162
  expect(payload.folder).toBe("legal");
162
163
  });
164
+
165
+ // TARGET has no collectionId, so the collection's declared contentFormat is
166
+ // always undefined here — the write payload must fall back to the loaded
167
+ // entry's own contentFormat ("plain") instead of silently defaulting to
168
+ // "markdown" via the write schema's contentFormatSchema.default("markdown").
169
+ test("sendet das geladene contentFormat unverändert mit, statt es auf markdown zu defaulten", async () => {
170
+ // biome-ignore lint/suspicious/noExplicitAny: Bun mock function
171
+ (useShellUser as any).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
172
+ const write = mock(() => Promise.resolve({ isSuccess: true, data: { isNew: false } }));
173
+ // biome-ignore lint/suspicious/noExplicitAny: Bun mock function
174
+ (useDispatcher as any).mockReturnValue({ write, query: mock() });
175
+
176
+ const Editor = getEditor();
177
+ render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
178
+
179
+ await act(async () => {
180
+ fireEvent.click(screen.getByRole("button", { name: /speichern/i }));
181
+ await Promise.resolve();
182
+ });
183
+
184
+ expect(write).toHaveBeenCalledTimes(1);
185
+ const [, payload] = write.mock.calls[0] as unknown as [unknown, { contentFormat?: string }];
186
+ expect(payload.contentFormat).toBe("plain");
187
+ });
163
188
  });