@cosmicdrift/kumiko-framework 0.118.0 → 0.119.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.
@@ -0,0 +1,53 @@
1
+ // Sofortiges Blind-Index-Nulling nach einem Subject-Erase (#818).
2
+ //
3
+ // Nach kms.eraseKey ist der Ciphertext unlesbar, aber die deterministische
4
+ // bidx-Spalte bliebe bis zum nächsten Write/Rebuild matchbar — ein
5
+ // Linkage-Fenster ("hat irgendeine Row den Wert X"). Dieser Sweep schließt
6
+ // es sofort: der Ciphertext nennt sein Subject inline
7
+ // (kumiko-pii:v1:<subjectKey>:...), also findet ein LIKE-Prefix-Match exakt
8
+ // die Rows des erased Subjects — pro lookupable-Feld ein UPDATE.
9
+ //
10
+ // Rows, die der Forget-Lauf ohnehin via Executor löscht/anonymisiert,
11
+ // bekommen ihren bidx dort automatisch neu berechnet; dieser Sweep deckt
12
+ // die liegen bleibenden Rows ab (fremde Entities mit userOwned-Feldern).
13
+
14
+ import { collectLookupableFields } from "../crypto/blind-index";
15
+ import type { FeatureDefinition } from "../engine/types";
16
+ import { toSnakeCase } from "../utils/case";
17
+ import type { DbRunner } from "./connection";
18
+ import { resolveTableName } from "./entity-table-meta";
19
+ import { executeRawQuery } from "./queries/raw-sql";
20
+
21
+ function quoteIdent(name: string): string {
22
+ return `"${name.replace(/"/g, '""')}"`;
23
+ }
24
+
25
+ function escapeLikePattern(value: string): string {
26
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
27
+ }
28
+
29
+ export async function nullBlindIndexesForSubject(
30
+ db: DbRunner,
31
+ features: ReadonlyMap<string, FeatureDefinition>,
32
+ subjectKey: string,
33
+ ): Promise<void> {
34
+ const likePattern = `kumiko-pii:v1:${escapeLikePattern(subjectKey)}:%`;
35
+ for (const feature of features.values()) {
36
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
37
+ const lookupable = collectLookupableFields(entity);
38
+ if (lookupable.length === 0) continue;
39
+ // Kein featureName-Prefix — der Dispatcher baut Entity-Tables ohne
40
+ // (buildEntityTable ohne featureName-Option), der Sweep muss dieselben
41
+ // Namen treffen.
42
+ const tableName = resolveTableName(entityName, entity, undefined);
43
+ for (const fieldName of lookupable) {
44
+ const snake = toSnakeCase(fieldName);
45
+ await executeRawQuery(
46
+ db,
47
+ `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`,
48
+ [likePattern],
49
+ );
50
+ }
51
+ }
52
+ }
53
+ }
@@ -18,6 +18,7 @@
18
18
  // auf Sondercases beschränken (child-projection-tables ohne tenant,
19
19
  // append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
20
20
 
21
+ import { collectPiiSubjectFields } from "../crypto";
21
22
  import type {
22
23
  EntityDefinition,
23
24
  EntityIndexDef,
@@ -92,6 +93,10 @@ export type EntityTableMeta = {
92
93
  // trägt Verantwortung. Migration-Generator + Tooling können den
93
94
  // discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
94
95
  readonly source: "managed" | "unmanaged";
96
+ // PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
97
+ // buildEntityTableMeta so the registry can reject r.unmanagedTable stores
98
+ // whose direct writes would skip the executor's encryption (#820).
99
+ readonly piiSubjectFields?: readonly string[];
95
100
  };
96
101
 
97
102
  // Standard base-columns für event-sourced Read-Model-Tabellen. Spiegelt
@@ -153,7 +158,7 @@ function fieldToColumnMeta(
153
158
  case "text":
154
159
  case "longText": {
155
160
  const def = fieldDefaultLiteral(field);
156
- return [
161
+ const cols: ColumnMeta[] = [
157
162
  {
158
163
  name: snake,
159
164
  pgType: "text",
@@ -161,6 +166,13 @@ function fieldToColumnMeta(
161
166
  ...(def !== undefined && { defaultSql: def }),
162
167
  },
163
168
  ];
169
+ // lookupable → HMAC-Blind-Index-Pendant. Nullable ist Pflicht:
170
+ // managedChangeRequiresRecreate würde eine NOT-NULL-Spalte ohne
171
+ // Default auf Bestandstabellen als DROP+Rebuild diffen.
172
+ if (field.type === "text" && field.lookupable === true) {
173
+ cols.push({ name: `${snake}_bidx`, pgType: "text", notNull: false });
174
+ }
175
+ return cols;
164
176
  }
165
177
  case "boolean": {
166
178
  const def = fieldDefaultLiteral(field);
@@ -305,10 +317,17 @@ export function buildEntityTableMeta(
305
317
  for (const c of baseCols) colByName.set(c.name, c);
306
318
 
307
319
  const fieldNameToSnake = new Map<string, string>();
320
+ const bidxSnakeByFieldSnake = new Map<string, string>();
308
321
  for (const [name, field] of Object.entries(entity.fields)) {
309
322
  const fieldCols = fieldToColumnMeta(name, field, entity);
310
323
  for (const c of fieldCols) colByName.set(c.name, c);
311
- if (fieldCols.length === 1 && fieldCols[0]) fieldNameToSnake.set(name, fieldCols[0].name);
324
+ // Multi-column fields map to their primary column when its name IS the
325
+ // field's snake (text+bidx, money+currency) — matches the toSnakeCase
326
+ // fallback below, so explicit indexes keep resolving.
327
+ const primary = fieldCols[0];
328
+ if (primary && primary.name === toSnakeCase(name)) fieldNameToSnake.set(name, primary.name);
329
+ const bidxCol = fieldCols.find((c) => c.name.endsWith("_bidx"));
330
+ if (primary && bidxCol) bidxSnakeByFieldSnake.set(primary.name, bidxCol.name);
312
331
  }
313
332
 
314
333
  // Preserve base-col order, then any new user-col-names in fields-order.
@@ -347,6 +366,12 @@ export function buildEntityTableMeta(
347
366
  indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
348
367
  }
349
368
 
369
+ // lookupable-Felder: Index auf der bidx-Spalte — der OR-Rewrite der
370
+ // Query-Compiler trifft sie bei jedem Equality-Lookup.
371
+ for (const bidxSnake of bidxSnakeByFieldSnake.values()) {
372
+ indexes.push({ name: `${tableName}_${bidxSnake}_idx`, columns: [bidxSnake] });
373
+ }
374
+
350
375
  // Explizit deklarierte indexes (EntityIndexDef). `def.where` ist ein
351
376
  // SqlExpression (`sql\`…\`` aus @cosmicdrift/kumiko-framework/db) —
352
377
  // renderbar via `.text`. Unbekannte where-Shapes bleiben needsManualWhere.
@@ -364,13 +389,33 @@ export function buildEntityTableMeta(
364
389
  ...(whereSql !== undefined && { whereSql }),
365
390
  ...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
366
391
  });
392
+ // Unique-Index über lookupable-Spalten: partielles bidx-Pendant, damit
393
+ // Uniqueness auch für verschlüsselte Rows greift. Das Original bleibt
394
+ // für Klartext-Alt-Rows; partial (IS NOT NULL) weil bidx bei erased/
395
+ // key-losen Rows NULL ist.
396
+ if (def.unique === true && def.where === undefined) {
397
+ const bidxCols = cols.map((c) => bidxSnakeByFieldSnake.get(c) ?? c);
398
+ if (bidxCols.some((c, i) => c !== cols[i])) {
399
+ const notNullParts = bidxCols
400
+ .filter((c, i) => c !== cols[i])
401
+ .map((c) => `"${c}" IS NOT NULL`);
402
+ indexes.push({
403
+ name: `${indexName}_bidx`,
404
+ columns: bidxCols,
405
+ unique: true,
406
+ whereSql: notNullParts.join(" AND "),
407
+ });
408
+ }
409
+ }
367
410
  }
368
411
 
412
+ const piiSubjectFields = collectPiiSubjectFields(entity);
369
413
  return {
370
414
  tableName,
371
415
  columns,
372
416
  indexes,
373
417
  source: "managed",
418
+ ...(piiSubjectFields.length > 0 && { piiSubjectFields }),
374
419
  };
375
420
  }
376
421
 
@@ -1,4 +1,14 @@
1
1
  import { requestContext } from "../api/request-context";
2
+ import {
3
+ collectPiiSubjectFields,
4
+ computeBlindIndex,
5
+ configuredBlindIndexKey,
6
+ configuredPiiSubjectKms,
7
+ decryptPiiFieldValues,
8
+ encryptPiiFieldValues,
9
+ type KmsContext,
10
+ type LocalKeyKmsAdapter,
11
+ } from "../crypto";
2
12
  import { executeRawQuery } from "../db/queries/raw-sql";
3
13
  import type { WhereObject } from "../db/query";
4
14
  import { coerceRow, extractTableInfo, selectMany } from "../db/query";
@@ -121,6 +131,8 @@ export type EventStoreExecutorOptions = {
121
131
  entityCache?: EntityCache;
122
132
  /** Override the boot-injected cipher for fields marked `encrypted: true`. */
123
133
  encryption?: EnvelopeCipher;
134
+ /** Override the boot-injected subject KMS for pii-annotated fields. */
135
+ kms?: LocalKeyKmsAdapter;
124
136
  };
125
137
 
126
138
  // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
@@ -307,28 +319,64 @@ export function createEventStoreExecutor(
307
319
  const encryptedFields = collectEncryptedFieldNames(entity);
308
320
  const hasEncryptedFields = encryptedFields.size > 0;
309
321
 
322
+ const piiSubjectFields = collectPiiSubjectFields(entity);
323
+ const hasPiiFields = piiSubjectFields.length > 0;
324
+
310
325
  function fieldCipher(): EnvelopeCipher {
311
326
  if (options.encryption) return options.encryption;
312
327
  return resolveEntityFieldEncryption();
313
328
  }
314
329
 
330
+ // No adapter configured = crypto-shredding off; pii fields stay plaintext
331
+ // (pre-#724 behavior). The hard boot gate ships with the prod-grade
332
+ // PgKmsAdapter (phase E).
333
+ function piiKms(): LocalKeyKmsAdapter | undefined {
334
+ return options.kms ?? configuredPiiSubjectKms();
335
+ }
336
+
337
+ function kmsContextFor(user?: SessionUser): KmsContext {
338
+ return {
339
+ requestId: requestContext.get()?.requestId ?? "event-store-executor",
340
+ ...(user && { tenantId: user.tenantId, userId: String(user.id) }),
341
+ };
342
+ }
343
+
315
344
  // Async on purpose: the envelope cipher wraps/unwraps DEKs via the
316
345
  // MasterKeyProvider. Callers MUST await — a missed await here writes
317
346
  // "[object Promise]" into the projection, which the Promise return
318
347
  // types turn into a compile error at every call site.
319
348
  async function encryptForStorage(
320
349
  row: Record<string, unknown>,
321
- onlyKeys?: Iterable<string>,
350
+ user: SessionUser,
351
+ opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
322
352
  ): Promise<Record<string, unknown>> {
323
- if (!hasEncryptedFields) return row;
324
- return encryptEntityFieldValues(row, encryptedFields, fieldCipher(), {
325
- ...(onlyKeys !== undefined && { onlyKeys }),
326
- });
353
+ let out = row;
354
+ if (hasEncryptedFields) {
355
+ out = await encryptEntityFieldValues(out, encryptedFields, fieldCipher(), {
356
+ ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
357
+ });
358
+ }
359
+ const kms = piiKms();
360
+ if (hasPiiFields && kms) {
361
+ out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
362
+ tenantId: user.tenantId,
363
+ ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
364
+ ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
365
+ });
366
+ }
367
+ return out;
327
368
  }
328
369
 
329
370
  async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
330
- if (!hasEncryptedFields) return row;
331
- return decryptEntityFieldValues(row, encryptedFields, fieldCipher());
371
+ let out = row;
372
+ if (hasEncryptedFields) {
373
+ out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
374
+ }
375
+ const kms = piiKms();
376
+ if (hasPiiFields && kms) {
377
+ out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
378
+ }
379
+ return out;
332
380
  }
333
381
 
334
382
  function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
@@ -469,7 +517,12 @@ export function createEventStoreExecutor(
469
517
  // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
470
518
  // dieselbe Pipeline. Caller schickt combined API-Form, Framework
471
519
  // speichert flat DB-Form. Siehe db/compound-types.ts.
472
- const flatData = await encryptForStorage(flattenCompoundTypes(data, entity));
520
+ // subjectSource carries the freshly minted aggregateId: the create
521
+ // payload has no id column, but a pii:true self-subject resolves from it.
522
+ const flatCreateData = flattenCompoundTypes(data, entity);
523
+ const flatData = await encryptForStorage(flatCreateData, user, {
524
+ subjectSource: { ...flatCreateData, id: aggregateId },
525
+ });
473
526
 
474
527
  // 1. Append event (same TX as the projection write — both must succeed
475
528
  // or both roll back; the dispatcher wraps both in one transaction).
@@ -563,7 +616,9 @@ export function createEventStoreExecutor(
563
616
  previous: {},
564
617
  isNew: true,
565
618
  entityName,
566
- event,
619
+ // Persisted event carries ciphertext by design — the caller-facing
620
+ // echo must be plaintext like every other response field (#820).
621
+ event: { ...event, payload: stripSensitive(flatCreateData) },
567
622
  },
568
623
  };
569
624
  },
@@ -650,10 +705,13 @@ export function createEventStoreExecutor(
650
705
 
651
706
  try {
652
707
  // Compound-Types Auto-Convert (alle in einem Pass).
653
- const flatChanges = await encryptForStorage(
654
- flattenCompoundTypes(payload.changes, entity),
655
- Object.keys(payload.changes),
656
- );
708
+ // subjectSource: partial changes may carry a pii field without its
709
+ // ownerField — the merged row still names the subject.
710
+ const flatChangesPlain = flattenCompoundTypes(payload.changes, entity);
711
+ const flatChanges = await encryptForStorage(flatChangesPlain, user, {
712
+ onlyKeys: Object.keys(payload.changes),
713
+ subjectSource: mergedNew,
714
+ });
657
715
 
658
716
  // The event payload carries BOTH `changes` (what the user asked for) AND
659
717
  // `previous` (the pre-update row). Cross-aggregate projections need the
@@ -673,7 +731,7 @@ export function createEventStoreExecutor(
673
731
  type: entityEventName(entityName, "updated"),
674
732
  payload: {
675
733
  changes: stripSensitive(flatChanges),
676
- previous: stripSensitive(await encryptForStorage(previous)),
734
+ previous: stripSensitive(await encryptForStorage(previous, user)),
677
735
  },
678
736
  metadata: buildEventMetadata(user),
679
737
  });
@@ -717,7 +775,13 @@ export function createEventStoreExecutor(
717
775
  previous,
718
776
  isNew: false,
719
777
  entityName,
720
- event,
778
+ event: {
779
+ ...event,
780
+ payload: {
781
+ changes: stripSensitive(flatChangesPlain),
782
+ previous: stripSensitive(previous),
783
+ },
784
+ },
721
785
  },
722
786
  };
723
787
  } catch (e) {
@@ -783,7 +847,7 @@ export function createEventStoreExecutor(
783
847
  tenantId: streamTenantFor(user),
784
848
  expectedVersion: currentVersion,
785
849
  type: entityEventName(entityName, "deleted"),
786
- payload: { previous: stripSensitive(await encryptForStorage(existing)) },
850
+ payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
787
851
  metadata: buildEventMetadata(user),
788
852
  });
789
853
 
@@ -805,7 +869,13 @@ export function createEventStoreExecutor(
805
869
 
806
870
  return {
807
871
  isSuccess: true,
808
- data: { kind: "delete", id: payload.id, data: existing, entityName, event },
872
+ data: {
873
+ kind: "delete",
874
+ id: payload.id,
875
+ data: existing,
876
+ entityName,
877
+ event: { ...event, payload: { previous: stripSensitive(existing) } },
878
+ },
809
879
  };
810
880
  },
811
881
 
@@ -846,7 +916,9 @@ export function createEventStoreExecutor(
846
916
  tenantId: streamTenantFor(user),
847
917
  expectedVersion: currentVersion,
848
918
  type: entityEventName(entityName, "forgotten"),
849
- payload: { previous: stripSensitive(existing) },
919
+ // Re-encrypt like delete(): `existing` came decrypted from loadById —
920
+ // plaintext must not land in the immutable log, least of all on forget.
921
+ payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
850
922
  metadata: buildEventMetadata(user),
851
923
  });
852
924
 
@@ -863,7 +935,13 @@ export function createEventStoreExecutor(
863
935
 
864
936
  return {
865
937
  isSuccess: true,
866
- data: { kind: "delete", id: payload.id, data: existing, entityName, event },
938
+ data: {
939
+ kind: "delete",
940
+ id: payload.id,
941
+ data: existing,
942
+ entityName,
943
+ event: { ...event, payload: { previous: stripSensitive(existing) } },
944
+ },
867
945
  };
868
946
  },
869
947
 
@@ -947,6 +1025,7 @@ export function createEventStoreExecutor(
947
1025
  rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
948
1026
  );
949
1027
 
1028
+ const previousPlain = await decryptForRead(data);
950
1029
  return {
951
1030
  isSuccess: true,
952
1031
  data: {
@@ -954,10 +1033,10 @@ export function createEventStoreExecutor(
954
1033
  id: payload.id,
955
1034
  data: restoredHydrated,
956
1035
  changes: { isDeleted: false },
957
- previous: await decryptForRead(data),
1036
+ previous: previousPlain,
958
1037
  isNew: false,
959
1038
  entityName,
960
- event,
1039
+ event: { ...event, payload: { previous: stripSensitive(previousPlain) } },
961
1040
  },
962
1041
  };
963
1042
  },
@@ -1067,8 +1146,19 @@ export function createEventStoreExecutor(
1067
1146
  whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
1068
1147
  }
1069
1148
  } else {
1070
- params.push(value);
1071
- whereSql.push(`${colSql(field)} = $${params.length}`);
1149
+ // Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
1150
+ // in bun-db/query.ts Equality auf lookupable-Feldern matcht
1151
+ // Klartext-Arm ODER HMAC-Arm.
1152
+ const bidxKey = configuredBlindIndexKey();
1153
+ if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
1154
+ params.push(value, computeBlindIndex(bidxKey, value));
1155
+ whereSql.push(
1156
+ `(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
1157
+ );
1158
+ } else {
1159
+ params.push(value);
1160
+ whereSql.push(`${colSql(field)} = $${params.length}`);
1161
+ }
1072
1162
  }
1073
1163
  }
1074
1164
  };
@@ -1086,21 +1176,26 @@ export function createEventStoreExecutor(
1086
1176
  const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
1087
1177
 
1088
1178
  const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
1089
- // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
1179
+ // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
1180
+ // Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
1181
+ // names, while the encrypted/pii field lists are camelCase — decrypting
1182
+ // first silently skipped every multi-word field (ciphertext leaked to
1183
+ // the caller).
1090
1184
  const tableInfo = extractTableInfo(table);
1091
- const rows = await Promise.all(
1092
- rawRows.map(async (r) =>
1093
- coerceRow(await decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
1094
- ),
1185
+ const encryptedRows = rawRows.map((r) =>
1186
+ coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
1095
1187
  );
1188
+ const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
1096
1189
 
1097
1190
  // list rows carry the READ-ROW version (display-only), never an optimistic-lock
1098
1191
  // base — edit flows reload via detail(), which reconciles the stream version.
1192
+ // Cache the still-encrypted form: same at-rest guarantee as detail()'s
1193
+ // encryptForStorage round-trip, without paying a re-encrypt.
1099
1194
  if (entityCache && entityName && rows.length > 0) {
1100
1195
  await entityCache.mset(
1101
1196
  user.tenantId,
1102
1197
  entityName,
1103
- rows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
1198
+ encryptedRows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
1104
1199
  );
1105
1200
  }
1106
1201
 
@@ -1180,7 +1275,7 @@ export function createEventStoreExecutor(
1180
1275
  user.tenantId,
1181
1276
  entityName,
1182
1277
  payload.id,
1183
- await encryptForStorage(coerced),
1278
+ await encryptForStorage(coerced, user),
1184
1279
  );
1185
1280
  }
1186
1281
 
package/src/db/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { assertExistsIn } from "./assert-exists-in";
2
+ export { nullBlindIndexesForSubject } from "./blind-index-cleanup";
2
3
  export { collectTableMetas } from "./collect-table-metas";
3
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
4
5
  export { seedConfigValues } from "./config-seed";
@@ -69,7 +69,13 @@ function fieldToColumns(
69
69
  const base = text(snakeName);
70
70
  const withDefault =
71
71
  "default" in field && field.default !== undefined ? base.default(field.default) : base;
72
- return { [name]: field.required ? withDefault.notNull() : withDefault };
72
+ const mainCol = { [name]: field.required ? withDefault.notNull() : withDefault };
73
+ // lookupable → nullable HMAC-Blind-Index-Spalte (lock-step mit
74
+ // fieldToColumnMeta in entity-table-meta.ts).
75
+ if (field.type === "text" && field.lookupable === true) {
76
+ return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
77
+ }
78
+ return mainCol;
73
79
  }
74
80
  case "boolean":
75
81
  return {
@@ -272,63 +278,72 @@ type NullCol<_T> = ColumnHandle & { readonly __notNull: false };
272
278
  // columns (resolved via FileRef table). `notNull` propagiert von
273
279
  // `field.required` (literal preserved by createXField generics).
274
280
  type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
275
- type: "text" | "select" | "tz";
281
+ type: "text";
282
+ lookupable: true;
276
283
  }
277
- ? F extends { required: true }
278
- ? { readonly [P in K]: Col<string> }
279
- : { readonly [P in K]: NullCol<string> }
280
- : F extends { type: "boolean" }
281
- ? // boolean default OR required → notNull (DB has DEFAULT, structurally never-null)
282
- F extends { default: boolean } | { required: true }
283
- ? { readonly [P in K]: Col<boolean> }
284
- : { readonly [P in K]: NullCol<boolean> }
285
- : F extends { type: "multiSelect" }
286
- ? // jsonb default `[]`, immer notNull
287
- { readonly [P in K]: Col<readonly string[]> }
288
- : F extends { type: "number" }
289
- ? F extends { required: true }
290
- ? { readonly [P in K]: Col<number> }
291
- : { readonly [P in K]: NullCol<number> }
292
- : F extends { type: "decimal" }
284
+ ? (F extends { required: true }
285
+ ? { readonly [P in K]: Col<string> }
286
+ : { readonly [P in K]: NullCol<string> }) & {
287
+ readonly [P in `${K}Bidx`]: NullCol<string>;
288
+ }
289
+ : F extends {
290
+ type: "text" | "select" | "tz";
291
+ }
292
+ ? F extends { required: true }
293
+ ? { readonly [P in K]: Col<string> }
294
+ : { readonly [P in K]: NullCol<string> }
295
+ : F extends { type: "boolean" }
296
+ ? // boolean default OR required notNull (DB has DEFAULT, structurally never-null)
297
+ F extends { default: boolean } | { required: true }
298
+ ? { readonly [P in K]: Col<boolean> }
299
+ : { readonly [P in K]: NullCol<boolean> }
300
+ : F extends { type: "multiSelect" }
301
+ ? // jsonb default `[]`, immer notNull
302
+ { readonly [P in K]: Col<readonly string[]> }
303
+ : F extends { type: "number" }
293
304
  ? F extends { required: true }
294
305
  ? { readonly [P in K]: Col<number> }
295
306
  : { readonly [P in K]: NullCol<number> }
296
- : F extends { type: "money" }
307
+ : F extends { type: "decimal" }
297
308
  ? F extends { required: true }
298
- ? { readonly [P in K]: Col<number> } & {
299
- readonly [P in `${K}Currency`]: Col<string>;
300
- }
301
- : { readonly [P in K]: NullCol<number> } & {
302
- readonly [P in `${K}Currency`]: Col<string>;
303
- }
304
- : F extends { type: "reference"; multiple: true }
305
- ? { readonly [P in K]: Col<readonly string[]> }
306
- : F extends { type: "reference" }
307
- ? F extends { required: true }
308
- ? { readonly [P in K]: Col<string> }
309
- : { readonly [P in K]: NullCol<string> }
310
- : F extends { type: "embedded" }
311
- ? // jsonb default `{}`, immer notNull
312
- { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
313
- : F extends { type: "date" | "timestamp" }
314
- ? F extends { required: true }
315
- ? { readonly [P in K]: Col<Temporal.Instant> }
316
- : { readonly [P in K]: NullCol<Temporal.Instant> }
317
- : F extends { type: "locatedTimestamp" }
309
+ ? { readonly [P in K]: Col<number> }
310
+ : { readonly [P in K]: NullCol<number> }
311
+ : F extends { type: "money" }
312
+ ? F extends { required: true }
313
+ ? { readonly [P in K]: Col<number> } & {
314
+ readonly [P in `${K}Currency`]: Col<string>;
315
+ }
316
+ : { readonly [P in K]: NullCol<number> } & {
317
+ readonly [P in `${K}Currency`]: Col<string>;
318
+ }
319
+ : F extends { type: "reference"; multiple: true }
320
+ ? { readonly [P in K]: Col<readonly string[]> }
321
+ : F extends { type: "reference" }
322
+ ? F extends { required: true }
323
+ ? { readonly [P in K]: Col<string> }
324
+ : { readonly [P in K]: NullCol<string> }
325
+ : F extends { type: "embedded" }
326
+ ? // jsonb default `{}`, immer notNull
327
+ { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
328
+ : F extends { type: "date" | "timestamp" }
318
329
  ? F extends { required: true }
319
- ? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
320
- readonly [P in `${K}Tz`]: Col<string>;
321
- }
322
- : { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
323
- readonly [P in `${K}Tz`]: NullCol<string>;
324
- }
325
- : F extends { type: "file" | "image" }
330
+ ? { readonly [P in K]: Col<Temporal.Instant> }
331
+ : { readonly [P in K]: NullCol<Temporal.Instant> }
332
+ : F extends { type: "locatedTimestamp" }
326
333
  ? F extends { required: true }
327
- ? { readonly [P in K]: Col<string> }
328
- : { readonly [P in K]: NullCol<string> }
329
- : F extends { type: "files" | "images" }
330
- ? Record<never, never>
331
- : never;
334
+ ? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
335
+ readonly [P in `${K}Tz`]: Col<string>;
336
+ }
337
+ : { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
338
+ readonly [P in `${K}Tz`]: NullCol<string>;
339
+ }
340
+ : F extends { type: "file" | "image" }
341
+ ? F extends { required: true }
342
+ ? { readonly [P in K]: Col<string> }
343
+ : { readonly [P in K]: NullCol<string> }
344
+ : F extends { type: "files" | "images" }
345
+ ? Record<never, never>
346
+ : never;
332
347
 
333
348
  type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
334
349
  k: infer I,
@@ -526,6 +541,18 @@ export function buildEntityTable<E extends EntityDefinition>(
526
541
  ).on(column);
527
542
  }
528
543
  }
544
+ // lookupable-Felder: Index auf der bidx-Spalte (lock-step mit
545
+ // buildEntityTableMeta).
546
+ const bidxFieldByField = new Map<string, string>();
547
+ for (const [name, field] of Object.entries(entity.fields)) {
548
+ if (field.type !== "text" || field.lookupable !== true) continue;
549
+ bidxFieldByField.set(name, `${name}Bidx`);
550
+ const column = tHandle[`${name}Bidx`];
551
+ if (column) {
552
+ const idxName = `${tableName}_${toSnakeCase(name)}_bidx_idx`;
553
+ indexes[idxName] = index(idxName).on(column);
554
+ }
555
+ }
529
556
  // entity.indexes = composite/unique-Indices die der Author explizit
530
557
  // deklariert hat. Spalten werden via field-name (camelCase) angesprochen,
531
558
  // der Index-Name folgt der Convention <table>_<col1>_<col2>_<unique|idx>
@@ -546,6 +573,26 @@ export function buildEntityTable<E extends EntityDefinition>(
546
573
  chain = chain.where(def.where as SqlExpression);
547
574
  }
548
575
  indexes[indexName] = chain;
576
+ // Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
577
+ // (lock-step mit buildEntityTableMeta).
578
+ if (def.unique === true && def.where === undefined) {
579
+ const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
580
+ if (bidxFieldNames.some((c, i) => c !== def.columns[i])) {
581
+ const bidxCols = bidxFieldNames
582
+ .map((c) => tHandle[c])
583
+ .filter((col): col is ColumnHandle => col !== undefined);
584
+ if (bidxCols.length === bidxFieldNames.length) {
585
+ const whereText = bidxFieldNames
586
+ .filter((c, i) => c !== def.columns[i])
587
+ .map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
588
+ .join(" AND ");
589
+ const partialWhere: SqlExpression = { kind: "sql-expr", text: whereText, params: [] };
590
+ indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
591
+ .on(...bidxCols)
592
+ .where(partialWhere);
593
+ }
594
+ }
595
+ }
549
596
  }
550
597
  return indexes;
551
598
  },