@cosmicdrift/kumiko-framework 0.113.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/schema-cli.integration.test.ts +0 -29
- package/src/api/__tests__/batch.integration.test.ts +1 -4
- package/src/api/__tests__/request-id-middleware.test.ts +0 -51
- package/src/api/auth-middleware.ts +1 -65
- package/src/api/auth-routes.ts +0 -11
- package/src/api/index.ts +1 -3
- package/src/api/jwt.ts +4 -1
- package/src/api/request-context.ts +0 -3
- package/src/api/request-id-middleware.ts +0 -2
- package/src/api/routes.ts +1 -26
- package/src/api/server.ts +1 -29
- package/src/bun-db/__tests__/write-brand.test.ts +0 -21
- package/src/bun-db/query.ts +0 -17
- package/src/db/__tests__/config-seed.integration.test.ts +5 -13
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
- package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
- package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
- package/src/db/config-seed.ts +9 -9
- package/src/db/encryption.ts +0 -7
- package/src/db/entity-field-encryption.ts +24 -81
- package/src/db/event-store-executor.ts +24 -37
- package/src/db/index.ts +0 -3
- package/src/db/query.ts +0 -1
- package/src/db/tenant-db.ts +9 -14
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
- package/src/engine/__tests__/boot-validator.test.ts +9 -33
- package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
- package/src/engine/__tests__/registry.test.ts +0 -28
- package/src/engine/__tests__/screen.test.ts +0 -40
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
- package/src/engine/boot-validator/gdpr-storage.ts +1 -2
- package/src/engine/boot-validator/index.ts +2 -7
- package/src/engine/boot-validator/screens-nav.ts +0 -21
- package/src/engine/define-feature.ts +0 -17
- package/src/engine/define-handler.ts +2 -16
- package/src/engine/extensions/user-data.ts +1 -12
- package/src/engine/index.ts +2 -5
- package/src/engine/registry.ts +21 -44
- package/src/engine/soft-delete-cleanup.ts +2 -6
- package/src/engine/types/feature.ts +21 -29
- package/src/engine/types/fields.ts +1 -3
- package/src/engine/types/handlers.ts +2 -16
- package/src/engine/types/index.ts +0 -2
- package/src/engine/types/projection.ts +0 -22
- package/src/engine/types/screen.ts +0 -29
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/index.ts +0 -8
- package/src/files/provider-resolver.ts +15 -28
- package/src/i18n/required-surface-keys.ts +0 -12
- package/src/observability/__tests__/observability.integration.test.ts +1 -4
- package/src/pipeline/dispatcher.ts +6 -2
- package/src/pipeline/msp-rebuild.ts +3 -36
- package/src/pipeline/projection-rebuild.ts +3 -61
- package/src/schema-cli.ts +15 -24
- package/src/secrets/__tests__/contains-secret.test.ts +0 -34
- package/src/secrets/dek-cache.ts +5 -26
- package/src/secrets/envelope.ts +3 -5
- package/src/secrets/index.ts +1 -13
- package/src/secrets/types.ts +3 -18
- package/src/testing/e2e-generator.ts +0 -3
- package/src/testing/index.ts +0 -2
- package/src/testing/mutable-master-key-provider.ts +3 -29
- package/src/time/__tests__/tz-dateline.test.ts +10 -8
- package/src/ui-types/index.ts +0 -1
- package/src/api/__tests__/pat-scope.test.ts +0 -36
- package/src/api/pat-scope.ts +0 -14
- package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
- package/src/event-store/rebuild-dead-letter.ts +0 -111
- package/src/files/__tests__/provider-resolver.test.ts +0 -70
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
- package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
- package/src/secrets/envelope-cipher.ts +0 -81
- package/src/secrets/stored-envelope.ts +0 -46
|
@@ -29,14 +29,8 @@ describe("defineFeature — bare CRUD verb → entity mapping", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
test("feature-name match wins (the fallback is the secondary path)", () => {
|
|
32
|
-
// Two entities (516/1): with only "note" registered, this would also
|
|
33
|
-
// pass via the single-entity fallback, proving nothing about the
|
|
34
|
-
// name-match branch specifically. A second entity ("tag") disables the
|
|
35
|
-
// fallback (entityKeys.length !== 1), so success here can only come
|
|
36
|
-
// from entities[name] matching — the branch this test claims to cover.
|
|
37
32
|
const f = defineFeature("note", (r) => {
|
|
38
33
|
r.entity("note", noteEntity);
|
|
39
|
-
r.entity("tag", tagEntity);
|
|
40
34
|
r.writeHandler(bareCreate);
|
|
41
35
|
});
|
|
42
36
|
expect(f.handlerEntityMappings?.["create"]).toBe("note");
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { z } from "zod";
|
|
3
2
|
import { createTenantConfig } from "../config-helpers";
|
|
4
3
|
import { defineFeature } from "../define-feature";
|
|
5
|
-
import { createEntity, createTextField } from "../factories";
|
|
6
4
|
import { createRegistry } from "../registry";
|
|
7
5
|
import type { FeatureDefinition } from "../types/feature";
|
|
8
6
|
|
|
@@ -132,30 +130,4 @@ describe("extensionSelector boot-validation", () => {
|
|
|
132
130
|
}),
|
|
133
131
|
).toThrow(/declared twice/);
|
|
134
132
|
});
|
|
135
|
-
|
|
136
|
-
// 437/2: createRegistry now delegates this check to
|
|
137
|
-
// validateExtensionPreSaveWiring (shared with validateBoot's standalone
|
|
138
|
-
// callers) instead of a duplicate inline computation — pins that
|
|
139
|
-
// createRegistry itself still enforces it, not just validateBoot.
|
|
140
|
-
test("throws when extension preSave targets entity with no mapped write handlers", () => {
|
|
141
|
-
const ext = defineFeature("cap-ext", (r) => {
|
|
142
|
-
r.extendsRegistrar("credit-cap", {
|
|
143
|
-
hooks: { preSave: async (changes) => changes },
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
const consumer = defineFeature("money-horse", (r) => {
|
|
147
|
-
r.requires("cap-ext");
|
|
148
|
-
r.entity("credit", createEntity({ table: "Credits", fields: { name: createTextField() } }));
|
|
149
|
-
r.writeHandler(
|
|
150
|
-
"doSomething",
|
|
151
|
-
z.object({}),
|
|
152
|
-
async () => ({ isSuccess: true as const, data: {} }),
|
|
153
|
-
{ access: { openToAll: true } },
|
|
154
|
-
);
|
|
155
|
-
r.useExtension("credit-cap", "credit");
|
|
156
|
-
});
|
|
157
|
-
expect(() => createRegistry([ext, consumer])).toThrow(
|
|
158
|
-
/no write handler is entity-mapped to "credit"/i,
|
|
159
|
-
);
|
|
160
|
-
});
|
|
161
133
|
});
|
|
@@ -45,46 +45,6 @@ describe("r.screen() — registration", () => {
|
|
|
45
45
|
expect(feature.screens["product-list"]?.type).toBe("entityList");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
test("stores a projectionList screen bound to an explicit (cross-feature) query", () => {
|
|
49
|
-
const feature = defineFeature("app", (r) => {
|
|
50
|
-
r.screen({
|
|
51
|
-
id: "rent-list",
|
|
52
|
-
type: "projectionList",
|
|
53
|
-
query: "ledger:query:schedule:list",
|
|
54
|
-
columns: [
|
|
55
|
-
{ field: "description", label: "app:col.desc" },
|
|
56
|
-
{
|
|
57
|
-
field: "amount",
|
|
58
|
-
label: "app:col.amount",
|
|
59
|
-
renderer: { react: { __component: "EuroCell" } },
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
const screen = feature.screens["rent-list"];
|
|
65
|
-
expect(screen?.type).toBe("projectionList");
|
|
66
|
-
if (screen?.type !== "projectionList") throw new Error("type-narrow failed");
|
|
67
|
-
expect(screen.query).toBe("ledger:query:schedule:list");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("validateBoot rejects a projectionList with empty columns", () => {
|
|
71
|
-
const features = [
|
|
72
|
-
defineFeature("app", (r) => {
|
|
73
|
-
r.screen({ id: "x", type: "projectionList", query: "app:query:foo:list", columns: [] });
|
|
74
|
-
}),
|
|
75
|
-
];
|
|
76
|
-
expect(() => validateBoot(features)).toThrow(/empty columns list/i);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("validateBoot rejects a projectionList with an empty query", () => {
|
|
80
|
-
const features = [
|
|
81
|
-
defineFeature("app", (r) => {
|
|
82
|
-
r.screen({ id: "x", type: "projectionList", query: "", columns: ["name"] });
|
|
83
|
-
}),
|
|
84
|
-
];
|
|
85
|
-
expect(() => validateBoot(features)).toThrow(/empty or non-string query/i);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
48
|
test("stores an entityEdit screen with sections + conditional fields", () => {
|
|
89
49
|
const feature = defineFeature("shop", (r) => {
|
|
90
50
|
r.entity("product", productEntity());
|
|
@@ -31,9 +31,6 @@ describe("registry soft-delete auto-wiring", () => {
|
|
|
31
31
|
const key = registry.getConfigKey(SOFT_DELETE_GRACE_DAYS_KEY);
|
|
32
32
|
expect(key?.type).toBe("number");
|
|
33
33
|
expect(key?.default).toBe(DEFAULT_GRACE_DAYS);
|
|
34
|
-
// 565/2: min must be 1, not 0 — graceDays: 0 means "hard-delete every
|
|
35
|
-
// currently soft-deleted row on the next cron run", not "no cleanup".
|
|
36
|
-
expect(key?.bounds?.min).toBe(1);
|
|
37
34
|
});
|
|
38
35
|
|
|
39
36
|
test("also injects the system-scope cleanup job (not perTenant)", () => {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { EXT_USER_DATA } from "../extension-names";
|
|
2
1
|
import type { FeatureDefinition } from "../types";
|
|
3
2
|
|
|
4
3
|
// Providers whose bytes do not survive a process restart. Only "inmemory"
|
|
@@ -66,7 +65,7 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
|
|
|
66
65
|
export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
|
|
67
66
|
for (const feature of features) {
|
|
68
67
|
for (const usage of feature.extensionUsages) {
|
|
69
|
-
if (usage.extensionName !==
|
|
68
|
+
if (usage.extensionName !== "userData") continue;
|
|
70
69
|
const hasExport = typeof usage.options?.["export"] === "function";
|
|
71
70
|
const hasDelete = typeof usage.options?.["delete"] === "function";
|
|
72
71
|
if (hasExport && !hasDelete) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
1
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
2
|
import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
|
|
4
3
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
@@ -180,12 +179,8 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
180
179
|
validateGdprStoragePersistence(features);
|
|
181
180
|
validateGdprHookCompleteness(features);
|
|
182
181
|
|
|
183
|
-
if (hasEncryptedFields) {
|
|
184
|
-
|
|
185
|
-
// catches malformed keys (wrong length, bad base64) at boot instead of
|
|
186
|
-
// on the first encrypted read in prod. An injected cipher (test seam,
|
|
187
|
-
// custom KMS provider) satisfies the requirement without env keys.
|
|
188
|
-
validateEntityFieldEncryptionAvailable();
|
|
182
|
+
if (hasEncryptedFields && !process.env["ENCRYPTION_KEY"]) {
|
|
183
|
+
throw new Error("ENCRYPTION_KEY environment variable is required (encrypted fields in use)");
|
|
189
184
|
}
|
|
190
185
|
|
|
191
186
|
if (hasFileFields && !process.env["FILE_STORAGE_PROVIDER"]) {
|
|
@@ -141,27 +141,6 @@ export function validateScreens(
|
|
|
141
141
|
continue;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
if (screen.type === "projectionList") {
|
|
145
|
-
// Query-getrieben, keine Entity → nur query + columns prüfen (die
|
|
146
|
-
// Column-Felder können nicht gegen eine Entity gecheckt werden; sie
|
|
147
|
-
// werden zur Render-Zeit gegen die Projection-Rows aufgelöst).
|
|
148
|
-
if (!screen.query || typeof screen.query !== "string") {
|
|
149
|
-
throw new Error(
|
|
150
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query.`,
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
if (screen.columns.length === 0) {
|
|
154
|
-
throw new Error(
|
|
155
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has an empty columns list — ` +
|
|
156
|
-
`declare at least one column.`,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
for (const col of screen.columns) {
|
|
160
|
-
validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
|
|
161
|
-
}
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
144
|
if (screen.type === "configEdit") {
|
|
166
145
|
// configEdit: layout/fields wie actionForm validieren, plus
|
|
167
146
|
// Cross-Check dass jeder qualifizierte Config-Key registriert
|
|
@@ -15,7 +15,6 @@ import type {
|
|
|
15
15
|
ConfigKeyType,
|
|
16
16
|
ConfigSeedDef,
|
|
17
17
|
EntityDefinition,
|
|
18
|
-
EntityProjectionExtension,
|
|
19
18
|
EntityRef,
|
|
20
19
|
EventDef,
|
|
21
20
|
EventMigrationDef,
|
|
@@ -142,7 +141,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
142
141
|
const secretKeys: Record<string, SecretKeyDefinition> = {};
|
|
143
142
|
const projections: Record<string, ProjectionDefinition> = {};
|
|
144
143
|
const multiStreamProjections: Record<string, MultiStreamProjectionDefinition> = {};
|
|
145
|
-
const entityProjectionExtensions: Record<string, EntityProjectionExtension[]> = {};
|
|
146
144
|
const rawTables: Record<string, RawTableEntry> = {};
|
|
147
145
|
const unmanagedTables: Record<string, UnmanagedTableEntry> = {};
|
|
148
146
|
const authClaimsHooks: AuthClaimsFn[] = [];
|
|
@@ -729,20 +727,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
729
727
|
multiStreamProjections[definition.name] = definition;
|
|
730
728
|
},
|
|
731
729
|
|
|
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
|
-
|
|
746
730
|
authClaims(fn: AuthClaimsFn): void {
|
|
747
731
|
authClaimsHooks.push(fn);
|
|
748
732
|
},
|
|
@@ -1013,7 +997,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
1013
997
|
metrics,
|
|
1014
998
|
secretKeys,
|
|
1015
999
|
projections,
|
|
1016
|
-
entityProjectionExtensions,
|
|
1017
1000
|
multiStreamProjections,
|
|
1018
1001
|
authClaimsHooks,
|
|
1019
1002
|
claimKeys,
|
|
@@ -90,20 +90,7 @@ 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
|
-
|
|
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>
|
|
93
|
+
..._noSecretInResponse: ContainsSecret<TData> extends true
|
|
107
94
|
? [
|
|
108
95
|
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
|
|
109
96
|
]
|
|
@@ -187,8 +174,7 @@ export function defineQueryHandler<
|
|
|
187
174
|
def: QueryHandlerDefinition<TName, TSchema, TResult, TMap>,
|
|
188
175
|
// R6: phantom rest-param — see defineWriteHandler. Forbids a Secret<> in the
|
|
189
176
|
// inferred query response `TResult` at compile time; `[]` for clean responses.
|
|
190
|
-
|
|
191
|
-
..._noSecretInResponse: true extends ContainsSecret<TResult>
|
|
177
|
+
..._noSecretInResponse: ContainsSecret<TResult> extends true
|
|
192
178
|
? [
|
|
193
179
|
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
|
|
194
180
|
]
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Siehe docs/plans/datenschutz/user-data-rights.md.
|
|
15
15
|
|
|
16
16
|
import type { DbRunner } from "../../db/connection";
|
|
17
|
-
import type {
|
|
17
|
+
import type { TenantId } from "../types";
|
|
18
18
|
|
|
19
19
|
// SessionUser.id ist plattformweit `string` (kein Brand-Type). Wenn
|
|
20
20
|
// jemals ein UserId-Brand eingefuehrt wird, ersetzt man hier den
|
|
@@ -70,17 +70,6 @@ export interface UserDataStorageProvider {
|
|
|
70
70
|
|
|
71
71
|
export interface UserDataHookCtx {
|
|
72
72
|
readonly db: DbRunner;
|
|
73
|
-
/**
|
|
74
|
-
* The app registry. A forget hook that must erase CHILD read-model rows past
|
|
75
|
-
* the entity's own row — m:n join projections, per-parent detail projections —
|
|
76
|
-
* uses it to run those custom projections for the executor's
|
|
77
|
-
* `<entity>.forgotten` event: `runProjectionsForEvent(result.data.event,
|
|
78
|
-
* ctx.registry, ctx.db)`. `executor.forget` purges only its OWN projection,
|
|
79
|
-
* and the dispatcher's post-command projection pass does not fire in the forget
|
|
80
|
-
* pipeline (a job, not a dispatched command), so the hook must trigger the
|
|
81
|
-
* cascade itself. Live-erase + rebuild `<parent>.forgotten`-apply converge.
|
|
82
|
-
*/
|
|
83
|
-
readonly registry: Registry;
|
|
84
73
|
readonly tenantId: TenantId;
|
|
85
74
|
readonly userId: UserId;
|
|
86
75
|
/**
|
package/src/engine/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
validateAppCustomScreenWriteQns,
|
|
7
7
|
validateBoot,
|
|
8
8
|
} from "./boot-validator";
|
|
9
|
+
export { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
|
|
9
10
|
export { type BuildAppSchemaOptions, buildAppSchema } from "./build-app-schema";
|
|
10
11
|
export type { ConfigFeatureSchema } from "./build-config-feature-schema";
|
|
11
12
|
export {
|
|
@@ -169,12 +170,8 @@ export {
|
|
|
169
170
|
checkWriteFieldRoles,
|
|
170
171
|
filterReadFields,
|
|
171
172
|
} from "./field-access";
|
|
172
|
-
// findForbiddenMembershipRole/isForbiddenMembershipRole/
|
|
173
|
-
// stripForbiddenMembershipRoles are Public API for host apps that build
|
|
174
|
-
// their own membership handlers. FORBIDDEN_MEMBERSHIP_ROLES itself stays
|
|
175
|
-
// internal (637/3) — exporting the raw Set would make its representation a
|
|
176
|
-
// semver promise; the predicate functions are the intended surface.
|
|
177
173
|
export {
|
|
174
|
+
FORBIDDEN_MEMBERSHIP_ROLES,
|
|
178
175
|
findForbiddenMembershipRole,
|
|
179
176
|
isForbiddenMembershipRole,
|
|
180
177
|
stripForbiddenMembershipRoles,
|
package/src/engine/registry.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
import { asEntityTableMeta } from "../db/query";
|
|
8
8
|
import { buildEntityTable } from "../db/table-builder";
|
|
9
9
|
import { buildMetricName, validateMetricName } from "../observability";
|
|
10
|
-
import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
|
|
11
10
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
12
11
|
import {
|
|
13
12
|
buildSoftDeleteCleanupJob,
|
|
@@ -23,7 +22,6 @@ import type {
|
|
|
23
22
|
ConfigKeyDefinition,
|
|
24
23
|
ConfigSeedDef,
|
|
25
24
|
EntityDefinition,
|
|
26
|
-
EntityProjectionExtension,
|
|
27
25
|
EntityRelations,
|
|
28
26
|
EventDef,
|
|
29
27
|
EventUpcastFn,
|
|
@@ -83,7 +81,6 @@ function buildImplicitProjection(
|
|
|
83
81
|
entity: EntityDefinition,
|
|
84
82
|
qualify: typeof qualifyEntityName,
|
|
85
83
|
backingTable?: unknown,
|
|
86
|
-
extensions: readonly EntityProjectionExtension[] = [],
|
|
87
84
|
): ProjectionDefinition {
|
|
88
85
|
const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
|
|
89
86
|
// Backing table (r.entity(name, def, { table })) is the one physical table
|
|
@@ -118,28 +115,9 @@ function buildImplicitProjection(
|
|
|
118
115
|
if (entity.softDelete) {
|
|
119
116
|
apply[`${entityName}.restored`] = handler;
|
|
120
117
|
}
|
|
121
|
-
// r.extendEntityProjection: merge extension applies into the rebuild
|
|
122
|
-
// replay. Collisions with lifecycle applies (or another extension) are
|
|
123
|
-
// authoring bugs — fail at boot, not by silently overwriting a handler.
|
|
124
|
-
const extraSources: string[] = [];
|
|
125
|
-
for (const ext of extensions) {
|
|
126
|
-
for (const [eventType, fn] of Object.entries(ext.apply)) {
|
|
127
|
-
if (apply[eventType]) {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`Implicit projection "${name}": extendEntityProjection apply-key "${eventType}" ` +
|
|
130
|
-
`collides with an existing handler (entity lifecycle apply or another extension).`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
apply[eventType] = fn;
|
|
134
|
-
}
|
|
135
|
-
for (const s of ext.sources ?? []) {
|
|
136
|
-
if (s !== entityName && !extraSources.includes(s)) extraSources.push(s);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
118
|
return {
|
|
140
119
|
name,
|
|
141
120
|
source: entityName,
|
|
142
|
-
...(extraSources.length > 0 && { extraSources }),
|
|
143
121
|
table: drizzleTable,
|
|
144
122
|
apply,
|
|
145
123
|
isImplicit: true,
|
|
@@ -953,18 +931,6 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
953
931
|
// vermeidet Kollisionen wenn jemand z.B. eine Cross-Aggregate-Projection
|
|
954
932
|
// mit Entity-Name registriert.
|
|
955
933
|
for (const feature of features) {
|
|
956
|
-
// extendEntityProjection targets must exist as r.entity in the SAME
|
|
957
|
-
// feature — a typo'd entity name would otherwise vanish silently and the
|
|
958
|
-
// extension's events would still be wiped on rebuild.
|
|
959
|
-
for (const extEntity of Object.keys(feature.entityProjectionExtensions ?? {})) {
|
|
960
|
-
if (!feature.entities?.[extEntity]) {
|
|
961
|
-
throw new Error(
|
|
962
|
-
`[Feature ${feature.name}] extendEntityProjection("${extEntity}"): no r.entity ` +
|
|
963
|
-
`with that name in this feature. Declare the entity first — the extension ` +
|
|
964
|
-
`merges into its implicit projection.`,
|
|
965
|
-
);
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
934
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
969
935
|
const def = buildImplicitProjection(
|
|
970
936
|
feature.name,
|
|
@@ -972,7 +938,6 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
972
938
|
entity,
|
|
973
939
|
qualify,
|
|
974
940
|
feature.entityTables?.[entityName],
|
|
975
|
-
feature.entityProjectionExtensions?.[entityName] ?? [],
|
|
976
941
|
);
|
|
977
942
|
if (projectionMap.has(def.name)) {
|
|
978
943
|
throw new Error(
|
|
@@ -1052,11 +1017,26 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1052
1017
|
}
|
|
1053
1018
|
}
|
|
1054
1019
|
|
|
1055
|
-
// Extension preSave: useExtension on an entity must have at least one mapped
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1020
|
+
// Extension preSave: useExtension on an entity must have at least one mapped write handler.
|
|
1021
|
+
const extensionsWithPreSave = new Set<string>();
|
|
1022
|
+
for (const f of features) {
|
|
1023
|
+
for (const [extName, def] of Object.entries(f.registrarExtensions ?? {})) {
|
|
1024
|
+
if (def.hooks?.preSave) extensionsWithPreSave.add(extName);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
for (const usage of extensionUsages) {
|
|
1028
|
+
if (!extensionsWithPreSave.has(usage.extensionName)) continue;
|
|
1029
|
+
const hasMapped = [...writeHandlerMap.keys()].some(
|
|
1030
|
+
(qn) => handlerEntityMap.get(qn) === usage.entityName,
|
|
1031
|
+
);
|
|
1032
|
+
if (!hasMapped) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
`Feature "${usage.featureName}" uses extension "${usage.extensionName}" with preSave on entity "${usage.entityName}" ` +
|
|
1035
|
+
`but no write handler is entity-mapped to "${usage.entityName}". ` +
|
|
1036
|
+
`Use create/update/delete on a matching entity or name the handler "entity:verb".`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1060
1040
|
|
|
1061
1041
|
// Validate: all relation targets must reference existing entities
|
|
1062
1042
|
for (const [entityName, rels] of relationMap) {
|
|
@@ -1141,9 +1121,6 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1141
1121
|
const allDomainEventNames = new Set(eventMap.keys());
|
|
1142
1122
|
for (const [projName, projDef] of projectionMap) {
|
|
1143
1123
|
const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
|
|
1144
|
-
// extraSources (r.extendEntityProjection) sit in the rebuild filter, so
|
|
1145
|
-
// their auto-verbs are legitimately observable apply-keys too.
|
|
1146
|
-
const rebuildSources = [...sources, ...(projDef.extraSources ?? [])];
|
|
1147
1124
|
const validEventTypes = new Set<string>();
|
|
1148
1125
|
// Two source-modes are legal:
|
|
1149
1126
|
//
|
|
@@ -1161,7 +1138,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1161
1138
|
// or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
|
|
1162
1139
|
// A "Shape-Anchor"-entity is no longer needed for this case.
|
|
1163
1140
|
const isEventsOnlySource = !sources.every((src) => entityMap.has(src));
|
|
1164
|
-
for (const src of
|
|
1141
|
+
for (const src of sources) {
|
|
1165
1142
|
if (entityMap.has(src)) {
|
|
1166
1143
|
for (const verb of AUTO_EVENT_VERBS) validEventTypes.add(`${src}.${verb}`);
|
|
1167
1144
|
}
|
|
@@ -29,11 +29,7 @@ export const softDeleteGraceDaysConfig: ConfigKeyDefinition = {
|
|
|
29
29
|
default: DEFAULT_GRACE_DAYS,
|
|
30
30
|
scope: "tenant",
|
|
31
31
|
access: { read: ["TenantAdmin", "SystemAdmin"], write: ["SystemAdmin"] },
|
|
32
|
-
|
|
33
|
-
// "hard-delete every currently soft-deleted row on the next cron run".
|
|
34
|
-
// Clamping (not rejecting) keeps the resolve-config-or-param clamp path's
|
|
35
|
-
// existing silent-below-bound / audited-crossing behavior.
|
|
36
|
-
bounds: { min: 1 },
|
|
32
|
+
bounds: { min: 0 },
|
|
37
33
|
};
|
|
38
34
|
|
|
39
35
|
export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
|
|
@@ -60,7 +56,7 @@ export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
|
|
|
60
56
|
db,
|
|
61
57
|
)
|
|
62
58
|
: undefined;
|
|
63
|
-
const graceDays = typeof resolved === "number" && resolved >=
|
|
59
|
+
const graceDays = typeof resolved === "number" && resolved >= 0 ? resolved : DEFAULT_GRACE_DAYS;
|
|
64
60
|
const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
|
|
65
61
|
|
|
66
62
|
for (const proj of registry.getAllProjections().values()) {
|
|
@@ -62,11 +62,7 @@ import type {
|
|
|
62
62
|
} from "./hooks";
|
|
63
63
|
import type { HttpRouteDefinition } from "./http-route";
|
|
64
64
|
import type { NavDefinition } from "./nav";
|
|
65
|
-
import type {
|
|
66
|
-
EntityProjectionExtension,
|
|
67
|
-
MultiStreamProjectionDefinition,
|
|
68
|
-
ProjectionDefinition,
|
|
69
|
-
} from "./projection";
|
|
65
|
+
import type { MultiStreamProjectionDefinition, ProjectionDefinition } from "./projection";
|
|
70
66
|
import type { EntityRelations, RelationDefinition } from "./relations";
|
|
71
67
|
import type { ScreenDefinition } from "./screen";
|
|
72
68
|
import type { TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe } from "./tree-node";
|
|
@@ -185,15 +181,26 @@ export type UnmanagedTableDef = UnmanagedTableEntry & {
|
|
|
185
181
|
// Keep this list lean. Anything that already has a home on the feature
|
|
186
182
|
// (configKeys.scope/default/encrypted, secretKeys, requires, etc.) lives there.
|
|
187
183
|
// Only add fields here that are genuinely UI-only.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
};
|
|
197
204
|
|
|
198
205
|
export type UiHints = {
|
|
199
206
|
// Picker-facing label ("Auth · Email + Password" instead of the bare
|
|
@@ -303,12 +310,6 @@ export type FeatureDefinition = {
|
|
|
303
310
|
// Projections declared via r.projection(). Keyed by projection name; executor
|
|
304
311
|
// looks them up by source-entity at write-time.
|
|
305
312
|
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
|
-
>;
|
|
312
313
|
// Multi-stream projections — cross-aggregate async read-models. Keyed by
|
|
313
314
|
// short name; the dispatcher wraps each into an EventConsumer with its
|
|
314
315
|
// own cursor.
|
|
@@ -623,15 +624,6 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
623
624
|
// per-consumer ordering and dead-letter behaviour.
|
|
624
625
|
multiStreamProjection(definition: MultiStreamProjectionDefinition): void;
|
|
625
626
|
|
|
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
|
-
|
|
635
627
|
// Register a function that contributes claims into SessionUser.claims at
|
|
636
628
|
// login time. Multiple features (and multiple calls within one feature)
|
|
637
629
|
// are allowed; returns are merged. Keys are auto-prefixed with the feature
|
|
@@ -524,9 +524,7 @@ export function isFileField(field: FieldDefinition | undefined): field is AnyFil
|
|
|
524
524
|
// --- Derived (computed) fields ---
|
|
525
525
|
//
|
|
526
526
|
// A derived field is read-time only: its value is computed from the stored row
|
|
527
|
-
// (and the clock) when
|
|
528
|
-
// "list" case in entity-handlers.ts calls augmentDerivedFields), never
|
|
529
|
-
// persisted. It lives in
|
|
527
|
+
// (and the clock) when a list/detail query runs, never persisted. It lives in
|
|
530
528
|
// `EntityDefinition.derivedFields` — deliberately NOT in `fields`, so it
|
|
531
529
|
// produces no DB column, never enters a write schema, and can't be the target
|
|
532
530
|
// of an entityEdit. A declarative `entityList` can name it like any column and
|
|
@@ -40,19 +40,6 @@ 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
|
-
};
|
|
56
43
|
};
|
|
57
44
|
|
|
58
45
|
// --- Claim Keys (r.claimKey declarations) ---
|
|
@@ -235,9 +222,8 @@ type SharedContextFields = {
|
|
|
235
222
|
// Encryption round-trip partner for the config feature. Separate from
|
|
236
223
|
// configResolver so the read-only resolver contract stays clean — the
|
|
237
224
|
// set handler needs to encrypt on write, the resolver needs to decrypt
|
|
238
|
-
// on read, and both reach for the same
|
|
239
|
-
|
|
240
|
-
readonly configEncryption?: import("../../secrets").EnvelopeCipher;
|
|
225
|
+
// on read, and both reach for the same provider. Wired via extraContext.
|
|
226
|
+
readonly configEncryption?: import("../../db").EncryptionProvider;
|
|
241
227
|
// Rate-limit resolver. Wired by the framework when the `rate-limiting`
|
|
242
228
|
// feature is loaded — pipeline reads handler.rateLimit and calls
|
|
243
229
|
// .enforce() on this resolver before access-check. Absent when the
|
|
@@ -196,7 +196,6 @@ export type { EntityId, TenantId } from "./identifiers";
|
|
|
196
196
|
export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./identifiers";
|
|
197
197
|
export type { NavDefinition } from "./nav";
|
|
198
198
|
export type {
|
|
199
|
-
EntityProjectionExtension,
|
|
200
199
|
MspErrorMode,
|
|
201
200
|
MspErrorPolicy,
|
|
202
201
|
MultiStreamApplyFn,
|
|
@@ -230,7 +229,6 @@ export type {
|
|
|
230
229
|
FormatSpec,
|
|
231
230
|
ListColumnSpec,
|
|
232
231
|
PlatformComponent,
|
|
233
|
-
ProjectionListScreenDefinition,
|
|
234
232
|
RowAction,
|
|
235
233
|
RowActionNavigate,
|
|
236
234
|
RowActionWriteHandler,
|
|
@@ -51,11 +51,6 @@ export type ProjectionDefinition = {
|
|
|
51
51
|
// index projections so the executor doesn't scan all projections on every
|
|
52
52
|
// write.
|
|
53
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[];
|
|
59
54
|
// Drizzle-table the projection materializes into. User owns the schema —
|
|
60
55
|
// framework just guarantees the TX and event delivery.
|
|
61
56
|
readonly table: ProjectionTable;
|
|
@@ -72,23 +67,6 @@ export type ProjectionDefinition = {
|
|
|
72
67
|
readonly isImplicit?: boolean;
|
|
73
68
|
};
|
|
74
69
|
|
|
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
|
-
|
|
92
70
|
// Per-lifecycle error policy for a MultiStreamProjection. Mirrors Marten's
|
|
93
71
|
// Projections.Errors / Projections.RebuildErrors split — a projection can
|
|
94
72
|
// be lenient during steady-state delivery but strict during rebuild (or
|