@cosmicdrift/kumiko-framework 0.70.0 → 0.72.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 +2 -2
- package/src/__tests__/schema-cli-rebuild.integration.test.ts +15 -0
- package/src/api/auth-middleware.ts +4 -2
- package/src/db/__tests__/assert-exists-in.integration.test.ts +16 -0
- package/src/db/__tests__/eagerload.integration.test.ts +17 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +14 -0
- package/src/db/event-store-executor.ts +9 -2
- package/src/engine/__tests__/boot-validator.test.ts +12 -0
- package/src/engine/__tests__/engine.test.ts +33 -0
- package/src/engine/__tests__/feature-manifest.test.ts +20 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +129 -0
- package/src/engine/define-feature.ts +10 -0
- package/src/engine/entity-handlers.ts +5 -0
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round1.ts +11 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +3 -0
- package/src/engine/feature-ast/patterns.ts +12 -0
- package/src/engine/feature-ast/render.ts +7 -0
- package/src/engine/feature-manifest.ts +6 -1
- package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
- package/src/engine/pattern-library/library.ts +11 -0
- package/src/engine/registry.ts +20 -0
- package/src/engine/soft-delete-cleanup.ts +84 -0
- package/src/engine/types/feature.ts +50 -0
- package/src/engine/types/handlers.ts +6 -0
- package/src/engine/types/index.ts +2 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +25 -0
- package/src/files/feature.ts +5 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +13 -0
- package/src/pipeline/dispatcher.ts +11 -1
- package/src/utils/__tests__/ids.test.ts +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.72.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -173,4 +173,19 @@ describe("runSchemaCli apply — projection rebuild", () => {
|
|
|
173
173
|
expect(cap.log.join("\n")).not.toContain("Rebuild");
|
|
174
174
|
expect(await counterFor(group)).toBeUndefined();
|
|
175
175
|
});
|
|
176
|
+
|
|
177
|
+
// Marker zeigt auf eine Tabelle die KEINE registrierte Projektion hat →
|
|
178
|
+
// projections.size === 0 → Early-Return vor dem Rebuild-Schritt (kein Throw,
|
|
179
|
+
// exit 0). Bisher nur "kein Marker" und "keine Features" getestet.
|
|
180
|
+
test("with features: marker for a table with no registered projection → no rebuild, exit 0", async () => {
|
|
181
|
+
await executor.create({ groupId: group, name: "x" }, admin, tdb);
|
|
182
|
+
|
|
183
|
+
const appCwd = writeMigration("0004_unknown_table", ["read_nonexistent_table"]);
|
|
184
|
+
const cap = captureOut();
|
|
185
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, { features: [feature] });
|
|
186
|
+
|
|
187
|
+
expect(code).toBe(0);
|
|
188
|
+
expect(cap.log.join("\n")).not.toContain("Rebuild");
|
|
189
|
+
expect(await counterFor(group)).toBeUndefined();
|
|
190
|
+
});
|
|
176
191
|
});
|
|
@@ -24,8 +24,10 @@ export type AuthTransport = "cookie" | "bearer";
|
|
|
24
24
|
|
|
25
25
|
// Status of a sid from the server's perspective. The sessions feature owns
|
|
26
26
|
// the DB-backed implementation; middleware just consults whatever function
|
|
27
|
-
// the app wires in.
|
|
28
|
-
|
|
27
|
+
// the app wires in. "blocked" = sid is live but the user it belongs to is
|
|
28
|
+
// locked (restricted/deleted) — defense-in-depth so a missed session-revoke
|
|
29
|
+
// can't keep a locked account authenticated.
|
|
30
|
+
export type AuthSessionStatus = "live" | "revoked" | "expired" | "missing" | "blocked";
|
|
29
31
|
|
|
30
32
|
// Called by the middleware after JWT-verify. Gets the sid AND the expected
|
|
31
33
|
// userId from the JWT's `sub` — the checker MUST confirm the session row
|
|
@@ -66,6 +66,16 @@ describe("assertExistsIn — DbConnection + explizite tenantId", () => {
|
|
|
66
66
|
});
|
|
67
67
|
expect(r).toBeInstanceOf(NotFoundError);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
test("zusätzliches where matcht → null (existiert)", async () => {
|
|
71
|
+
const r = await assertExistsIn(stack.db, orderTable, {
|
|
72
|
+
field: "id",
|
|
73
|
+
value: ID_A,
|
|
74
|
+
tenantId: tenantA,
|
|
75
|
+
where: { name: "A-Order" },
|
|
76
|
+
});
|
|
77
|
+
expect(r).toBeNull();
|
|
78
|
+
});
|
|
69
79
|
});
|
|
70
80
|
|
|
71
81
|
describe("assertExistsIn — TenantDb auto-filter", () => {
|
|
@@ -79,6 +89,12 @@ describe("assertExistsIn — TenantDb auto-filter", () => {
|
|
|
79
89
|
const r = await assertExistsIn(dbA, orderTable, { field: "id", value: ID_B });
|
|
80
90
|
expect(r).toBeInstanceOf(NotFoundError);
|
|
81
91
|
});
|
|
92
|
+
|
|
93
|
+
test("fehlende Row via TenantDb → NotFoundError", async () => {
|
|
94
|
+
const dbA = createTenantDb(stack.db, tenantA, "tenant");
|
|
95
|
+
const r = await assertExistsIn(dbA, orderTable, { field: "id", value: MISSING });
|
|
96
|
+
expect(r).toBeInstanceOf(NotFoundError);
|
|
97
|
+
});
|
|
82
98
|
});
|
|
83
99
|
|
|
84
100
|
describe("assertExistsIn — Fehler-Benennung + where", () => {
|
|
@@ -109,6 +109,23 @@ describe("enrichWithReferences", () => {
|
|
|
109
109
|
expect(single(row, "author")).toBeUndefined();
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
// multiple-ref-Contract: IMMER ein Array (gefiltert), nie undefined — anders
|
|
113
|
+
// als single (oben). Ein Renderer kann auf .map() vertrauen ohne null-Check.
|
|
114
|
+
test("multiple-ref filtert cross-tenant raus, behält den Rest als Array", async () => {
|
|
115
|
+
const row = await enrichA({ id: "p1", tags: [A1, BX] });
|
|
116
|
+
expect(many(row, "tags")?.map((t) => t["name"])).toEqual(["Ada"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("multiple-ref nur cross-tenant → [] (leeres Array, NICHT undefined)", async () => {
|
|
120
|
+
const row = await enrichA({ id: "p1", tags: [BX] });
|
|
121
|
+
expect(many(row, "tags")).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("multiple-ref null → [] (leeres Array, NICHT undefined)", async () => {
|
|
125
|
+
const row = await enrichA({ id: "p1", tags: null });
|
|
126
|
+
expect(many(row, "tags")).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
112
129
|
test("TENANT-ISOLATION: cross-tenant-ref wird NICHT aufgelöst", async () => {
|
|
113
130
|
// bx gehört tenantB; dbA ist auf tenantA gescoped → der Lookup filtert
|
|
114
131
|
// ihn raus, _refs bleibt undefined (Renderer fällt auf die UUID zurück).
|
|
@@ -104,6 +104,20 @@ describe("event-store-executor", () => {
|
|
|
104
104
|
const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
|
|
105
105
|
expect(detail).toBeNull();
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
test("list hides soft-deleted rows; includeDeleted returns them (trash query)", async () => {
|
|
109
|
+
const created = await crud.create({ email: "trash@test.de" }, adminUser, tdb);
|
|
110
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
111
|
+
await crud.delete({ id: created.data.id }, adminUser, tdb);
|
|
112
|
+
|
|
113
|
+
const live = await crud.list({}, adminUser, tdb);
|
|
114
|
+
expect(live.rows.find((r) => r["id"] === created.data.id)).toBeUndefined();
|
|
115
|
+
|
|
116
|
+
const trash = await crud.list({}, adminUser, tdb, { includeDeleted: true });
|
|
117
|
+
const row = trash.rows.find((r) => r["id"] === created.data.id);
|
|
118
|
+
expect(row).toBeDefined();
|
|
119
|
+
expect(row?.["isDeleted"]).toBe(true);
|
|
120
|
+
});
|
|
107
121
|
});
|
|
108
122
|
|
|
109
123
|
// Sensitive-field stripping: passwords/tokens/IBANs stay in the entity row
|
|
@@ -218,7 +218,14 @@ export type EventStoreExecutor = {
|
|
|
218
218
|
* hier zur Runtime einen aus ctx.searchAdapter durchreichen.
|
|
219
219
|
* options.searchAdapter (build-time) gewinnt — runtime-Override
|
|
220
220
|
* ist Fallback für die default-Wrapper. */
|
|
221
|
-
runtimeOptions?: {
|
|
221
|
+
runtimeOptions?: {
|
|
222
|
+
readonly searchAdapter?: SearchAdapter;
|
|
223
|
+
// Trash query: skip the implicit `isDeleted = FALSE` filter so soft-
|
|
224
|
+
// deleted rows are returned too. Tenant + ownership clauses still apply
|
|
225
|
+
// — includeDeleted only relaxes the soft-delete predicate, never the
|
|
226
|
+
// visibility ones, so it can ride untrusted query input safely.
|
|
227
|
+
readonly includeDeleted?: boolean;
|
|
228
|
+
},
|
|
222
229
|
) => Promise<CursorResult<Record<string, unknown>>>;
|
|
223
230
|
|
|
224
231
|
detail: (
|
|
@@ -862,7 +869,7 @@ export function createEventStoreExecutor(
|
|
|
862
869
|
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
863
870
|
whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
864
871
|
}
|
|
865
|
-
if (softDelete && table["isDeleted"]) {
|
|
872
|
+
if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
|
|
866
873
|
whereSql.push(`${colSql("isDeleted")} = FALSE`);
|
|
867
874
|
}
|
|
868
875
|
if (payload.cursor) {
|
|
@@ -2016,6 +2016,7 @@ describe("boot-validator", () => {
|
|
|
2016
2016
|
describe("entityList rowAction row-meta allowlist (#331, #323)", () => {
|
|
2017
2017
|
function makeFeature(opts: {
|
|
2018
2018
|
pick?: readonly string[];
|
|
2019
|
+
map?: Record<string, string>;
|
|
2019
2020
|
visibleField?: string;
|
|
2020
2021
|
softDelete?: boolean;
|
|
2021
2022
|
}) {
|
|
@@ -2038,6 +2039,7 @@ describe("boot-validator", () => {
|
|
|
2038
2039
|
label: "actions.archive",
|
|
2039
2040
|
handler: "shop:write:archive",
|
|
2040
2041
|
...(opts.pick ? { payload: { pick: [...opts.pick] } } : {}),
|
|
2042
|
+
...(opts.map ? { payload: { map: { ...opts.map } } } : {}),
|
|
2041
2043
|
...(opts.visibleField ? { visible: { field: opts.visibleField, eq: true } } : {}),
|
|
2042
2044
|
},
|
|
2043
2045
|
],
|
|
@@ -2071,6 +2073,16 @@ describe("boot-validator", () => {
|
|
|
2071
2073
|
);
|
|
2072
2074
|
});
|
|
2073
2075
|
|
|
2076
|
+
test("map-Extractor mit Base-Column tenantId → kein Throw", () => {
|
|
2077
|
+
expect(() => validateBoot([makeFeature({ map: { target: "tenantId" } })])).not.toThrow();
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
test("map-Extractor mit unknown Field → Throw", () => {
|
|
2081
|
+
expect(() => validateBoot([makeFeature({ map: { target: "ghost" } })])).toThrow(
|
|
2082
|
+
/payload references unknown field "ghost"/,
|
|
2083
|
+
);
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2074
2086
|
test("softDelete-Entity: pick isDeleted → kein Throw", () => {
|
|
2075
2087
|
expect(() =>
|
|
2076
2088
|
validateBoot([makeFeature({ pick: ["id", "isDeleted"], softDelete: true })]),
|
|
@@ -241,6 +241,39 @@ describe("defineFeature", () => {
|
|
|
241
241
|
|
|
242
242
|
expect(rolesOf(feature.writeHandlers["user:invite"]?.access)).toEqual(["Admin", "SystemAdmin"]);
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
test("r.uiHints() flows into the definition", () => {
|
|
246
|
+
const feature = defineFeature("test", (r) => {
|
|
247
|
+
r.uiHints({
|
|
248
|
+
displayLabel: "Test Feature",
|
|
249
|
+
category: "demo",
|
|
250
|
+
recommended: true,
|
|
251
|
+
configurableOptions: [
|
|
252
|
+
{ key: "fancy", label: "Fancy mode", type: "boolean", default: false },
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
expect(feature.uiHints?.displayLabel).toBe("Test Feature");
|
|
257
|
+
expect(feature.uiHints?.category).toBe("demo");
|
|
258
|
+
expect(feature.uiHints?.recommended).toBe(true);
|
|
259
|
+
expect(feature.uiHints?.configurableOptions).toEqual([
|
|
260
|
+
{ key: "fancy", label: "Fancy mode", type: "boolean", default: false },
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("uiHints is absent when r.uiHints() is not called", () => {
|
|
265
|
+
const feature = defineFeature("test", () => {});
|
|
266
|
+
expect("uiHints" in feature).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("r.uiHints() throws when called twice", () => {
|
|
270
|
+
expect(() =>
|
|
271
|
+
defineFeature("test", (r) => {
|
|
272
|
+
r.uiHints({ displayLabel: "A" });
|
|
273
|
+
r.uiHints({ displayLabel: "B" });
|
|
274
|
+
}),
|
|
275
|
+
).toThrow(/r\.uiHints\(\) called twice/);
|
|
276
|
+
});
|
|
244
277
|
});
|
|
245
278
|
|
|
246
279
|
// --- Field Factories ---
|
|
@@ -84,4 +84,24 @@ describe("buildManifestFromRegistry — deterministic codepoint sort (#330)", ()
|
|
|
84
84
|
expect(manifest.tier).toBeUndefined();
|
|
85
85
|
expect(manifest.features[0]?.tier).toBeUndefined();
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
test("uiHints flow through to the manifest when set", () => {
|
|
89
|
+
const registry = createRegistry([
|
|
90
|
+
defineFeature("with-hints", (r) => {
|
|
91
|
+
r.uiHints({ displayLabel: "With Hints", category: "demo", recommended: true });
|
|
92
|
+
}),
|
|
93
|
+
defineFeature("no-hints", () => {}),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const manifest = buildManifestFromRegistry(registry, { source: "test" });
|
|
97
|
+
const withHints = manifest.features.find((f) => f.name === "with-hints");
|
|
98
|
+
const noHints = manifest.features.find((f) => f.name === "no-hints");
|
|
99
|
+
|
|
100
|
+
expect(withHints?.uiHints).toEqual({
|
|
101
|
+
displayLabel: "With Hints",
|
|
102
|
+
category: "demo",
|
|
103
|
+
recommended: true,
|
|
104
|
+
});
|
|
105
|
+
expect(noHints && "uiHints" in noHints).toBe(false);
|
|
106
|
+
});
|
|
87
107
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../define-feature";
|
|
3
|
+
import { createRegistry } from "../registry";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_GRACE_DAYS,
|
|
6
|
+
SOFT_DELETE_CLEANUP_JOB,
|
|
7
|
+
SOFT_DELETE_GRACE_DAYS_KEY,
|
|
8
|
+
softDeleteCleanupJob,
|
|
9
|
+
} from "../soft-delete-cleanup";
|
|
10
|
+
import type { AppContext } from "../types/handlers";
|
|
11
|
+
|
|
12
|
+
function featureWith(softDelete: boolean | undefined) {
|
|
13
|
+
return defineFeature("probe-sd", (r) => {
|
|
14
|
+
r.entity("thing", {
|
|
15
|
+
fields: { label: { type: "text" } },
|
|
16
|
+
...(softDelete !== undefined && { softDelete }),
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("registry soft-delete auto-wiring", () => {
|
|
22
|
+
test("injects cleanup job + grace-days config when an entity opts into softDelete", () => {
|
|
23
|
+
const registry = createRegistry([featureWith(true)]);
|
|
24
|
+
expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_JOB)).toBe(true);
|
|
25
|
+
expect(registry.getAllConfigKeys().has(SOFT_DELETE_GRACE_DAYS_KEY)).toBe(true);
|
|
26
|
+
const job = registry.getJob(SOFT_DELETE_CLEANUP_JOB);
|
|
27
|
+
expect(job?.trigger).toEqual({ cron: "0 3 * * *" });
|
|
28
|
+
expect(job?.perTenant).toBe(true);
|
|
29
|
+
const key = registry.getConfigKey(SOFT_DELETE_GRACE_DAYS_KEY);
|
|
30
|
+
expect(key?.type).toBe("number");
|
|
31
|
+
expect(key?.default).toBe(DEFAULT_GRACE_DAYS);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("does NOT inject when no entity uses softDelete", () => {
|
|
35
|
+
const registry = createRegistry([featureWith(false)]);
|
|
36
|
+
expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_JOB)).toBe(false);
|
|
37
|
+
expect(registry.getAllConfigKeys().has(SOFT_DELETE_GRACE_DAYS_KEY)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
type DeleteCall = { table: unknown; where: Record<string, unknown> };
|
|
42
|
+
|
|
43
|
+
function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext {
|
|
44
|
+
// Shaped to satisfy bun-db's tenantDbDelegate() probe so deleteMany() routes
|
|
45
|
+
// to this recorder instead of trying to extract real table metadata.
|
|
46
|
+
const fakeDb = {
|
|
47
|
+
tenantId: "t1",
|
|
48
|
+
raw: { unsafe: async () => [] },
|
|
49
|
+
selectMany: async () => [],
|
|
50
|
+
fetchOne: async () => undefined,
|
|
51
|
+
insertOne: async () => undefined,
|
|
52
|
+
updateMany: async () => [],
|
|
53
|
+
deleteMany: async (table: unknown, where: Record<string, unknown>) => {
|
|
54
|
+
opts.calls.push({ table, where });
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const projections = new Map<string, unknown>([
|
|
58
|
+
// softDelete entity WITH a tenantId column → tenant-scoped delete
|
|
59
|
+
["thing~impl", { isImplicit: true, source: "thing", table: { tenantId: {}, isDeleted: {} } }],
|
|
60
|
+
// softDelete entity WITHOUT a tenantId column → system-global delete
|
|
61
|
+
["sys~impl", { isImplicit: true, source: "sysThing", table: { isDeleted: {} } }],
|
|
62
|
+
// softDelete:false entity → skipped
|
|
63
|
+
[
|
|
64
|
+
"audit~impl",
|
|
65
|
+
{ isImplicit: true, source: "auditEntry", table: { tenantId: {}, isDeleted: {} } },
|
|
66
|
+
],
|
|
67
|
+
// explicit (non-implicit) projection → skipped
|
|
68
|
+
["custom", { isImplicit: false, source: "thing", table: { isDeleted: {} } }],
|
|
69
|
+
]);
|
|
70
|
+
const registry = {
|
|
71
|
+
getAllProjections: () => projections,
|
|
72
|
+
getEntity: (name: string) =>
|
|
73
|
+
({
|
|
74
|
+
thing: { softDelete: true },
|
|
75
|
+
sysThing: { softDelete: true },
|
|
76
|
+
auditEntry: { softDelete: false },
|
|
77
|
+
})[name],
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
db: fakeDb,
|
|
81
|
+
registry,
|
|
82
|
+
systemUser: { tenantId: "t1" },
|
|
83
|
+
...(opts.graceDays !== undefined && {
|
|
84
|
+
configResolver: { get: async () => opts.graceDays },
|
|
85
|
+
}),
|
|
86
|
+
} as unknown as AppContext;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("softDeleteCleanupJob handler", () => {
|
|
90
|
+
test("hard-deletes only softDelete implicit projections, tenant-scoped where the table has tenantId", async () => {
|
|
91
|
+
const calls: DeleteCall[] = [];
|
|
92
|
+
await softDeleteCleanupJob({}, makeCtx({ calls }));
|
|
93
|
+
|
|
94
|
+
// thing + sysThing deleted; auditEntry (softDelete:false) + custom (explicit) skipped
|
|
95
|
+
expect(calls).toHaveLength(2);
|
|
96
|
+
|
|
97
|
+
const tenantScoped = calls.filter((c) => c.where["tenantId"] === "t1");
|
|
98
|
+
const systemScoped = calls.filter((c) => c.where["tenantId"] === undefined);
|
|
99
|
+
expect(tenantScoped).toHaveLength(1);
|
|
100
|
+
expect(systemScoped).toHaveLength(1);
|
|
101
|
+
|
|
102
|
+
for (const c of calls) {
|
|
103
|
+
expect(c.where["isDeleted"]).toBe(true);
|
|
104
|
+
expect(c.where["deletedAt"]).toBeDefined();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("cutoff defaults to DEFAULT_GRACE_DAYS when no config resolver", async () => {
|
|
109
|
+
const calls: DeleteCall[] = [];
|
|
110
|
+
await softDeleteCleanupJob({}, makeCtx({ calls }));
|
|
111
|
+
const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
|
|
112
|
+
const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
|
|
113
|
+
expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("honours a per-tenant grace-days value from the config resolver", async () => {
|
|
117
|
+
const calls: DeleteCall[] = [];
|
|
118
|
+
await softDeleteCleanupJob({}, makeCtx({ calls, graceDays: 7 }));
|
|
119
|
+
const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
|
|
120
|
+
const expected = Temporal.Now.instant().subtract({ hours: 7 * 24 });
|
|
121
|
+
expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("throws when the job context is missing db/registry", async () => {
|
|
125
|
+
await expect(softDeleteCleanupJob({}, {} as AppContext)).rejects.toThrow(
|
|
126
|
+
/ctx.db \+ ctx.registry/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -63,6 +63,7 @@ import type {
|
|
|
63
63
|
TreeActionDef,
|
|
64
64
|
TreeActionsHandle,
|
|
65
65
|
TreeChildrenSubscribe,
|
|
66
|
+
UiHints,
|
|
66
67
|
UnmanagedTableEntry,
|
|
67
68
|
ValidationHookFn,
|
|
68
69
|
WriteHandlerDef,
|
|
@@ -157,6 +158,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
157
158
|
let isSystemScoped = false;
|
|
158
159
|
let toggleableDefault: boolean | undefined;
|
|
159
160
|
let description: string | undefined;
|
|
161
|
+
let uiHints: UiHints | undefined;
|
|
160
162
|
// Visual-Tree-Slots — at-most-one per feature, only-once-guard im
|
|
161
163
|
// registrar (siehe r.treeActions / r.tree). Undefined wenn das Feature
|
|
162
164
|
// keinen Visual-Tree-Beitrag liefert (Zero-Whitelist-Filter).
|
|
@@ -240,6 +242,13 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
240
242
|
toggleableDefault = options.default;
|
|
241
243
|
},
|
|
242
244
|
|
|
245
|
+
uiHints(hints: UiHints): void {
|
|
246
|
+
if (uiHints !== undefined) {
|
|
247
|
+
throw new Error(`[Feature ${name}] r.uiHints() called twice — UI hints are declared once`);
|
|
248
|
+
}
|
|
249
|
+
uiHints = hints;
|
|
250
|
+
},
|
|
251
|
+
|
|
243
252
|
entity(
|
|
244
253
|
entityName: string,
|
|
245
254
|
definition: EntityDefinition,
|
|
@@ -944,6 +953,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
944
953
|
requiredProjections,
|
|
945
954
|
requiredSteps,
|
|
946
955
|
...(toggleableDefault !== undefined && { toggleableDefault }),
|
|
956
|
+
...(uiHints !== undefined && { uiHints }),
|
|
947
957
|
entities,
|
|
948
958
|
entityTables,
|
|
949
959
|
relations,
|
|
@@ -85,6 +85,10 @@ const listSchema = z.object({
|
|
|
85
85
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
86
86
|
offset: z.number().int().nonnegative().optional(),
|
|
87
87
|
totalCount: z.boolean().optional(),
|
|
88
|
+
// Trash query: include soft-deleted rows. Honoured only for softDelete
|
|
89
|
+
// entities; the dispatcher mirrors it onto ctx.includeDeleted. Tenant +
|
|
90
|
+
// ownership filters still apply.
|
|
91
|
+
includeDeleted: z.boolean().optional(),
|
|
88
92
|
filter: z
|
|
89
93
|
.object({
|
|
90
94
|
field: z.string(),
|
|
@@ -219,6 +223,7 @@ export function defineEntityQueryHandler(
|
|
|
219
223
|
const listPayload = query.payload as ListPayload; // @cast-boundary engine-payload
|
|
220
224
|
const result = await executor.list(listPayload, query.user, ctx.db, {
|
|
221
225
|
...(ctx.searchAdapter !== undefined && { searchAdapter: ctx.searchAdapter }),
|
|
226
|
+
...(ctx.includeDeleted === true && { includeDeleted: true }),
|
|
222
227
|
});
|
|
223
228
|
if (!hasRefFields) return result;
|
|
224
229
|
const enrichedRows = await enrichWithReferences(
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
RequiresPattern,
|
|
7
7
|
SystemScopePattern,
|
|
8
8
|
ToggleablePattern,
|
|
9
|
+
UiHintsPattern,
|
|
9
10
|
} from "../patterns";
|
|
10
11
|
import { sourceLocationFromNode } from "../source-location";
|
|
11
12
|
import {
|
|
@@ -84,6 +85,16 @@ export function extractSystemScope(
|
|
|
84
85
|
});
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
export function extractUiHints(
|
|
89
|
+
call: CallExpression,
|
|
90
|
+
sourceFile: SourceFile,
|
|
91
|
+
): ExtractOutput<UiHintsPattern> {
|
|
92
|
+
return ok({
|
|
93
|
+
kind: "uiHints",
|
|
94
|
+
source: sourceLocationFromNode(call, sourceFile),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
export function extractDescribe(
|
|
88
99
|
call: CallExpression,
|
|
89
100
|
sourceFile: SourceFile,
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
extractTranslations,
|
|
59
59
|
extractTree,
|
|
60
60
|
extractTreeActions,
|
|
61
|
+
extractUiHints,
|
|
61
62
|
extractUnmanagedTable,
|
|
62
63
|
extractUseExtension,
|
|
63
64
|
extractUsesApi,
|
|
@@ -301,6 +302,8 @@ function dispatchExtractor(
|
|
|
301
302
|
return extractToggleable(call, sourceFile);
|
|
302
303
|
case "describe":
|
|
303
304
|
return extractDescribe(call, sourceFile);
|
|
305
|
+
case "uiHints":
|
|
306
|
+
return extractUiHints(call, sourceFile);
|
|
304
307
|
// Round 2 — object-literal-based static patterns
|
|
305
308
|
case "entity":
|
|
306
309
|
return extractEntity(call, sourceFile);
|
|
@@ -80,6 +80,7 @@ export type PatternId =
|
|
|
80
80
|
| { readonly kind: "systemScope" }
|
|
81
81
|
| { readonly kind: "toggleable" }
|
|
82
82
|
| { readonly kind: "describe" }
|
|
83
|
+
| { readonly kind: "uiHints" }
|
|
83
84
|
| { readonly kind: "config" }
|
|
84
85
|
| { readonly kind: "translations" }
|
|
85
86
|
| { readonly kind: "authClaims" }
|
|
@@ -272,6 +273,7 @@ export const SINGLETON_KINDS: ReadonlySet<PatternId["kind"]> = new Set([
|
|
|
272
273
|
"systemScope",
|
|
273
274
|
"toggleable",
|
|
274
275
|
"describe",
|
|
276
|
+
"uiHints",
|
|
275
277
|
"config",
|
|
276
278
|
"translations",
|
|
277
279
|
"authClaims",
|
|
@@ -329,6 +331,7 @@ function callMatchesId(call: CallExpression, id: PatternId): boolean {
|
|
|
329
331
|
case "systemScope":
|
|
330
332
|
case "toggleable":
|
|
331
333
|
case "describe":
|
|
334
|
+
case "uiHints":
|
|
332
335
|
case "config":
|
|
333
336
|
case "translations":
|
|
334
337
|
case "authClaims":
|
|
@@ -203,6 +203,16 @@ export type DescribePattern = {
|
|
|
203
203
|
readonly text: string;
|
|
204
204
|
};
|
|
205
205
|
|
|
206
|
+
// `r.uiHints({ displayLabel, category, recommended, configurableOptions })` —
|
|
207
|
+
// picker/scaffolder UI metadata that flows into feature-manifest.json under
|
|
208
|
+
// `feature.uiHints`. Tracked as opaque here (no structured re-parse): the
|
|
209
|
+
// picker reads the manifest, not the AST. The pattern exists so the
|
|
210
|
+
// UnknownPattern dispatcher doesn't trip on a new r.* call.
|
|
211
|
+
export type UiHintsPattern = {
|
|
212
|
+
readonly kind: "uiHints";
|
|
213
|
+
readonly source: SourceLocation;
|
|
214
|
+
};
|
|
215
|
+
|
|
206
216
|
// `r.metric(shortName, options)` — declares a metric under its short name
|
|
207
217
|
// (without the `kumiko_<feature>_` prefix; the framework qualifies it at
|
|
208
218
|
// boot and validates snake_case + type suffix). Runtime usage:
|
|
@@ -590,6 +600,7 @@ export type FeaturePattern =
|
|
|
590
600
|
| SystemScopePattern
|
|
591
601
|
| ToggleablePattern
|
|
592
602
|
| DescribePattern
|
|
603
|
+
| UiHintsPattern
|
|
593
604
|
| MetricPattern
|
|
594
605
|
| SecretPattern
|
|
595
606
|
| ClaimKeyPattern
|
|
@@ -675,6 +686,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
|
|
|
675
686
|
case "extendsRegistrar":
|
|
676
687
|
case "tree":
|
|
677
688
|
case "envSchema":
|
|
689
|
+
case "uiHints":
|
|
678
690
|
case "unknown":
|
|
679
691
|
return "opaque";
|
|
680
692
|
default: {
|
|
@@ -50,6 +50,7 @@ import type {
|
|
|
50
50
|
TranslationsPattern,
|
|
51
51
|
TreeActionsPattern,
|
|
52
52
|
TreePattern,
|
|
53
|
+
UiHintsPattern,
|
|
53
54
|
UnknownPattern,
|
|
54
55
|
UseExtensionPattern,
|
|
55
56
|
UsesApiPattern,
|
|
@@ -80,6 +81,8 @@ export function renderPattern(pattern: FeaturePattern): string {
|
|
|
80
81
|
return renderToggleable(pattern);
|
|
81
82
|
case "describe":
|
|
82
83
|
return renderDescribe(pattern);
|
|
84
|
+
case "uiHints":
|
|
85
|
+
return renderUiHints(pattern);
|
|
83
86
|
case "entity":
|
|
84
87
|
return renderEntity(pattern);
|
|
85
88
|
case "relation":
|
|
@@ -517,6 +520,10 @@ function renderExposesApi(p: ExposesApiPattern): string {
|
|
|
517
520
|
return `r.exposesApi(${JSON.stringify(p.apiName)});`;
|
|
518
521
|
}
|
|
519
522
|
|
|
523
|
+
function renderUiHints(p: UiHintsPattern): string {
|
|
524
|
+
return p.source.raw;
|
|
525
|
+
}
|
|
526
|
+
|
|
520
527
|
function renderUnknown(p: UnknownPattern): string {
|
|
521
528
|
// Round-trip preservation only: emit the raw call text from the
|
|
522
529
|
// SourceLocation so the rendered file stays semantically identical
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { compareByCodepoint } from "../utils";
|
|
10
10
|
import { qualifyEntityName } from "./qualified-name";
|
|
11
|
-
import type { Registry } from "./types/feature";
|
|
11
|
+
import type { Registry, UiHints } from "./types/feature";
|
|
12
12
|
|
|
13
13
|
export type ManifestConfigKey = {
|
|
14
14
|
readonly key: string;
|
|
@@ -57,6 +57,10 @@ export type ManifestFeature = {
|
|
|
57
57
|
* Von `collectWriteHandlerQns` abgeleitet — dient als Source-of-Truth
|
|
58
58
|
* für den Client-seitigen Typcheck von `dispatcher.write`-Calls. */
|
|
59
59
|
readonly writeHandlers: readonly string[];
|
|
60
|
+
/** Picker/scaffolder UI metadata declared via r.uiHints(). Optional —
|
|
61
|
+
* absent for features that haven't been annotated yet (the picker falls
|
|
62
|
+
* back to feature.name + feature.description). */
|
|
63
|
+
readonly uiHints?: UiHints;
|
|
60
64
|
/** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
|
|
61
65
|
readonly tier?: string;
|
|
62
66
|
};
|
|
@@ -148,6 +152,7 @@ export function buildManifestFromRegistry(
|
|
|
148
152
|
configKeys,
|
|
149
153
|
secrets,
|
|
150
154
|
writeHandlers: writeHandlerQns,
|
|
155
|
+
...(feature.uiHints !== undefined && { uiHints: feature.uiHints }),
|
|
151
156
|
...(options.tier !== undefined && { tier: options.tier }),
|
|
152
157
|
});
|
|
153
158
|
}
|
|
@@ -31,6 +31,7 @@ const ALL_KINDS: FeaturePatternKind[] = [
|
|
|
31
31
|
"systemScope",
|
|
32
32
|
"toggleable",
|
|
33
33
|
"describe",
|
|
34
|
+
"uiHints",
|
|
34
35
|
"entity",
|
|
35
36
|
"relation",
|
|
36
37
|
"nav",
|
|
@@ -200,6 +201,8 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
|
|
|
200
201
|
return { kind, source: PLACEHOLDER_LOC, default: false };
|
|
201
202
|
case "describe":
|
|
202
203
|
return { kind, source: PLACEHOLDER_LOC, text: "x" };
|
|
204
|
+
case "uiHints":
|
|
205
|
+
return { kind, source: PLACEHOLDER_LOC };
|
|
203
206
|
case "entity":
|
|
204
207
|
return { kind, source: PLACEHOLDER_LOC, entityName: "x", definition: { fields: {} } };
|
|
205
208
|
case "relation":
|
|
@@ -194,6 +194,16 @@ const describeSchema: PatternFormSchema = {
|
|
|
194
194
|
],
|
|
195
195
|
};
|
|
196
196
|
|
|
197
|
+
const uiHintsSchema: PatternFormSchema = {
|
|
198
|
+
kind: "uiHints",
|
|
199
|
+
label: { en: "UI hints", de: "UI-Hinweise" },
|
|
200
|
+
summary: { en: "Picker/scaffolder metadata. Opaque to the Designer; rendered as raw TS source." },
|
|
201
|
+
category: "meta",
|
|
202
|
+
editability: "opaque",
|
|
203
|
+
singleton: true,
|
|
204
|
+
fields: [],
|
|
205
|
+
};
|
|
206
|
+
|
|
197
207
|
const entitySchema: PatternFormSchema = {
|
|
198
208
|
kind: "entity",
|
|
199
209
|
label: { en: "Entity", de: "Entität" },
|
|
@@ -1161,6 +1171,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
|
|
|
1161
1171
|
systemScope: systemScopeSchema,
|
|
1162
1172
|
toggleable: toggleableSchema,
|
|
1163
1173
|
describe: describeSchema,
|
|
1174
|
+
uiHints: uiHintsSchema,
|
|
1164
1175
|
entity: entitySchema,
|
|
1165
1176
|
relation: relationSchema,
|
|
1166
1177
|
nav: navSchema,
|
package/src/engine/registry.ts
CHANGED
|
@@ -8,6 +8,12 @@ import { asEntityTableMeta } from "../db/query";
|
|
|
8
8
|
import { buildEntityTable } from "../db/table-builder";
|
|
9
9
|
import { buildMetricName, validateMetricName } from "../observability";
|
|
10
10
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
11
|
+
import {
|
|
12
|
+
buildSoftDeleteCleanupJob,
|
|
13
|
+
SOFT_DELETE_CLEANUP_JOB,
|
|
14
|
+
SOFT_DELETE_GRACE_DAYS_KEY,
|
|
15
|
+
softDeleteGraceDaysConfig,
|
|
16
|
+
} from "./soft-delete-cleanup";
|
|
11
17
|
import type {
|
|
12
18
|
AuthClaimsHookDef,
|
|
13
19
|
ClaimKeyDefinition,
|
|
@@ -1320,6 +1326,20 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1320
1326
|
return false;
|
|
1321
1327
|
})();
|
|
1322
1328
|
|
|
1329
|
+
// Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
|
|
1330
|
+
// entity opts into softDelete — the framework owns this machinery, no feature
|
|
1331
|
+
// declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
|
|
1332
|
+
// ungated; config-resolver reads getConfigKey → default. Reserved owner
|
|
1333
|
+
// segment, guarded against a real-feature collision.
|
|
1334
|
+
if ([...entityMap.values()].some((e) => e.softDelete)) {
|
|
1335
|
+
if (!jobMap.has(SOFT_DELETE_CLEANUP_JOB)) {
|
|
1336
|
+
jobMap.set(SOFT_DELETE_CLEANUP_JOB, buildSoftDeleteCleanupJob());
|
|
1337
|
+
}
|
|
1338
|
+
if (!configKeyMap.has(SOFT_DELETE_GRACE_DAYS_KEY)) {
|
|
1339
|
+
configKeyMap.set(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1323
1343
|
return {
|
|
1324
1344
|
features: featureMap,
|
|
1325
1345
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Auto-generated soft-delete maintenance. Hard-deletes entity rows that have
|
|
2
|
+
// been soft-deleted longer than the per-tenant grace period. Injected into the
|
|
3
|
+
// registry (the job + its config key) whenever ANY entity opts into softDelete
|
|
4
|
+
// — see createRegistry. Runtime twin to the auto restore-handler: softDelete:
|
|
5
|
+
// true buys an entity restore + trash (ctx.includeDeleted) + this cleanup, with
|
|
6
|
+
// no feature to wire.
|
|
7
|
+
//
|
|
8
|
+
// Hard-deleting the projection row leaves the event stream intact (source of
|
|
9
|
+
// truth) — a full projection rebuild would replay created+deleted and
|
|
10
|
+
// resurrect the row as isDeleted=true. That's acceptable: cleanup bounds LIVE
|
|
11
|
+
// table growth; irreversible event-log purging is data-retention's job
|
|
12
|
+
// (pruneEvents), a separate, consumer-lag-guarded path.
|
|
13
|
+
|
|
14
|
+
import { deleteMany, type WhereObject } from "../db/query";
|
|
15
|
+
import { SYSTEM_USER_ID } from "./system-user";
|
|
16
|
+
import type { ConfigKeyDefinition, JobDefinition, JobHandlerFn } from "./types/config";
|
|
17
|
+
|
|
18
|
+
// qualifyEntityName convention (feature:type:kebab-name) with a reserved
|
|
19
|
+
// "soft-delete" owner — no real feature owns these; the framework synthesizes
|
|
20
|
+
// them. The job-runner keys cron scheduling off the name, the config-resolver
|
|
21
|
+
// off the key.
|
|
22
|
+
export const SOFT_DELETE_CLEANUP_JOB = "soft-delete:job:cleanup";
|
|
23
|
+
export const SOFT_DELETE_GRACE_DAYS_KEY = "soft-delete:config:grace-days";
|
|
24
|
+
export const DEFAULT_GRACE_DAYS = 30;
|
|
25
|
+
|
|
26
|
+
export const softDeleteGraceDaysConfig: ConfigKeyDefinition = {
|
|
27
|
+
type: "number",
|
|
28
|
+
default: DEFAULT_GRACE_DAYS,
|
|
29
|
+
scope: "tenant",
|
|
30
|
+
access: { read: ["TenantAdmin", "SystemAdmin"], write: ["SystemAdmin"] },
|
|
31
|
+
bounds: { min: 0 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
|
|
35
|
+
const { db, registry } = ctx;
|
|
36
|
+
if (!db || !registry) {
|
|
37
|
+
throw new Error("soft-delete cleanup: ctx.db + ctx.registry required (JobContext incomplete)");
|
|
38
|
+
}
|
|
39
|
+
// perTenant fan-out → one run per active tenant, systemUser scoped to it.
|
|
40
|
+
// The job's db is the boot DbConnection (NOT tenant-scoped), so every delete
|
|
41
|
+
// is explicitly tenant-filtered below — otherwise a tenant with a short grace
|
|
42
|
+
// would purge another tenant's still-within-grace rows.
|
|
43
|
+
const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
|
|
44
|
+
if (tenantId === undefined) {
|
|
45
|
+
// skip: cron fired without a perTenant fan-out tenant — nothing scoped to purge
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const resolved = ctx.configResolver
|
|
50
|
+
? await ctx.configResolver.get(
|
|
51
|
+
SOFT_DELETE_GRACE_DAYS_KEY,
|
|
52
|
+
softDeleteGraceDaysConfig,
|
|
53
|
+
tenantId,
|
|
54
|
+
SYSTEM_USER_ID,
|
|
55
|
+
db,
|
|
56
|
+
)
|
|
57
|
+
: undefined;
|
|
58
|
+
const graceDays = typeof resolved === "number" && resolved >= 0 ? resolved : DEFAULT_GRACE_DAYS;
|
|
59
|
+
const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
|
|
60
|
+
|
|
61
|
+
for (const proj of registry.getAllProjections().values()) {
|
|
62
|
+
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
|
|
63
|
+
const entity = registry.getEntity(proj.source);
|
|
64
|
+
if (!entity?.softDelete) continue;
|
|
65
|
+
const where: WhereObject = { isDeleted: true, deletedAt: { lt: cutoff } };
|
|
66
|
+
// @cast-boundary column-presence probe — identical access the executor's
|
|
67
|
+
// list() does on table["tenantId"] to decide tenant-scoping.
|
|
68
|
+
if ((proj.table as Record<string, unknown>)["tenantId"] !== undefined) {
|
|
69
|
+
where["tenantId"] = tenantId;
|
|
70
|
+
}
|
|
71
|
+
await deleteMany(db, proj.table, where);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function buildSoftDeleteCleanupJob(): JobDefinition {
|
|
76
|
+
return {
|
|
77
|
+
name: SOFT_DELETE_CLEANUP_JOB,
|
|
78
|
+
handler: softDeleteCleanupJob,
|
|
79
|
+
trigger: { cron: "0 3 * * *" },
|
|
80
|
+
perTenant: true,
|
|
81
|
+
concurrency: "skip",
|
|
82
|
+
runIn: "worker",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -171,6 +171,51 @@ export type UnmanagedTableDef = UnmanagedTableEntry & {
|
|
|
171
171
|
readonly featureName: string;
|
|
172
172
|
};
|
|
173
173
|
|
|
174
|
+
// --- UI-Hints (manifest-only, picker/scaffolder metadata) ---
|
|
175
|
+
|
|
176
|
+
// Optional, declarative UI metadata declared via `r.uiHints({...})`. Surfaces
|
|
177
|
+
// in feature-manifest.json under `feature.uiHints`. Consumers (the picker in
|
|
178
|
+
// `create-kumiko-app`, the docs feature-reference) treat absent hints as
|
|
179
|
+
// "no special treatment" — bare feature.name + feature.description still work.
|
|
180
|
+
//
|
|
181
|
+
// Keep this list lean. Anything that already has a home on the feature
|
|
182
|
+
// (configKeys.scope/default/encrypted, secretKeys, requires, etc.) lives there.
|
|
183
|
+
// Only add fields here that are genuinely UI-only.
|
|
184
|
+
export type UiHintOption =
|
|
185
|
+
| {
|
|
186
|
+
readonly key: string;
|
|
187
|
+
readonly label: string;
|
|
188
|
+
readonly type: "boolean";
|
|
189
|
+
readonly default: boolean;
|
|
190
|
+
}
|
|
191
|
+
| {
|
|
192
|
+
readonly key: string;
|
|
193
|
+
readonly label: string;
|
|
194
|
+
readonly type: "select";
|
|
195
|
+
readonly options: readonly string[];
|
|
196
|
+
readonly default: string;
|
|
197
|
+
}
|
|
198
|
+
| {
|
|
199
|
+
readonly key: string;
|
|
200
|
+
readonly label: string;
|
|
201
|
+
readonly type: "text";
|
|
202
|
+
readonly default?: string;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export type UiHints = {
|
|
206
|
+
// Picker-facing label ("Auth · Email + Password" instead of the bare
|
|
207
|
+
// feature-name "auth-email-password").
|
|
208
|
+
readonly displayLabel?: string;
|
|
209
|
+
// Grouping for the picker. Free-form string; the picker sorts/groups by it.
|
|
210
|
+
readonly category?: string;
|
|
211
|
+
// Pre-checked in the picker when the user runs `bun create kumiko-app`.
|
|
212
|
+
readonly recommended?: boolean;
|
|
213
|
+
// Sub-options the picker asks about per-feature (e.g. "Password-Reset-Flow
|
|
214
|
+
// on/off"). The scaffolder maps each key to a generator decision; the
|
|
215
|
+
// framework doesn't act on them at runtime.
|
|
216
|
+
readonly configurableOptions?: readonly UiHintOption[];
|
|
217
|
+
};
|
|
218
|
+
|
|
174
219
|
// --- Feature Definition (output of defineFeature) ---
|
|
175
220
|
|
|
176
221
|
export type FeatureDefinition = {
|
|
@@ -197,6 +242,9 @@ export type FeatureDefinition = {
|
|
|
197
242
|
// means the feature is always-on (e.g. auth, tenant, user — core infra
|
|
198
243
|
// that would brick the system if switchable).
|
|
199
244
|
readonly toggleableDefault?: boolean;
|
|
245
|
+
// Declarative UI metadata for picker/scaffolder tooling. Set via r.uiHints().
|
|
246
|
+
// Pure manifest-side info — the framework runtime doesn't read it.
|
|
247
|
+
readonly uiHints?: UiHints;
|
|
200
248
|
// entities/hooks/entityHooks are optional: defineFeature always
|
|
201
249
|
// materializes them, but hand-built definitions at system boundaries
|
|
202
250
|
// (test fixtures, partial boots — see registry.test.ts "slot robustness")
|
|
@@ -365,6 +413,8 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
365
413
|
// feature; calling on an always-on feature (e.g. auth/tenant/user) is a
|
|
366
414
|
// bug — and one nothing catches at boot, so don't.
|
|
367
415
|
toggleable(options: { default: boolean }): void;
|
|
416
|
+
// Picker/scaffolder metadata — see UiHints. At most once per feature.
|
|
417
|
+
uiHints(hints: UiHints): void;
|
|
368
418
|
|
|
369
419
|
entity(
|
|
370
420
|
name: string,
|
|
@@ -329,6 +329,12 @@ export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedCon
|
|
|
329
329
|
readonly triggeredBy?: { readonly id: string; readonly tenantId: TenantId } | null;
|
|
330
330
|
readonly _userId?: string | undefined;
|
|
331
331
|
readonly _handlerType?: string | undefined;
|
|
332
|
+
// Trash query opt-in (soft-delete). When true, the auto entity-list handler
|
|
333
|
+
// asks the executor to include soft-deleted rows; a custom query handler can
|
|
334
|
+
// read it to branch its own logic. Set by the dispatcher from the query
|
|
335
|
+
// payload's `includeDeleted` field — visibility (tenant/ownership) filters
|
|
336
|
+
// still apply, so this never widens what a user may see beyond the live list.
|
|
337
|
+
readonly includeDeleted?: boolean;
|
|
332
338
|
|
|
333
339
|
readonly query: (qn: string, payload: unknown) => Promise<unknown>;
|
|
334
340
|
readonly queryAs: (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
|
|
@@ -200,3 +200,28 @@ describe("tenant-storage-usage MSP", () => {
|
|
|
200
200
|
).toBeGreaterThanOrEqual(0);
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
|
+
|
|
204
|
+
// The MSP tests above prove upload *accounting*; nothing asserts the file
|
|
205
|
+
// actually comes back. This pins the full HTTP read path (GET /files/:id →
|
|
206
|
+
// provider readStream → response body) — the user-facing roundtrip.
|
|
207
|
+
describe("file download roundtrip (GET /files/:id)", () => {
|
|
208
|
+
async function download(user: SessionUser, id: string): Promise<Response> {
|
|
209
|
+
const token = await stack.jwt.sign(user);
|
|
210
|
+
return stack.app.request(`/api/files/${id}`, {
|
|
211
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
test("returns the exact bytes that were uploaded", async () => {
|
|
216
|
+
const id = await upload(admin, "roundtrip.png", LARGE);
|
|
217
|
+
const res = await download(admin, id);
|
|
218
|
+
expect(res.status).toBe(200);
|
|
219
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(LARGE);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a deleted file is no longer downloadable", async () => {
|
|
223
|
+
const id = await upload(admin, "gone.png", SMALL);
|
|
224
|
+
await deleteFile(admin, id);
|
|
225
|
+
expect((await download(admin, id)).status).toBe(404);
|
|
226
|
+
});
|
|
227
|
+
});
|
package/src/files/feature.ts
CHANGED
|
@@ -19,6 +19,11 @@ export function createFilesFeature(): FeatureDefinition {
|
|
|
19
19
|
r.describe(
|
|
20
20
|
"Exposes the `fileRef` entity and `createFilesFeature` from the framework core so that uploaded files \u2014 tracked in the `file_refs` table by `createFileRoutes` \u2014 participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and future tenant-lifecycle cleanup will delete all refs on tenant destroy. This feature does not add upload or download routes; those remain in the server bootstrap via the `options.files` parameter.",
|
|
21
21
|
);
|
|
22
|
+
r.uiHints({
|
|
23
|
+
displayLabel: "Files \u00b7 Metadata",
|
|
24
|
+
category: "storage",
|
|
25
|
+
recommended: false,
|
|
26
|
+
});
|
|
22
27
|
r.entity("fileRef", fileRefEntity);
|
|
23
28
|
});
|
|
24
29
|
}
|
|
@@ -200,6 +200,19 @@ describe("pending-rebuilds queue", () => {
|
|
|
200
200
|
expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] });
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
// Zwei applied-Migrations, die dieselbe Tabelle anfassen, dürfen sie nur
|
|
204
|
+
// EINMAL queuen (interner Set) — sonst rebuildet ein Run dieselbe Projektion
|
|
205
|
+
// doppelt.
|
|
206
|
+
test("two applied migrations touching the same table → deduplicated to one entry", async () => {
|
|
207
|
+
writeRebuildMarker(markerDir, "0006_a.sql", ["read_pending_counts"]);
|
|
208
|
+
writeRebuildMarker(markerDir, "0006_b.sql", ["read_pending_counts"]);
|
|
209
|
+
const queued = await queueRebuildsFromMarkers(testDb.db, {
|
|
210
|
+
migrationsDir: markerDir,
|
|
211
|
+
appliedIds: ["0006_a", "0006_b"],
|
|
212
|
+
});
|
|
213
|
+
expect(queued).toEqual(["read_pending_counts"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
203
216
|
// #361: eine managed-Tabelle (Marker tragen nur managed), die in DIESEM Run
|
|
204
217
|
// geleert wurde, aber keine Projektion auflöst = owning-Feature fehlt in der
|
|
205
218
|
// Komposition → laut (unresolvedManaged), aber non-fatal (gedraint, kein Throw).
|
|
@@ -247,6 +247,7 @@ export function createDispatcher(
|
|
|
247
247
|
user: SessionUser,
|
|
248
248
|
tx?: DbTx,
|
|
249
249
|
afterCommitHooks?: AfterCommitHook[],
|
|
250
|
+
includeDeleted?: boolean,
|
|
250
251
|
): HandlerContext {
|
|
251
252
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
252
253
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -573,6 +574,7 @@ export function createDispatcher(
|
|
|
573
574
|
_userId: user.id,
|
|
574
575
|
_tenantId: user.tenantId,
|
|
575
576
|
_handlerType: type,
|
|
577
|
+
...(includeDeleted && { includeDeleted: true }),
|
|
576
578
|
...bridge,
|
|
577
579
|
} as HandlerContext; // @cast-boundary engine-bridge
|
|
578
580
|
}
|
|
@@ -773,7 +775,15 @@ export function createDispatcher(
|
|
|
773
775
|
throw validationErrorFromZod(parsed.error);
|
|
774
776
|
}
|
|
775
777
|
|
|
776
|
-
|
|
778
|
+
// Trash opt-in rides the validated query payload: only the entity-list
|
|
779
|
+
// schema (and custom query schemas that opt in) carries `includeDeleted`,
|
|
780
|
+
// so other handlers never see the flag. Visibility filters still apply
|
|
781
|
+
// downstream (see HandlerContext.includeDeleted) — safe from raw input.
|
|
782
|
+
const includeDeleted =
|
|
783
|
+
typeof parsed.data === "object" &&
|
|
784
|
+
parsed.data !== null &&
|
|
785
|
+
(parsed.data as Record<string, unknown>)["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
786
|
+
const handlerContext = buildHandlerContext(type, user, tx, undefined, includeDeleted);
|
|
777
787
|
let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
778
788
|
|
|
779
789
|
// postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { generateId } from "../ids";
|
|
3
|
+
|
|
4
|
+
// generateId is the row/stream/correlation ID source. The callers rely on
|
|
5
|
+
// it being a UUIDv7 (time-sortable → dense B-Tree indexes), not a v4 — a
|
|
6
|
+
// swap to v4 would silently kill the lexicographic-time ordering the event
|
|
7
|
+
// store and time-range queries depend on. These tests pin that contract.
|
|
8
|
+
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
9
|
+
|
|
10
|
+
describe("generateId", () => {
|
|
11
|
+
test("returns a well-formed lowercase UUID", () => {
|
|
12
|
+
expect(generateId()).toMatch(UUID_RX);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("is UUID version 7 (time-sortable, not v4)", () => {
|
|
16
|
+
// Version nibble lives in the 13th hex digit (index 14 incl. dashes).
|
|
17
|
+
expect(generateId()[14]).toBe("7");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("is collision-free across a batch", () => {
|
|
21
|
+
const ids = Array.from({ length: 10_000 }, generateId);
|
|
22
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
23
|
+
});
|
|
24
|
+
});
|