@cosmicdrift/kumiko-framework 0.105.2 → 0.109.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 (85) 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__/pat-scope.test.ts +36 -0
  8. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  9. package/src/api/auth-middleware.ts +65 -1
  10. package/src/api/auth-routes.ts +11 -0
  11. package/src/api/index.ts +3 -1
  12. package/src/api/jwt.ts +1 -4
  13. package/src/api/pat-scope.ts +14 -0
  14. package/src/api/request-context.ts +3 -0
  15. package/src/api/request-id-middleware.ts +2 -0
  16. package/src/api/routes.ts +22 -0
  17. package/src/api/server.ts +29 -1
  18. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  19. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  20. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  21. package/src/bun-db/query.ts +40 -9
  22. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  23. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  25. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  26. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  27. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  28. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  29. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  31. package/src/db/apply-entity-event.ts +19 -8
  32. package/src/db/event-store-executor.ts +91 -8
  33. package/src/db/queries/shadow-swap.ts +1 -1
  34. package/src/db/query.ts +1 -0
  35. package/src/db/table-builder.ts +23 -1
  36. package/src/db/tenant-db.ts +6 -0
  37. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  38. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  39. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  40. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  41. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  42. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  43. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  44. package/src/engine/boot-validator/entity-handler.ts +45 -0
  45. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  46. package/src/engine/boot-validator/index.ts +14 -1
  47. package/src/engine/boot-validator/screens-nav.ts +90 -6
  48. package/src/engine/build-app-schema.ts +15 -7
  49. package/src/engine/define-feature.ts +17 -0
  50. package/src/engine/define-handler.ts +16 -2
  51. package/src/engine/entity-handlers.ts +32 -13
  52. package/src/engine/index.ts +6 -1
  53. package/src/engine/projection-helpers.ts +8 -5
  54. package/src/engine/registry.ts +47 -2
  55. package/src/engine/schema-builder.ts +3 -1
  56. package/src/engine/soft-delete-cleanup.ts +41 -4
  57. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  58. package/src/engine/tier-resolver-extension.ts +11 -0
  59. package/src/engine/types/feature.ts +29 -21
  60. package/src/engine/types/fields.ts +12 -0
  61. package/src/engine/types/handlers.ts +13 -0
  62. package/src/engine/types/index.ts +2 -0
  63. package/src/engine/types/projection.ts +33 -5
  64. package/src/event-store/index.ts +8 -0
  65. package/src/event-store/rebuild-dead-letter.ts +111 -0
  66. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  67. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  68. package/src/pipeline/dispatcher.ts +4 -10
  69. package/src/pipeline/msp-rebuild.ts +36 -3
  70. package/src/pipeline/projection-rebuild.ts +49 -3
  71. package/src/pipeline/projections-runner.ts +1 -1
  72. package/src/schema-cli.ts +24 -15
  73. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  74. package/src/secrets/types.ts +8 -1
  75. package/src/testing/db-cleanup.ts +4 -1
  76. package/src/testing/index.ts +1 -0
  77. package/src/testing/seed.ts +50 -0
  78. package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
  79. package/src/time/__tests__/geo-tz.test.ts +9 -3
  80. package/src/time/__tests__/iana.test.ts +9 -0
  81. package/src/time/boot-tz-warning.ts +5 -1
  82. package/src/time/iana.ts +17 -15
  83. package/src/time/tz-context.ts +6 -1
  84. package/src/utils/__tests__/serialization.test.ts +6 -0
  85. package/src/utils/serialization.ts +10 -3
@@ -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 };
@@ -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
@@ -199,6 +199,11 @@ export type NumberFieldDef = {
199
199
  readonly sensitive?: boolean;
200
200
  readonly default?: number;
201
201
  readonly access?: FieldAccess;
202
+ // Write-boundary constraints (Zod-level, no migration/storage impact — the
203
+ // Postgres column stays a plain numeric). Opt-in, so existing entities are
204
+ // unaffected.
205
+ readonly min?: number;
206
+ readonly integer?: boolean;
202
207
  } & PiiAnnotations;
203
208
 
204
209
  /**
@@ -557,6 +562,13 @@ export type DerivedFieldDef = {
557
562
 
558
563
  export type DerivedFieldsMap = Readonly<Record<string, DerivedFieldDef>>;
559
564
 
565
+ /** Client-facing projection of DerivedFieldDef — `derive` is server-only and
566
+ * not JSON-safe (would trip the output-walk guard), so the browser schema
567
+ * only ever carries `valueType`. A real `Pick`, not a same-shape cast: TS
568
+ * itself proves `derive` isn't there instead of a `{ valueType } as
569
+ * DerivedFieldDef` cast lying about a field that's actually missing. */
570
+ export type ClientDerivedFieldDef = Pick<DerivedFieldDef, "valueType">;
571
+
560
572
  // --- Entity ---
561
573
 
562
574
  // --- State Transitions ---
@@ -40,6 +40,19 @@ export type SessionUser = {
40
40
  // When present, middleware can validate that the sid is still alive before
41
41
  // accepting the request (session revocation).
42
42
  readonly sid?: string;
43
+ // Set ONLY when the request authenticated via a Personal Access Token
44
+ // (bearer, PAT_TOKEN_PREFIX). Absent for cookie/JWT logins, which stay
45
+ // unrestricted. `allowedQns` are the QN globs the token's granted scopes
46
+ // expand to; the API boundary (routes.ts) rejects any dispatch type not
47
+ // matched by one of them (fail-closed). `scopes` are the granted scope
48
+ // names, kept for audit/display only.
49
+ readonly pat?: {
50
+ // The token row id — the per-token key for PAT rate limiting. Not the
51
+ // secret; safe to carry on the principal.
52
+ readonly tokenId: string;
53
+ readonly scopes: readonly string[];
54
+ readonly allowedQns: readonly string[];
55
+ };
43
56
  };
44
57
 
45
58
  // --- Claim Keys (r.claimKey declarations) ---
@@ -83,6 +83,7 @@ export type {
83
83
  AnyFileFieldDef,
84
84
  BigIntFieldDef,
85
85
  BooleanFieldDef,
86
+ ClientDerivedFieldDef,
86
87
  DateFieldDef,
87
88
  DecimalFieldDef,
88
89
  DefaultCurrency,
@@ -195,6 +196,7 @@ export type { EntityId, TenantId } from "./identifiers";
195
196
  export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./identifiers";
196
197
  export type { NavDefinition } from "./nav";
197
198
  export type {
199
+ EntityProjectionExtension,
198
200
  MspErrorMode,
199
201
  MspErrorPolicy,
200
202
  MultiStreamApplyFn,
@@ -14,17 +14,23 @@ import type { RunIn } from "./config";
14
14
  export type ProjectionTable = TableColumns<any>;
15
15
 
16
16
  // Single-stream projection apply: runs inline in the write-TX of the event
17
- // it projects. Gets the event + TX-scoped DbRunner that's it. Inline
18
- // projections must not spawn further events (no ctx) because they run
19
- // inside the command's transaction and the framework guarantees a single
20
- // commit boundary per command.
17
+ // it projects. Gets the event, the TX-scoped DbRunner, and the projection's
18
+ // own `table` (already erased to ProjectionTable). Write through THAT table
19
+ // it is the only ES-blessed write into a managed projection outside the
20
+ // executor, and it is reachable only here (an arbitrary handler has no such
21
+ // arg), so the write-brand cannot be bypassed by closing over the branded
22
+ // table constant. Inline projections must not spawn further events (no ctx)
23
+ // because they run inside the command's transaction and the framework
24
+ // guarantees a single commit boundary per command.
21
25
  //
22
26
  // Generic über payload-shape. Default = Record<string, unknown> behält
23
27
  // rückwärtskompatibles Verhalten; Konkrete Apply-Handler annotieren
24
- // `SingleStreamApplyFn<MyPayload>` für typed event.payload-Access.
28
+ // `SingleStreamApplyFn<MyPayload>` für typed event.payload-Access. Der
29
+ // `table`-Param ist additiv — 2-arg-Applies (event, tx) bleiben gültig.
25
30
  export type SingleStreamApplyFn<TPayload = Record<string, unknown>> = (
26
31
  event: StoredEvent<TPayload>,
27
32
  tx: DbRunner,
33
+ table: ProjectionTable,
28
34
  ) => Promise<void>;
29
35
 
30
36
  // Multi-stream projection apply: runs asynchronously via the event-dispatcher
@@ -45,6 +51,11 @@ export type ProjectionDefinition = {
45
51
  // index projections so the executor doesn't scan all projections on every
46
52
  // write.
47
53
  readonly source: string | readonly string[];
54
+ // Additional aggregate-types whose events the rebuild replay must include
55
+ // beyond `source`. Fed by r.extendEntityProjection — an extension can react
56
+ // to events on foreign streams (e.g. "field-definition") while `source`
57
+ // keeps meaning "the owning entity" for consumers like soft-delete-cleanup.
58
+ readonly extraSources?: readonly string[];
48
59
  // Drizzle-table the projection materializes into. User owns the schema —
49
60
  // framework just guarantees the TX and event delivery.
50
61
  readonly table: ProjectionTable;
@@ -61,6 +72,23 @@ export type ProjectionDefinition = {
61
72
  readonly isImplicit?: boolean;
62
73
  };
63
74
 
75
+ // Extension merged into an entity's implicit projection at registry build.
76
+ // Lets a bundled feature that writes into the HOST entity's table via events
77
+ // (custom-fields pattern) hook those event types into the entity's rebuild
78
+ // replay — without it, a rebuild resets everything the extension wrote.
79
+ // Live delivery stays with the extension's own MSP: implicit projections are
80
+ // skipped by the inline runner, so the apply here runs ONLY during rebuild.
81
+ export type EntityProjectionExtension = {
82
+ // Aggregate-types beyond the entity's own stream whose events the rebuild
83
+ // must scan (e.g. "field-definition"). Omit when all extension events are
84
+ // appended on the host entity's stream.
85
+ readonly sources?: readonly string[];
86
+ // Keyed by fully-qualified event type. Must not collide with the entity's
87
+ // built-in lifecycle applies (<entity>.created/updated/...) or another
88
+ // extension — collisions fail at boot.
89
+ readonly apply: Readonly<Record<string, SingleStreamApplyFn>>;
90
+ };
91
+
64
92
  // Per-lifecycle error policy for a MultiStreamProjection. Mirrors Marten's
65
93
  // Projections.Errors / Projections.RebuildErrors split — a projection can
66
94
  // be lenient during steady-state delivery but strict during rebuild (or
@@ -24,6 +24,14 @@ export {
24
24
  streamAllEventsByType,
25
25
  } from "./event-store";
26
26
  export { createEventsTable, eventsTable } from "./events-schema";
27
+ export {
28
+ createRebuildDeadLetterTable,
29
+ listRebuildDeadLetters,
30
+ type RebuildDeadLetterRow,
31
+ rebuildDeadLetterTable,
32
+ recordRebuildDeadLetters,
33
+ type SkippedApply,
34
+ } from "./rebuild-dead-letter";
27
35
  export { toStoredEvent } from "./row-to-stored-event";
28
36
  export {
29
37
  createSnapshotsTable,