@cosmicdrift/kumiko-framework 0.68.0 → 0.71.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.68.0",
3
+ "version": "0.71.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.68.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.71.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
- export type AuthSessionStatus = "live" | "revoked" | "expired" | "missing";
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?: { readonly searchAdapter?: SearchAdapter },
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 })]),
@@ -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
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ZodType, z } from "zod";
2
+ import type { ContainsSecret } from "../secrets/types";
2
3
  import { runPipeline } from "./run-pipeline";
3
4
  import type {
4
5
  AccessRule,
@@ -83,6 +84,17 @@ export function defineWriteHandler<
83
84
  TMap extends object = KumikoEventTypeMap,
84
85
  >(
85
86
  def: WriteHandlerInput<TName, TSchema, TData, TMap>,
87
+ // R6: a phantom rest-param. When the inferred response `TData` carries a
88
+ // Secret<> anywhere, ContainsSecret<TData> is `true` and this resolves to a
89
+ // 1-tuple the caller can't supply → compile error at the leak site. Clean
90
+ // responses get `[]`, so existing call-sites are unaffected. Checking it in a
91
+ // parameter post-inference (not as a `TData extends …` constraint, which TS
92
+ // rejects as circular, TS2313) is what makes inference survive.
93
+ ..._noSecretInResponse: ContainsSecret<TData> extends true
94
+ ? [
95
+ secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
96
+ ]
97
+ : []
86
98
  ): WriteHandlerDefinition<TName, TSchema, TData, TMap> {
87
99
  // Runtime-guard against accidentally setting BOTH handler+perform.
88
100
  // The discriminated-union type-error
@@ -160,6 +172,13 @@ export function defineQueryHandler<
160
172
  TMap extends object = KumikoEventTypeMap,
161
173
  >(
162
174
  def: QueryHandlerDefinition<TName, TSchema, TResult, TMap>,
175
+ // R6: phantom rest-param — see defineWriteHandler. Forbids a Secret<> in the
176
+ // inferred query response `TResult` at compile time; `[]` for clean responses.
177
+ ..._noSecretInResponse: ContainsSecret<TResult> extends true
178
+ ? [
179
+ secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
180
+ ]
181
+ : []
163
182
  ): QueryHandlerDefinition<TName, TSchema, TResult, TMap> {
164
183
  return def;
165
184
  }
@@ -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(
@@ -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
+ }
@@ -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,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
- const handlerContext = buildHandlerContext(type, user, tx);
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
package/src/schema-cli.ts CHANGED
@@ -296,7 +296,7 @@ export async function runSchemaCli(
296
296
  return 0;
297
297
  } catch (e) {
298
298
  out.err("");
299
- out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
299
+ out.err(` ✗ ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
300
300
  out.err("");
301
301
  return 1;
302
302
  } finally {
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import type { TenantId } from "../../engine";
4
+ import { defineQueryHandler } from "../../engine/define-handler";
5
+ import { type ContainsSecret, createSecret, type Secret } from "../index";
6
+
7
+ // R6 is a COMPILE-TIME guard. The type-level assertions below are the real
8
+ // coverage — they are checked by tsc (the bun runtime strips types without
9
+ // checking). The runtime test only proves the guarded registration functions
10
+ // still build a clean handler.
11
+
12
+ type Expect<T extends true> = T;
13
+ type Equal<A, B> =
14
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
15
+
16
+ // One exported tuple so each case is checked by tsc without tripping
17
+ // noUnusedLocals on a named alias. Any wrong predicate fails to compile here.
18
+ export type _R6TypeAssertions = [
19
+ // Clean responses → false, incl. branded primitives (TenantId, via Primitive)
20
+ // and opaque leaves (Temporal.Instant, Date, via SafeLeaf). These double as
21
+ // the false-positive guard: a mangled leaf would flip the case to `true`.
22
+ Expect<Equal<ContainsSecret<{ ok: true; id: string }>, false>>,
23
+ Expect<Equal<ContainsSecret<{ id: TenantId; when: Temporal.Instant; at: Date }>, false>>,
24
+ Expect<Equal<ContainsSecret<{ items: { label: string }[]; total: number }>, false>>,
25
+ Expect<Equal<ContainsSecret<{ apiKey: string }>, false>>, // revealed → plain string
26
+ // A Secret<> anywhere → true.
27
+ Expect<Equal<ContainsSecret<{ apiKey: Secret<string> }>, true>>,
28
+ Expect<Equal<ContainsSecret<{ creds: { token: Secret<string> } }>, true>>,
29
+ Expect<Equal<ContainsSecret<{ keys: Secret<string>[] }>, true>>,
30
+ Expect<Equal<ContainsSecret<{ when: Temporal.Instant; secret: Secret<string> }>, true>>,
31
+ // Uninspectable types are biased to `false` (allowed) — the runtime guard is
32
+ // the backstop. This is what keeps generic-over-response handlers compiling.
33
+ Expect<Equal<ContainsSecret<unknown>, false>>,
34
+ Expect<Equal<ContainsSecret<never>, false>>,
35
+ ];
36
+
37
+ const schema = z.object({ q: z.string() });
38
+ declare const aSecret: Secret<string>;
39
+
40
+ // End-to-end: a Secret<> in the query response is a compile error at the call.
41
+ // @ts-expect-error — R6: Secret<> must not appear in a handler response
42
+ defineQueryHandler({
43
+ name: "t:query:leak",
44
+ schema,
45
+ handler: async () => ({ apiKey: aSecret }),
46
+ });
47
+
48
+ // Regression: a handler generic over its response (the createTokenRequestHandler
49
+ // pattern) must NOT false-flag — the guard allows what it cannot prove. This
50
+ // call compiling at all is the assertion.
51
+ function genericResponseHandler<K extends string>(kind: K) {
52
+ return defineQueryHandler({
53
+ name: "t:query:generic",
54
+ schema,
55
+ handler: async () => ({ kind, ok: true }) as { kind: K; ok: boolean },
56
+ });
57
+ }
58
+ void genericResponseHandler;
59
+
60
+ describe("R6 ContainsSecret", () => {
61
+ test("a clean query handler still builds — the guard param is invisible", () => {
62
+ const def = defineQueryHandler({
63
+ name: "t:query:clean",
64
+ schema,
65
+ handler: async () => ({ ok: true, value: createSecret("x").reveal() }),
66
+ });
67
+ expect(def.name).toBe("t:query:clean");
68
+ });
69
+ });
@@ -8,6 +8,7 @@ export { decryptValue, encryptValue } from "./envelope";
8
8
  export { assertNoSecretLeak } from "./leak-guard";
9
9
  export { rewrapDek } from "./rotation";
10
10
  export {
11
+ type ContainsSecret,
11
12
  createSecret,
12
13
  type Envelope,
13
14
  isSecret,
@@ -34,6 +34,58 @@ export function isSecret(v: unknown): v is Secret<unknown> {
34
34
  return typeof v === "object" && v !== null && SecretBrand in v;
35
35
  }
36
36
 
37
+ // --- Compile-time response guard (R6) --------------------------------------
38
+ //
39
+ // ContainsSecret<T> is `true` only when a Secret<> is DEFINITELY present
40
+ // somewhere in T. The handler-registration guard (defineWriteHandler/
41
+ // defineQueryHandler) turns a `true` into a compile error — the static twin of
42
+ // assertNoSecretLeak's runtime walk.
43
+ //
44
+ // Biased to `false`: anything it cannot inspect — a bare generic type param (a
45
+ // handler generic over its response), `unknown`/`any`, `never` — resolves to
46
+ // `false` = allowed, with the runtime guard as the backstop. The alternative
47
+ // (default-to-leak) false-flags every legitimate generic-over-response handler.
48
+ //
49
+ // Branch order is load-bearing: never/unknown/any first (uninspectable), then
50
+ // Secret, then primitives (covers branded primitives like TenantId without
51
+ // enumerating them), then the SafeLeaf allowlist (opaque class instances that
52
+ // blind `{ [K in keyof T] }` recursion would mangle — the type-level mirror of
53
+ // leak-guard.ts skipping non-plain objects), then arrays, then a "does any
54
+ // field contain a secret" fold over plain objects.
55
+ type Primitive = string | number | boolean | bigint | symbol | null | undefined;
56
+
57
+ // Opaque built-in leaves a response legitimately carries; never recurse into
58
+ // them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
59
+ type SafeLeaf =
60
+ | Date
61
+ | RegExp
62
+ | Temporal.Instant
63
+ | Temporal.ZonedDateTime
64
+ | Temporal.PlainDate
65
+ | Temporal.PlainDateTime
66
+ | Temporal.PlainTime
67
+ | Temporal.PlainYearMonth
68
+ | Temporal.PlainMonthDay
69
+ | Temporal.Duration;
70
+
71
+ export type ContainsSecret<T> = [T] extends [never]
72
+ ? false
73
+ : unknown extends T
74
+ ? false
75
+ : T extends Secret<unknown>
76
+ ? true
77
+ : T extends Primitive
78
+ ? false
79
+ : T extends SafeLeaf
80
+ ? false
81
+ : T extends readonly (infer U)[]
82
+ ? ContainsSecret<U>
83
+ : T extends object
84
+ ? true extends { [K in keyof T]-?: ContainsSecret<T[K]> }[keyof T]
85
+ ? true
86
+ : false
87
+ : false;
88
+
37
89
  // Per-read audit context. Populated by requireSecretsContext() wrapper so
38
90
  // handlers don't need to pass userId/handlerName manually on every call.
39
91
  // Undefined for framework-internal reads (rotation job, tests) — the audit