@cosmicdrift/kumiko-framework 0.105.1 → 0.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/jwt.test.ts +27 -0
  8. package/src/api/__tests__/pat-scope.test.ts +36 -0
  9. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  10. package/src/api/auth-middleware.ts +65 -1
  11. package/src/api/auth-routes.ts +11 -0
  12. package/src/api/index.ts +3 -1
  13. package/src/api/jwt.ts +5 -5
  14. package/src/api/pat-scope.ts +14 -0
  15. package/src/api/request-context.ts +3 -0
  16. package/src/api/request-id-middleware.ts +2 -0
  17. package/src/api/routes.ts +22 -0
  18. package/src/api/server.ts +29 -1
  19. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  20. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  21. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  22. package/src/bun-db/query.ts +40 -9
  23. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  24. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  25. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  26. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  27. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  28. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  29. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  31. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  32. package/src/db/apply-entity-event.ts +19 -8
  33. package/src/db/event-store-executor.ts +91 -8
  34. package/src/db/queries/shadow-swap.ts +1 -1
  35. package/src/db/query.ts +1 -0
  36. package/src/db/table-builder.ts +23 -1
  37. package/src/db/tenant-db.ts +6 -0
  38. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  39. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  40. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  41. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  42. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  43. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  44. package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
  45. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  46. package/src/engine/boot-validator/entity-handler.ts +45 -0
  47. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  48. package/src/engine/boot-validator/index.ts +14 -1
  49. package/src/engine/boot-validator/screens-nav.ts +90 -6
  50. package/src/engine/build-app-schema.ts +15 -7
  51. package/src/engine/define-feature.ts +17 -0
  52. package/src/engine/define-handler.ts +16 -2
  53. package/src/engine/entity-handlers.ts +32 -13
  54. package/src/engine/extensions/user-data.ts +6 -0
  55. package/src/engine/index.ts +6 -1
  56. package/src/engine/projection-helpers.ts +8 -5
  57. package/src/engine/registry.ts +47 -2
  58. package/src/engine/schema-builder.ts +3 -1
  59. package/src/engine/soft-delete-cleanup.ts +41 -4
  60. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  61. package/src/engine/tier-resolver-extension.ts +11 -0
  62. package/src/engine/types/feature.ts +29 -21
  63. package/src/engine/types/fields.ts +12 -0
  64. package/src/engine/types/handlers.ts +13 -0
  65. package/src/engine/types/index.ts +2 -0
  66. package/src/engine/types/projection.ts +33 -5
  67. package/src/event-store/index.ts +8 -0
  68. package/src/event-store/rebuild-dead-letter.ts +111 -0
  69. package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
  70. package/src/files/file-routes.ts +1 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
  72. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  73. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
  74. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  75. package/src/pipeline/dispatcher.ts +4 -10
  76. package/src/pipeline/msp-rebuild.ts +36 -3
  77. package/src/pipeline/projection-rebuild.ts +55 -3
  78. package/src/pipeline/projections-runner.ts +1 -1
  79. package/src/schema-cli.ts +24 -15
  80. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  81. package/src/secrets/types.ts +8 -1
  82. package/src/testing/db-cleanup.ts +4 -1
  83. package/src/testing/index.ts +1 -0
  84. package/src/testing/seed.ts +50 -0
  85. package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
  86. package/src/time/__tests__/geo-tz.test.ts +9 -3
  87. package/src/time/__tests__/iana.test.ts +9 -0
  88. package/src/time/boot-tz-warning.ts +5 -1
  89. package/src/time/iana.ts +17 -15
  90. package/src/time/tz-context.ts +6 -1
  91. package/src/utils/__tests__/serialization.test.ts +6 -0
  92. package/src/utils/serialization.ts +10 -3
@@ -32,7 +32,7 @@ import {
32
32
  SETTINGS_HUB_FEATURE,
33
33
  } from "./build-config-feature-schema";
34
34
  import type { Registry } from "./types/feature";
35
- import type { DerivedFieldDef, FieldDefinition } from "./types/fields";
35
+ import type { ClientDerivedFieldDef, DerivedFieldDef, FieldDefinition } from "./types/fields";
36
36
 
37
37
  export type BuildAppSchemaOptions = {
38
38
  /** Dev-server authoring hints (Settings-Hub placement). Default off — only
@@ -290,7 +290,7 @@ function projectEntity(entity: EntityDefinition): EntityDefinition {
290
290
  // `entity.derivedFields[field].valueType` auf — fehlt der Eintrag, wirft es
291
291
  // "references unknown field". Der executor hat den Wert server-seitig schon
292
292
  // an die Row gehängt; der Client braucht nur den valueType für den Renderer.
293
- const derivedOut: Record<string, DerivedFieldDef> = {};
293
+ const derivedOut: Record<string, ClientDerivedFieldDef> = {};
294
294
  for (const [name, derivedDef] of Object.entries(entity.derivedFields ?? {})) {
295
295
  derivedOut[name] = projectDerivedField(derivedDef);
296
296
  }
@@ -300,16 +300,24 @@ function projectEntity(entity: EntityDefinition): EntityDefinition {
300
300
  // Kein Cast nötig: alle weggelassenen Felder sind `?`-optional.
301
301
  return {
302
302
  fields: fieldsOut,
303
- ...(Object.keys(derivedOut).length > 0 && { derivedFields: derivedOut }),
303
+ // @cast-boundary schema-walk: EntityDefinition.derivedFields is typed for
304
+ // the SERVER (derive required); this is the one place a client schema
305
+ // narrows it to ClientDerivedFieldDef (valueType only) — the cast lives
306
+ // at the actual server/client type boundary instead of inside
307
+ // projectDerivedField, which now honestly returns the narrower type.
308
+ ...(Object.keys(derivedOut).length > 0 && {
309
+ derivedFields: derivedOut as unknown as Record<string, DerivedFieldDef>,
310
+ }),
304
311
  ...(typeof entity.table === "string" && { table: entity.table }),
305
312
  };
306
313
  }
307
314
 
308
315
  // Nur valueType durch — die derive-fn ist Server-only und NICHT JSON-safe
309
- // (würde sonst die Output-Walk-Guard triggern). Der Cast bridged die
310
- // fn-lose Projektion auf DerivedFieldDef (Client liest nur valueType).
311
- function projectDerivedField(derivedDef: DerivedFieldDef): DerivedFieldDef {
312
- return { valueType: derivedDef.valueType } as DerivedFieldDef; // @cast-boundary schema-walk
316
+ // (würde sonst die Output-Walk-Guard triggern). Gibt ehrlich
317
+ // ClientDerivedFieldDef zurück statt eine DerivedFieldDef vorzutäuschen,
318
+ // die keine derive-fn hat.
319
+ function projectDerivedField(derivedDef: DerivedFieldDef): ClientDerivedFieldDef {
320
+ return { valueType: derivedDef.valueType };
313
321
  }
314
322
 
315
323
  // Whitelist pro Field. `default` darf nur durch wenn Literal (string/
@@ -15,6 +15,7 @@ import type {
15
15
  ConfigKeyType,
16
16
  ConfigSeedDef,
17
17
  EntityDefinition,
18
+ EntityProjectionExtension,
18
19
  EntityRef,
19
20
  EventDef,
20
21
  EventMigrationDef,
@@ -141,6 +142,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
141
142
  const secretKeys: Record<string, SecretKeyDefinition> = {};
142
143
  const projections: Record<string, ProjectionDefinition> = {};
143
144
  const multiStreamProjections: Record<string, MultiStreamProjectionDefinition> = {};
145
+ const entityProjectionExtensions: Record<string, EntityProjectionExtension[]> = {};
144
146
  const rawTables: Record<string, RawTableEntry> = {};
145
147
  const unmanagedTables: Record<string, UnmanagedTableEntry> = {};
146
148
  const authClaimsHooks: AuthClaimsFn[] = [];
@@ -727,6 +729,20 @@ export function defineFeature<const TName extends string, TExports = undefined>(
727
729
  multiStreamProjections[definition.name] = definition;
728
730
  },
729
731
 
732
+ extendEntityProjection(entityName: string, extension: EntityProjectionExtension): void {
733
+ if (Object.keys(extension.apply).length === 0) {
734
+ throw new Error(
735
+ `[Feature ${name}] extendEntityProjection("${entityName}") has no apply handlers. ` +
736
+ `Declare at least one event type, otherwise the rebuild replay has nothing to do.`,
737
+ );
738
+ }
739
+ // Entity existence + apply-key collisions are validated at registry
740
+ // build — r.entity may legally be called after this in the same feature.
741
+ const list = entityProjectionExtensions[entityName] ?? [];
742
+ list.push(extension);
743
+ entityProjectionExtensions[entityName] = list;
744
+ },
745
+
730
746
  authClaims(fn: AuthClaimsFn): void {
731
747
  authClaimsHooks.push(fn);
732
748
  },
@@ -997,6 +1013,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
997
1013
  metrics,
998
1014
  secretKeys,
999
1015
  projections,
1016
+ entityProjectionExtensions,
1000
1017
  multiStreamProjections,
1001
1018
  authClaimsHooks,
1002
1019
  claimKeys,
@@ -90,7 +90,20 @@ export function defineWriteHandler<
90
90
  // responses get `[]`, so existing call-sites are unaffected. Checking it in a
91
91
  // parameter post-inference (not as a `TData extends …` constraint, which TS
92
92
  // rejects as circular, TS2313) is what makes inference survive.
93
- ..._noSecretInResponse: ContainsSecret<TData> extends true
93
+ //
94
+ // Membership form `true extends ContainsSecret<TData>` (556/1), not
95
+ // `ContainsSecret<TData> extends true`: when TData is a union like
96
+ // `{ok:true} | {s:Secret<string>}`, the naked-type-parameter conditional in
97
+ // ContainsSecret DISTRIBUTES over the union, so the result is
98
+ // `false | true` = `boolean`, not the literal `true` — `ContainsSecret<
99
+ // TData> extends true` is then false (boolean isn't assignable to the
100
+ // literal true) and the old check silently fell through to `[]` even with
101
+ // a real leak in one branch. Putting the naked `true` on the LEFT instead
102
+ // keeps the check fail-closed for the union case (`true extends boolean`
103
+ // is true) without eagerly normalizing ContainsSecret<TData> against a
104
+ // literal — which is what blew up TS's instantiation depth on generic
105
+ // call-sites (createTokenRequestHandler's still-unresolved TSuccessKind).
106
+ ..._noSecretInResponse: true extends ContainsSecret<TData>
94
107
  ? [
95
108
  secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
96
109
  ]
@@ -174,7 +187,8 @@ export function defineQueryHandler<
174
187
  def: QueryHandlerDefinition<TName, TSchema, TResult, TMap>,
175
188
  // R6: phantom rest-param — see defineWriteHandler. Forbids a Secret<> in the
176
189
  // inferred query response `TResult` at compile time; `[]` for clean responses.
177
- ..._noSecretInResponse: ContainsSecret<TResult> extends true
190
+ // Membership form (556/1) — see defineWriteHandler's comment for why.
191
+ ..._noSecretInResponse: true extends ContainsSecret<TResult>
178
192
  ? [
179
193
  secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
180
194
  ]
@@ -1,19 +1,20 @@
1
1
  import { type ZodType, z } from "zod";
2
2
  import type { DbRow } from "../db/connection";
3
- import type { TableColumns } from "../db/dialect";
4
3
  import {
5
4
  collectReferenceFields,
6
5
  enrichRowWithReferences,
7
6
  enrichWithReferences,
8
7
  } from "../db/eagerload";
9
8
  import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-store-executor";
10
- import { buildEntityTable } from "../db/table-builder";
9
+ import { buildEntityTable, type EntityTable } from "../db/table-builder";
10
+ import { createTenantDb, type TenantDb } from "../db/tenant-db";
11
11
  import { assertUnreachable } from "../utils";
12
12
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
13
13
  import type {
14
14
  AccessRule,
15
15
  DeriveContext,
16
16
  EntityDefinition,
17
+ HandlerContext,
17
18
  QueryHandlerDef,
18
19
  WriteHandlerDef,
19
20
  } from "./types";
@@ -222,7 +223,7 @@ function augmentDerivedFields(
222
223
  export function defineEntityQueryHandler(
223
224
  name: string,
224
225
  entity: EntityDefinition,
225
- options?: { access?: AccessRule },
226
+ options?: { access?: AccessRule; crossTenant?: boolean },
226
227
  ): QueryHandlerDef {
227
228
  const { entityName, verb } = parseHandlerName(name, QUERY_VERBS);
228
229
 
@@ -240,6 +241,17 @@ export function defineEntityQueryHandler(
240
241
  // Wrapper zu nutzen).
241
242
  const hasRefFields = collectReferenceFields(entity).length > 0;
242
243
 
244
+ // crossTenant: this ONE handler reads across every tenant (e.g. a
245
+ // SystemAdmin-only operator inspector) without making the whole feature
246
+ // r.systemScope() — that would drop tenant isolation from every OTHER
247
+ // handler the feature registers too. The executor's list()/detail() only
248
+ // add a tenant filter when db.mode === "tenant" (see event-store-executor
249
+ // .ts), so handing them a "system"-mode TenantDb built from the same raw
250
+ // connection is enough to skip it — access-control (who may call this
251
+ // handler at all) is unaffected, still gated by `options.access`.
252
+ const dbFor = (ctx: HandlerContext): TenantDb =>
253
+ options?.crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
254
+
243
255
  switch (verb) {
244
256
  case "list":
245
257
  schema = listSchema;
@@ -250,7 +262,8 @@ export function defineEntityQueryHandler(
250
262
  // Definition-Time gebaut, kennt den Adapter also nicht —
251
263
  // Runtime-Override holt das.
252
264
  const listPayload = query.payload as ListPayload; // @cast-boundary engine-payload
253
- const result = await executor.list(listPayload, query.user, ctx.db, {
265
+ const db = dbFor(ctx);
266
+ const result = await executor.list(listPayload, query.user, db, {
254
267
  ...(ctx.searchAdapter !== undefined && { searchAdapter: ctx.searchAdapter }),
255
268
  ...(ctx.includeDeleted === true && { includeDeleted: true }),
256
269
  });
@@ -259,7 +272,7 @@ export function defineEntityQueryHandler(
259
272
  result.rows,
260
273
  entity,
261
274
  (name) => ctx.registry.getEntity(name),
262
- ctx.db,
275
+ db,
263
276
  )
264
277
  : result.rows;
265
278
  return { ...result, rows: augmentDerivedFields(enrichedRows, entity) };
@@ -268,9 +281,10 @@ export function defineEntityQueryHandler(
268
281
  case "detail":
269
282
  schema = idSchema;
270
283
  handler = async (query, ctx) => {
271
- const row = await executor.detail(query.payload as IdPayload, query.user, ctx.db); // @cast-boundary engine-payload
284
+ const db = dbFor(ctx);
285
+ const row = await executor.detail(query.payload as IdPayload, query.user, db); // @cast-boundary engine-payload
272
286
  if (row === null || !hasRefFields) return row;
273
- return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), ctx.db);
287
+ return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db);
274
288
  };
275
289
  break;
276
290
  default:
@@ -306,6 +320,14 @@ export function defineEntityQueryHandler(
306
320
  // — feasible but not yet wired; the runtime guard catches misuse.
307
321
 
308
322
  type EntityHandlerOptions = { readonly access?: AccessRule };
323
+ type EntityQueryHandlerOptions = EntityHandlerOptions & {
324
+ /** Reads across every tenant instead of the caller's own — for a
325
+ * SystemAdmin-only operator inspector over an otherwise tenant-scoped
326
+ * entity. Scope this to the ONE handler that needs it rather than making
327
+ * the whole feature r.systemScope(), which would drop tenant isolation
328
+ * from every other handler the feature registers too. */
329
+ readonly crossTenant?: boolean;
330
+ };
309
331
 
310
332
  // @wrapper-known semantic-alias
311
333
  export function defineEntityCreateHandler(
@@ -347,7 +369,7 @@ export function defineEntityRestoreHandler(
347
369
  export function defineEntityListHandler(
348
370
  entityName: string,
349
371
  entity: EntityDefinition,
350
- options?: EntityHandlerOptions,
372
+ options?: EntityQueryHandlerOptions,
351
373
  ): QueryHandlerDef {
352
374
  return defineEntityQueryHandler(`${entityName}:list`, entity, options);
353
375
  }
@@ -356,14 +378,11 @@ export function defineEntityListHandler(
356
378
  export function defineEntityDetailHandler(
357
379
  entityName: string,
358
380
  entity: EntityDefinition,
359
- options?: EntityHandlerOptions,
381
+ options?: EntityQueryHandlerOptions,
360
382
  ): QueryHandlerDef {
361
383
  return defineEntityQueryHandler(`${entityName}:detail`, entity, options);
362
384
  }
363
385
 
364
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle dynamic table — erased on purpose, same as db/event-store-executor.ts does.
365
- type AnyTable = TableColumns<any>;
366
-
367
386
  // Bundle the two calls every custom write-handler opens with: build the
368
387
  // Drizzle table from the entity, then wire an event-store executor onto it.
369
388
  // The pair is identical in every sample that hand-writes handlers, so the
@@ -377,7 +396,7 @@ type AnyTable = TableColumns<any>;
377
396
  export function createEntityExecutor(
378
397
  entityName: string,
379
398
  entity: EntityDefinition,
380
- ): { readonly table: AnyTable; readonly executor: EventStoreExecutor } {
399
+ ): { readonly table: EntityTable; readonly executor: EventStoreExecutor } {
381
400
  const table = buildEntityTable(entityName, entity);
382
401
  const executor = createEventStoreExecutor(table, entity, { entityName });
383
402
  return { table, executor };
@@ -90,6 +90,12 @@ export interface UserDataHookCtx {
90
90
  * touch tenant-scoped rows. Absent → treat as `"multi-user"` (no erasure).
91
91
  */
92
92
  readonly tenantModel?: TenantUserModel;
93
+ /**
94
+ * Original user email captured before the forget transaction anonymizes it.
95
+ * Set on delete hooks during `runForgetCleanup` so matchers (e.g. email
96
+ * subscriptions) work in every tenant pass. Absent on export hooks.
97
+ */
98
+ readonly userEmailBeforeDelete?: string | null;
93
99
  }
94
100
 
95
101
  /**
@@ -170,8 +170,12 @@ export {
170
170
  checkWriteFieldRoles,
171
171
  filterReadFields,
172
172
  } from "./field-access";
173
+ // findForbiddenMembershipRole/isForbiddenMembershipRole/
174
+ // stripForbiddenMembershipRoles are Public API for host apps that build
175
+ // their own membership handlers. FORBIDDEN_MEMBERSHIP_ROLES itself stays
176
+ // internal (637/3) — exporting the raw Set would make its representation a
177
+ // semver promise; the predicate functions are the intended surface.
173
178
  export {
174
- FORBIDDEN_MEMBERSHIP_ROLES,
175
179
  findForbiddenMembershipRole,
176
180
  isForbiddenMembershipRole,
177
181
  stripForbiddenMembershipRoles,
@@ -214,6 +218,7 @@ export {
214
218
  export {
215
219
  type EffectiveFeaturesResolver,
216
220
  findTierResolverUsage,
221
+ isTierResolverPlugin,
217
222
  TENANT_TIER_RESOLVER_EXT,
218
223
  type TierResolverPlugin,
219
224
  type TrialGate,
@@ -11,16 +11,19 @@ import type { MultiStreamApplyFn, ProjectionTable, SingleStreamApplyFn } from ".
11
11
  // Der Helper ist ein purer Type-Vehikel — zur Laufzeit identitäts-fn:
12
12
  //
13
13
  // apply: {
14
- // "user.created": defineApply<UserCreatedPayload>(async (event, tx) => {
15
- // // event.payload ist UserCreatedPayload, nicht Record<string, unknown>
16
- // await tx.insert(usersTable).values({ id: event.aggregateId, ...event.payload });
14
+ // "user.created": defineApply<UserCreatedPayload>(async (event, tx, table) => {
15
+ // // event.payload ist UserCreatedPayload, nicht Record<string, unknown>.
16
+ // // Write through the passed `table` (ES-blessed inside apply), not a
17
+ // // closed-over branded constant.
18
+ // await insertOne(tx, table, { id: event.aggregateId, ...event.payload });
17
19
  // }),
18
20
  // }
19
21
  //
20
22
  // Default-Generic = Record<string, unknown> behält rückwärtskompatibles
21
- // Verhalten für Apply-Handler die ohne Type-Argument geschrieben sind.
23
+ // Verhalten für Apply-Handler die ohne Type-Argument geschrieben sind. Der
24
+ // `table`-Param ist optional zu nutzen — 2-arg-Applies bleiben gültig.
22
25
  export function defineApply<TPayload = Record<string, unknown>>(
23
- fn: (event: StoredEvent<TPayload>, tx: DbRunner) => Promise<void>,
26
+ fn: (event: StoredEvent<TPayload>, tx: DbRunner, table: ProjectionTable) => Promise<void>,
24
27
  ): SingleStreamApplyFn {
25
28
  // @cast-boundary engine-bridge — typed user-fn → erased internal storage
26
29
  return fn as SingleStreamApplyFn;
@@ -10,7 +10,9 @@ import { buildMetricName, validateMetricName } from "../observability";
10
10
  import { type QnType, qualifyEntityName } from "./qualified-name";
11
11
  import {
12
12
  buildSoftDeleteCleanupJob,
13
+ buildSoftDeleteCleanupSystemJob,
13
14
  SOFT_DELETE_CLEANUP_JOB,
15
+ SOFT_DELETE_CLEANUP_SYSTEM_JOB,
14
16
  SOFT_DELETE_GRACE_DAYS_KEY,
15
17
  softDeleteGraceDaysConfig,
16
18
  } from "./soft-delete-cleanup";
@@ -20,6 +22,7 @@ import type {
20
22
  ConfigKeyDefinition,
21
23
  ConfigSeedDef,
22
24
  EntityDefinition,
25
+ EntityProjectionExtension,
23
26
  EntityRelations,
24
27
  EventDef,
25
28
  EventUpcastFn,
@@ -79,6 +82,7 @@ function buildImplicitProjection(
79
82
  entity: EntityDefinition,
80
83
  qualify: typeof qualifyEntityName,
81
84
  backingTable?: unknown,
85
+ extensions: readonly EntityProjectionExtension[] = [],
82
86
  ): ProjectionDefinition {
83
87
  const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
84
88
  // Backing table (r.entity(name, def, { table })) is the one physical table
@@ -102,6 +106,9 @@ function buildImplicitProjection(
102
106
  [`${entityName}.created`]: handler,
103
107
  [`${entityName}.updated`]: handler,
104
108
  [`${entityName}.deleted`]: handler,
109
+ // forget/purge (Art. 17): hard-deletes the row even for softDelete entities.
110
+ // Registered for every entity so the erasure replays on rebuild.
111
+ [`${entityName}.forgotten`]: handler,
105
112
  };
106
113
  // Restore-Verb existiert nur für softDelete-Entities. Hard-Delete-
107
114
  // Entities sollten keine restored-Events produzieren — würden sie es
@@ -110,9 +117,28 @@ function buildImplicitProjection(
110
117
  if (entity.softDelete) {
111
118
  apply[`${entityName}.restored`] = handler;
112
119
  }
120
+ // r.extendEntityProjection: merge extension applies into the rebuild
121
+ // replay. Collisions with lifecycle applies (or another extension) are
122
+ // authoring bugs — fail at boot, not by silently overwriting a handler.
123
+ const extraSources: string[] = [];
124
+ for (const ext of extensions) {
125
+ for (const [eventType, fn] of Object.entries(ext.apply)) {
126
+ if (apply[eventType]) {
127
+ throw new Error(
128
+ `Implicit projection "${name}": extendEntityProjection apply-key "${eventType}" ` +
129
+ `collides with an existing handler (entity lifecycle apply or another extension).`,
130
+ );
131
+ }
132
+ apply[eventType] = fn;
133
+ }
134
+ for (const s of ext.sources ?? []) {
135
+ if (s !== entityName && !extraSources.includes(s)) extraSources.push(s);
136
+ }
137
+ }
113
138
  return {
114
139
  name,
115
140
  source: entityName,
141
+ ...(extraSources.length > 0 && { extraSources }),
116
142
  table: drizzleTable,
117
143
  apply,
118
144
  isImplicit: true,
@@ -926,6 +952,18 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
926
952
  // vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
927
953
  // mit Entity-Name registriert.
928
954
  for (const feature of features) {
955
+ // extendEntityProjection targets must exist as r.entity in the SAME
956
+ // feature — a typo'd entity name would otherwise vanish silently and the
957
+ // extension's events would still be wiped on rebuild.
958
+ for (const extEntity of Object.keys(feature.entityProjectionExtensions ?? {})) {
959
+ if (!feature.entities?.[extEntity]) {
960
+ throw new Error(
961
+ `[Feature ${feature.name}] extendEntityProjection("${extEntity}"): no r.entity ` +
962
+ `with that name in this feature. Declare the entity first — the extension ` +
963
+ `merges into its implicit projection.`,
964
+ );
965
+ }
966
+ }
929
967
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
930
968
  const def = buildImplicitProjection(
931
969
  feature.name,
@@ -933,6 +971,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
933
971
  entity,
934
972
  qualify,
935
973
  feature.entityTables?.[entityName],
974
+ feature.entityProjectionExtensions?.[entityName] ?? [],
936
975
  );
937
976
  if (projectionMap.has(def.name)) {
938
977
  throw new Error(
@@ -1112,10 +1151,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1112
1151
  // CRUD types per source entity PLUS every domain event registered via
1113
1152
  // r.defineEvent — an apply-handler for a domain event is how a projection
1114
1153
  // reacts to ctx.appendEvent writes on the same aggregate stream.
1115
- const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored"] as const;
1154
+ const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
1116
1155
  const allDomainEventNames = new Set(eventMap.keys());
1117
1156
  for (const [projName, projDef] of projectionMap) {
1118
1157
  const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
1158
+ // extraSources (r.extendEntityProjection) sit in the rebuild filter, so
1159
+ // their auto-verbs are legitimately observable apply-keys too.
1160
+ const rebuildSources = [...sources, ...(projDef.extraSources ?? [])];
1119
1161
  const validEventTypes = new Set<string>();
1120
1162
  // Two source-modes are legal:
1121
1163
  //
@@ -1133,7 +1175,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1133
1175
  // or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
1134
1176
  // A "Shape-Anchor"-entity is no longer needed for this case.
1135
1177
  const isEventsOnlySource = !sources.every((src) => entityMap.has(src));
1136
- for (const src of sources) {
1178
+ for (const src of rebuildSources) {
1137
1179
  if (entityMap.has(src)) {
1138
1180
  for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
1139
1181
  }
@@ -1335,6 +1377,9 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1335
1377
  if (!jobMap.has(SOFT_DELETE_CLEANUP_JOB)) {
1336
1378
  jobMap.set(SOFT_DELETE_CLEANUP_JOB, buildSoftDeleteCleanupJob());
1337
1379
  }
1380
+ if (!jobMap.has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)) {
1381
+ jobMap.set(SOFT_DELETE_CLEANUP_SYSTEM_JOB, buildSoftDeleteCleanupSystemJob());
1382
+ }
1338
1383
  if (!configKeyMap.has(SOFT_DELETE_GRACE_DAYS_KEY)) {
1339
1384
  configKeyMap.set(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig);
1340
1385
  }
@@ -94,7 +94,9 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
94
94
  return field.default !== undefined ? schema.default([...field.default]) : schema;
95
95
  }
96
96
  case "number": {
97
- const schema = z.number();
97
+ let schema = z.number();
98
+ if (field.integer) schema = schema.int();
99
+ if (field.min !== undefined) schema = schema.min(field.min);
98
100
  return field.default !== undefined ? schema.default(field.default) : schema;
99
101
  }
100
102
  case "decimal": {
@@ -20,6 +20,7 @@ import type { ConfigKeyDefinition, JobDefinition, JobHandlerFn } from "./types/c
20
20
  // them. The job-runner keys cron scheduling off the name, the config-resolver
21
21
  // off the key.
22
22
  export const SOFT_DELETE_CLEANUP_JOB = "soft-delete:job:cleanup";
23
+ export const SOFT_DELETE_CLEANUP_SYSTEM_JOB = "soft-delete:job:cleanup-system";
23
24
  export const SOFT_DELETE_GRACE_DAYS_KEY = "soft-delete:config:grace-days";
24
25
  export const DEFAULT_GRACE_DAYS = 30;
25
26
 
@@ -62,16 +63,42 @@ export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
62
63
  if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
63
64
  const entity = registry.getEntity(proj.source);
64
65
  if (!entity?.softDelete) continue;
65
- const where: WhereObject = { isDeleted: true, deletedAt: { lt: cutoff } };
66
66
  // @cast-boundary column-presence probe — identical access the executor's
67
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
- }
68
+ const hasTenantColumn = (proj.table as Record<string, unknown>)["tenantId"] !== undefined;
69
+ // System-global entities (no tenantId column, e.g. `user`) are NOT swept
70
+ // here: this handler runs once PER TENANT, so a tenant-scoped grace value
71
+ // would purge every OTHER tenant's still-within-grace rows too (effective
72
+ // grace = min() across all tenants). softDeleteCleanupSystemJob handles
73
+ // these separately, once, with a fixed grace period.
74
+ if (!hasTenantColumn) continue;
75
+ const where: WhereObject = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
71
76
  await deleteMany(db, proj.table, where);
72
77
  }
73
78
  };
74
79
 
80
+ // System-global soft-deleted entities (no tenantId column) can't take a
81
+ // per-tenant grace period — there's no "this tenant's view" of a row that
82
+ // isn't scoped to any tenant. Runs once (not perTenant) with a fixed grace
83
+ // period; a future system-scope config key could make this configurable
84
+ // without reintroducing the per-tenant-fanout bug.
85
+ export const softDeleteCleanupSystemJob: JobHandlerFn = async (_payload, ctx) => {
86
+ const { db, registry } = ctx;
87
+ if (!db || !registry) {
88
+ throw new Error(
89
+ "soft-delete system cleanup: ctx.db + ctx.registry required (JobContext incomplete)",
90
+ );
91
+ }
92
+ const cutoff = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
93
+ for (const proj of registry.getAllProjections().values()) {
94
+ if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
95
+ const entity = registry.getEntity(proj.source);
96
+ if (!entity?.softDelete) continue;
97
+ if ((proj.table as Record<string, unknown>)["tenantId"] !== undefined) continue;
98
+ await deleteMany(db, proj.table, { isDeleted: true, deletedAt: { lt: cutoff } });
99
+ }
100
+ };
101
+
75
102
  export function buildSoftDeleteCleanupJob(): JobDefinition {
76
103
  return {
77
104
  name: SOFT_DELETE_CLEANUP_JOB,
@@ -82,3 +109,13 @@ export function buildSoftDeleteCleanupJob(): JobDefinition {
82
109
  runIn: "worker",
83
110
  };
84
111
  }
112
+
113
+ export function buildSoftDeleteCleanupSystemJob(): JobDefinition {
114
+ return {
115
+ name: SOFT_DELETE_CLEANUP_SYSTEM_JOB,
116
+ handler: softDeleteCleanupSystemJob,
117
+ trigger: { cron: "15 3 * * *" },
118
+ concurrency: "skip",
119
+ runIn: "worker",
120
+ };
121
+ }
@@ -16,6 +16,7 @@
16
16
  // must commit in the same TX as the aggregate-mutation that triggered
17
17
  // it (stronger consistency than an async projection). Reviewer judges.
18
18
 
19
+ import type { EntityTableMeta } from "../../db/entity-table-meta";
19
20
  import { deleteMany, type WhereObject } from "../../db/query";
20
21
  import { defineStep } from "../define-step";
21
22
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
@@ -36,7 +37,10 @@ defineStep<UnsafeProjectionDeleteArgs, void>({
36
37
  defaultFailureStrategy: "throw",
37
38
  run: async (args, ctx: PipelineCtx) => {
38
39
  const where = resolveRequired(args.where, ctx);
39
- await deleteMany(ctx.db.raw, args.table, where);
40
+ // The `unsafe*`-step IS the sanctioned direct projection write — it exists
41
+ // to bypass the executor deliberately (boot-validated allowlist + reviewer
42
+ // gate). The brand-strip cast makes that explicit at the one blessed seam.
43
+ await deleteMany(ctx.db.raw, args.table as EntityTableMeta, where);
40
44
  },
41
45
  });
42
46
 
@@ -81,6 +81,17 @@ export type TierResolverPlugin = {
81
81
  * Geteilter helper für runDevApp + runProdApp damit der Pickup-Pfad
82
82
  * bit-identisch ist (drift-resistent).
83
83
  */
84
+ /** Narrows a registration's untyped `options` bag to the tier-resolver
85
+ * plugin shape — the only thing every implementation must provide is the
86
+ * `build` factory. */
87
+ export function isTierResolverPlugin(v: unknown): v is TierResolverPlugin {
88
+ return (
89
+ typeof v === "object" &&
90
+ v !== null &&
91
+ typeof (v as Record<string, unknown>)["build"] === "function"
92
+ );
93
+ }
94
+
84
95
  export function findTierResolverUsage(
85
96
  features: readonly FeatureDefinition[],
86
97
  ): RegistrarExtensionRegistration | undefined {
@@ -62,7 +62,11 @@ import type {
62
62
  } from "./hooks";
63
63
  import type { HttpRouteDefinition } from "./http-route";
64
64
  import type { NavDefinition } from "./nav";
65
- import type { MultiStreamProjectionDefinition, ProjectionDefinition } from "./projection";
65
+ import type {
66
+ EntityProjectionExtension,
67
+ MultiStreamProjectionDefinition,
68
+ ProjectionDefinition,
69
+ } from "./projection";
66
70
  import type { EntityRelations, RelationDefinition } from "./relations";
67
71
  import type { ScreenDefinition } from "./screen";
68
72
  import type { TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe } from "./tree-node";
@@ -181,26 +185,15 @@ export type UnmanagedTableDef = UnmanagedTableEntry & {
181
185
  // Keep this list lean. Anything that already has a home on the feature
182
186
  // (configKeys.scope/default/encrypted, secretKeys, requires, etc.) lives there.
183
187
  // 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
- };
188
+ // "select"/"text" variants dropped (569/1) — no bundled feature uses anything
189
+ // but "boolean" yet and the picker doesn't render them either; re-add once a
190
+ // real feature needs them.
191
+ export type UiHintOption = {
192
+ readonly key: string;
193
+ readonly label: string;
194
+ readonly type: "boolean";
195
+ readonly default: boolean;
196
+ };
204
197
 
205
198
  export type UiHints = {
206
199
  // Picker-facing label ("Auth · Email + Password" instead of the bare
@@ -310,6 +303,12 @@ export type FeatureDefinition = {
310
303
  // Projections declared via r.projection(). Keyed by projection name; executor
311
304
  // looks them up by source-entity at write-time.
312
305
  readonly projections: Readonly<Record<string, ProjectionDefinition>>;
306
+ // Implicit-projection extensions declared via r.extendEntityProjection().
307
+ // Keyed by entity name; merged into that entity's implicit projection at
308
+ // registry build so rebuildProjection replays the extension's events.
309
+ readonly entityProjectionExtensions?: Readonly<
310
+ Record<string, readonly EntityProjectionExtension[]>
311
+ >;
313
312
  // Multi-stream projections — cross-aggregate async read-models. Keyed by
314
313
  // short name; the dispatcher wraps each into an EventConsumer with its
315
314
  // own cursor.
@@ -624,6 +623,15 @@ export type FeatureRegistrar<TFeature extends string = string> = {
624
623
  // per-consumer ordering and dead-letter behaviour.
625
624
  multiStreamProjection(definition: MultiStreamProjectionDefinition): void;
626
625
 
626
+ // Merge extra apply handlers (+ extra event sources) into an entity's
627
+ // implicit projection so rebuildProjection replays event types a bundled
628
+ // extension materializes into the HOST entity's table (custom-fields
629
+ // pattern). Rebuild-only: the inline runner skips implicit projections —
630
+ // live delivery stays with the extension's own MSP. The entity must be
631
+ // declared via r.entity in the SAME feature; unknown entities and
632
+ // apply-key collisions fail at registry build.
633
+ extendEntityProjection(entityName: string, extension: EntityProjectionExtension): void;
634
+
627
635
  // Register a function that contributes claims into SessionUser.claims at
628
636
  // login time. Multiple features (and multiple calls within one feature)
629
637
  // are allowed; returns are merged. Keys are auto-prefixed with the feature