@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.
Files changed (76) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/schema-cli.integration.test.ts +0 -29
  3. package/src/api/__tests__/batch.integration.test.ts +1 -4
  4. package/src/api/__tests__/request-id-middleware.test.ts +0 -51
  5. package/src/api/auth-middleware.ts +1 -65
  6. package/src/api/auth-routes.ts +0 -11
  7. package/src/api/index.ts +1 -3
  8. package/src/api/jwt.ts +4 -1
  9. package/src/api/request-context.ts +0 -3
  10. package/src/api/request-id-middleware.ts +0 -2
  11. package/src/api/routes.ts +1 -26
  12. package/src/api/server.ts +1 -29
  13. package/src/bun-db/__tests__/write-brand.test.ts +0 -21
  14. package/src/bun-db/query.ts +0 -17
  15. package/src/db/__tests__/config-seed.integration.test.ts +5 -13
  16. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
  18. package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
  19. package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
  20. package/src/db/config-seed.ts +9 -9
  21. package/src/db/encryption.ts +0 -7
  22. package/src/db/entity-field-encryption.ts +24 -81
  23. package/src/db/event-store-executor.ts +24 -37
  24. package/src/db/index.ts +0 -3
  25. package/src/db/query.ts +0 -1
  26. package/src/db/tenant-db.ts +9 -14
  27. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
  28. package/src/engine/__tests__/boot-validator.test.ts +9 -33
  29. package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
  30. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
  31. package/src/engine/__tests__/registry.test.ts +0 -28
  32. package/src/engine/__tests__/screen.test.ts +0 -40
  33. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
  34. package/src/engine/boot-validator/gdpr-storage.ts +1 -2
  35. package/src/engine/boot-validator/index.ts +2 -7
  36. package/src/engine/boot-validator/screens-nav.ts +0 -21
  37. package/src/engine/define-feature.ts +0 -17
  38. package/src/engine/define-handler.ts +2 -16
  39. package/src/engine/extensions/user-data.ts +1 -12
  40. package/src/engine/index.ts +2 -5
  41. package/src/engine/registry.ts +21 -44
  42. package/src/engine/soft-delete-cleanup.ts +2 -6
  43. package/src/engine/types/feature.ts +21 -29
  44. package/src/engine/types/fields.ts +1 -3
  45. package/src/engine/types/handlers.ts +2 -16
  46. package/src/engine/types/index.ts +0 -2
  47. package/src/engine/types/projection.ts +0 -22
  48. package/src/engine/types/screen.ts +0 -29
  49. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  50. package/src/event-store/index.ts +0 -8
  51. package/src/files/provider-resolver.ts +15 -28
  52. package/src/i18n/required-surface-keys.ts +0 -12
  53. package/src/observability/__tests__/observability.integration.test.ts +1 -4
  54. package/src/pipeline/dispatcher.ts +6 -2
  55. package/src/pipeline/msp-rebuild.ts +3 -36
  56. package/src/pipeline/projection-rebuild.ts +3 -61
  57. package/src/schema-cli.ts +15 -24
  58. package/src/secrets/__tests__/contains-secret.test.ts +0 -34
  59. package/src/secrets/dek-cache.ts +5 -26
  60. package/src/secrets/envelope.ts +3 -5
  61. package/src/secrets/index.ts +1 -13
  62. package/src/secrets/types.ts +3 -18
  63. package/src/testing/e2e-generator.ts +0 -3
  64. package/src/testing/index.ts +0 -2
  65. package/src/testing/mutable-master-key-provider.ts +3 -29
  66. package/src/time/__tests__/tz-dateline.test.ts +10 -8
  67. package/src/ui-types/index.ts +0 -1
  68. package/src/api/__tests__/pat-scope.test.ts +0 -36
  69. package/src/api/pat-scope.ts +0 -14
  70. package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
  71. package/src/event-store/rebuild-dead-letter.ts +0 -111
  72. package/src/files/__tests__/provider-resolver.test.ts +0 -70
  73. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
  74. package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
  75. package/src/secrets/envelope-cipher.ts +0 -81
  76. 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 !== EXT_USER_DATA) continue;
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
- // Availability check, not env-presence: eagerly building the keyring
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
- // Membership form (556/1) — see defineWriteHandler's comment for why.
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 { Registry, TenantId } from "../types";
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
  /**
@@ -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,
@@ -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
- // write handler. Shared with validateBoot's standalone callers (437/2)
1057
- // schema-cli's `validate` runs validateBoot on raw FEATURES without ever
1058
- // building a Registry, so this can't live only here.
1059
- validateExtensionPreSaveWiring(features);
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 rebuildSources) {
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
- // min: 1, not 0 (565/2) — graceDays: 0 doesn't mean "no cleanup", it means
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 >= 1 ? resolved : DEFAULT_GRACE_DAYS;
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
- // "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
- };
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 an entityList query runs (676/2 NOT detail; only the
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 cipher. Wired via extraContext;
239
- // run{Prod,Dev}App build it from the secrets master key automatically.
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