@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.
- package/dist/db/entity-field-encryption.d.ts +10 -1
- package/dist/db/entity-field-encryption.js +31 -2
- package/dist/db/event-store-executor-context.d.ts +8 -2
- package/dist/db/event-store-executor-context.js +41 -9
- package/dist/db/event-store-executor-read.js +22 -11
- package/dist/db/event-store-executor-write.js +95 -56
- package/dist/db/queries/event-store.d.ts +1 -0
- package/dist/db/queries/event-store.js +11 -0
- package/dist/engine/build-app-schema.js +32 -5
- package/dist/engine/cross-tenant.d.ts +2 -1
- package/dist/engine/cross-tenant.js +18 -14
- package/dist/engine/entity-handlers.js +17 -16
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +7 -1
- package/dist/engine/registry-validate.js +82 -2
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +35 -2
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +4 -1
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/snapshot.d.ts +1 -0
- package/dist/event-store/snapshot.js +12 -3
- package/dist/jobs/job-runner.js +32 -17
- package/dist/observability/escape-hatch-report.d.ts +5 -2
- package/dist/observability/escape-hatch-report.js +52 -22
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/pipeline/dispatch-query.js +4 -0
- package/dist/pipeline/dispatch-shared.js +33 -6
- package/dist/pipeline/dispatch-stream.js +4 -0
- package/dist/pipeline/dispatcher-utils.d.ts +4 -0
- package/dist/pipeline/dispatcher-utils.js +10 -0
- package/dist/pipeline/dispatcher.js +7 -7
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/pre-handler-gates.js +4 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- 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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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:
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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 === "
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
// event-store-executor-context.ts), not
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
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
|
|
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
|
|
239
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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[]>>>>;
|
|
@@ -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 }),
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/dist/engine/index.js
CHANGED
|
@@ -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 {
|
|
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 {};
|