@cosmicdrift/kumiko-framework 0.155.1 → 0.156.1

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 (78) hide show
  1. package/README.md +9 -38
  2. package/package.json +2 -2
  3. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  4. package/src/__tests__/raw-table.integration.test.ts +17 -40
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +105 -1
  6. package/src/crypto/index.ts +1 -0
  7. package/src/crypto/pii-field-encryption.ts +19 -0
  8. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
  9. package/src/db/__tests__/blind-index.integration.test.ts +16 -0
  10. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  11. package/src/db/__tests__/event-store-executor.integration.test.ts +75 -0
  12. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  13. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  14. package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
  15. package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
  16. package/src/db/collect-table-metas.ts +6 -7
  17. package/src/db/entity-table-meta.ts +1 -1
  18. package/src/db/event-store-executor-write.ts +23 -2
  19. package/src/db/event-store-executor.ts +1 -1
  20. package/src/db/feature-table-sources.ts +5 -11
  21. package/src/db/migrate-generator.ts +39 -6
  22. package/src/db/queries/shadow-swap.ts +85 -3
  23. package/src/db/tenant-db.ts +8 -0
  24. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
  25. package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
  26. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  27. package/src/engine/__tests__/field-access.test.ts +23 -1
  28. package/src/engine/__tests__/raw-table.test.ts +134 -84
  29. package/src/engine/__tests__/registry.test.ts +40 -0
  30. package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
  31. package/src/engine/boot-validator/action-wiring.ts +2 -2
  32. package/src/engine/boot-validator/config-deps.ts +35 -0
  33. package/src/engine/boot-validator/index.ts +2 -0
  34. package/src/engine/boot-validator/pii-retention.ts +35 -3
  35. package/src/engine/build-config-feature-schema.ts +3 -3
  36. package/src/engine/config-helpers.ts +2 -0
  37. package/src/engine/define-feature.ts +2 -6
  38. package/src/engine/entity-handlers.ts +11 -5
  39. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  40. package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
  41. package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
  42. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
  43. package/src/engine/feature-ast/extractors/events.ts +322 -0
  44. package/src/engine/feature-ast/extractors/handlers.ts +222 -0
  45. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  46. package/src/engine/feature-ast/extractors/index.ts +32 -24
  47. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  48. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  49. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  50. package/src/engine/feature-ast/parse.ts +3 -3
  51. package/src/engine/feature-ast/render.ts +16 -11
  52. package/src/engine/feature-builder-state.ts +0 -3
  53. package/src/engine/feature-config-events-jobs.ts +3 -6
  54. package/src/engine/feature-entity-handlers.ts +9 -1
  55. package/src/engine/feature-ui-extensions.ts +10 -40
  56. package/src/engine/field-access.ts +13 -2
  57. package/src/engine/object-form.ts +15 -0
  58. package/src/engine/registry-facade.ts +4 -0
  59. package/src/engine/registry-ingest.ts +19 -29
  60. package/src/engine/registry-state.ts +1 -7
  61. package/src/engine/registry-validate.ts +5 -28
  62. package/src/engine/registry.ts +0 -2
  63. package/src/engine/types/config.ts +7 -0
  64. package/src/engine/types/feature.ts +40 -68
  65. package/src/engine/types/fields.ts +6 -0
  66. package/src/engine/types/index.ts +0 -3
  67. package/src/es-ops/README.md +1 -1
  68. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  69. package/src/es-ops/context.ts +4 -2
  70. package/src/es-ops/types.ts +8 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +29 -0
  72. package/src/pipeline/msp-rebuild.ts +4 -0
  73. package/src/pipeline/projection-rebuild.ts +35 -0
  74. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
  75. package/src/stack/test-stack.ts +17 -2
  76. package/src/ui-types/index.ts +1 -0
  77. package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
  78. package/src/engine/feature-ast/extractors/round4.ts +0 -1227
@@ -19,7 +19,6 @@ import type {
19
19
  SearchPayloadContributorFn,
20
20
  TreeActionDef,
21
21
  TreeActionsHandle,
22
- UnmanagedTableOptions,
23
22
  ValidationHookFn,
24
23
  } from "./types";
25
24
  import { HookPhases } from "./types";
@@ -417,39 +416,7 @@ export function buildUiExtensionsMethods<TName extends string>(
417
416
  }
418
417
  state.httpRoutes[key] = definition;
419
418
  },
420
- rawTable(rawTableName: string, table: unknown, options: RawTableOptions): void {
421
- // Same kebab guard as r.projection / r.screen / r.nav so authoring-time
422
- // mistakes surface at the feature file, not deep in registry boot.
423
- if (!isKebabSegment(rawTableName)) {
424
- throw new Error(
425
- `[Feature ${name}] Raw-table name "${rawTableName}" must be kebab-case ` +
426
- `(lowercase letters, digits, dashes; start with a letter). ` +
427
- `Got "${rawTableName}" — try "${toKebab(rawTableName).replace(/_/g, "-")}".`,
428
- );
429
- }
430
- if (state.rawTables[rawTableName]) {
431
- throw new Error(
432
- `[Feature ${name}] r.rawTable("${rawTableName}") already registered. ` +
433
- `Raw-table names must be unique per feature.`,
434
- );
435
- }
436
- // The `reason` is the marker that justifies the bypass — empty
437
- // strings would defeat the audit trail. Reject early so the
438
- // failure points at the feature file.
439
- if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
440
- throw new Error(
441
- `[Feature ${name}] r.rawTable("${rawTableName}"): options.reason must be a ` +
442
- `non-empty string. The reason is the marker that justifies the bypass — ` +
443
- `if you can't write one, declare data via r.entity() instead.`,
444
- );
445
- }
446
- state.rawTables[rawTableName] = {
447
- name: rawTableName,
448
- table,
449
- reason: options.reason,
450
- };
451
- },
452
- unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void {
419
+ rawTable(meta: EntityTableMeta, options: RawTableOptions): void {
453
420
  // Name comes from the meta itself — apps already give the table a
454
421
  // name when calling defineUnmanagedTable, no need to repeat it.
455
422
  const tableName = meta.tableName;
@@ -457,24 +424,27 @@ export function buildUiExtensionsMethods<TName extends string>(
457
424
  // EntityTableMeta uses snake_case for tableName (matches Postgres
458
425
  // convention); we just guard against truly broken input.
459
426
  throw new Error(
460
- `[Feature ${name}] Unmanaged-table name "${tableName}" must be a ` +
427
+ `[Feature ${name}] Raw-table name "${tableName}" must be a ` +
461
428
  `valid identifier (lowercase letters, digits, underscores; start with a letter).`,
462
429
  );
463
430
  }
464
- if (state.unmanagedTables[tableName]) {
431
+ if (state.rawTables[tableName]) {
465
432
  throw new Error(
466
- `[Feature ${name}] r.unmanagedTable("${tableName}") already registered. ` +
467
- `Unmanaged-table names must be unique per feature.`,
433
+ `[Feature ${name}] r.rawTable("${tableName}") already registered. ` +
434
+ `Raw-table names must be unique per feature.`,
468
435
  );
469
436
  }
437
+ // The `reason` is the marker that justifies the bypass — empty
438
+ // strings would defeat the audit trail. Reject early so the
439
+ // failure points at the feature file.
470
440
  if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
471
441
  throw new Error(
472
- `[Feature ${name}] r.unmanagedTable("${tableName}"): options.reason must be a ` +
442
+ `[Feature ${name}] r.rawTable("${tableName}"): options.reason must be a ` +
473
443
  `non-empty string. The reason justifies the audit-trail bypass — ` +
474
444
  `if you can't write one, declare data via r.entity() instead.`,
475
445
  );
476
446
  }
477
- state.unmanagedTables[tableName] = {
447
+ state.rawTables[tableName] = {
478
448
  name: tableName,
479
449
  meta,
480
450
  reason: options.reason,
@@ -2,6 +2,13 @@ import type { DbRow } from "../db/connection";
2
2
  import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership";
3
3
  import type { EntityDefinition, SessionUser } from "./types";
4
4
 
5
+ // piiEncrypted fields deliberately break that silence (kumiko-platform#463):
6
+ // the field's whole point is "a legitimate reader may see the plaintext",
7
+ // so an unauthorized reader seeing the field exist-but-masked is the
8
+ // intended signal, not a leak — unlike a secret whose existence itself
9
+ // should stay hidden.
10
+ export const PII_MASKED_VALUE = "••••••";
11
+
5
12
  // Field-level read filtering. Returns a copy of `data` with fields stripped
6
13
  // if the user's roles don't grant read access OR the ownership-rule for the
7
14
  // matching role doesn't accept this concrete row. Fields without access
@@ -11,7 +18,8 @@ import type { EntityDefinition, SessionUser } from "./types";
11
18
  // one place in the ownership system where silence is the right default:
12
19
  // reporting "you tried to read X but can't" leaks the field's existence.
13
20
  // Writes do the opposite (loud error) because a silent drop there masks
14
- // save-bugs.
21
+ // save-bugs. piiEncrypted fields are the one exception — see
22
+ // PII_MASKED_VALUE above.
15
23
  export function filterReadFields(
16
24
  entity: EntityDefinition,
17
25
  data: Readonly<Record<string, unknown>>,
@@ -29,7 +37,10 @@ export function filterReadFields(
29
37
 
30
38
  const accessMap = normalizeAccessEntry(field.access?.read);
31
39
  if (!userCanReadFieldRow(user, accessMap, data)) {
32
- continue; // entire field stripped
40
+ if ("piiEncrypted" in field && field.piiEncrypted === true) {
41
+ result[key] = PII_MASKED_VALUE;
42
+ }
43
+ continue; // entire field stripped (masked instead, for piiEncrypted)
33
44
  }
34
45
 
35
46
  // For embedded fields: filter sub-fields with access restrictions
@@ -10,3 +10,18 @@ export function splitNamedDefinition<T extends { readonly name: string }>(
10
10
  const { name, ...rest } = definition;
11
11
  return [name, rest];
12
12
  }
13
+
14
+ // Shared helper for registrar methods that accept either variadic strings
15
+ // (hand-written call sites) or a single Object-Form object wrapping the
16
+ // same string array under `key` (the feature-ast renderer's canonical
17
+ // shape for Designer/AI-generated code).
18
+ export function unwrapArrayForm<K extends string>(
19
+ args: readonly [Record<K, readonly string[]>] | readonly string[],
20
+ key: K,
21
+ ): readonly string[] {
22
+ const [first] = args;
23
+ if (typeof first === "object" && first !== null && key in first) {
24
+ return (first as Record<K, readonly string[]>)[key];
25
+ }
26
+ return args as readonly string[];
27
+ }
@@ -68,6 +68,10 @@ export function buildRegistryFacade(state: RegistryState): Registry {
68
68
  return state.queryHandlerMap.get(name);
69
69
  },
70
70
 
71
+ getAllQueryHandlers(): ReadonlyMap<string, QueryHandlerDef> {
72
+ return state.queryHandlerMap;
73
+ },
74
+
71
75
  getSearchableFields(entityName: string): readonly string[] {
72
76
  return state.searchableFieldsCache.get(entityName) ?? [];
73
77
  },
@@ -20,10 +20,10 @@ export function populateFeatureCore(state: RegistryState, feature: FeatureDefini
20
20
  state.entityMap.set(name, entity);
21
21
  const physical = resolveTableName(name, entity, feature.name);
22
22
  const clash = state.physicalTableOwners.get(physical);
23
- if (clash?.kind === "unmanaged") {
23
+ if (clash?.kind === "raw") {
24
24
  throw new Error(
25
25
  `Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
26
- `collides with r.unmanagedTable("${physical}") (feature "${clash.featureName}"). ` +
26
+ `collides with r.rawTable("${physical}") (feature "${clash.featureName}"). ` +
27
27
  `Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
28
28
  );
29
29
  }
@@ -145,12 +145,14 @@ export function populateEvents(state: RegistryState, feature: FeatureDefinition)
145
145
  }
146
146
  }
147
147
 
148
- // Translations prefixed with featureName: (i18next namespace convention).
149
148
  export function populateTranslations(state: RegistryState, feature: FeatureDefinition): void {
150
149
  // Translations prefixed with featureName: (i18next namespace convention).
151
150
  // Keys that already carry the feature's own namespace prefix (e.g. a nav
152
151
  // label referencing "cap-counter:nav.cap-list" verbatim) must NOT be
153
- // re-prefixed, else server-side t() can never resolve them (#1105).
152
+ // re-prefixed, else server-side t() can never resolve them (#1105). Both
153
+ // bare and qualified spellings for the same key are accepted; if a
154
+ // feature defines both, whichever is declared last wins (Object.entries
155
+ // = insertion order, not "qualified always wins").
154
156
  const prefix = `${feature.name}:`;
155
157
  for (const [key, value] of Object.entries(feature.translations ?? {})) {
156
158
  const qualifiedKey = key.startsWith(prefix) ? key : `${prefix}${key}`;
@@ -302,7 +304,9 @@ export function populateProjectionsAndTables(
302
304
  // these bypass the qualified-name namespace because they have no
303
305
  // event-stream binding to disambiguate). Reject cross-feature
304
306
  // duplicates at boot so the dev-server doesn't race two CREATE TABLE
305
- // statements that target the same physical table name.
307
+ // statements that target the same physical table name. Two features
308
+ // registering the same physical tableName would also race two CREATE
309
+ // TABLE statements via migrate-runner.
306
310
  for (const [rawName, rawDef] of Object.entries(feature.rawTables ?? {})) {
307
311
  const existing = state.rawTableMap.get(rawName);
308
312
  if (existing) {
@@ -311,43 +315,29 @@ export function populateProjectionsAndTables(
311
315
  `"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
312
316
  );
313
317
  }
314
- state.rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
315
- }
316
-
317
- // Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
318
- // Two features registering the same physical tableName would race two
319
- // CREATE TABLE statements via migrate-runner.
320
- for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
321
- const existing = state.unmanagedTableMap.get(umName);
322
- if (existing) {
323
- throw new Error(
324
- `Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
325
- `"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
326
- );
327
- }
328
- const physicalClash = state.physicalTableOwners.get(umName);
318
+ const physicalClash = state.physicalTableOwners.get(rawName);
329
319
  if (physicalClash?.kind === "entity") {
330
320
  throw new Error(
331
- `Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
321
+ `Raw-table "${rawName}" (feature "${feature.name}") collides with the physical ` +
332
322
  `table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
333
- `Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
323
+ `Pick a different tableName — both would emit CREATE TABLE "${rawName}".`,
334
324
  );
335
325
  }
336
- const piiFields = umDef.meta.piiSubjectFields ?? [];
337
- if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
326
+ const piiFields = rawDef.meta.piiSubjectFields ?? [];
327
+ if (piiFields.length > 0 && !rawDef.piiEncryptedOnWrite) {
338
328
  throw new Error(
339
- `Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
329
+ `Raw-table "${rawName}" (feature "${feature.name}") has PII-annotated fields ` +
340
330
  `(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
341
331
  `Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
342
332
  `{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
343
333
  );
344
334
  }
345
- state.physicalTableOwners.set(umName, {
346
- kind: "unmanaged",
347
- owner: umName,
335
+ state.physicalTableOwners.set(rawName, {
336
+ kind: "raw",
337
+ owner: rawName,
348
338
  featureName: feature.name,
349
339
  });
350
- state.unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
340
+ state.rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
351
341
  }
352
342
  }
353
343
 
@@ -38,7 +38,6 @@ import type {
38
38
  SearchPayloadContributorFn,
39
39
  SecretKeyDefinition,
40
40
  TreeActionDef,
41
- UnmanagedTableDef,
42
41
  WorkspaceDefinition,
43
42
  WriteHandlerDef,
44
43
  } from "./types";
@@ -201,11 +200,7 @@ export type RegistryState = {
201
200
  multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
202
201
  multiStreamProjectionFeatureMap: Map<string, string>;
203
202
  rawTableMap: Map<string, RawTableDef>;
204
- unmanagedTableMap: Map<string, UnmanagedTableDef>;
205
- physicalTableOwners: Map<
206
- string,
207
- { kind: "entity" | "unmanaged"; owner: string; featureName: string }
208
- >;
203
+ physicalTableOwners: Map<string, { kind: "entity" | "raw"; owner: string; featureName: string }>;
209
204
  authClaimsHooks: AuthClaimsHookDef[];
210
205
  claimKeyMap: Map<string, ClaimKeyDefinition>;
211
206
  screenMap: Map<string, ScreenDefinition>;
@@ -266,7 +261,6 @@ export function createInitialState(): RegistryState {
266
261
  multiStreamProjectionMap: new Map(),
267
262
  multiStreamProjectionFeatureMap: new Map(),
268
263
  rawTableMap: new Map(),
269
- unmanagedTableMap: new Map(),
270
264
  physicalTableOwners: new Map(),
271
265
  authClaimsHooks: [],
272
266
  claimKeyMap: new Map(),
@@ -223,27 +223,6 @@ export function buildImplicitProjections(
223
223
  }
224
224
  }
225
225
 
226
- export function validateNoRawTableProjectionClash(state: RegistryState): void {
227
- // Cross-cut: a r.rawTable() PgTable must not coincide with any
228
- // registered projection's table. Silent dedupe via Set would mask a
229
- // real authoring bug (two owners writing to the same physical table).
230
- // Run after both passes so implicit projections are visible too.
231
- const projectionTables = new Set<unknown>();
232
- for (const proj of state.projectionMap.values()) projectionTables.add(proj.table);
233
- for (const msp of state.multiStreamProjectionMap.values()) {
234
- if (msp.table) projectionTables.add(msp.table);
235
- }
236
- for (const raw of state.rawTableMap.values()) {
237
- if (projectionTables.has(raw.table)) {
238
- throw new Error(
239
- `r.rawTable "${raw.name}" (feature "${raw.featureName}") shares a Drizzle ` +
240
- `PgTable with a registered projection. Pick one owner: r.entity() / ` +
241
- `r.projection() for event-sourced reads, r.rawTable() for the bypass.`,
242
- );
243
- }
244
- }
245
- }
246
-
247
226
  export function buildSearchIncludesAndIncomingRelations(state: RegistryState): void {
248
227
  for (const [entityName, rels] of state.relationMap) {
249
228
  const includes = new Map<string, readonly string[]>();
@@ -320,13 +299,11 @@ export function validateEventMigrationVersions(
320
299
  for (const feature of features) {
321
300
  for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
322
301
  const qualified = qualify(feature.name, "event", shortName);
323
- const eventDef = state.eventMap.get(qualified);
324
- if (!eventDef) {
325
- throw new Error(
326
- `Feature "${feature.name}" has migrations declared for event "${shortName}" ` +
327
- `but no r.defineEvent exists for that name. Register the event first.`,
328
- );
329
- }
302
+ // registerEventMigration only ever runs alongside state.events[name]=def
303
+ // in the same defineEvent call (feature-config-events-jobs.ts:260-269),
304
+ // so a migrations entry here guarantees a matching eventMap entry.
305
+ // biome-ignore lint/style/noNonNullAssertion: invariant proven above
306
+ const eventDef = state.eventMap.get(qualified)!;
330
307
  for (const m of migrations) {
331
308
  if (m.toVersion > eventDef.version) {
332
309
  throw new Error(
@@ -34,7 +34,6 @@ import {
34
34
  validateFieldAccessHandlersAreEntityMapped,
35
35
  validateJobTriggers,
36
36
  validateLifecycleHookTargets,
37
- validateNoRawTableProjectionClash,
38
37
  validateProjectionApplyKeys,
39
38
  validateRelationTargetsExist,
40
39
  validateRequiredFeatures,
@@ -68,7 +67,6 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
68
67
  applyExtensionUsages(state);
69
68
  buildSearchableSortableCaches(state);
70
69
  buildImplicitProjections(state, features);
71
- validateNoRawTableProjectionClash(state);
72
70
  buildSearchIncludesAndIncomingRelations(state);
73
71
  validateFieldAccessHandlersAreEntityMapped(state, features);
74
72
  validateExtensionPreSaveWiring(features);
@@ -89,6 +89,13 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
89
89
  readonly scope: ConfigScope;
90
90
  readonly access: ConfigKeyAccess;
91
91
  readonly encrypted?: boolean;
92
+ /** User/admin may legitimately see the value — unlike `encrypted`
93
+ * (shared master-key cipher), this is the subject-KMS: the value is
94
+ * encrypted under the DEK of the scope actually written to (tenant-row
95
+ * → tenant subject, user-row → user subject). Only on `type: "text"`,
96
+ * `scope !== "system"` (no subject there), and mutually exclusive with
97
+ * `encrypted` (kumiko-platform#231/#459). */
98
+ readonly piiEncrypted?: boolean;
92
99
  readonly options?: readonly string[];
93
100
  readonly bounds?: ConfigBounds;
94
101
  // Per-key string-pattern validation for type="text". The value must match
@@ -1,12 +1,7 @@
1
1
  import type { ZodType, z } from "zod";
2
2
  import type { EntityTableMeta } from "../../db/entity-table-meta";
3
-
4
- // PgTable historically came from drizzle-orm/pg-core; the native dialect
5
- // no longer carries drizzle internal class types. Every caller really
6
- // needs "an opaque table-object with Symbol-based introspection".
7
- type PgTable = unknown;
8
-
9
3
  import type { QueryHandlerDefinition, WriteHandlerDefinition } from "../define-handler";
4
+ import type { RegisterEntityCrudOptions } from "../entity-handlers";
10
5
  import type {
11
6
  ConfigKeyDefinition,
12
7
  ConfigKeyHandle,
@@ -131,6 +126,11 @@ export type SecretKeyHandle = {
131
126
  };
132
127
 
133
128
  // --- Raw tables (declared by features via r.rawTable()) ---
129
+ // Post-drizzle-cut: unified with the former r.unmanagedTable() — both
130
+ // carried the same reason/audit contract, differing only in whether the
131
+ // table value was a legacy Drizzle PgTable (rawTable) or the framework-
132
+ // native EntityTableMeta (unmanagedTable, consumed by migrate-runner).
133
+ // EntityTableMeta was the forward-compatible shape, so rawTable adopted it.
134
134
 
135
135
  /** Options accepted by `r.rawTable()`. The `reason` is required so the
136
136
  * bypass leaves an audit trail at the registration site — reviewers can
@@ -142,15 +142,23 @@ export type RawTableOptions = {
142
142
  * payload cache, write-only by webhook handler", "denormalised
143
143
  * projection of a non-Kumiko data source". */
144
144
  readonly reason: string;
145
+ /** Direct-write stores skip the executor, so the executor's PII
146
+ * encryption never runs for them — a feature whose meta carries
147
+ * piiSubjectFields must encrypt those fields itself before every
148
+ * insert/update and declare that here, or boot fails (#820). */
149
+ readonly piiEncryptedOnWrite?: true;
145
150
  };
146
151
 
147
- /** Per-feature raw-table registration. Carries the bypass-justification
148
- * reason but knows nothing about the owning feature — that's added when
149
- * the registry aggregates entries cross-feature into `RawTableDef`. */
152
+ /** Per-feature raw-table registration. `meta` is the `EntityTableMeta`
153
+ * (framework-native shape used by `migrate-runner`). Carries the
154
+ * bypass-justification reason but knows nothing about the owning
155
+ * feature — that's added when the registry aggregates entries
156
+ * cross-feature into `RawTableDef`. */
150
157
  export type RawTableEntry = {
151
158
  readonly name: string;
152
- readonly table: PgTable;
159
+ readonly meta: EntityTableMeta;
153
160
  readonly reason: string;
161
+ readonly piiEncryptedOnWrite?: true;
154
162
  };
155
163
 
156
164
  /** Registry-aggregated raw-table — the per-feature `RawTableEntry` plus
@@ -160,32 +168,6 @@ export type RawTableDef = RawTableEntry & {
160
168
  readonly featureName: string;
161
169
  };
162
170
 
163
- // --- Unmanaged tables (declared by features via r.unmanagedTable()) ---
164
-
165
- /** Per-feature unmanaged-table registration. `meta` is the
166
- * `EntityTableMeta` (framework-native shape used by `migrate-runner`).
167
- * The `reason` justifies the bypass at the registration site — same
168
- * contract as `r.rawTable`. */
169
- export type UnmanagedTableEntry = {
170
- readonly name: string;
171
- readonly meta: EntityTableMeta;
172
- readonly reason: string;
173
- readonly piiEncryptedOnWrite?: true;
174
- };
175
-
176
- /** Options for r.unmanagedTable(). Direct-write stores skip the executor,
177
- * so the executor's PII encryption never runs for them — a feature whose
178
- * meta carries piiSubjectFields must encrypt those fields itself before
179
- * every insert/update and declare that here, or boot fails (#820). */
180
- export type UnmanagedTableOptions = RawTableOptions & {
181
- readonly piiEncryptedOnWrite?: true;
182
- };
183
-
184
- /** Registry-aggregated unmanaged-table — adds the owning feature name. */
185
- export type UnmanagedTableDef = UnmanagedTableEntry & {
186
- readonly featureName: string;
187
- };
188
-
189
171
  // --- UI-Hints (manifest-only, picker/scaffolder metadata) ---
190
172
 
191
173
  // Optional, declarative UI metadata declared via `r.uiHints({...})`. Surfaces
@@ -360,15 +342,12 @@ export type FeatureDefinition = {
360
342
  // writeHandlers — Routes leben mit dem Feature, nicht im Bootstrap.
361
343
  readonly httpRoutes: Readonly<Record<string, HttpRouteDefinition>>;
362
344
  // Raw tables declared via r.rawTable() — bypass the event-sourcing
363
- // system. Keyed by feature-local short name. The registry attaches
364
- // featureName on aggregation, lifting RawTableEntry → RawTableDef.
345
+ // system. Keyed by feature-local short name (derived from
346
+ // meta.tableName). The registry attaches featureName on aggregation,
347
+ // lifting RawTableEntry → RawTableDef. `kumiko schema generate`
348
+ // aggregates these alongside r.entity()-derived metas to build the
349
+ // full schema.
365
350
  readonly rawTables: Readonly<Record<string, RawTableEntry>>;
366
- // Unmanaged tables declared via r.unmanagedTable() — `EntityTableMeta`
367
- // shape (post-drizzle), keyed by feature-local table-name. Cousin of
368
- // rawTables: same bypass-justification contract, different storage
369
- // shape. `kumiko schema generate` aggregates these alongside
370
- // r.entity()-derived metas to build the full schema.
371
- readonly unmanagedTables: Readonly<Record<string, UnmanagedTableEntry>>;
372
351
  // Optional Zod-schema for env-vars this feature reads at runtime.
373
352
  // Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
374
353
  // this to build one app-wide schema for boot-validation + dry-run
@@ -438,6 +417,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
438
417
  ): EntityRef;
439
418
  entity(definition: { readonly name: string } & EntityDefinition): EntityRef;
440
419
 
420
+ // Shorthand for registerEntityCrud(r, ...), scoped to this registrar.
421
+ crud(entityName: string, entity: EntityDefinition, options?: RegisterEntityCrudOptions): void;
422
+
441
423
  writeHandler<TName extends string, TSchema extends ZodType>(
442
424
  def: WriteHandlerDefinition<TName, TSchema>,
443
425
  ): HandlerRef;
@@ -737,34 +719,23 @@ export type FeatureRegistrar<TFeature extends string = string> = {
737
719
  // bleibt runProdApp.extraRoutes.
738
720
  httpRoute(definition: HttpRouteDefinition): void;
739
721
 
740
- // Declare a raw Drizzle table that bypasses the event-sourcing system.
741
- // Reserved for legacy-import, read-only caches, write-only webhook
742
- // payload buffers, or any other case where the event-sourced flow
743
- // doesn't fit. The dev-server iterates these alongside r.entity()
744
- // projections at boot so the table exists before the first query.
745
- // Apps still declare the table in `drizzle/schema.ts` so drizzle-kit
746
- // tracks migrations and schema-drift detection works automatically.
722
+ // Declare an "unmanaged" framework-native table that bypasses the
723
+ // event-sourcing system. Reserved for legacy-import, read-only caches,
724
+ // write-only webhook payload buffers, or read-side projections of
725
+ // event-streams (delivery-attempts, job-run-logs) where r.entity()'s
726
+ // aggregate-lifecycle assumptions don't fit. The dev-server iterates
727
+ // these alongside r.entity() projections at boot so the table exists
728
+ // before the first query.
729
+ //
730
+ // EntityTableMeta carries the same column-shape that r.entity() builds,
731
+ // minus the audit-trail + base-columns scaffolding. The `meta` argument
732
+ // is the result of `defineUnmanagedTable(...)` / `buildEntityTableMeta(...)`
733
+ // from `@cosmicdrift/kumiko-framework/db`.
747
734
  //
748
735
  // The required `reason` string is the marker that justifies the bypass —
749
736
  // a non-empty string is the contract. If you can't write a reason,
750
737
  // declare data via `r.entity()` instead.
751
- rawTable(name: string, table: PgTable, options: RawTableOptions): void;
752
-
753
- // Declare an "unmanaged" framework-native table (post-drizzle).
754
- // EntityTableMeta carries the same column-shape that r.entity() builds,
755
- // minus the audit-trail + base-columns scaffolding — used for read-side
756
- // projections of event-streams (delivery-attempts, job-run-logs) where
757
- // r.entity()'s aggregate-lifecycle assumptions don't fit.
758
- //
759
- // The `meta` argument is the result of `defineUnmanagedTable(...)` from
760
- // `@cosmicdrift/kumiko-framework/db`. Reason-justification + audit-trail
761
- // contract identical to `r.rawTable`.
762
- //
763
- // Why this exists separate from `r.rawTable`: rawTable carries a Drizzle
764
- // `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
765
- // shape that `migrate-runner` consumes. After the full drizzle-cut they
766
- // will likely merge; for now they coexist.
767
- unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void;
738
+ rawTable(meta: EntityTableMeta, options: RawTableOptions): void;
768
739
 
769
740
  // Register the tree-actions schema for this feature — a map of
770
741
  // action-name → action-definition (with optional typed args). At-most-
@@ -815,6 +786,7 @@ export type Registry = {
815
786
  getAllEntities(): ReadonlyMap<string, EntityDefinition>;
816
787
  getWriteHandler(name: string): WriteHandlerDef | undefined;
817
788
  getQueryHandler(name: string): QueryHandlerDef | undefined;
789
+ getAllQueryHandlers(): ReadonlyMap<string, QueryHandlerDef>;
818
790
  getSearchableFields(entityName: string): readonly string[];
819
791
  getSortableFields(entityName: string): readonly string[];
820
792
  getRelations(entityName: string): EntityRelations;
@@ -107,6 +107,12 @@ export type TextFieldDef = {
107
107
  * Default: false — analog zu `sortable`, opt-in. */
108
108
  readonly filterable?: boolean;
109
109
  readonly encrypted?: boolean;
110
+ /** User/admin may legitimately see the value (their own IBAN, passport
111
+ * number) — unlike `r.secret()`, which only server code reads. A
112
+ * declarative alias over the subject-KMS (pii/userOwned/tenantOwned):
113
+ * only allowed on `type: "text"`, can't combine with `searchable`/
114
+ * `sortable` (boot validator, kumiko-platform#231/#456). */
115
+ readonly piiEncrypted?: boolean;
110
116
  readonly sensitive?: boolean;
111
117
  readonly format?: "email" | "url" | "phone";
112
118
  readonly default?: string;
@@ -76,9 +76,6 @@ export type {
76
76
  SecretOptions,
77
77
  UiHintOption,
78
78
  UiHints,
79
- UnmanagedTableDef,
80
- UnmanagedTableEntry,
81
- UnmanagedTableOptions,
82
79
  } from "./feature";
83
80
  export type {
84
81
  AnyFileFieldDef,
@@ -7,7 +7,7 @@ ES-Operations für Kumiko-Apps. Phase 1+1.5 liefert `seed-migrations` als file-b
7
7
  ## Quick API
8
8
 
9
9
  ```ts
10
- import { runProdApp } from "@cosmicdrift/kumiko-dev-server";
10
+ import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
11
11
 
12
12
  await runProdApp({
13
13
  features: [...],
@@ -72,6 +72,14 @@ const seedTestFeature = defineFeature("esopstest", (r) => {
72
72
  },
73
73
  { access: { openToAll: true } },
74
74
  );
75
+ // Role-gated handler (no openToAll): the bare system actor has no bypass
76
+ // for explicit role gates — only systemWriteAs' extraRoles reaches it.
77
+ r.writeHandler(
78
+ "probe:admin-only",
79
+ z.object({ label: z.string().min(1) }),
80
+ async (event, ctx) => probeExecutor.create(event.payload, event.user, ctx.db),
81
+ { access: { roles: ["SystemAdmin"] } },
82
+ );
75
83
  });
76
84
 
77
85
  let testDb: TestDb;
@@ -233,6 +241,72 @@ describe("runPendingSeedMigrations (integration)", () => {
233
241
  }
234
242
  });
235
243
 
244
+ test("systemWriteAs ohne extraRoles → access_denied gegen rollen-gated Handler, mit extraRoles → durch", async () => {
245
+ const denyDir = makeTempSeedsDir([
246
+ {
247
+ name: "2026-05-20-denied.ts",
248
+ content: `
249
+ export default {
250
+ description: "no extraRoles against a role-gated handler",
251
+ run: async (ctx) => {
252
+ await ctx.systemWriteAs("esopstest:write:probe:admin-only", { label: "denied" });
253
+ },
254
+ };
255
+ `,
256
+ },
257
+ ]);
258
+ try {
259
+ await expect(
260
+ runPendingSeedMigrations({
261
+ db: testDb.db,
262
+ seedsDir: denyDir,
263
+ appliedBy: "boot",
264
+ registry,
265
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
266
+ logger: () => {},
267
+ }),
268
+ ).rejects.toThrow(/access_denied/);
269
+ expect(await selectEvents()).toHaveLength(0);
270
+ } finally {
271
+ rmSync(denyDir, { recursive: true, force: true });
272
+ }
273
+
274
+ const allowDir = makeTempSeedsDir([
275
+ {
276
+ name: "2026-05-20-allowed.ts",
277
+ content: `
278
+ export default {
279
+ description: "extraRoles reaches a role-gated handler",
280
+ run: async (ctx) => {
281
+ await ctx.systemWriteAs(
282
+ "esopstest:write:probe:admin-only",
283
+ { label: "allowed" },
284
+ undefined,
285
+ ["SystemAdmin"],
286
+ );
287
+ },
288
+ };
289
+ `,
290
+ },
291
+ ]);
292
+ try {
293
+ await runPendingSeedMigrations({
294
+ db: testDb.db,
295
+ seedsDir: allowDir,
296
+ appliedBy: "boot",
297
+ registry,
298
+ createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
299
+ logger: () => {},
300
+ });
301
+ const events = await selectEvents();
302
+ expect(events).toHaveLength(1);
303
+ expect(events[0]?.type).toBe("esops-probe.created");
304
+ expect(events[0]?.tenant_id).toBe(SYSTEM_TENANT_ID);
305
+ } finally {
306
+ rmSync(allowDir, { recursive: true, force: true });
307
+ }
308
+ });
309
+
236
310
  test("WriteResult{isSuccess:false} → throw + Marker NICHT geschrieben", async () => {
237
311
  // Critical: ohne diese Garantie würde ein silent-failed Write den Seed
238
312
  // als "applied" markieren → beim nächsten Boot kein retry → DB-Drift