@cosmicdrift/kumiko-framework 0.209.1 → 0.211.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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
  3. package/src/changes.json +56 -0
  4. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
  6. package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
  7. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  8. package/src/db/__tests__/cursor.test.ts +26 -1
  9. package/src/db/__tests__/eagerload.integration.test.ts +3 -3
  10. package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
  11. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  12. package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
  13. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
  14. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
  15. package/src/db/cursor.ts +32 -0
  16. package/src/db/event-store-executor-read.ts +80 -9
  17. package/src/db/index.ts +2 -2
  18. package/src/db/pg-error.ts +7 -0
  19. package/src/db/queries/backfill-pii.ts +188 -18
  20. package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
  22. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
  23. package/src/engine/__tests__/factories-personal.test.ts +130 -0
  24. package/src/engine/__tests__/field-access.test.ts +1 -23
  25. package/src/engine/__tests__/store-table.test.ts +4 -1
  26. package/src/engine/boot-validator/pii-retention.ts +22 -54
  27. package/src/engine/build-config-feature-schema.ts +9 -1
  28. package/src/engine/factories.ts +108 -30
  29. package/src/engine/field-access.ts +2 -13
  30. package/src/engine/index.ts +7 -1
  31. package/src/engine/types/index.ts +7 -1
  32. package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
  33. package/src/files/file-ref-entity.ts +2 -2
  34. package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
  35. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
  36. package/src/testing/shared-entities.ts +6 -3
@@ -8,7 +8,7 @@ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
8
8
  import { UnprocessableError } from "../errors";
9
9
  import { getStreamVersion } from "../event-store";
10
10
  import { rehydrateCompoundTypes } from "./compound-types";
11
- import { decodeCursor, encodeCursor } from "./cursor";
11
+ import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
12
12
  import type { EventStoreExecutor } from "./event-store-executor";
13
13
  import { buildFilterWhere, type ExecutorContext } from "./event-store-executor-context";
14
14
  import { toSnakeCase } from "./table-builder";
@@ -43,6 +43,54 @@ export function resolveListPagination(payload: {
43
43
  return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
44
44
  }
45
45
 
46
+ // Cursor sort values travel as text and bind as plain string params — Postgres
47
+ // infers the parameter type from the column they are compared against, the same
48
+ // way prepareValue binds a timestamptz as an ISO string. undefined means the
49
+ // driver value has no faithful text form, which downgrades the page to the
50
+ // legacy id-only boundary instead of emitting a wrong one.
51
+ function toCursorSortText(value: unknown): string | null | undefined {
52
+ if (value === undefined) return undefined;
53
+ if (value === null) return null;
54
+ if (typeof value === "string") return value;
55
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
56
+ return String(value);
57
+ }
58
+ if (value instanceof Date) return value.toISOString();
59
+ if (typeof value === "object") {
60
+ const tag = (value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag];
61
+ if (typeof tag === "string" && tag.startsWith("Temporal.")) return String(value);
62
+ return JSON.stringify(value);
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ // Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
68
+ // null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
69
+ // would change the visible order and cost the sort its index.
70
+ function keysetBoundarySql(
71
+ sortCol: string,
72
+ idCol: string,
73
+ cursor: { readonly id: string; readonly sortValue: string | null },
74
+ descending: boolean,
75
+ params: unknown[],
76
+ ): string {
77
+ params.push(cursor.id);
78
+ const afterId = `${idCol} > $${params.length}`;
79
+ const nullsComeFirst = descending;
80
+ if (cursor.sortValue === null) {
81
+ return nullsComeFirst
82
+ ? `(${sortCol} IS NOT NULL OR ${afterId})`
83
+ : `(${sortCol} IS NULL AND ${afterId})`;
84
+ }
85
+ params.push(cursor.sortValue);
86
+ const sortParam = `$${params.length}`;
87
+ const beyond = `${sortCol} ${descending ? "<" : ">"} ${sortParam}`;
88
+ const tie = `(${sortCol} = ${sortParam} AND ${afterId})`;
89
+ return nullsComeFirst
90
+ ? `(${sortCol} IS NOT NULL AND (${beyond} OR ${tie}))`
91
+ : `(${sortCol} IS NULL OR ${beyond} OR ${tie})`;
92
+ }
93
+
46
94
  export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
47
95
  const {
48
96
  table,
@@ -105,8 +153,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
105
153
  const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
106
154
  const whereSql: string[] = [];
107
155
  const params: unknown[] = [];
108
- const colSql = (field: string): string =>
109
- `"${(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field)}"`;
156
+ const physicalCol = (field: string): string =>
157
+ (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
158
+ const colSql = (field: string): string => `"${physicalCol(field)}"`;
159
+ const sortField = payload.sort && table[payload.sort] ? payload.sort : undefined;
160
+ const sortDescending = payload.sortDirection === "desc";
110
161
 
111
162
  // Tenant-Filter (replicates TenantDb's readWhere semantics).
112
163
  if (table["tenantId"] !== undefined && db.mode === "tenant") {
@@ -117,8 +168,21 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
117
168
  whereSql.push(`${colSql("isDeleted")} = FALSE`);
118
169
  }
119
170
  if (payload.cursor) {
120
- params.push(decodeCursor(payload.cursor));
121
- whereSql.push(`${colSql("id")} > $${params.length}`);
171
+ const cursor = decodeKeysetCursor(payload.cursor);
172
+ if (sortField === undefined || cursor.sortValue === undefined) {
173
+ params.push(cursor.id);
174
+ whereSql.push(`${colSql("id")} > $${params.length}`);
175
+ } else {
176
+ whereSql.push(
177
+ keysetBoundarySql(
178
+ colSql(sortField),
179
+ colSql("id"),
180
+ { id: cursor.id, sortValue: cursor.sortValue },
181
+ sortDescending,
182
+ params,
183
+ ),
184
+ );
185
+ }
122
186
  }
123
187
  if (filterIds) {
124
188
  const placeholders = filterIds.map((id) => {
@@ -199,8 +263,8 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
199
263
  if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
200
264
 
201
265
  const orderByClause =
202
- payload.sort && table[payload.sort]
203
- ? ` ORDER BY ${colSql(payload.sort)} ${payload.sortDirection === "desc" ? "DESC" : "ASC"}, ${colSql("id")} ASC`
266
+ sortField !== undefined
267
+ ? ` ORDER BY ${colSql(sortField)} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
204
268
  : ` ORDER BY ${colSql("id")} ASC`;
205
269
  const useOffset = !payload.cursor && offset > 0;
206
270
  const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
@@ -234,8 +298,15 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
234
298
  }
235
299
 
236
300
  const lastRow = rows[rows.length - 1];
237
- const nextCursor =
238
- rows.length === limit && lastRow ? encodeCursor(lastRow["id"] as string) : null; // @cast-boundary engine-payload
301
+ const lastRaw = rawRows[rawRows.length - 1];
302
+ let nextCursor: string | null = null;
303
+ if (rows.length === limit && lastRow && lastRaw) {
304
+ const cursorId = lastRow["id"] as string; // @cast-boundary engine-payload
305
+ const sortText =
306
+ sortField === undefined ? undefined : toCursorSortText(lastRaw[physicalCol(sortField)]);
307
+ nextCursor =
308
+ sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
309
+ }
239
310
 
240
311
  // total: extra COUNT(*) — nur wenn explizit angefordert (Pager-UI).
241
312
  // Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
package/src/db/index.ts CHANGED
@@ -12,8 +12,8 @@ export type {
12
12
  DbTx,
13
13
  } from "./connection";
14
14
  export { createDbConnection, dbConnectionOptionsFromEnv } from "./connection";
15
- export type { CursorQueryOptions, CursorResult } from "./cursor";
16
- export { decodeCursor, encodeCursor } from "./cursor";
15
+ export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cursor";
16
+ export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
17
17
  export type { SchemaTable, SelectQuery, TableColumns } from "./dialect";
18
18
  export {
19
19
  bigint,
@@ -49,6 +49,13 @@ export function isLockNotAvailable(e: unknown): boolean {
49
49
  return extractPgError(e)?.code === "55P03";
50
50
  }
51
51
 
52
+ // PG SQLSTATE 42P01 — "undefined table". A projection table for an entity
53
+ // that was never mounted/rebuilt (or was dropped since) — callers treat
54
+ // this as "no row found", not as a fatal error.
55
+ export function isUndefinedTable(e: unknown): boolean {
56
+ return extractPgError(e)?.code === "42P01";
57
+ }
58
+
52
59
  export function constraintOf(e: unknown): string | undefined {
53
60
  return extractPgError(e)?.constraint_name;
54
61
  }
@@ -20,12 +20,25 @@
20
20
  // second run reports 0 updates. One failing event does not abort the run;
21
21
  // failures are collected and reported (fail-loud at the caller).
22
22
  //
23
+ // Owner resolution for entity-lifecycle events (backfill-only — the live
24
+ // write path in event-store-executor-write.ts / subject-resolver.ts stays
25
+ // fail-closed and is untouched by this):
26
+ // 1. payload — the event section itself names the owner field.
27
+ // 2. projection ({@link PiiBackfillOptions.resolveOwnerFromProjection}) —
28
+ // pre-owner-field-addition events don't carry it; read it from the
29
+ // entity's current projection row instead.
30
+ // 3. erase ({@link PiiBackfillOptions.eraseUnresolvableSubjects}) — still
31
+ // unresolvable (hard-deleted aggregate, no projection) → PII_ERASED_SENTINEL.
32
+ // Both stages are opt-in and off by default; without them an unresolvable
33
+ // subject still fails loud into `failures`, as before.
34
+ //
23
35
  // Snapshots of touched aggregates are dropped (they may cache plaintext);
24
36
  // the next snapshotting load recreates them. AFTER a run, rebuild the
25
37
  // affected projections — applyEntityEvent materializes ciphertext AND the
26
38
  // blind-index columns, which keeps equality lookups (login by email) alive.
27
39
 
28
40
  import { asRawClient } from "../../bun-db";
41
+ import { quoteIdent } from "../../crypto/ciphertext-pattern";
29
42
  import { configuredEventPiiCatalog } from "../../crypto/event-pii";
30
43
  import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
31
44
  import { KeyErasedError } from "../../crypto/kms-adapter";
@@ -35,9 +48,16 @@ import {
35
48
  isPiiCiphertext,
36
49
  PII_ERASED_SENTINEL,
37
50
  } from "../../crypto/pii-field-encryption";
38
- import { collectPiiSubjectFields, resolveSubjectForField } from "../../crypto/subject-resolver";
51
+ import {
52
+ collectPiiSubjectFields,
53
+ resolveSubjectForField,
54
+ SubjectResolutionError,
55
+ } from "../../crypto/subject-resolver";
39
56
  import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
40
57
  import type { DbRunner } from "../connection";
58
+ import { resolveTableName } from "../entity-table-meta";
59
+ import { isUndefinedTable } from "../pg-error";
60
+ import { toSnakeCase } from "../table-builder";
41
61
 
42
62
  const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
43
63
 
@@ -51,6 +71,12 @@ export type PiiBackfillResult = {
51
71
  readonly updatedEvents: number;
52
72
  readonly encryptedFields: number;
53
73
  readonly erasedFields: number;
74
+ // Subset of encryptedFields + erasedFields whose owner came from stage 2
75
+ // (the payload itself lacked it).
76
+ readonly ownerFromProjection: number;
77
+ // Subset of erasedFields written because the subject stayed unresolvable
78
+ // through stage 3, not because the subject was already forgotten.
79
+ readonly erasedUnresolvable: number;
54
80
  readonly deletedSnapshots: number;
55
81
  readonly failures: readonly PiiBackfillFailure[];
56
82
  };
@@ -59,6 +85,12 @@ export type PiiBackfillOptions = {
59
85
  readonly batchSize?: number;
60
86
  // Scan + count only, write nothing.
61
87
  readonly dryRun?: boolean;
88
+ // Stage 2: fall back to the entity's projection row (by aggregate_id)
89
+ // when a lifecycle event's payload doesn't name the owner field.
90
+ readonly resolveOwnerFromProjection?: boolean;
91
+ // Stage 3: write PII_ERASED_SENTINEL for subjects still unresolvable
92
+ // after stage 1+2, instead of failing the event into `failures`.
93
+ readonly eraseUnresolvableSubjects?: boolean;
62
94
  };
63
95
 
64
96
  type EventRow = {
@@ -105,6 +137,8 @@ export async function backfillEventPiiEncryption(
105
137
  updatedEvents: 0,
106
138
  encryptedFields: 0,
107
139
  erasedFields: 0,
140
+ ownerFromProjection: 0,
141
+ erasedUnresolvable: 0,
108
142
  deletedSnapshots: 0,
109
143
  failures: [] as PiiBackfillFailure[],
110
144
  };
@@ -132,13 +166,17 @@ export async function backfillEventPiiEncryption(
132
166
  )) as ReadonlyArray<EventRow>;
133
167
  if (rows.length === 0) break;
134
168
 
169
+ const projectionOwnersByType = await loadProjectionOwners(rows);
170
+
135
171
  for (const row of rows) {
136
172
  result.scannedEvents++;
137
173
  try {
138
- const outcome = await transformEvent(row);
174
+ const outcome = await transformEvent(row, projectionOwnersByType.get(row.aggregate_type));
139
175
  if (outcome === null) continue;
140
176
  result.encryptedFields += outcome.encrypted;
141
177
  result.erasedFields += outcome.erased;
178
+ result.ownerFromProjection += outcome.ownerFromProjection;
179
+ result.erasedUnresolvable += outcome.erasedUnresolvable;
142
180
  if (!options.dryRun) {
143
181
  await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
144
182
  outcome.payload,
@@ -148,9 +186,15 @@ export async function backfillEventPiiEncryption(
148
186
  result.updatedEvents++;
149
187
  touchedAggregates.add(row.aggregate_id);
150
188
  } catch (e) {
189
+ const reason = e instanceof Error ? e.message : String(e);
151
190
  result.failures.push({
152
191
  eventId: String(row.id),
153
- reason: e instanceof Error ? e.message : String(e),
192
+ reason:
193
+ e instanceof SubjectResolutionError
194
+ ? `${reason} (retry with { resolveOwnerFromProjection: true } to resolve the owner ` +
195
+ "from the entity's projection table, and/or { eraseUnresolvableSubjects: true } to " +
196
+ "erase fields whose subject stays unresolvable)"
197
+ : reason,
154
198
  });
155
199
  }
156
200
  }
@@ -170,10 +214,57 @@ export async function backfillEventPiiEncryption(
170
214
 
171
215
  return result;
172
216
 
217
+ async function loadProjectionOwners(
218
+ rows: readonly EventRow[],
219
+ ): Promise<ReadonlyMap<string, ReadonlyMap<string, Record<string, unknown>>>> {
220
+ const byType = new Map<string, ReadonlyMap<string, Record<string, unknown>>>();
221
+ if (!options.resolveOwnerFromProjection) return byType;
222
+
223
+ const idsByType = new Map<string, Set<string>>();
224
+ for (const row of rows) {
225
+ if (!entityTargets.has(row.aggregate_type)) continue;
226
+ const ids = idsByType.get(row.aggregate_type) ?? new Set<string>();
227
+ ids.add(row.aggregate_id);
228
+ idsByType.set(row.aggregate_type, ids);
229
+ }
230
+
231
+ for (const [aggregateType, ids] of idsByType) {
232
+ const target = entityTargets.get(aggregateType);
233
+ if (!target) continue;
234
+ const tableName = resolveTableName(aggregateType, target.entity, undefined);
235
+ const ownersById = new Map<string, Record<string, unknown>>();
236
+ try {
237
+ const projectionRows = (await raw.unsafe(
238
+ `SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`,
239
+ [[...ids]],
240
+ )) as ReadonlyArray<Record<string, unknown>>;
241
+ for (const projectionRow of projectionRows) {
242
+ const id = projectionRow["id"];
243
+ if (typeof id === "string") {
244
+ ownersById.set(id, projectionRowToCamel(target.entity, projectionRow));
245
+ }
246
+ }
247
+ } catch (e) {
248
+ // Entity never mounted/rebuilt (no projection table yet) — stage 3
249
+ // still gets a chance; this batch just contributes no owners.
250
+ if (!isUndefinedTable(e)) throw e;
251
+ }
252
+ byType.set(aggregateType, ownersById);
253
+ }
254
+ return byType;
255
+ }
256
+
173
257
  async function transformEvent(
174
258
  row: EventRow,
175
- ): Promise<{ payload: Record<string, unknown>; encrypted: number; erased: number } | null> {
176
- const counters = { encrypted: 0, erased: 0 };
259
+ projectionOwners: ReadonlyMap<string, Record<string, unknown>> | undefined,
260
+ ): Promise<{
261
+ payload: Record<string, unknown>;
262
+ encrypted: number;
263
+ erased: number;
264
+ ownerFromProjection: number;
265
+ erasedUnresolvable: number;
266
+ } | null> {
267
+ const counters = { encrypted: 0, erased: 0, ownerFromProjection: 0, erasedUnresolvable: 0 };
177
268
  const payload = structuredClone(row.payload);
178
269
 
179
270
  const catalogFields = eventCatalog.get(row.type);
@@ -187,6 +278,21 @@ export async function backfillEventPiiEncryption(
187
278
  } else {
188
279
  const target = entityTargets.get(row.aggregate_type);
189
280
  if (!target || !isLifecycleEventOf(row.type, row.aggregate_type)) return null;
281
+ await applyEntityLifecycleFields(target, projectionOwners?.get(row.aggregate_id));
282
+ }
283
+
284
+ if (counters.encrypted === 0 && counters.erased === 0) return null;
285
+ return { payload, ...counters };
286
+
287
+ function bump(outcome: FieldOutcome): void {
288
+ if (outcome === "encrypted") counters.encrypted++;
289
+ if (outcome === "erased") counters.erased++;
290
+ }
291
+
292
+ async function applyEntityLifecycleFields(
293
+ target: { readonly entity: EntityDefinition; readonly piiFields: readonly string[] },
294
+ projectionOwnerRow: Record<string, unknown> | undefined,
295
+ ): Promise<void> {
190
296
  const sections = lifecycleSections(payload);
191
297
  for (const section of sections) {
192
298
  // Update-changes may carry a pii field without its owner field —
@@ -198,25 +304,39 @@ export async function backfillEventPiiEncryption(
198
304
  ...section,
199
305
  };
200
306
  for (const field of target.piiFields) {
201
- const subject = resolveSubjectForField(target.entity, field, subjectSource, {
307
+ const value = section[field];
308
+ if (value === null || value === undefined) continue;
309
+ if (typeof value !== "string") continue;
310
+ // Already-ciphertext/sentinel fields must never enter owner
311
+ // resolution — an unresolvable owner on an already-handled field
312
+ // must stay untouched, not get erased by stage 3.
313
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) continue;
314
+
315
+ const resolution = resolveOwnerSubject(
316
+ target.entity,
317
+ field,
318
+ subjectSource,
319
+ projectionOwnerRow,
202
320
  // @cast-boundary db-read — tenant_id column is the branded TenantId
203
- tenantId: row.tenant_id as TenantId,
204
- });
205
- if (subject === null) continue;
206
- const outcome = await encryptField(section, field, subject);
321
+ row.tenant_id as TenantId,
322
+ );
323
+ if (resolution.kind === "unannotated") continue;
324
+ if (resolution.kind === "unresolved") {
325
+ if (options.eraseUnresolvableSubjects) {
326
+ section[field] = PII_ERASED_SENTINEL;
327
+ counters.erased++;
328
+ counters.erasedUnresolvable++;
329
+ continue;
330
+ }
331
+ throw resolution.error;
332
+ }
333
+ if (resolution.viaProjection) counters.ownerFromProjection++;
334
+ const outcome = await encryptField(section, field, resolution.subject);
207
335
  bump(outcome);
208
336
  }
209
337
  }
210
338
  }
211
339
 
212
- if (counters.encrypted === 0 && counters.erased === 0) return null;
213
- return { payload, ...counters };
214
-
215
- function bump(outcome: FieldOutcome): void {
216
- if (outcome === "encrypted") counters.encrypted++;
217
- if (outcome === "erased") counters.erased++;
218
- }
219
-
220
340
  async function encryptField(
221
341
  section: Record<string, unknown>,
222
342
  field: string,
@@ -255,6 +375,56 @@ export async function backfillEventPiiEncryption(
255
375
  }
256
376
  }
257
377
 
378
+ type OwnerSubjectResolution =
379
+ | { readonly kind: "unannotated" }
380
+ | { readonly kind: "unresolved"; readonly error: SubjectResolutionError }
381
+ | { readonly kind: "resolved"; readonly subject: SubjectId; readonly viaProjection: boolean };
382
+
383
+ // Stage 1 (payload) then stage 2 (projection row, when supplied) — stage 3
384
+ // (erase) is the caller's call, made from the "unresolved" outcome.
385
+ function resolveOwnerSubject(
386
+ entity: EntityDefinition,
387
+ field: string,
388
+ subjectSource: Record<string, unknown>,
389
+ projectionOwnerRow: Record<string, unknown> | undefined,
390
+ tenantId: TenantId,
391
+ ): OwnerSubjectResolution {
392
+ try {
393
+ const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId });
394
+ return subject === null
395
+ ? { kind: "unannotated" }
396
+ : { kind: "resolved", subject, viaProjection: false };
397
+ } catch (e) {
398
+ if (!(e instanceof SubjectResolutionError)) throw e;
399
+ if (projectionOwnerRow) {
400
+ try {
401
+ const augmented = { ...subjectSource, ...projectionOwnerRow };
402
+ const subject = resolveSubjectForField(entity, field, augmented, { tenantId });
403
+ if (subject !== null) return { kind: "resolved", subject, viaProjection: true };
404
+ } catch (e2) {
405
+ if (!(e2 instanceof SubjectResolutionError)) throw e2;
406
+ }
407
+ }
408
+ return { kind: "unresolved", error: e };
409
+ }
410
+ }
411
+
412
+ // Projection columns are snake_case (author_id); event-payload/subject
413
+ // fields are camelCase (authorId) — forward-map from entity.fields like
414
+ // reindexEntity's rowToState, so any owner field resolves generically
415
+ // instead of hand-listing column names per entity.
416
+ function projectionRowToCamel(
417
+ entity: EntityDefinition,
418
+ row: Record<string, unknown>,
419
+ ): Record<string, unknown> {
420
+ const camel: Record<string, unknown> = {};
421
+ for (const fieldName of Object.keys(entity.fields)) {
422
+ const snake = toSnakeCase(fieldName);
423
+ if (Object.hasOwn(row, snake)) camel[fieldName] = row[snake];
424
+ }
425
+ return camel;
426
+ }
427
+
258
428
  function isLifecycleEventOf(eventType: string, aggregateType: string): boolean {
259
429
  if (!eventType.startsWith(`${aggregateType}.`)) return false;
260
430
  const verb = eventType.slice(aggregateType.length + 1);
@@ -25,7 +25,12 @@ const requiresUserDataHook = (features: readonly { readonly name: string }[]) =>
25
25
 
26
26
  const promptStore = () =>
27
27
  defineFeature("prompt-store", (r) => {
28
- const promptFields = { text: createTextField({ pii: true }) };
28
+ const promptFields = {
29
+ text: createTextField({
30
+ personal: "self",
31
+ find: "none",
32
+ }),
33
+ };
29
34
  r.entity("prompt", createEntity({ fields: promptFields }));
30
35
  r.bootCheck(({ features }) => {
31
36
  // Conditional on this feature's own shape (has a pii field), closed