@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.
- package/package.json +7 -7
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +43 -1
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +237 -0
- package/src/file-derivatives/feature.ts +142 -21
- package/src/file-derivatives/handlers/public-variant.query.ts +122 -0
- package/src/file-derivatives/index.ts +3 -2
- package/src/file-derivatives/presets.ts +7 -0
- package/src/form-draft/__tests__/cleanup.integration.test.ts +30 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +26 -1
- package/src/form-draft/__tests__/feature-optional-config.integration.test.ts +90 -0
- package/src/form-draft/db/queries/cleanup.ts +15 -14
- package/src/form-draft/db/queries/owned-file-refs.ts +13 -2
- package/src/form-draft/feature.ts +7 -1
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +55 -0
- package/src/form-draft/handlers/cleanup.job.ts +57 -11
- package/src/form-draft/handlers/discard.write.ts +1 -0
- package/src/form-draft/lookup.ts +2 -0
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +60 -0
- package/src/form-draft-user-data/hooks.ts +12 -1
- package/src/template-resolver/handlers/by-slug.query.ts +1 -0
- package/src/template-resolver/handlers/collection-shared.ts +2 -0
- package/src/template-resolver/web/__tests__/editor-read-only.test.tsx +25 -0
- package/src/template-resolver/web/client-plugin.tsx +22 -0
- 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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
}
|
package/src/form-draft/lookup.ts
CHANGED
|
@@ -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
|
};
|
|
@@ -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
|
});
|