@cosmicdrift/kumiko-framework 0.155.1 → 0.156.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 (74) 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 +77 -0
  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__/feature-table-sources.test.ts +7 -7
  12. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  13. package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
  15. package/src/db/collect-table-metas.ts +6 -7
  16. package/src/db/entity-table-meta.ts +1 -1
  17. package/src/db/feature-table-sources.ts +5 -11
  18. package/src/db/migrate-generator.ts +39 -6
  19. package/src/db/queries/shadow-swap.ts +85 -3
  20. package/src/db/tenant-db.ts +8 -0
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
  22. package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
  23. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  24. package/src/engine/__tests__/field-access.test.ts +23 -1
  25. package/src/engine/__tests__/raw-table.test.ts +134 -84
  26. package/src/engine/__tests__/registry.test.ts +40 -0
  27. package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
  28. package/src/engine/boot-validator/action-wiring.ts +2 -2
  29. package/src/engine/boot-validator/config-deps.ts +35 -0
  30. package/src/engine/boot-validator/index.ts +2 -0
  31. package/src/engine/boot-validator/pii-retention.ts +35 -3
  32. package/src/engine/build-config-feature-schema.ts +3 -3
  33. package/src/engine/config-helpers.ts +2 -0
  34. package/src/engine/define-feature.ts +2 -6
  35. package/src/engine/entity-handlers.ts +2 -4
  36. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  37. package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
  38. package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
  39. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
  40. package/src/engine/feature-ast/extractors/events.ts +322 -0
  41. package/src/engine/feature-ast/extractors/handlers.ts +222 -0
  42. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  43. package/src/engine/feature-ast/extractors/index.ts +32 -24
  44. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  45. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  46. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  47. package/src/engine/feature-ast/parse.ts +3 -3
  48. package/src/engine/feature-ast/render.ts +16 -11
  49. package/src/engine/feature-builder-state.ts +0 -3
  50. package/src/engine/feature-config-events-jobs.ts +3 -6
  51. package/src/engine/feature-entity-handlers.ts +9 -1
  52. package/src/engine/feature-ui-extensions.ts +10 -40
  53. package/src/engine/field-access.ts +13 -2
  54. package/src/engine/object-form.ts +15 -0
  55. package/src/engine/registry-facade.ts +4 -0
  56. package/src/engine/registry-ingest.ts +19 -29
  57. package/src/engine/registry-state.ts +1 -7
  58. package/src/engine/registry-validate.ts +5 -28
  59. package/src/engine/registry.ts +0 -2
  60. package/src/engine/types/config.ts +7 -0
  61. package/src/engine/types/feature.ts +40 -68
  62. package/src/engine/types/fields.ts +6 -0
  63. package/src/engine/types/index.ts +0 -3
  64. package/src/es-ops/README.md +1 -1
  65. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  66. package/src/es-ops/context.ts +4 -2
  67. package/src/es-ops/types.ts +8 -1
  68. package/src/pipeline/msp-rebuild.ts +4 -0
  69. package/src/pipeline/projection-rebuild.ts +35 -0
  70. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
  71. package/src/stack/test-stack.ts +17 -2
  72. package/src/ui-types/index.ts +1 -0
  73. package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
  74. package/src/engine/feature-ast/extractors/round4.ts +0 -1227
@@ -1,10 +1,9 @@
1
1
  // collectTableMetas — kanonische ENTITY_METAS-Quelle für `kumiko schema
2
2
  // generate`. Erfasst dieselben Tabellen-Quellen wie der setupTestStack-
3
- // auto-push (entities, unmanagedTables, projections, multiStreamProjections,
4
- // rawTables) — die frühere Template-Variante sammelte nur entities +
5
- // unmanagedTables, wodurch projection-only-Tabellen (z.B. billing-foundation
6
- // read_subscriptions) nie in Migrations landeten und der erste Prod-Write
7
- // crashte (#255).
3
+ // auto-push (entities, rawTables, projections, multiStreamProjections) —
4
+ // die frühere Template-Variante sammelte nur entities + rawTables, wodurch
5
+ // projection-only-Tabellen (z.B. billing-foundation read_subscriptions) nie
6
+ // in Migrations landeten und der erste Prod-Write crashte (#255).
8
7
 
9
8
  import type { FeatureDefinition } from "../engine/types";
10
9
  import { compareByCodepoint } from "../utils";
@@ -60,11 +59,11 @@ export function collectTableMetas(
60
59
  metas.push(meta);
61
60
  byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
62
61
  }
63
- for (const entry of Object.values(feature.unmanagedTables)) {
62
+ for (const entry of Object.values(feature.rawTables)) {
64
63
  metas.push(entry.meta);
65
64
  byName.set(entry.meta.tableName, {
66
65
  meta: entry.meta,
67
- origin: `unmanagedTable "${entry.name}" (${feature.name})`,
66
+ origin: `rawTable "${entry.name}" (${feature.name})`,
68
67
  });
69
68
  }
70
69
  }
@@ -95,7 +95,7 @@ export type EntityTableMeta = {
95
95
  // discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
96
96
  readonly source: "managed" | "unmanaged";
97
97
  // PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
98
- // buildEntityTableMeta so the registry can reject r.unmanagedTable stores
98
+ // buildEntityTableMeta so the registry can reject r.rawTable stores
99
99
  // whose direct writes would skip the executor's encryption (#820).
100
100
  readonly piiSubjectFields?: readonly string[];
101
101
  };
@@ -1,8 +1,8 @@
1
1
  // Single enumeration of every table-bearing registration on a feature
2
- // (r.projection, r.multiStreamProjection with table, r.rawTable,
3
- // r.unmanagedTable). Consumed by BOTH the setupTestStack auto-push and
4
- // collectTableMetas — one list, so test-DB-push and `kumiko schema generate`
5
- // cannot drift apart again (#255).
2
+ // (r.projection, r.multiStreamProjection with table, r.rawTable).
3
+ // Consumed by BOTH the setupTestStack auto-push and collectTableMetas —
4
+ // one list, so test-DB-push and `kumiko schema generate` cannot drift
5
+ // apart again (#255).
6
6
  // A new table-bearing registrar must be added HERE, not in the consumers.
7
7
 
8
8
  import type { FeatureDefinition } from "../engine/types";
@@ -30,13 +30,7 @@ export function enumerateFeatureTableSources(
30
30
  });
31
31
  }
32
32
  for (const [name, raw] of Object.entries(feature.rawTables)) {
33
- sources.push({ table: raw.table, origin: `rawTable "${name}" (${feature.name})` });
34
- }
35
- for (const entry of Object.values(feature.unmanagedTables)) {
36
- sources.push({
37
- table: entry.meta,
38
- origin: `unmanagedTable "${entry.name}" (${feature.name})`,
39
- });
33
+ sources.push({ table: raw.meta, origin: `rawTable "${name}" (${feature.name})` });
40
34
  }
41
35
  return sources;
42
36
  }
@@ -202,13 +202,40 @@ export function diffSnapshots(prev: Snapshot | null, next: Snapshot): SchemaDiff
202
202
  }
203
203
 
204
204
  // Managed projections are event-stream derivatives: in-place-unsafe changes (NOT NULL w/o default, UNIQUE, SET NOT NULL, type change, dropped col) → DROP+CREATE + replay; additive-safe changes stay cheap ALTERs.
205
+ function describeRecreateReasons(td: TableDiff): readonly string[] {
206
+ const reasons: string[] = [];
207
+ if (td.droppedColumns.length > 0) {
208
+ reasons.push(`dropped column(s): ${td.droppedColumns.join(", ")}`);
209
+ }
210
+ const notNullNoDefault = td.newColumns
211
+ .filter((c) => c.notNull && c.defaultSql === undefined)
212
+ .map((c) => c.name);
213
+ if (notNullNoDefault.length > 0) {
214
+ reasons.push(`new NOT NULL column(s) without default: ${notNullNoDefault.join(", ")}`);
215
+ }
216
+ const newUniqueIndexes = td.newIndexes
217
+ .filter((idx) => idx.unique === true)
218
+ .map((idx) => idx.name);
219
+ if (newUniqueIndexes.length > 0) {
220
+ reasons.push(`new UNIQUE index(es): ${newUniqueIndexes.join(", ")}`);
221
+ }
222
+ const madeNotNull = td.changedColumns
223
+ .filter((c) => c.nullabilityChanged?.to === true)
224
+ .map((c) => c.name);
225
+ if (madeNotNull.length > 0) {
226
+ reasons.push(`column(s) set NOT NULL: ${madeNotNull.join(", ")}`);
227
+ }
228
+ const typeChanged = td.changedColumns
229
+ .filter((c) => c.typeChanged !== undefined)
230
+ .map((c) => c.name);
231
+ if (typeChanged.length > 0) {
232
+ reasons.push(`column type change: ${typeChanged.join(", ")}`);
233
+ }
234
+ return reasons;
235
+ }
236
+
205
237
  export function managedChangeRequiresRecreate(td: TableDiff): boolean {
206
- if (td.droppedColumns.length > 0) return true;
207
- if (td.newColumns.some((c) => c.notNull && c.defaultSql === undefined)) return true;
208
- if (td.newIndexes.some((idx) => idx.unique === true)) return true;
209
- return td.changedColumns.some(
210
- (c) => c.nullabilityChanged?.to === true || c.typeChanged !== undefined,
211
- );
238
+ return describeRecreateReasons(td).length > 0;
212
239
  }
213
240
 
214
241
  // --- SQL-Render ---------------------------------------------------------
@@ -296,6 +323,12 @@ export function renderMigrationSql(
296
323
  lines.push(`-- ${td.tableName}`);
297
324
  if (td.nextMeta.source === "managed" && managedChangeRequiresRecreate(td)) {
298
325
  lines.push("-- managed projection — recreated + rebuilt from events (see .rebuild.json)");
326
+ lines.push(
327
+ `-- WARN: destructive change (${describeRecreateReasons(td).join("; ")}) forces DROP+CREATE + full event replay.`,
328
+ );
329
+ lines.push(
330
+ "-- Consider an Expand/Contract split across two releases to avoid the rebuild — see docs/guides/expand-contract-managed-projections.md.",
331
+ );
299
332
  lines.push(`DROP TABLE IF EXISTS ${quoteIdent(td.tableName)};`);
300
333
  lines.push(...renderTableDdl(td.nextMeta));
301
334
  lines.push("");
@@ -173,8 +173,8 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
173
173
  // by the #494 backfill-then-rebuild flow.
174
174
  // Checking event existence INCLUDING archived streams leaves every one of them
175
175
  // alone: those rows all have a real event, so they are not ghosts. Column-level
176
- // drift detection (fail-hard vs. graceful repair) is the open question deferred
177
- // from #722.
176
+ // drift is a SEPARATE, non-blocking check see countColumnDrift below (#916,
177
+ // resolves the #722 open question: observe, don't block).
178
178
  //
179
179
  // Implicit projections only (caller-gated). aggregate_id and the entity id are
180
180
  // both uuid, so the anti-join probes the events index without a cast.
@@ -207,12 +207,94 @@ export async function assertNoUnreachableLiveRows(
207
207
  `projection-rebuild "${projectionName}": ${countLabel} live rows in "${tableName}" have no ` +
208
208
  `event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
209
209
  `would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
210
- `without emitting a .created event. Fix: register the table with r.unmanagedTable(meta, ` +
210
+ `without emitting a .created event. Fix: register the table with r.rawTable(meta, ` +
211
211
  `{ reason }) to opt out of rebuild, or emit the missing events. See ` +
212
212
  `docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
213
213
  );
214
214
  }
215
215
 
216
+ // Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
217
+ // live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
218
+ // recomputed to NULL on GDPR key-shredding; the NULL is the intended end
219
+ // state, not drift. Everything else that legitimately diverges (archived
220
+ // streams, #494 backfill) either never reaches this comparison (archived rows
221
+ // are absent from the shadow entirely, see swapShadowIntoLive) or IS real
222
+ // column drift that the #494 backfill-then-rebuild flow relies on replay to
223
+ // heal — reporting it (without blocking) is correct, not a false positive.
224
+ const COLUMN_DRIFT_SAMPLE_LIMIT = 20;
225
+
226
+ export type ColumnDriftResult = {
227
+ readonly rowCount: number;
228
+ // Capped sample of "<id>.<column>" pairs for log/ops triage.
229
+ readonly sample: readonly string[];
230
+ };
231
+
232
+ // Runs INSIDE the rebuild tx, in the same slot as assertNoUnreachableLiveRows
233
+ // (after replay settles, before swapShadowIntoLive). Non-blocking counterpart
234
+ // to the ghost-row guard: reports live rows whose column values differ from
235
+ // the freshly-replayed shadow, WITHOUT aborting the swap (#916, resolves the
236
+ // #722 open question in favor of observe-not-block).
237
+ //
238
+ // Why non-blocking: a legacy column direct-written before its handler emitted
239
+ // events (#494) diverges from replay by design — that divergence is exactly
240
+ // what the backfill-then-rebuild flow relies on replay to heal. Failing hard
241
+ // here would make rebuild mutually exclusive with that shipped healing path.
242
+ // There is no reliable metadata to distinguish "#494 healing in progress" from
243
+ // "someone else corrupted this row" short of an open-ended per-column policy
244
+ // blocklist — wrong-by-default whenever a class is missed. So: surface it,
245
+ // don't police it. The caller logs the result; ops decides.
246
+ //
247
+ // Caveat: sensitive CUSTOM fields still diverge until #972 (Subject-DEK
248
+ // design) — regular sensitive fields carry event-payload ciphertext parity
249
+ // post-#973 and don't drift. Both are reported the same as any other column
250
+ // drift; this is deliberate (see module comment above), not an oversight.
251
+ //
252
+ // Relies on assertLiveColumnsMatchMeta having already run: live/shadow/meta
253
+ // column sets are known to match, so the diff can walk meta.columns directly.
254
+ export async function countColumnDrift(
255
+ tx: AnyDb,
256
+ tableName: string,
257
+ meta: EntityTableMeta,
258
+ ): Promise<ColumnDriftResult> {
259
+ const comparable = meta.columns.filter((c) => c.primaryKey !== true && !c.name.endsWith("_bidx"));
260
+ // skip: nothing to compare (id-only or all-bidx table) — no drift is possible
261
+ if (comparable.length === 0) return { rowCount: 0, sample: [] };
262
+ const t = quoteTableIdent(tableName);
263
+ const raw = asRawClient(tx);
264
+ const driftCte = `WITH drifted AS (
265
+ SELECT l."id" AS id, string_agg(diff.col, ',') AS drifted_columns
266
+ FROM public.${t} l
267
+ JOIN ${SCHEMA_IDENT}.${t} s ON s."id" = l."id"
268
+ CROSS JOIN LATERAL (
269
+ VALUES ${comparable
270
+ .map(
271
+ (c) =>
272
+ `('${c.name}', l.${quoteTableIdent(c.name)} IS DISTINCT FROM s.${quoteTableIdent(c.name)})`,
273
+ )
274
+ .join(", ")}
275
+ ) AS diff(col, differs)
276
+ WHERE diff.differs
277
+ GROUP BY l."id"
278
+ )`;
279
+ // Two passes over `drifted`: an unbounded COUNT for the true total (replay
280
+ // already scanned every row this run, so a second scan here is cheap by
281
+ // comparison) plus a capped sample for the log. rowCount must NEVER be
282
+ // min(actual, LIMIT) — that would silently understate severity to ops.
283
+ const totalRows = await raw.unsafe<{ total: string }>(
284
+ `${driftCte} SELECT count(*)::text AS total FROM drifted`,
285
+ );
286
+ const rowCount = Number(totalRows[0]?.total ?? "0");
287
+ // skip: no drift — nothing to sample
288
+ if (rowCount === 0) return { rowCount: 0, sample: [] };
289
+ const rows = await raw.unsafe<{ id: unknown; drifted_columns: string }>(
290
+ `${driftCte} SELECT id, drifted_columns FROM drifted LIMIT ${COLUMN_DRIFT_SAMPLE_LIMIT}`,
291
+ );
292
+ const sample = rows.flatMap((r) =>
293
+ r.drifted_columns.split(",").map((col) => `${String(r.id)}.${col}`),
294
+ );
295
+ return { rowCount, sample };
296
+ }
297
+
216
298
  // Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
217
299
  // active shadow search_path can't redirect them. DROP without CASCADE: if any
218
300
  // object depends on the live table the swap fails loud and the whole rebuild
@@ -1,4 +1,5 @@
1
1
  import {
2
+ asEntityTableMeta,
2
3
  asRawClient,
3
4
  deleteMany as bunDeleteMany,
4
5
  fetchOne as bunFetchOne,
@@ -79,7 +80,14 @@ function tableNameOf(table: Table): string {
79
80
  return typeof sym === "string" ? sym : "<unknown>";
80
81
  }
81
82
 
83
+ // Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
84
+ // or a plain buildEntityTableMeta/defineUnmanagedTable result), not a direct
85
+ // `table.tenantId` property read — the latter only exists on branded EntityTables
86
+ // and silently returned false (no tenant filter!) for plain EntityTableMeta
87
+ // tables like unmanaged direct-write stores, e.g. userSessionTable.
82
88
  function hasTenantColumn(table: Table): boolean {
89
+ const meta = asEntityTableMeta(table);
90
+ if (meta) return meta.columns.some((c) => c.name === "tenant_id");
83
91
  return (table as Record<string, unknown>)["tenantId"] !== undefined;
84
92
  }
85
93
 
@@ -708,3 +708,105 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
708
708
  expect(() => validateBoot([feature])).toThrow(/sortable.*cannot work/);
709
709
  });
710
710
  });
711
+
712
+ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
713
+ test("piiEncrypted on a plain text field passes", () => {
714
+ const feature = defineFeature("test", (r) => {
715
+ r.entity(
716
+ "tenant",
717
+ createEntity({
718
+ fields: {
719
+ iban: createTextField({
720
+ piiEncrypted: true,
721
+ tenantOwned: true,
722
+ access: { read: ["TenantAdmin"] },
723
+ }),
724
+ },
725
+ }),
726
+ );
727
+ });
728
+ expect(() => validateBoot([feature])).not.toThrow();
729
+ });
730
+
731
+ test("piiEncrypted on a non-text field throws", () => {
732
+ const feature = defineFeature("test", (r) => {
733
+ r.entity(
734
+ "tenant",
735
+ createEntity({
736
+ fields: {
737
+ amount: { ...createNumberField(), piiEncrypted: true } as unknown as ReturnType<
738
+ typeof createNumberField
739
+ >,
740
+ },
741
+ }),
742
+ );
743
+ });
744
+ expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*only applies to text fields/);
745
+ });
746
+
747
+ test("piiEncrypted without a subject annotation throws (kumiko-platform#457)", () => {
748
+ const feature = defineFeature("test", (r) => {
749
+ r.entity(
750
+ "tenant",
751
+ createEntity({
752
+ fields: {
753
+ iban: createTextField({ piiEncrypted: true }),
754
+ },
755
+ }),
756
+ );
757
+ });
758
+ expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without a subject annotation/);
759
+ });
760
+
761
+ test("piiEncrypted combined with searchable throws", () => {
762
+ const feature = defineFeature("test", (r) => {
763
+ r.entity(
764
+ "tenant",
765
+ createEntity({
766
+ fields: {
767
+ iban: createTextField({
768
+ piiEncrypted: true,
769
+ tenantOwned: true,
770
+ access: { read: ["TenantAdmin"] },
771
+ searchable: true,
772
+ }),
773
+ },
774
+ }),
775
+ );
776
+ });
777
+ expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*searchable.*cannot work/);
778
+ });
779
+
780
+ test("piiEncrypted combined with sortable throws", () => {
781
+ const feature = defineFeature("test", (r) => {
782
+ r.entity(
783
+ "tenant",
784
+ createEntity({
785
+ fields: {
786
+ iban: createTextField({
787
+ piiEncrypted: true,
788
+ tenantOwned: true,
789
+ access: { read: ["TenantAdmin"] },
790
+ sortable: true,
791
+ }),
792
+ },
793
+ }),
794
+ );
795
+ });
796
+ expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*sortable.*cannot work/);
797
+ });
798
+
799
+ test("piiEncrypted without access.read throws (kumiko-platform#460)", () => {
800
+ const feature = defineFeature("test", (r) => {
801
+ r.entity(
802
+ "tenant",
803
+ createEntity({
804
+ fields: {
805
+ iban: createTextField({ piiEncrypted: true, tenantOwned: true }),
806
+ },
807
+ }),
808
+ );
809
+ });
810
+ expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without.*access.*read/);
811
+ });
812
+ });
@@ -435,4 +435,20 @@ describe("buildConfigFeatureSchema — cross-feature group", () => {
435
435
  });
436
436
  expect(() => buildConfigFeatureSchema(createRegistry([feature]))).toThrow(/kebab-case/);
437
437
  });
438
+
439
+ test("invalid (non-kebab) group value throws even on a maskless key", () => {
440
+ // Maskless keys never appear in the hub — group is a no-op for them at
441
+ // runtime — but the type contract still promises kebab-case is
442
+ // boot-validated regardless of `mask`. collectMaskedKeys used to check
443
+ // the mask-gate before the group check, so a maskless key with a bad
444
+ // group silently passed boot; the check now runs before the mask-gate.
445
+ const feature = defineFeature("badgroupmaskless", (r) => {
446
+ r.config({
447
+ keys: {
448
+ flag: createTenantConfig("boolean", { group: "Not Kebab!" }),
449
+ },
450
+ });
451
+ });
452
+ expect(() => buildConfigFeatureSchema(createRegistry([feature]))).toThrow(/kebab-case/);
453
+ });
438
454
  });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "../define-feature";
3
+ import { createEntity, createTextField } from "../factories";
4
+
5
+ const taskEntity = createEntity({
6
+ table: "crud_shorthand_tasks",
7
+ fields: { title: createTextField({ required: true }) },
8
+ softDelete: true,
9
+ });
10
+
11
+ describe("r.crud", () => {
12
+ test("registers the entity + full CRUD handler set, same as registerEntityCrud", () => {
13
+ const write = { access: { roles: ["Admin"] } } as const;
14
+ const read = { access: { openToAll: true } } as const;
15
+
16
+ const feature = defineFeature("via-crud", (r) => {
17
+ r.crud("task", taskEntity, { write, read });
18
+ });
19
+
20
+ expect(Object.keys(feature.entities ?? {})).toEqual(["task"]);
21
+ expect(Object.keys(feature.writeHandlers ?? {}).sort()).toEqual(
22
+ ["task:create", "task:delete", "task:restore", "task:update"].sort(),
23
+ );
24
+ expect(Object.keys(feature.queryHandlers ?? {}).sort()).toEqual(
25
+ ["task:detail", "task:list"].sort(),
26
+ );
27
+ });
28
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { checkWriteFieldRoles, filterReadFields } from "../field-access";
2
+ import { checkWriteFieldRoles, filterReadFields, PII_MASKED_VALUE } from "../field-access";
3
3
  import type { EntityDefinition } from "../types";
4
4
 
5
5
  const entity: EntityDefinition = {
@@ -25,6 +25,28 @@ describe("filterReadFields", () => {
25
25
  const filtered = filterReadFields(entity, row, admin);
26
26
  expect(filtered["secret"]).toBe("visible");
27
27
  });
28
+
29
+ test("masks piiEncrypted fields instead of stripping them (kumiko-platform#463)", () => {
30
+ const entityWithPii: EntityDefinition = {
31
+ fields: {
32
+ ...entity.fields,
33
+ iban: {
34
+ type: "text",
35
+ piiEncrypted: true,
36
+ tenantOwned: true,
37
+ access: { read: { admin: "all" } },
38
+ },
39
+ },
40
+ };
41
+ const row = { id: 1, title: "Hello", secret: "hidden", iban: "DE89370400440532013000" };
42
+
43
+ const filteredForEditor = filterReadFields(entityWithPii, row, editor);
44
+ expect(filteredForEditor["secret"]).toBeUndefined();
45
+ expect(filteredForEditor["iban"]).toBe(PII_MASKED_VALUE);
46
+
47
+ const filteredForAdmin = filterReadFields(entityWithPii, row, admin);
48
+ expect(filteredForAdmin["iban"]).toBe("DE89370400440532013000");
49
+ });
28
50
  });
29
51
 
30
52
  describe("checkWriteFieldRoles", () => {