@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.
- package/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +1 -4
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +49 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- 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
|
|
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
|
-
|
|
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
|
|
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),
|
|
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?:
|
|
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?:
|
|
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:
|
|
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 };
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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;
|
package/src/engine/registry.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
package/src/event-store/index.ts
CHANGED
|
@@ -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,
|