@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
@@ -95,6 +95,10 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
95
95
  expect(sql).toContain('DROP TABLE IF EXISTS "read_secrets";');
96
96
  expect(sql).toContain('CREATE TABLE IF NOT EXISTS "read_secrets"');
97
97
  expect(sql).not.toContain("ADD COLUMN");
98
+ expect(sql).toContain(
99
+ "-- WARN: destructive change (new NOT NULL column(s) without default: envelope) forces DROP+CREATE + full event replay.",
100
+ );
101
+ expect(sql).toContain("Consider an Expand/Contract split across two releases");
98
102
  });
99
103
 
100
104
  test("managed: column rename (drop + add NOT NULL) → DROP+CREATE with new shape", () => {
@@ -121,6 +125,7 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
121
125
  const sql = renderMigrationSql(diffSnapshots(prev, next), { name: "note", sequenceNumber: 4 });
122
126
  expect(sql).toContain('ALTER TABLE "read_a" ADD COLUMN "note"');
123
127
  expect(sql).not.toContain("DROP TABLE");
128
+ expect(sql).not.toContain("WARN: destructive change");
124
129
  });
125
130
 
126
131
  test("unmanaged: NOT NULL column without default → in-place ADD (real data, never recreated)", () => {
@@ -134,5 +139,21 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
134
139
  });
135
140
  expect(sql).toContain('ALTER TABLE "app_data" ADD COLUMN "envelope"');
136
141
  expect(sql).not.toContain("DROP TABLE");
142
+ expect(sql).not.toContain("WARN: destructive change");
143
+ });
144
+
145
+ test("managed: multiple recreate reasons at once → all named in the warning", () => {
146
+ const prev = snapshotFromMetas([
147
+ meta("read_b", { name: "old_col", pgType: "text", notNull: false }, "managed"),
148
+ ]);
149
+ const next = snapshotFromMetas([
150
+ meta("read_b", { name: "envelope", pgType: "jsonb", notNull: true }, "managed"),
151
+ ]);
152
+ const sql = renderMigrationSql(diffSnapshots(prev, next), {
153
+ name: "multi",
154
+ sequenceNumber: 6,
155
+ });
156
+ expect(sql).toContain("dropped column(s): old_col");
157
+ expect(sql).toContain("new NOT NULL column(s) without default: envelope");
137
158
  });
138
159
  });
@@ -50,7 +50,7 @@ describe("createNumberField({ integer: true }) — still rejects fractional valu
50
50
  await expect(
51
51
  seedRow(stack.db, statsTable, {
52
52
  expectedValue: 1,
53
- wholeCount: 3.5 as unknown as number,
53
+ wholeCount: 3.5,
54
54
  tenantId: "00000000-0000-4000-8000-000000000001",
55
55
  }),
56
56
  ).rejects.toThrow(/invalid input syntax for type integer/);
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
3
  import { testTenantId } from "../../stack";
4
4
  import type { TableColumns } from "../dialect";
5
+ import { buildEntityTableMeta } from "../entity-table-meta";
5
6
  import { buildEntityTable } from "../table-builder";
6
7
  import { createTenantDb } from "../tenant-db";
7
8
 
@@ -116,3 +117,41 @@ describe("tenant-db WHERE merge — narrowing within the enforced scope", () =>
116
117
  expect(captured[0]?.values).not.toContain(foreign);
117
118
  });
118
119
  });
120
+
121
+ // Root-cause regression for the cross-tenant leak fixed in hasTenantColumn:
122
+ // unmanaged direct-write stores (buildEntityTableMeta, e.g. userSessionTable,
123
+ // apiTokenTable) store tenantId as a snake_case column-meta entry, not a
124
+ // direct `table.tenantId` property — a naive property check silently treated
125
+ // them as tenant-less and skipped the WHERE-scope entirely.
126
+ describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (buildEntityTableMeta)", () => {
127
+ const unmanagedEntity = createEntity({
128
+ table: "merge_meta_items",
129
+ fields: { tenantId: createTextField({ required: true }), name: createTextField() },
130
+ });
131
+ const unmanagedTable = buildEntityTableMeta("merge-meta-item", unmanagedEntity);
132
+
133
+ test("selectMany still applies the tenant scope (pre-fix: no WHERE at all)", async () => {
134
+ const captured: Captured[] = [];
135
+ const tdb = createTenantDb(recordingDb(captured), own);
136
+
137
+ // selectMany's Table param types the branded-EntityTable shape only;
138
+ // EntityTableMeta reads work identically at runtime (both normalize via
139
+ // asEntityTableMeta) — same unbranded-view cast as `table` above.
140
+ await tdb.selectMany(unmanagedTable as unknown as TableColumns, { name: "needle" });
141
+
142
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
143
+ expect(captured[0]?.values).toContain(own);
144
+ expect(captured[0]?.values).not.toContain(foreign);
145
+ });
146
+
147
+ test("updateMany still forces own tenantId (pre-fix: no scope on writes either)", async () => {
148
+ const captured: Captured[] = [];
149
+ const tdb = createTenantDb(recordingDb(captured), own);
150
+
151
+ await tdb.updateMany(unmanagedTable, { name: "x" }, { tenantId: foreign });
152
+
153
+ const update = captured.find((c) => /UPDATE/i.test(c.sql));
154
+ expect(update?.values).toContain(own);
155
+ expect(update?.values).not.toContain(foreign);
156
+ });
157
+ });
@@ -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
  };
@@ -32,6 +32,20 @@ import { selectMany } from "./query";
32
32
  // unchanged from the original, just relocated behind an explicit
33
33
  // ExecutorContext instead of capturing the factory's local scope directly.
34
34
 
35
+ // updateOptions.skipUnchanged (#464) — opt-in: a resubmitted-but-identical
36
+ // key is dropped before encryption so pii/encrypted fields don't get a fresh
37
+ // AEAD ciphertext (new nonce) for no real change, and the event's `changes`
38
+ // don't carry a phantom diff. Opt-in, not the executor's default, because
39
+ // direct executor.update() callers rely on the current behavior to FORCE a
40
+ // re-encrypt of an unchanged plaintext — KEK-rotation (auth-mfa reencrypt.job)
41
+ // and the user-data-rights #494 backfill both resubmit the current value on
42
+ // purpose to land a fresh event/ciphertext. Only the generic entity update
43
+ // handler (entity-handlers.ts) sets this flag; those two call their own
44
+ // executor directly and never see it.
45
+ function isUnchangedValue(a: unknown, b: unknown): boolean {
46
+ return JSON.stringify(a) === JSON.stringify(b);
47
+ }
48
+
35
49
  export function createWriteVerbs(
36
50
  ctx: ExecutorContext,
37
51
  ): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore"> {
@@ -282,9 +296,16 @@ export function createWriteVerbs(
282
296
  // Compound-Types Auto-Convert (alle in einem Pass).
283
297
  // subjectSource: partial changes may carry a pii field without its
284
298
  // ownerField — the merged row still names the subject.
285
- const flatChangesPlain = flattenCompoundTypes(payload.changes, entity);
299
+ const submittedChanges = updateOptions?.skipUnchanged
300
+ ? Object.fromEntries(
301
+ Object.entries(payload.changes).filter(
302
+ ([key, value]) => !isUnchangedValue(value, previous[key]),
303
+ ),
304
+ )
305
+ : payload.changes;
306
+ const flatChangesPlain = flattenCompoundTypes(submittedChanges, entity);
286
307
  const flatChanges = await encryptForStorage(flatChangesPlain, user, {
287
- onlyKeys: Object.keys(payload.changes),
308
+ onlyKeys: Object.keys(submittedChanges),
288
309
  subjectSource: mergedNew,
289
310
  });
290
311
 
@@ -53,7 +53,7 @@ export type EventStoreExecutor = {
53
53
  payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
54
54
  user: SessionUser,
55
55
  db: import("./tenant-db").TenantDb,
56
- options?: { skipOptimisticLock?: boolean },
56
+ options?: { skipOptimisticLock?: boolean; skipUnchanged?: boolean },
57
57
  ) => Promise<WriteResult<SaveContext>>;
58
58
 
59
59
  delete: (
@@ -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", () => {