@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.
- package/package.json +9 -7
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/retention.integration.test.ts +2 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +254 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +165 -0
- package/src/form-draft/__tests__/form-draft.integration.test.ts +232 -0
- package/src/form-draft/changes.json +1 -0
- package/src/form-draft/constants.ts +31 -0
- package/src/form-draft/db/queries/cleanup.ts +50 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +26 -0
- package/src/form-draft/entity.ts +51 -0
- package/src/form-draft/executor.ts +7 -0
- package/src/form-draft/feature.ts +42 -0
- package/src/form-draft/handlers/cleanup.job.ts +120 -0
- package/src/form-draft/handlers/discard.write.ts +55 -0
- package/src/form-draft/handlers/get.query.ts +26 -0
- package/src/form-draft/handlers/list.query.ts +40 -0
- package/src/form-draft/handlers/save.write.ts +60 -0
- package/src/form-draft/i18n.ts +3 -0
- package/src/form-draft/index.ts +28 -0
- package/src/form-draft/lookup.ts +53 -0
- package/src/form-draft/release-file-refs.ts +69 -0
- package/src/form-draft/schemas.ts +47 -0
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +146 -0
- package/src/form-draft-user-data/changes.json +1 -0
- package/src/form-draft-user-data/hooks.ts +49 -0
- package/src/form-draft-user-data/index.ts +21 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +1 -1
- 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,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
|
+
});
|