@cosmicdrift/kumiko-framework 0.354.0 → 0.355.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 (47) hide show
  1. package/dist/db/entity-field-encryption.d.ts +10 -1
  2. package/dist/db/entity-field-encryption.js +31 -2
  3. package/dist/db/event-store-executor-context.d.ts +8 -2
  4. package/dist/db/event-store-executor-context.js +41 -9
  5. package/dist/db/event-store-executor-read.js +22 -11
  6. package/dist/db/event-store-executor-write.js +95 -56
  7. package/dist/db/queries/event-store.d.ts +1 -0
  8. package/dist/db/queries/event-store.js +11 -0
  9. package/dist/engine/build-app-schema.js +32 -5
  10. package/dist/engine/cross-tenant.d.ts +2 -1
  11. package/dist/engine/cross-tenant.js +18 -14
  12. package/dist/engine/entity-handlers.js +17 -16
  13. package/dist/engine/factories.d.ts +2 -0
  14. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  15. package/dist/engine/feature-entity-handlers.js +1 -0
  16. package/dist/engine/index.d.ts +1 -1
  17. package/dist/engine/index.js +1 -1
  18. package/dist/engine/registry-validate.d.ts +7 -1
  19. package/dist/engine/registry-validate.js +82 -2
  20. package/dist/engine/registry.js +2 -1
  21. package/dist/engine/schema-builder.js +35 -2
  22. package/dist/engine/types/index.d.ts +1 -1
  23. package/dist/errors/reasons.d.ts +1 -0
  24. package/dist/errors/reasons.js +2 -0
  25. package/dist/event-store/event-store.d.ts +1 -0
  26. package/dist/event-store/event-store.js +4 -1
  27. package/dist/event-store/index.d.ts +1 -1
  28. package/dist/event-store/index.js +1 -1
  29. package/dist/event-store/snapshot.d.ts +1 -0
  30. package/dist/event-store/snapshot.js +12 -3
  31. package/dist/jobs/job-runner.js +32 -17
  32. package/dist/observability/escape-hatch-report.d.ts +5 -2
  33. package/dist/observability/escape-hatch-report.js +52 -22
  34. package/dist/observability/index.d.ts +1 -1
  35. package/dist/observability/index.js +1 -1
  36. package/dist/pipeline/dispatch-query.js +4 -0
  37. package/dist/pipeline/dispatch-shared.js +33 -6
  38. package/dist/pipeline/dispatch-stream.js +4 -0
  39. package/dist/pipeline/dispatcher-utils.d.ts +4 -0
  40. package/dist/pipeline/dispatcher-utils.js +10 -0
  41. package/dist/pipeline/dispatcher.js +7 -7
  42. package/dist/pipeline/index.d.ts +1 -1
  43. package/dist/pipeline/index.js +1 -1
  44. package/dist/pipeline/pre-handler-gates.js +4 -0
  45. package/dist/ui-types/index.d.ts +1 -1
  46. package/package.json +4 -4
  47. package/src/changes.json +41 -0
@@ -11,7 +11,7 @@ import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline
11
11
  import { generateId } from "../utils/index.js";
12
12
  import { applyEntityEvent } from "./apply-entity-event.js";
13
13
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
14
- import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, tryMapUniqueViolation, } from "./event-store-executor-context.js";
14
+ import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
15
15
  import { projectionRegistryOf } from "./projection-registry-binding.js";
16
16
  import { runInSavepointIfSupported } from "./query.js";
17
17
  import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
@@ -118,12 +118,43 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
118
118
  }
119
119
  }
120
120
  export function createWriteVerbs(ctx) {
121
- const { table, entity, entityName, entityCache, softDelete, streamTenantFor, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
121
+ const { table, entity, entityName, entityCache, softDelete, streamTenantFor, streamTenantOverrideFailure, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
122
122
  const entityInstructionFieldNames = instructionFieldNames(entity);
123
+ const eventVersion = entity.eventVersion ?? 1;
123
124
  let insertSchema;
124
125
  let updateSchema;
125
126
  const createOutputSchema = () => (insertSchema ??= buildInsertSchema(entity));
126
127
  const updateOutputSchema = () => (updateSchema ??= buildUpdateSchema(entity));
128
+ // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
129
+ // selectMany(runner, ...) did not — any caller could un-delete a foreign
130
+ // tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
131
+ // handlers) still read unfiltered. No isDeleted filter in the read: restore
132
+ // targets exactly the soft-deleted row.
133
+ async function loadSoftDeletedRow(db, id, streamTenantId) {
134
+ const row = await db.fetchOne(table, { id });
135
+ if (!row)
136
+ return { failure: writeFailure(new NotFoundError(entityName, id)) };
137
+ const streamTenantMismatch = streamTenantOverrideFailure(db, row, streamTenantId);
138
+ if (streamTenantMismatch)
139
+ return { failure: streamTenantMismatch };
140
+ if (!row["isDeleted"]) {
141
+ return {
142
+ failure: writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" })),
143
+ };
144
+ }
145
+ return { row: row };
146
+ }
147
+ async function expectedStreamVersion(db, id, streamTenant, expect) {
148
+ const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
149
+ const row = snapshot.row;
150
+ if (!row)
151
+ return writeFailure(new PreconditionFailedError({ entityId: id, field: "id" }));
152
+ const mismatch = Object.entries(expect).find(([key, expected]) => row[key] !== expected);
153
+ if (mismatch) {
154
+ return writeFailure(new PreconditionFailedError({ entityId: id, field: mismatch[0] }));
155
+ }
156
+ return snapshot.streamVersion;
157
+ }
127
158
  return {
128
159
  async create(payload, user, db, options) {
129
160
  if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
@@ -218,6 +249,7 @@ export function createWriteVerbs(ctx) {
218
249
  tenantId: streamTenantFor(user),
219
250
  expectedVersion: 0,
220
251
  type: entityEventName(entityName, "created"),
252
+ eventVersion,
221
253
  payload: flatData,
222
254
  metadata: buildEventMetadata(user),
223
255
  }));
@@ -309,6 +341,10 @@ export function createWriteVerbs(ctx) {
309
341
  const previous = await loadById(payload.id, db);
310
342
  if (!previous)
311
343
  return writeFailure(new NotFoundError(entityName, payload.id));
344
+ const streamTenantId = updateOptions?.streamTenantId;
345
+ const streamTenantMismatch = streamTenantOverrideFailure(db, previous, streamTenantId);
346
+ if (streamTenantMismatch)
347
+ return streamTenantMismatch;
312
348
  const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update", updateOutputSchema);
313
349
  if ("failure" in preSaveResult)
314
350
  return preSaveResult.failure;
@@ -322,7 +358,7 @@ export function createWriteVerbs(ctx) {
322
358
  // prevents the attack where role A passes old, role B passes new and
323
359
  // aggregation would wrongly allow a row-grab.
324
360
  const mergedNew = { ...previous, ...changes };
325
- if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
361
+ if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, previous, mergedNew)) {
326
362
  return writeFailure(new UnprocessableError("ownership_denied", {
327
363
  i18nKey: "errors.ownershipDenied",
328
364
  details: {
@@ -350,7 +386,7 @@ export function createWriteVerbs(ctx) {
350
386
  // for a checked field is still evaluated against the full post-hook
351
387
  // row (`changes` merged onto `previous`) — an ownership rule can
352
388
  // reference a column only a hook populates (kumiko-framework#1672).
353
- const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, user, previous, changes);
389
+ const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
354
390
  if (fieldDeniedUpdate) {
355
391
  return writeFailure(new UnprocessableError("ownership_denied", {
356
392
  i18nKey: "errors.ownershipDenied",
@@ -367,7 +403,8 @@ export function createWriteVerbs(ctx) {
367
403
  const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
368
404
  if (unavailableOnUpdate)
369
405
  return unavailableOnUpdate;
370
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
406
+ const streamTenant = streamTenantFor(user, streamTenantId);
407
+ await assertStreamWritable(db, payload.id, streamTenant);
371
408
  // Stream-version is authoritative, not row.version. `ctx.appendEvent`
372
409
  // can bump the stream between CRUD writes (domain event on the same
373
410
  // aggregate); a stale row.version here would make the next CRUD write
@@ -376,22 +413,12 @@ export function createWriteVerbs(ctx) {
376
413
  //
377
414
  // With `expect:` the version comes from loadExpectSnapshot's combined
378
415
  // query instead; see its comment for why.
379
- let currentVersion;
380
- if (updateOptions?.expect) {
381
- const expectKeys = Object.keys(updateOptions.expect);
382
- const snapshot = await loadExpectSnapshot(db, payload.id, streamTenantFor(user), expectKeys);
383
- if (!snapshot.row) {
384
- return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: "id" }));
385
- }
386
- const mismatch = Object.entries(updateOptions.expect).find(([key, expected]) => snapshot.row?.[key] !== expected);
387
- if (mismatch) {
388
- return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: mismatch[0] }));
389
- }
390
- currentVersion = snapshot.streamVersion;
391
- }
392
- else {
393
- currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
394
- }
416
+ const expect = updateOptions?.expect;
417
+ const currentVersion = expect
418
+ ? await expectedStreamVersion(db, payload.id, streamTenant, expect)
419
+ : await getStreamVersion(runner, String(payload.id), streamTenant);
420
+ if (typeof currentVersion !== "number")
421
+ return currentVersion;
395
422
  if (!updateOptions?.skipOptimisticLock) {
396
423
  if (payload.version === undefined) {
397
424
  return writeFailure(new FrameworkVersionConflict({
@@ -419,6 +446,7 @@ export function createWriteVerbs(ctx) {
419
446
  const flatChanges = await encryptForStorage(flatChangesPlain, user, {
420
447
  onlyKeys: Object.keys(submittedChanges),
421
448
  subjectSource: mergedNew,
449
+ keyTenantId: streamTenantId,
422
450
  });
423
451
  // The event payload carries BOTH `changes` (what the user asked for) AND
424
452
  // `previous` (the pre-update row). Cross-aggregate projections need the
@@ -429,16 +457,19 @@ export function createWriteVerbs(ctx) {
429
457
  // re-encrypt it before it's persisted so plaintext of pii/encrypted
430
458
  // fields doesn't land in the immutable log (flatChanges is already
431
459
  // ciphertext from encryptForStorage above).
432
- const encryptedPrevious = await encryptForStorage(previous, user);
460
+ const encryptedPrevious = await encryptForStorage(previous, user, {
461
+ keyTenantId: streamTenantId,
462
+ });
433
463
  // Savepoint-scoped — see the create() append() above for why:
434
464
  // confines a losing writer's unique-violation to a nested scope
435
465
  // instead of poisoning the whole outer transaction.
436
466
  const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
437
467
  aggregateId: String(payload.id),
438
468
  aggregateType: entityName,
439
- tenantId: streamTenantFor(user),
469
+ tenantId: streamTenant,
440
470
  expectedVersion: currentVersion,
441
471
  type: entityEventName(entityName, "updated"),
472
+ eventVersion,
442
473
  payload: {
443
474
  changes: flatChanges,
444
475
  previous: encryptedPrevious,
@@ -472,7 +503,7 @@ export function createWriteVerbs(ctx) {
472
503
  const row = result.row;
473
504
  const data = await decryptForRead(rehydrateCompoundTypes(row, entity));
474
505
  if (entityCache && entityName) {
475
- await entityCache.del(user.tenantId, entityName, payload.id);
506
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
476
507
  }
477
508
  const echoEvent = {
478
509
  ...event,
@@ -514,7 +545,7 @@ export function createWriteVerbs(ctx) {
514
545
  throw e;
515
546
  }
516
547
  },
517
- async delete(payload, user, db) {
548
+ async delete(payload, user, db, options) {
518
549
  if (isIrreversibleEntityVerb("delete", entity)) {
519
550
  assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
520
551
  }
@@ -522,11 +553,15 @@ export function createWriteVerbs(ctx) {
522
553
  const existing = await loadById(payload.id, db);
523
554
  if (!existing)
524
555
  return writeFailure(new NotFoundError(entityName, payload.id));
556
+ const streamTenantId = options?.streamTenantId;
557
+ const streamTenantMismatch = streamTenantOverrideFailure(db, existing, streamTenantId);
558
+ if (streamTenantMismatch)
559
+ return streamTenantMismatch;
525
560
  // H.2 — entity-level write-ownership on delete. Only the pre-delete
526
561
  // row matters (there's no "new" row for a delete); passing existing
527
562
  // twice to userCanWriteFieldRow makes the Straddle check trivial
528
563
  // (same row on both sides) while keeping the multi-role-atomic shape.
529
- if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
564
+ if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
530
565
  return writeFailure(new UnprocessableError("ownership_denied", {
531
566
  i18nKey: "errors.ownershipDenied",
532
567
  details: {
@@ -538,9 +573,9 @@ export function createWriteVerbs(ctx) {
538
573
  },
539
574
  }));
540
575
  }
541
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
576
+ await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
542
577
  // Stream-version authoritative (see update() for rationale).
543
- const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
578
+ const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
544
579
  // Deletes carry the full pre-delete row as `previous`. That's what
545
580
  // projections and downstream consumers need to reverse any aggregates —
546
581
  // a `{}`-payload delete would make cross-aggregate projections impossible
@@ -552,10 +587,13 @@ export function createWriteVerbs(ctx) {
552
587
  event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
553
588
  aggregateId: String(payload.id),
554
589
  aggregateType: entityName,
555
- tenantId: streamTenantFor(user),
590
+ tenantId: streamTenantFor(user, streamTenantId),
556
591
  expectedVersion: currentVersion,
557
592
  type: entityEventName(entityName, "deleted"),
558
- payload: { previous: await encryptForStorage(existing, user) },
593
+ eventVersion,
594
+ payload: {
595
+ previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
596
+ },
559
597
  metadata: buildEventMetadata(user),
560
598
  }));
561
599
  }
@@ -578,7 +616,7 @@ export function createWriteVerbs(ctx) {
578
616
  return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
579
617
  }
580
618
  if (entityCache && entityName) {
581
- await entityCache.del(user.tenantId, entityName, payload.id);
619
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
582
620
  }
583
621
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
584
622
  await projectEntityEvent(db, runner, echoEvent);
@@ -597,15 +635,19 @@ export function createWriteVerbs(ctx) {
597
635
  // hard-deletes the row regardless of softDelete — and, being an auto-verb,
598
636
  // the erasure replays on rebuild (created → forgotten → row gone). Loads
599
637
  // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
600
- async forget(payload, user, db) {
638
+ async forget(payload, user, db, options) {
601
639
  assertIrreversibleOperationAllowed(`${entityName}.forget`);
602
640
  const runner = tenantDbRunner(db);
603
641
  const raw = await db.fetchOne(table, { id: payload.id });
604
642
  if (!raw)
605
643
  return writeFailure(new NotFoundError(entityName, payload.id));
644
+ const streamTenantId = options?.streamTenantId;
645
+ const streamTenantMismatch = streamTenantOverrideFailure(db, raw, streamTenantId);
646
+ if (streamTenantMismatch)
647
+ return streamTenantMismatch;
606
648
  const existing = await decryptForRead(rehydrateCompoundTypes(raw, entity));
607
649
  if (!isFrameworkSystemUser(user) &&
608
- !userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
650
+ !userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, existing, existing)) {
609
651
  return writeFailure(new UnprocessableError("ownership_denied", {
610
652
  i18nKey: "errors.ownershipDenied",
611
653
  details: {
@@ -617,19 +659,22 @@ export function createWriteVerbs(ctx) {
617
659
  },
618
660
  }));
619
661
  }
620
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
621
- const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
662
+ await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
663
+ const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
622
664
  let event;
623
665
  try {
624
666
  event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
625
667
  aggregateId: String(payload.id),
626
668
  aggregateType: entityName,
627
- tenantId: streamTenantFor(user),
669
+ tenantId: streamTenantFor(user, streamTenantId),
628
670
  expectedVersion: currentVersion,
629
671
  type: entityEventName(entityName, "forgotten"),
672
+ eventVersion,
630
673
  // Re-encrypt like delete(): `existing` came decrypted from loadById —
631
674
  // plaintext must not land in the immutable log, least of all on forget.
632
- payload: { previous: await encryptForStorage(existing, user) },
675
+ payload: {
676
+ previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
677
+ },
633
678
  metadata: buildEventMetadata(user),
634
679
  }));
635
680
  }
@@ -651,7 +696,7 @@ export function createWriteVerbs(ctx) {
651
696
  return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
652
697
  }
653
698
  if (entityCache && entityName) {
654
- await entityCache.del(user.tenantId, entityName, payload.id);
699
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
655
700
  }
656
701
  const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
657
702
  await projectEntityEvent(db, runner, echoEvent);
@@ -666,29 +711,22 @@ export function createWriteVerbs(ctx) {
666
711
  },
667
712
  };
668
713
  },
669
- async restore(payload, user, db) {
714
+ async restore(payload, user, db, options) {
670
715
  if (!softDelete) {
671
716
  return writeFailure(new UnprocessableError("soft_delete_not_enabled", {
672
717
  i18nKey: "errors.softDeleteNotEnabled",
673
718
  }));
674
719
  }
675
720
  const runner = tenantDbRunner(db);
676
- // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
677
- // selectMany(runner, ...) did not — any caller could un-delete a foreign
678
- // tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
679
- // handlers) still read unfiltered. No isDeleted filter here: restore
680
- // targets exactly the soft-deleted row.
681
- const row = await db.fetchOne(table, { id: payload.id });
682
- if (!row)
683
- return writeFailure(new NotFoundError(entityName, payload.id));
684
- const data = row;
685
- if (!data["isDeleted"]) {
686
- return writeFailure(new UnprocessableError("not_deleted", { i18nKey: "errors.notDeleted" }));
687
- }
721
+ const streamTenantId = options?.streamTenantId;
722
+ const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
723
+ if ("failure" in target)
724
+ return target.failure;
725
+ const data = target.row;
688
726
  // H.2 — entity-level write-ownership on restore. Same shape as delete:
689
727
  // only the stored row matters. Stored row carries pre-soft-delete
690
728
  // teamId/... fields, so the ownership predicate still applies cleanly.
691
- if (!userCanWriteFieldRow(user, entity.access?.write, data, data)) {
729
+ if (!userCanWriteFieldRow(ownershipSubject(user, streamTenantId), entity.access?.write, data, data)) {
692
730
  return writeFailure(new UnprocessableError("ownership_denied", {
693
731
  i18nKey: "errors.ownershipDenied",
694
732
  details: {
@@ -700,9 +738,9 @@ export function createWriteVerbs(ctx) {
700
738
  },
701
739
  }));
702
740
  }
703
- await assertStreamWritable(db, payload.id, streamTenantFor(user));
741
+ await assertStreamWritable(db, payload.id, streamTenantFor(user, streamTenantId));
704
742
  // Stream-version authoritative (see update() for rationale).
705
- const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
743
+ const currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user, streamTenantId));
706
744
  // Restore carries the soft-deleted snapshot as `previous` — mirror of
707
745
  // delete for symmetry. Projections that decremented on delete use
708
746
  // `previous` to re-increment on restore without re-querying the entity
@@ -713,9 +751,10 @@ export function createWriteVerbs(ctx) {
713
751
  event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
714
752
  aggregateId: String(payload.id),
715
753
  aggregateType: entityName,
716
- tenantId: streamTenantFor(user),
754
+ tenantId: streamTenantFor(user, streamTenantId),
717
755
  expectedVersion: currentVersion,
718
756
  type: entityEventName(entityName, "restored"),
757
+ eventVersion,
719
758
  payload: { previous: data },
720
759
  metadata: buildEventMetadata(user),
721
760
  }));
@@ -752,7 +791,7 @@ export function createWriteVerbs(ctx) {
752
791
  }
753
792
  const restored = restoreResult.row;
754
793
  if (entityCache && entityName) {
755
- await entityCache.del(user.tenantId, entityName, payload.id);
794
+ await entityCache.del(streamTenantId ?? user.tenantId, entityName, payload.id);
756
795
  }
757
796
  // Read-side auto-convert for compound types, same as update/list.
758
797
  // decryptForRead matches create/update/list/detail: the caller-facing
@@ -27,6 +27,7 @@ export type SubsequentEventInsertRow = {
27
27
  /** INSERT … SELECT … WHERE EXISTS predecessor — stays raw (typed builder can't express this). */
28
28
  export declare function insertSubsequentEventRow(db: AnyDb, params: SubsequentEventInsertParams): Promise<SubsequentEventInsertRow | undefined>;
29
29
  export declare function selectStreamMaxVersion(db: AnyDb, aggregateId: string, tenantId: string): Promise<number>;
30
+ export declare function selectStreamMaxVersions(db: AnyDb, aggregateIds: readonly string[], tenantId: string): Promise<Map<string, number>>;
30
31
  /** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
31
32
  export declare function selectAggregateMaxVersion(db: AnyDb, aggregateId: string): Promise<number>;
32
33
  export declare function selectEventsHighWaterMark(db: AnyDb): Promise<bigint>;
@@ -171,6 +171,17 @@ export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
171
171
  const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]);
172
172
  return rows[0]?.v ?? 0;
173
173
  }
174
+ export async function selectStreamMaxVersions(db, aggregateIds, tenantId) {
175
+ const versions = new Map();
176
+ if (aggregateIds.length === 0)
177
+ return versions;
178
+ const rows = await unsafeReadRetrying(db, `SELECT "aggregate_id" AS id, MAX("version") AS v FROM "kumiko_events" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2::uuid[]) GROUP BY "aggregate_id"`, [tenantId, [...aggregateIds]]);
179
+ for (const row of rows) {
180
+ if (row.v !== null)
181
+ versions.set(row.id, row.v);
182
+ }
183
+ return versions;
184
+ }
174
185
  /** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
175
186
  export async function selectAggregateMaxVersion(db, aggregateId) {
176
187
  const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]);
@@ -273,15 +273,42 @@ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreen
273
273
  return projectConfigEditScreen(screen, encryptedConfigKeyQns);
274
274
  if (screen.type === "projectionList")
275
275
  return projectProjectionListScreen(screen, registry);
276
- if (screen.type === "actionForm" ||
277
- screen.type === "secretMint" ||
278
- screen.type === "entityEdit") {
279
- const declared = urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId));
280
- return { ...screen, urlPrefillFields: declared === undefined ? [] : [...declared].sort() };
276
+ if (screen.type === "entityEdit") {
277
+ const { writeExcludedFields: _authoredExclusions, ...authoredScreen } = screen;
278
+ return {
279
+ ...authoredScreen,
280
+ urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
281
+ ...entityEditWriteExcludedFields(featureName, screen.entity, registry),
282
+ };
283
+ }
284
+ if (screen.type === "actionForm" || screen.type === "secretMint") {
285
+ return {
286
+ ...screen,
287
+ urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
288
+ };
281
289
  }
282
290
  return screen;
283
291
  });
284
292
  }
293
+ function sortedUrlPrefillFields(declared) {
294
+ return declared === undefined ? [] : [...declared].sort();
295
+ }
296
+ // The screen is the one thing the renderer reads, so the per-verb excludeFields
297
+ // of the entity's generic create/update handlers are projected onto it here.
298
+ function entityEditWriteExcludedFields(featureName, entityName, registry) {
299
+ const excludedFor = (verb) => registry.getWriteHandler(`${qualifyEntityName(featureName, QnTypes.write, entityName)}:${verb}`)
300
+ ?.excludedFields;
301
+ const create = excludedFor("create");
302
+ const update = excludedFor("update");
303
+ if (create === undefined && update === undefined)
304
+ return {};
305
+ return {
306
+ writeExcludedFields: {
307
+ ...(create !== undefined && { create }),
308
+ ...(update !== undefined && { update }),
309
+ },
310
+ };
311
+ }
285
312
  function collectEncryptedConfigKeyQns(registry) {
286
313
  const qns = new Set();
287
314
  for (const [featureName, feature] of registry.features) {
@@ -1,3 +1,4 @@
1
1
  import { AccessDeniedError } from "../errors/index.js";
2
2
  import type { SessionUser } from "./types/index.js";
3
- export declare function crossTenantOverrideDenied(user: SessionUser, tenantIdOverride: string | undefined, i18nKey: string): AccessDeniedError | undefined;
3
+ export declare function mayOverrideTenant(user: SessionUser): boolean;
4
+ export declare function tenantOverrideDenied(user: SessionUser, parsedPayload: unknown): AccessDeniedError | undefined;
@@ -1,18 +1,22 @@
1
- import { AccessDeniedError } from "../errors/index.js";
2
- // A payload-supplied target tenant (tenantIdOverride) on a TenantAdmin-reachable
3
- // handler is the cross-tenant escape hatch: hasAccess passes the handler for any
4
- // TenantAdmin, so without a SystemAdmin gate a TenantAdmin could act on another
5
- // tenant. Centralizes the check the override handlers used to inline. Returns
6
- // the denial to `throw` (queries) or wrap in `writeFailure` (writes), or
7
- // undefined when allowed. The i18nKey stays per-feature so existing
8
- // translations keep resolving.
9
- export function crossTenantOverrideDenied(user, tenantIdOverride, i18nKey) {
10
- if (tenantIdOverride === undefined)
11
- return undefined;
12
- if (user.roles.includes("SystemAdmin"))
1
+ import { ROLES } from "../auth/roles.js";
2
+ import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
3
+ export function mayOverrideTenant(user) {
4
+ return user.roles.includes(ROLES.SystemAdmin);
5
+ }
6
+ function carriesTenantOverride(payload) {
7
+ return (typeof payload === "object" &&
8
+ payload !== null &&
9
+ "tenantIdOverride" in payload &&
10
+ payload.tenantIdOverride !== undefined);
11
+ }
12
+ // A payload-supplied target tenant (tenantIdOverride) is the cross-tenant escape hatch:
13
+ // hasAccess passes a handler for any TenantAdmin, so the dispatcher refuses the field
14
+ // for everyone but a SystemAdmin before any handler sees it. Runs on the schema-parsed
15
+ // payload, so only handlers that declare the field are affected.
16
+ export function tenantOverrideDenied(user, parsedPayload) {
17
+ if (!carriesTenantOverride(parsedPayload) || mayOverrideTenant(user))
13
18
  return undefined;
14
19
  return new AccessDeniedError({
15
- i18nKey,
16
- details: { reason: "tenant_override_requires_system_admin" },
20
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
17
21
  });
18
22
  }
@@ -217,15 +217,14 @@ export function defineEntityWriteHandler(name, entity, options) {
217
217
  // actingUserFor reads the row it is about to touch anyway, so for these calls it reports the
218
218
  // cross-tenant use per row (with that row as target) instead of dbFor reporting once up front.
219
219
  const reportsPerTouchedRow = (ctx) => ctx.systemDb === undefined && crossTenantReason !== undefined && !entity.systemStream;
220
- // The event stream is keyed by the acting user's tenantId (streamTenantFor in
221
- // event-store-executor-context.ts), not by the db handed to the executor — an
222
- // unfiltered "system"-mode db alone reads the foreign row but appends to an
223
- // empty stream in the operator's own tenant, surfacing as a bogus
224
- // version_conflict. Rewriting the acting tenant also keeps PII envelope keys
225
- // derived from the row's tenant rather than the operator's.
226
- const actingUserFor = async (user, id, db, ctx) => {
220
+ // The event stream defaults to the acting user's tenant (streamTenantFor in
221
+ // event-store-executor-context.ts), not the db's — an unfiltered "system"-mode db alone
222
+ // reads the foreign row but appends to an empty stream in the operator's own tenant,
223
+ // surfacing as a bogus version_conflict. The row's tenant goes to the executor as
224
+ // streamTenantId so stream and PII keys follow the row while the actor stays the operator.
225
+ const foreignRowTenantFor = async (user, id, db, ctx) => {
227
226
  if (crossTenantReason === undefined || entity.systemStream)
228
- return user;
227
+ return undefined;
229
228
  const row = await db.fetchOne(table, { id });
230
229
  const rowTenantId = row?.["tenantId"];
231
230
  if (reportsPerTouchedRow(ctx)) {
@@ -235,8 +234,8 @@ export function defineEntityWriteHandler(name, entity, options) {
235
234
  : undefined);
236
235
  }
237
236
  if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
238
- return user;
239
- return { ...user, tenantId: rowTenantId };
237
+ return undefined;
238
+ return parseTenantId(rowTenantId) ?? undefined;
240
239
  };
241
240
  let schema;
242
241
  let handler;
@@ -272,14 +271,15 @@ export function defineEntityWriteHandler(name, entity, options) {
272
271
  ...submitted,
273
272
  changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
274
273
  };
275
- const user = await actingUserFor(event.user, payload.id, db, ctx);
274
+ const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
276
275
  // skipUnchanged (#464): API-driven updates diff against the stored
277
276
  // row so a resubmitted-but-identical field doesn't force a fresh
278
277
  // pii/encrypted ciphertext. Direct executor.update() callers (e.g.
279
278
  // KEK-rotation, the user-data-rights #494 backfill) don't go through
280
279
  // this handler and keep today's always-re-encrypt behavior, which
281
280
  // they rely on to intentionally force a fresh event/ciphertext.
282
- return executor.update(payload, user, db, {
281
+ return executor.update(payload, event.user, db, {
282
+ streamTenantId,
283
283
  skipUnchanged: true,
284
284
  preSave: runPreSave &&
285
285
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -291,8 +291,8 @@ export function defineEntityWriteHandler(name, entity, options) {
291
291
  handler = async (event, ctx) => {
292
292
  const db = dbFor(ctx);
293
293
  const payload = event.payload; // @cast-boundary engine-payload
294
- const user = await actingUserFor(event.user, payload.id, db, ctx);
295
- return executor.delete(payload, user, db);
294
+ const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
295
+ return executor.delete(payload, event.user, db, { streamTenantId });
296
296
  };
297
297
  break;
298
298
  case "restore":
@@ -300,8 +300,8 @@ export function defineEntityWriteHandler(name, entity, options) {
300
300
  handler = async (event, ctx) => {
301
301
  const db = dbFor(ctx);
302
302
  const payload = event.payload; // @cast-boundary engine-payload
303
- const user = await actingUserFor(event.user, payload.id, db, ctx);
304
- return executor.restore(payload, user, db);
303
+ const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
304
+ return executor.restore(payload, event.user, db, { streamTenantId });
305
305
  };
306
306
  break;
307
307
  default:
@@ -315,6 +315,7 @@ export function defineEntityWriteHandler(name, entity, options) {
315
315
  access: options.access,
316
316
  ...(options.description !== undefined && { description: options.description }),
317
317
  ...(agentHints !== undefined && { agent: agentHints }),
318
+ ...(excludedFields.length > 0 && { excludedFields }),
318
319
  };
319
320
  }
320
321
  // Append read-time derived-field values (EntityDefinition.derivedFields) to
@@ -179,6 +179,8 @@ export declare function createEntity<F, const T extends EntityTenancy = "tenant"
179
179
  /** Event stream lives on SYSTEM_TENANT_ID (tenant-independent aggregate, e.g.
180
180
  * user) instead of the creator's tenant. See EntityDefinition.systemStream. */
181
181
  readonly systemStream?: boolean;
182
+ readonly eventVersion?: number;
183
+ readonly eventMigrations?: EntityDefinition["eventMigrations"];
182
184
  readonly searchWeight?: number;
183
185
  readonly defaultCurrency?: string;
184
186
  readonly transitions?: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
@@ -29,6 +29,7 @@ const WRITE_HANDLER_KEY_KINDS = {
29
29
  access: "modeled",
30
30
  description: "modeled",
31
31
  agent: "modeled",
32
+ excludedFields: "opaque",
32
33
  unsafeSkipTransitionGuard: "modeled",
33
34
  rateLimit: "modeled",
34
35
  additionalRateLimits: "modeled",
@@ -103,6 +103,7 @@ export function buildEntityHandlerMethods(state, name) {
103
103
  handler: def.handler,
104
104
  access: def.access,
105
105
  ...agentSlots(def),
106
+ ...(def.excludedFields && { excludedFields: def.excludedFields }),
106
107
  ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
107
108
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
108
109
  ...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
@@ -14,7 +14,7 @@ export type { SystemHookName } from "./constants.js";
14
14
  export { ConcurrencyModes, ConfigScopes, LifecycleHookTypes, MessageKind, OnDeleteStrategies, SystemHookNames, SystemHookPriorities, TENANT_CURRENCY_CONFIG_KEY, tenantChannel, } from "./constants.js";
15
15
  export type { App, AppConfig } from "./create-app.js";
16
16
  export { createApp } from "./create-app.js";
17
- export { crossTenantOverrideDenied } from "./cross-tenant.js";
17
+ export { mayOverrideTenant, tenantOverrideDenied } from "./cross-tenant.js";
18
18
  export { dedupeFeatures } from "./dedupe-features.js";
19
19
  export { defineFeature } from "./define-feature.js";
20
20
  export type { PagedQueryHandlerDefinition, QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "./define-handler.js";
@@ -12,7 +12,7 @@ export { parseChangesetChanges } from "./changeset-changes.js";
12
12
  export { access, createSeed, createSystemConfig, createSystemSeed, createTenantConfig, createTenantSeed, createUserConfig, createUserSeed, isEncryptedAtRest, } from "./config-helpers.js";
13
13
  export { ConcurrencyModes, ConfigScopes, LifecycleHookTypes, MessageKind, OnDeleteStrategies, SystemHookNames, SystemHookPriorities, TENANT_CURRENCY_CONFIG_KEY, tenantChannel, } from "./constants.js";
14
14
  export { createApp } from "./create-app.js";
15
- export { crossTenantOverrideDenied } from "./cross-tenant.js";
15
+ export { mayOverrideTenant, tenantOverrideDenied } from "./cross-tenant.js";
16
16
  export { dedupeFeatures } from "./dedupe-features.js";
17
17
  export { defineFeature } from "./define-feature.js";
18
18
  export { definePagedQueryHandler, defineQueryHandler, defineWriteHandler, isPagedQueryHandler, } from "./define-handler.js";
@@ -1,5 +1,6 @@
1
+ import { type EntityLifecycleVerb } from "../db/event-store-executor-context.js";
1
2
  import type { RegistryState } from "./registry-state.js";
2
- import type { FeatureDefinition } from "./types/index.js";
3
+ import type { EntityEventMigration, EventUpcastFn, FeatureDefinition } from "./types/index.js";
3
4
  export declare function finalizeWorkspaceNavMembership(state: RegistryState): void;
4
5
  export declare function populateHandlerEntityMappings(state: RegistryState, features: readonly FeatureDefinition[]): void;
5
6
  export declare function validateExtensionSelectors(state: RegistryState): void;
@@ -10,6 +11,10 @@ export declare function buildIncomingRelations(state: RegistryState): void;
10
11
  export declare function validateFieldAccessHandlersAreEntityMapped(state: RegistryState, features: readonly FeatureDefinition[]): void;
11
12
  export declare function validateRelationTargetsExist(state: RegistryState): void;
12
13
  export declare function validateEventMigrationVersions(state: RegistryState, features: readonly FeatureDefinition[]): void;
14
+ export declare const ENTITY_LIFECYCLE_VERBS: readonly ["created", "updated", "deleted", "restored", "forgotten"];
15
+ type RowTransform = EntityEventMigration["transform"];
16
+ export declare function adaptRowTransformToPayload(verb: EntityLifecycleVerb, transform: RowTransform): EventUpcastFn;
17
+ export declare function buildEntityEventUpcasterChains(state: RegistryState): void;
13
18
  export declare function buildEventUpcasterChains(state: RegistryState, features: readonly FeatureDefinition[]): void;
14
19
  export declare function validateProjectionApplyKeys(state: RegistryState): void;
15
20
  export declare function validateRequiredFeatures(state: RegistryState, features: readonly FeatureDefinition[]): void;
@@ -24,3 +29,4 @@ export declare function validateExtensionUsageTargets(state: RegistryState): voi
24
29
  export declare function computeHasRateLimitedHandler(state: RegistryState): void;
25
30
  export declare function publishEventPiiCatalog(state: RegistryState): void;
26
31
  export declare function autoWireSoftDeleteJobs(state: RegistryState): void;
32
+ export {};