@cosmicdrift/kumiko-bundled-features 0.187.0 → 0.188.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 (29) hide show
  1. package/package.json +9 -7
  2. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +1 -1
  3. package/src/custom-fields/__tests__/retention.integration.test.ts +2 -2
  4. package/src/form-draft/__tests__/cleanup.integration.test.ts +254 -0
  5. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +165 -0
  6. package/src/form-draft/__tests__/form-draft.integration.test.ts +232 -0
  7. package/src/form-draft/changes.json +1 -0
  8. package/src/form-draft/constants.ts +31 -0
  9. package/src/form-draft/db/queries/cleanup.ts +50 -0
  10. package/src/form-draft/db/queries/owned-file-refs.ts +26 -0
  11. package/src/form-draft/entity.ts +51 -0
  12. package/src/form-draft/executor.ts +7 -0
  13. package/src/form-draft/feature.ts +42 -0
  14. package/src/form-draft/handlers/cleanup.job.ts +120 -0
  15. package/src/form-draft/handlers/discard.write.ts +55 -0
  16. package/src/form-draft/handlers/get.query.ts +26 -0
  17. package/src/form-draft/handlers/list.query.ts +40 -0
  18. package/src/form-draft/handlers/save.write.ts +60 -0
  19. package/src/form-draft/i18n.ts +3 -0
  20. package/src/form-draft/index.ts +28 -0
  21. package/src/form-draft/lookup.ts +53 -0
  22. package/src/form-draft/release-file-refs.ts +69 -0
  23. package/src/form-draft/schemas.ts +47 -0
  24. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +146 -0
  25. package/src/form-draft-user-data/changes.json +1 -0
  26. package/src/form-draft-user-data/hooks.ts +49 -0
  27. package/src/form-draft-user-data/index.ts +21 -0
  28. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +1 -1
  29. package/src/user-data-rights/__tests__/boot-checks.test.ts +14 -0
@@ -0,0 +1,232 @@
1
+ // Full-stack integration for the form-draft bundle. Drives save → get →
2
+ // discard through the real dispatcher + entity-projection + DB:
3
+ // - save upserts (tenantId, ownerId, draftKey) — a second save for the
4
+ // same key updates the existing row, it never duplicates
5
+ // - get resolves { draft: null } for a foreign user / foreign tenant,
6
+ // never leaking the other owner's row
7
+ // - discard only ever removes the caller's own row
8
+ // - stepIndex + savedAt round-trip through the blob shape fixed by #1889
9
+ // - list resolves a user's open drafts for a screenId (draftKey prefix
10
+ // match), never another user's/tenant's rows and never the blob values
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
+ import {
16
+ createTestUser,
17
+ setupTestStack,
18
+ type TestStack,
19
+ unsafeCreateEntityTable,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { createConfigFeature } from "../../config";
22
+ import { FormDraftHandlers, FormDraftQueries } from "../constants";
23
+ import { formDraftEntity } from "../entity";
24
+ import { formDraftFeature } from "../feature";
25
+ import type { GetDraftResult } from "../handlers/get.query";
26
+ import type { ListDraftsResult } from "../handlers/list.query";
27
+
28
+ let stack: TestStack;
29
+
30
+ beforeAll(async () => {
31
+ stack = await setupTestStack({ features: [formDraftFeature, createConfigFeature()] });
32
+ await unsafeCreateEntityTable(stack.db, formDraftEntity);
33
+ await createEventsTable(stack.db);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await stack.cleanup();
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
42
+ await asRawClient(stack.db).unsafe("DELETE FROM read_form_drafts");
43
+ });
44
+
45
+ // Distinct ids (default createTestUser() shares TestUsers.admin.id).
46
+ const owner = createTestUser({ id: 1, roles: ["TenantMember"] });
47
+ const otherUser = createTestUser({ id: 2, roles: ["TenantMember"] });
48
+ const otherTenant = createTestUser({
49
+ id: 1,
50
+ roles: ["TenantMember"],
51
+ tenantId: "00000000-0000-4000-8000-0000000000aa",
52
+ });
53
+
54
+ async function saveDraft(
55
+ draftKey: string,
56
+ values: Record<string, unknown>,
57
+ stepIndex: number,
58
+ user = owner,
59
+ ): Promise<unknown> {
60
+ return stack.http.writeOk(FormDraftHandlers.save, { draftKey, values, stepIndex }, user);
61
+ }
62
+
63
+ async function getDraft(draftKey: string, user = owner): Promise<GetDraftResult> {
64
+ return stack.http.queryOk<GetDraftResult>(FormDraftQueries.get, { draftKey }, user);
65
+ }
66
+
67
+ async function discardDraft(draftKey: string, user = owner): Promise<unknown> {
68
+ return stack.http.writeOk(FormDraftHandlers.discard, { draftKey }, user);
69
+ }
70
+
71
+ async function listDrafts(screenId: string, user = owner): Promise<ListDraftsResult> {
72
+ return stack.http.queryOk<ListDraftsResult>(FormDraftQueries.list, { screenId }, user);
73
+ }
74
+
75
+ describe("form-draft integration — save + get", () => {
76
+ test("save then get round-trips values, stepIndex, and a stamped savedAt", async () => {
77
+ await saveDraft("wizard:vehicle-create", { name: "Van" }, 1);
78
+ const { draft } = await getDraft("wizard:vehicle-create");
79
+ expect(draft).not.toBeNull();
80
+ expect(draft?.values).toEqual({ name: "Van" });
81
+ expect(draft?.stepIndex).toBe(1);
82
+ expect(draft?.savedAt).toBeTruthy();
83
+ });
84
+
85
+ test("get resolves { draft: null } when nothing was ever saved", async () => {
86
+ const { draft } = await getDraft("no-such-draft");
87
+ expect(draft).toBeNull();
88
+ });
89
+
90
+ test("savedAt is always a fresh server-side stamp, not a client-supplied value", async () => {
91
+ await stack.http.writeOk(
92
+ FormDraftHandlers.save,
93
+ {
94
+ draftKey: "wizard:x",
95
+ values: {},
96
+ stepIndex: 0,
97
+ savedAt: "1999-01-01T00:00:00Z",
98
+ },
99
+ owner,
100
+ );
101
+ const { draft } = await getDraft("wizard:x");
102
+ const savedAtMs = draft?.savedAt ? Date.parse(draft.savedAt) : Number.NaN;
103
+ expect(Number.isNaN(savedAtMs)).toBe(false);
104
+ expect(Date.now() - savedAtMs).toBeLessThan(10_000);
105
+ });
106
+ });
107
+
108
+ describe("form-draft integration — save is an upsert", () => {
109
+ test("saving the same draftKey twice updates the row, it never duplicates", async () => {
110
+ await saveDraft("wizard:vehicle-create", { name: "Van" }, 0);
111
+ await saveDraft("wizard:vehicle-create", { name: "Van", plate: "B-XY-123" }, 2);
112
+
113
+ const { draft } = await getDraft("wizard:vehicle-create");
114
+ expect(draft?.values).toEqual({ name: "Van", plate: "B-XY-123" });
115
+ expect(draft?.stepIndex).toBe(2);
116
+
117
+ const rows = await asRawClient(stack.db).unsafe(
118
+ "SELECT count(*)::int AS n FROM read_form_drafts WHERE draft_key = $1",
119
+ ["wizard:vehicle-create"],
120
+ );
121
+ expect((rows as Array<{ n: number }>)[0]?.n).toBe(1);
122
+ });
123
+
124
+ test("two different users saving the same draftKey each get their own draft", async () => {
125
+ await saveDraft("wizard:shared-key", { name: "Owner's" }, 0, owner);
126
+ await saveDraft("wizard:shared-key", { name: "Other's" }, 3, otherUser);
127
+
128
+ expect((await getDraft("wizard:shared-key", owner)).draft?.values).toEqual({
129
+ name: "Owner's",
130
+ });
131
+ expect((await getDraft("wizard:shared-key", otherUser)).draft?.values).toEqual({
132
+ name: "Other's",
133
+ });
134
+ });
135
+ });
136
+
137
+ describe("form-draft integration — discard", () => {
138
+ test("discard removes the draft — a subsequent get resolves null", async () => {
139
+ await saveDraft("wizard:to-discard", { name: "Gone soon" }, 0);
140
+ await discardDraft("wizard:to-discard");
141
+ expect((await getDraft("wizard:to-discard")).draft).toBeNull();
142
+ });
143
+
144
+ test("discarding a draftKey that was never saved is a no-op, not an error", async () => {
145
+ await discardDraft("wizard:never-saved");
146
+ expect((await getDraft("wizard:never-saved")).draft).toBeNull();
147
+ });
148
+ });
149
+
150
+ describe("form-draft integration — ownership isolation", () => {
151
+ test("a foreign user's get never sees another user's draft", async () => {
152
+ await saveDraft("wizard:private", { secret: "only mine" }, 0, owner);
153
+ expect((await getDraft("wizard:private", otherUser)).draft).toBeNull();
154
+ });
155
+
156
+ test("a foreign user's discard never removes another user's draft", async () => {
157
+ await saveDraft("wizard:private", { secret: "only mine" }, 0, owner);
158
+ await discardDraft("wizard:private", otherUser);
159
+ expect((await getDraft("wizard:private", owner)).draft?.values).toEqual({
160
+ secret: "only mine",
161
+ });
162
+ });
163
+
164
+ test("a foreign user's save under the same draftKey creates its own row, never overwrites", async () => {
165
+ await saveDraft("wizard:private", { secret: "only mine" }, 0, owner);
166
+ await saveDraft("wizard:private", { secret: "not mine" }, 5, otherUser);
167
+ expect((await getDraft("wizard:private", owner)).draft?.values).toEqual({
168
+ secret: "only mine",
169
+ });
170
+ expect((await getDraft("wizard:private", otherUser)).draft?.values).toEqual({
171
+ secret: "not mine",
172
+ });
173
+ });
174
+
175
+ test("a foreign tenant's get never sees another tenant's draft, even with the same user id", async () => {
176
+ await saveDraft("wizard:tenant-scoped", { secret: "tenant A" }, 0, owner);
177
+ expect((await getDraft("wizard:tenant-scoped", otherTenant)).draft).toBeNull();
178
+ expect((await getDraft("wizard:tenant-scoped", owner)).draft?.values).toEqual({
179
+ secret: "tenant A",
180
+ });
181
+ });
182
+ });
183
+
184
+ describe("form-draft integration — list", () => {
185
+ test("list returns open drafts for a screenId, newest first, without the blob's values", async () => {
186
+ await saveDraft("wizard:a", { name: "First" }, 0);
187
+ await saveDraft("wizard:b", { name: "Second" }, 1);
188
+
189
+ const { drafts } = await listDrafts("wizard");
190
+ expect(drafts.map((d) => d.draftKey)).toEqual(["wizard:b", "wizard:a"]);
191
+ expect(drafts[0]?.stepIndex).toBe(1);
192
+ expect(drafts[0]?.savedAt).toBeTruthy();
193
+ expect(drafts[0]).not.toHaveProperty("values");
194
+ });
195
+
196
+ test("list excludes a draft whose screenId is only a prefix, not a full segment match", async () => {
197
+ await saveDraft("wizard:a", {}, 0);
198
+ await saveDraft("wizardry:x", {}, 0);
199
+
200
+ const { drafts } = await listDrafts("wizard");
201
+ expect(drafts.map((d) => d.draftKey)).toEqual(["wizard:a"]);
202
+ });
203
+
204
+ test("a screenId containing LIKE metacharacters is matched literally, not as a wildcard", async () => {
205
+ await saveDraft("scr%en:a", {}, 0);
206
+ await saveDraft("wizard:a", {}, 0);
207
+
208
+ const { drafts } = await listDrafts("scr%en");
209
+ expect(drafts.map((d) => d.draftKey)).toEqual(["scr%en:a"]);
210
+ });
211
+
212
+ test("list resolves an empty array when nothing was ever saved for the screenId", async () => {
213
+ expect((await listDrafts("no-such-screen")).drafts).toEqual([]);
214
+ });
215
+
216
+ test("list excludes another user's drafts for the same screenId", async () => {
217
+ await saveDraft("wizard:mine", { secret: "only mine" }, 0, owner);
218
+ await saveDraft("wizard:theirs", { secret: "not mine" }, 0, otherUser);
219
+
220
+ expect((await listDrafts("wizard", owner)).drafts.map((d) => d.draftKey)).toEqual([
221
+ "wizard:mine",
222
+ ]);
223
+ expect((await listDrafts("wizard", otherUser)).drafts.map((d) => d.draftKey)).toEqual([
224
+ "wizard:theirs",
225
+ ]);
226
+ });
227
+
228
+ test("list excludes another tenant's drafts, even with the same user id", async () => {
229
+ await saveDraft("wizard:tenant-a", { secret: "tenant A" }, 0, owner);
230
+ expect((await listDrafts("wizard", otherTenant)).drafts).toEqual([]);
231
+ });
232
+ });
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,31 @@
1
+ // @runtime client
2
+ // form-draft bundle constants — feature-name + qualified handler/query names.
3
+ //
4
+ // Part of CosmicDriftGameStudio/kumiko-framework#1883 (wizard mode), issue #1889.
5
+
6
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ export const FORM_DRAFT_FEATURE_NAME = "form-draft";
9
+
10
+ // Qualified handler/query names (QN format: scope:type:name). Clients
11
+ // reference the object instead of magic strings.
12
+ export const FormDraftHandlers = {
13
+ save: "form-draft:write:save",
14
+ discard: "form-draft:write:discard",
15
+ } as const;
16
+
17
+ export const FormDraftQueries = {
18
+ get: "form-draft:query:get",
19
+ list: "form-draft:query:list",
20
+ } as const;
21
+
22
+ // Any authenticated tenant user may save/discard/read their OWN draft — the
23
+ // per-row ownerId check in the handlers (not roles) is what keeps a draft
24
+ // private to the user who created it. See handlers/*.ts.
25
+ export const FORM_DRAFT_ACCESS: AccessRule = { openToAll: true };
26
+
27
+ // Unique index name — shared between entity.ts (index declaration) and
28
+ // handlers/save.write.ts (unique-violation race detection).
29
+ export const FORM_DRAFT_UNIQUE_KEY_CONSTRAINT = "read_form_drafts_tenant_owner_key_uniq";
30
+
31
+ export const FORM_DRAFT_KEY_MAX_LENGTH = 256;
@@ -0,0 +1,50 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { FormDraftBlob } from "../../schemas";
5
+
6
+ export type StaleDraftRow = {
7
+ readonly id: string;
8
+ readonly tenantId: TenantId;
9
+ readonly ownerId: string;
10
+ readonly draft: FormDraftBlob;
11
+ };
12
+
13
+ // COALESCE(modified_at, inserted_at) — a draft's "last touched" timestamp is
14
+ // modified_at once the upsert path (save.write.ts) has updated it at least
15
+ // once; a draft saved exactly once (create, never updated) has no
16
+ // modified_at yet, so falls back to inserted_at.
17
+ //
18
+ // Reads the blob + tenantId BEFORE anything is deleted (issue #1915) — the
19
+ // 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.
23
+ export async function selectStaleDraftsBatch(
24
+ db: DbConnection,
25
+ olderThanDays: number,
26
+ batchSize: number,
27
+ ): Promise<readonly StaleDraftRow[]> {
28
+ const rows = (await asRawClient(db).unsafe(
29
+ `SELECT "id", "tenant_id", "owner_id", "draft" FROM "read_form_drafts"
30
+ WHERE COALESCE("modified_at", "inserted_at") < now() - ($1::int * interval '1 day')
31
+ ORDER BY COALESCE("modified_at", "inserted_at") ASC
32
+ LIMIT $2`,
33
+ [olderThanDays, batchSize],
34
+ )) as readonly { id: string; tenant_id: string; owner_id: string; draft: FormDraftBlob }[];
35
+ return rows.map((row) => ({
36
+ id: row.id,
37
+ tenantId: row.tenant_id as TenantId, // @cast-boundary db-row
38
+ ownerId: row.owner_id,
39
+ draft: row.draft,
40
+ }));
41
+ }
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
+ }
@@ -0,0 +1,26 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+
5
+ // Narrows a draft blob's raw FileRef-shaped storageKeys (collectDraftFileRefKeys
6
+ // output — extracted from free-form, client-supplied JSON, issue #1889) down to
7
+ // the ones with a real, non-deleted file_refs row owned by this exact
8
+ // (tenantId, ownerId). Without this, a crafted draft value like
9
+ // { storageKey: "<someone-else's-real-key>" } would reach the storage
10
+ // provider's delete() unchecked — the same ownership boundary file-routes.ts
11
+ // enforces (loadFileForTenant + FileAccessGuard) before every read/delete.
12
+ export async function filterOwnedStorageKeys(
13
+ db: DbRunner,
14
+ tenantId: TenantId,
15
+ ownerId: string,
16
+ candidateKeys: readonly string[],
17
+ ): Promise<readonly string[]> {
18
+ if (candidateKeys.length === 0) return [];
19
+ const rows = (await asRawClient(db).unsafe(
20
+ `SELECT "storage_key" FROM "file_refs"
21
+ WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
22
+ AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false`,
23
+ [tenantId, ownerId, candidateKeys],
24
+ )) as readonly { storage_key: string }[];
25
+ return rows.map((row) => row.storage_key);
26
+ }
@@ -0,0 +1,51 @@
1
+ import {
2
+ createEntity,
3
+ createJsonbField,
4
+ createTextField,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { FORM_DRAFT_KEY_MAX_LENGTH, FORM_DRAFT_UNIQUE_KEY_CONSTRAINT } from "./constants";
7
+
8
+ // form-draft — a per-user, pre-submission working copy of an in-progress
9
+ // form (e.g. a wizard-mode EditLayout). `draftKey` is caller-assigned
10
+ // (typically screenId + optional hostEntityId) and scoped to
11
+ // (tenant, ownerId): the same key from two different users, or the same
12
+ // user in two different tenants, is a different draft.
13
+ //
14
+ // Boundary (issue #1889): form-draft never holds anything that already
15
+ // lives in a domain stream — it's a Zwischenstand BEFORE the domain entity
16
+ // is created. Consumers delete the draft (discard handler) once the real
17
+ // submit succeeds; partially-created domain data past that point is the
18
+ // consuming app's concern, not this feature's.
19
+ export const formDraftEntity = createEntity({
20
+ table: "read_form_drafts",
21
+ fields: {
22
+ // Never client-supplied — stamped from event.user.id (see handlers), so
23
+ // a draft can't be saved/read/discarded as someone else. subjectRef
24
+ // feeds the GDPR-hook-coverage boot guard (it's a plain FK into `user`,
25
+ // not content of its own) — see ../form-draft-user-data for the
26
+ // required export/delete hook coverage.
27
+ ownerId: createTextField({ subjectRef: true }),
28
+ draftKey: createTextField({ required: true, maxLength: FORM_DRAFT_KEY_MAX_LENGTH }),
29
+ // The blob shape is fixed by issue #1889, not left to the caller:
30
+ // { values: Record<string, unknown>, stepIndex: number, savedAt: string }.
31
+ // Free-form because `values` mirrors whatever fields the in-progress
32
+ // form has, so it can carry arbitrary user-entered PII. NOT annotated
33
+ // `userOwned` — the framework's PII field-encryption engine only
34
+ // accepts string values (pii-field-encryption.ts throws on a non-string
35
+ // field), and this is a jsonb object. Erasure instead runs as a real
36
+ // physical row-delete on forget (see form-draft-user-data/hooks.ts),
37
+ // not crypto-shredding; `ownerId`'s `subjectRef` annotation alone
38
+ // already satisfies the GDPR-hook-coverage boot guard.
39
+ draft: createJsonbField(),
40
+ },
41
+ // One draft per (tenant, owner, draftKey) — save() is an upsert keyed on
42
+ // this index (see handlers/save.write.ts); a resume query must find at
43
+ // most one row.
44
+ indexes: [
45
+ {
46
+ columns: ["tenantId", "ownerId", "draftKey"],
47
+ unique: true,
48
+ name: FORM_DRAFT_UNIQUE_KEY_CONSTRAINT,
49
+ },
50
+ ],
51
+ });
@@ -0,0 +1,7 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { formDraftEntity } from "./entity";
3
+
4
+ export const { executor: formDraftExecutor, table: formDraftTable } = createEntityExecutor(
5
+ "form-draft",
6
+ formDraftEntity,
7
+ );
@@ -0,0 +1,42 @@
1
+ // form-draft — a per-user, per-tenant working copy of an in-progress form,
2
+ // saved BEFORE the real domain entity exists. Backing for wizard-mode resume
3
+ // (kumiko-framework#1884/#1883): a caller-assigned draftKey (typically
4
+ // screenId + optional hostEntityId) round-trips { values, stepIndex,
5
+ // savedAt } through save/discard/get. See entity.ts for the ownership +
6
+ // uniqueness model.
7
+
8
+ import { defineFeature, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { FORM_DRAFT_FEATURE_NAME } from "./constants";
10
+ import { formDraftEntity } from "./entity";
11
+ import { cleanupDraftsJob, formDraftRetentionDaysConfig } from "./handlers/cleanup.job";
12
+ import { discardDraftWrite } from "./handlers/discard.write";
13
+ import { getDraftQuery } from "./handlers/get.query";
14
+ import { listDraftsQuery } from "./handlers/list.query";
15
+ import { saveDraftWrite } from "./handlers/save.write";
16
+ import { FORM_DRAFT_FEATURE_I18N } from "./i18n";
17
+
18
+ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>): void {
19
+ r.describe(
20
+ "Per-user, per-tenant working copy of an in-progress form, saved BEFORE the real domain entity exists. Owns one event-sourced entity, `form-draft` (`read_form_drafts`), keyed by a caller-assigned draftKey (typically screenId + optional hostEntityId), unique per (tenant, owner, draftKey). `save` upserts the draft blob ({ values, stepIndex, savedAt } — savedAt stamped server-side), `discard` deletes it (called once the real submit succeeds), `get` resumes it, `list` finds a user's open drafts for a given screenId (draftKey prefix match) — the fallback when a client-generated draftId is lost. Ownership is enforced by a per-row owner filter in every handler, not by roles — a foreign user's save/discard/get/list for someone else's draftKey never sees or touches that row. Never holds anything that already lives in a domain stream; the consuming app is responsible for discarding once the domain write succeeds. A daily cron job hard-deletes drafts past a configurable retention window (`form-draft:config:retention-days`, SystemAdmin-writable, default 30 days).",
21
+ );
22
+ r.uiHints({
23
+ displayLabel: "Form Drafts",
24
+ category: "data",
25
+ recommended: false,
26
+ });
27
+
28
+ r.requires("config");
29
+ r.entity("form-draft", formDraftEntity);
30
+
31
+ r.writeHandler(saveDraftWrite);
32
+ r.writeHandler(discardDraftWrite);
33
+ r.queryHandler(getDraftQuery);
34
+ r.queryHandler(listDraftsQuery);
35
+
36
+ r.config({ keys: { retentionDays: formDraftRetentionDaysConfig } });
37
+ r.job("cleanup", { trigger: { cron: "0 3 * * *" }, concurrency: "skip" }, cleanupDraftsJob);
38
+
39
+ r.translations({ keys: FORM_DRAFT_FEATURE_I18N });
40
+ }
41
+
42
+ export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
@@ -0,0 +1,120 @@
1
+ // form-draft cleanup — hard-deletes drafts whose last save is older than a
2
+ // configurable retention window (system-wide, one value for the whole
3
+ // deployment — form-draft drafts are ephemeral scratch state, not a
4
+ // per-tenant compliance policy like data-retention). No perTenant fan-out:
5
+ // a single cron run reads the one config value and sweeps the whole table.
6
+ // Chunked DELETE (default 1000/batch) keeps lock durations bounded, mirror
7
+ // of sessions/handlers/cleanup.job.ts.
8
+
9
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
+ import {
11
+ type AppContext,
12
+ access,
13
+ type ConfigKeyDefinition,
14
+ createSystemConfig,
15
+ type JobHandlerFn,
16
+ SYSTEM_TENANT_ID,
17
+ SYSTEM_USER_ID,
18
+ } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
20
+ import {
21
+ deleteDraftsByIds,
22
+ type StaleDraftRow,
23
+ selectStaleDraftsBatch,
24
+ } from "../db/queries/cleanup";
25
+ import { filterOwnedStorageKeys } from "../db/queries/owned-file-refs";
26
+ import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
27
+
28
+ export const FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY = "form-draft:config:retention-days";
29
+ export const FORM_DRAFT_DEFAULT_RETENTION_DAYS = 30;
30
+ const DEFAULT_BATCH_SIZE = 1000;
31
+
32
+ export const formDraftRetentionDaysConfig: ConfigKeyDefinition<"number"> = createSystemConfig(
33
+ "number",
34
+ {
35
+ default: FORM_DRAFT_DEFAULT_RETENTION_DAYS,
36
+ bounds: { min: 1 },
37
+ write: access.systemAdmin,
38
+ read: access.admin,
39
+ },
40
+ );
41
+
42
+ async function releaseRowFileRefs(
43
+ row: StaleDraftRow,
44
+ db: DbConnection,
45
+ fileProviderResolver: AppContext["_fileProviderResolver"],
46
+ log: AppContext["log"],
47
+ ): Promise<void> {
48
+ const keys = collectDraftFileRefKeys(row.draft);
49
+ // skip: no FileRefs in this row's blob — nothing to release.
50
+ if (keys.length === 0) return;
51
+ if (!fileProviderResolver) {
52
+ // skip: no resolver wired (files feature not mounted) — row still
53
+ // gets deleted below, but its FileRefs leak as storage orphans.
54
+ log?.warn?.(
55
+ `[form-draft:cleanup] tenant=${row.tenantId} has ${keys.length} FileRef(s) but no _fileProviderResolver is wired — row will be deleted without releasing storage`,
56
+ );
57
+ // skip: warning already logged above — nothing more to do for this row.
58
+ return;
59
+ }
60
+
61
+ try {
62
+ // Only storageKeys with a real file_refs row owned by this row's
63
+ // draft owner are releasable — `draft.values` is free-form JSON the
64
+ // owning user controls, so an unverified key could target someone
65
+ // else's file (see db/queries/owned-file-refs.ts).
66
+ const ownedKeys = await filterOwnedStorageKeys(db, row.tenantId, row.ownerId, keys);
67
+ const provider = await fileProviderResolver(row.tenantId);
68
+ await releaseDraftFileRefs(ownedKeys, (key) => provider.delete(key), log);
69
+ } catch (err) {
70
+ log?.warn?.(
71
+ `[form-draft:cleanup] no file provider resolvable for tenant=${row.tenantId} — FileRefs NOT released (row still deleted): ${err instanceof Error ? err.message : String(err)}`,
72
+ );
73
+ }
74
+ }
75
+
76
+ export const cleanupDraftsJob: JobHandlerFn = async (_payload, ctx) => {
77
+ if (!ctx.db || !ctx.registry) {
78
+ throw new InternalError({
79
+ message: "[form-draft:cleanup] ctx.db + ctx.registry required (JobContext incomplete)",
80
+ });
81
+ }
82
+ const db = ctx.db as DbConnection;
83
+
84
+ const resolved = ctx.configResolver
85
+ ? await ctx.configResolver.get(
86
+ FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY,
87
+ formDraftRetentionDaysConfig,
88
+ SYSTEM_TENANT_ID,
89
+ SYSTEM_USER_ID,
90
+ db,
91
+ )
92
+ : undefined;
93
+ const retentionDays =
94
+ typeof resolved === "number" && resolved >= 1 ? resolved : FORM_DRAFT_DEFAULT_RETENTION_DAYS;
95
+
96
+ const fileProviderResolver = ctx._fileProviderResolver;
97
+
98
+ let deleted = 0;
99
+ while (true) {
100
+ const batch = await selectStaleDraftsBatch(db, retentionDays, DEFAULT_BATCH_SIZE);
101
+ if (batch.length === 0) break;
102
+
103
+ // Rows span arbitrary tenants (this job sweeps the whole table under
104
+ // SYSTEM_TENANT_ID, not per-tenant) — ctx.files is resolved for a single
105
+ // tenant per job run and would be wrong here, so each row resolves its
106
+ // own tenant's provider via _fileProviderResolver instead. Skipped
107
+ // entirely for rows with no FileRefs in the blob, the common case.
108
+ for (const row of batch) {
109
+ await releaseRowFileRefs(row, db, fileProviderResolver, ctx.log);
110
+ }
111
+
112
+ const batchDeleted = await deleteDraftsByIds(
113
+ db,
114
+ batch.map((row) => row.id),
115
+ );
116
+ deleted += batchDeleted;
117
+ if (batchDeleted === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
118
+ }
119
+ ctx.log?.info?.(`[form-draft:cleanup] deleted=${deleted} retentionDays=${retentionDays}`);
120
+ };
@@ -0,0 +1,55 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { FORM_DRAFT_ACCESS } from "../constants";
3
+ import { filterOwnedStorageKeys } from "../db/queries/owned-file-refs";
4
+ import { formDraftExecutor } from "../executor";
5
+ import { lookupDraft } from "../lookup";
6
+ import { collectDraftFileRefKeys, releaseDraftFileRefs } from "../release-file-refs";
7
+ import { discardDraftPayloadSchema } from "../schemas";
8
+
9
+ // discard — the caller's own draft only. Ownership is enforced by the
10
+ // lookup predicate (tenantId + ownerId + draftKey), not by a separate
11
+ // permission check: a foreign user's discard call for someone else's
12
+ // draftKey finds no row and no-ops (isSuccess, nothing deleted) rather than
13
+ // erroring — same "own rows only, silently absent otherwise" shape as
14
+ // personal-access-tokens' revoke.
15
+ export const discardDraftWrite = defineWriteHandler({
16
+ name: "discard",
17
+ schema: discardDraftPayloadSchema,
18
+ access: FORM_DRAFT_ACCESS,
19
+ handler: async (event, ctx) => {
20
+ const ownerId = event.user.id;
21
+ const existing = await lookupDraft(
22
+ ctx.db,
23
+ event.user.tenantId,
24
+ ownerId,
25
+ event.payload.draftKey,
26
+ );
27
+ if (!existing) {
28
+ return { isSuccess: true as const, data: { discarded: false } };
29
+ }
30
+ const result = await formDraftExecutor.delete({ id: existing.id }, event.user, ctx.db);
31
+ if (!result.isSuccess) return result;
32
+ // Release AFTER the row delete succeeds — a version-conflict/failed
33
+ // delete leaves the draft (and its FileRefs) intact rather than
34
+ // orphaning a photo behind a draft that's still there. Gated on
35
+ // releaseFiles: a successful-submit discard must NOT release — the
36
+ // domain entity the submit just wrote carries the same storageKeys
37
+ // forward (see schemas.ts comment on discardDraftPayloadSchema).
38
+ const files = ctx.files;
39
+ if (files && event.payload.releaseFiles === true) {
40
+ const candidateKeys = collectDraftFileRefKeys(existing.draft);
41
+ // Only storageKeys with a real file_refs row owned by THIS caller are
42
+ // releasable — `values` is free-form JSON the caller controls, so an
43
+ // unverified key could target someone else's file (see
44
+ // db/queries/owned-file-refs.ts).
45
+ const ownedKeys = await filterOwnedStorageKeys(
46
+ ctx.db.raw,
47
+ event.user.tenantId,
48
+ ownerId,
49
+ candidateKeys,
50
+ );
51
+ await releaseDraftFileRefs(ownedKeys, (key) => files.ref(key).delete(), ctx.log);
52
+ }
53
+ return { isSuccess: true as const, data: { discarded: true } };
54
+ },
55
+ });
@@ -0,0 +1,26 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { FORM_DRAFT_ACCESS } from "../constants";
3
+ import { lookupDraft } from "../lookup";
4
+ import type { FormDraftBlob } from "../schemas";
5
+ import { getDraftPayloadSchema } from "../schemas";
6
+
7
+ export type GetDraftResult = { readonly draft: FormDraftBlob | null };
8
+
9
+ // get — resolves to `{ draft: null }` for both "no draft saved yet" and "a
10
+ // draft exists but belongs to someone else / another tenant" — the lookup
11
+ // is scoped by (tenantId, ownerId, draftKey), so a foreign caller's query
12
+ // never surfaces another user's row, it just looks like no draft exists.
13
+ export const getDraftQuery = defineQueryHandler({
14
+ name: "get",
15
+ schema: getDraftPayloadSchema,
16
+ access: FORM_DRAFT_ACCESS,
17
+ handler: async (query, ctx): Promise<GetDraftResult> => {
18
+ const row = await lookupDraft(
19
+ ctx.db,
20
+ query.user.tenantId,
21
+ query.user.id,
22
+ query.payload.draftKey,
23
+ );
24
+ return { draft: row?.draft ?? null };
25
+ },
26
+ });