@cosmicdrift/kumiko-framework 0.378.0 → 0.379.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/AGENTS.md +4 -1
- package/agents/examples/compose-stacks.md +1 -2
- package/agents/examples/currencies-global.md +2 -3
- package/agents/examples/embedded.md +23 -37
- package/agents/examples/event-sourcing.md +6 -5
- package/agents/examples/legal-pages.md +5 -5
- package/agents/examples/reference-data.md +26 -21
- package/agents/examples/relations.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +27 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/server.js +10 -3
- package/dist/api/sse-route.d.ts +5 -0
- package/dist/api/sse-route.js +59 -9
- package/dist/db/entity-table-from-registry.d.ts +2 -1
- package/dist/db/entity-table-meta.d.ts +0 -2
- package/dist/db/entity-table-meta.js +1 -3
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +6 -0
- package/dist/db/executor-managed-tables.js +40 -0
- package/dist/db/index.d.ts +4 -2
- package/dist/db/index.js +4 -2
- package/dist/db/money.d.ts +1 -1
- package/dist/db/queries/backfill-pii.js +22 -12
- package/dist/db/queries/database-now.d.ts +5 -0
- package/dist/db/queries/database-now.js +12 -0
- package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
- package/dist/db/queries/event-store-orphan-rows.js +32 -0
- package/dist/db/query.js +28 -0
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.js +2 -0
- package/dist/db/tenant-db-runner.d.ts +2 -0
- package/dist/db/tenant-db-runner.js +7 -0
- package/dist/db/tenant-db.d.ts +3 -0
- package/dist/db/tenant-db.js +50 -18
- package/dist/engine/boot-validator/entity-handler.js +9 -4
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +34 -9
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/registry-state.js +2 -0
- package/dist/engine/registry-validate.js +4 -1
- package/dist/engine/schema-builder.js +18 -4
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +3 -0
- package/dist/engine/soft-delete-cleanup.js +53 -24
- package/dist/engine/steps/unsafe-projection-delete.js +2 -1
- package/dist/entrypoint/index.d.ts +1 -3
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +4 -1
- package/dist/errors/serialize.d.ts +1 -0
- package/dist/errors/serialize.js +9 -5
- package/dist/errors/write-error-info.js +3 -2
- package/dist/errors/zod-bridge.js +7 -0
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/files/action-form-upload.d.ts +16 -0
- package/dist/files/action-form-upload.js +45 -0
- package/dist/files/bind-pending-file-refs.d.ts +14 -0
- package/dist/files/bind-pending-file-refs.js +44 -4
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +41 -13
- package/dist/files/file-routes.js +29 -2
- package/dist/files/index.d.ts +2 -1
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
- package/dist/files/purge-expired-file-ref-bytes.js +11 -5
- package/dist/internal/testing.d.ts +2 -0
- package/dist/internal/testing.js +15 -1
- package/dist/migrations/pii-event-backfill.js +14 -7
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +14 -3
- package/dist/pipeline/cascade-handler.js +79 -10
- package/dist/pipeline/dispatch-shared.js +1 -9
- package/dist/pipeline/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- package/dist/pipeline/projections-runner.js +2 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/middleware.d.ts +4 -0
- package/dist/rate-limit/middleware.js +5 -2
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +2 -2
- package/dist/ui-types/list-row-meta.d.ts +3 -0
- package/dist/ui-types/list-row-meta.js +12 -1
- package/kumiko-es-tables.json +6 -0
- package/package.json +4 -3
- package/src/changes.json +110 -0
- package/src/scripts/codemod/README.md +20 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +416 -0
- package/src/scripts/codemod/package-merge.ts +0 -11
package/dist/api/sse-route.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import { hasAccess } from "../engine/access.js";
|
|
3
4
|
import { tenantChannel } from "../engine/constants.js";
|
|
4
5
|
import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
5
6
|
import { Routes } from "./api-constants.js";
|
|
@@ -40,6 +41,11 @@ function isEntityEventData(data) {
|
|
|
40
41
|
// cookie is stale. A downgraded stream answers 200, so EventSource never reports the
|
|
41
42
|
// session end by itself.
|
|
42
43
|
const SESSION_ENDED_SSE_EVENT = "session-ended";
|
|
44
|
+
// A broadcast batch legitimately queues many frames at once, so a client is dropped for
|
|
45
|
+
// making no progress, not for queue depth.
|
|
46
|
+
export const SSE_STALLED_CLIENT_TIMEOUT_MS = SSE_HEARTBEAT_INTERVAL_MS;
|
|
47
|
+
// Memory backstop only: a client that still makes progress but queues this many frames is dropped.
|
|
48
|
+
export const SSE_MAX_PENDING_WRITES_PER_CLIENT = 10_000;
|
|
43
49
|
// Whitelist, never a spread: the wire frame is a signal without field
|
|
44
50
|
// values even if a broker or pusher put a payload into event.data.
|
|
45
51
|
function toEntitySignalWire(event, data) {
|
|
@@ -68,16 +74,26 @@ function isAddressedTo(event, userId) {
|
|
|
68
74
|
const addressee = event.data["userId"];
|
|
69
75
|
return typeof addressee === "string" && addressee === userId;
|
|
70
76
|
}
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
// A role-gated rule means every holder may read every row, so id and version leak nothing.
|
|
78
|
+
// An openToAll rule leaves row visibility to the handler (e.g. an owner check on form drafts),
|
|
79
|
+
// which the signal cannot know: such a connection only learns that the entity changed.
|
|
80
|
+
function entitySignalDetail(user, aggregateType, options) {
|
|
81
|
+
if (isAnonymousSessionUser(user)) {
|
|
82
|
+
return options.anonymousLiveEntities.has(aggregateType) ? "anonymous" : "none";
|
|
83
|
+
}
|
|
84
|
+
const granting = (options.entityReadAccess.get(aggregateType) ?? []).filter((rule) => hasAccess(user, rule));
|
|
85
|
+
if (granting.length === 0)
|
|
86
|
+
return "none";
|
|
87
|
+
return granting.some((rule) => "roles" in rule) ? "identified" : "anonymous";
|
|
73
88
|
}
|
|
74
89
|
function decideWireFrame(event, user, options) {
|
|
75
90
|
const data = event.data;
|
|
76
91
|
if (isEntityEventData(data)) {
|
|
77
|
-
// skip:
|
|
78
|
-
|
|
92
|
+
// skip: connections only get signals for entities a query lets their roles read
|
|
93
|
+
const detail = entitySignalDetail(user, data.aggregateType, options);
|
|
94
|
+
if (detail === "none")
|
|
79
95
|
return undefined;
|
|
80
|
-
const signal =
|
|
96
|
+
const signal = detail === "anonymous"
|
|
81
97
|
? toAnonymousEntitySignalWire(event, data)
|
|
82
98
|
: toEntitySignalWire(event, data);
|
|
83
99
|
return { name: data.aggregateType, data: signal };
|
|
@@ -88,6 +104,7 @@ function decideWireFrame(event, user, options) {
|
|
|
88
104
|
return { name: event.type, data };
|
|
89
105
|
}
|
|
90
106
|
export function createSseRoute(broker, options) {
|
|
107
|
+
const now = options.now ?? Date.now;
|
|
91
108
|
const route = new Hono();
|
|
92
109
|
route.get(Routes.sse, async (c) => {
|
|
93
110
|
const user = getUser(c);
|
|
@@ -116,12 +133,41 @@ export function createSseRoute(broker, options) {
|
|
|
116
133
|
resolveEnded();
|
|
117
134
|
void stream.close();
|
|
118
135
|
};
|
|
136
|
+
let pendingWrites = 0;
|
|
137
|
+
let lastProgressAt = now();
|
|
138
|
+
const isStalled = () => pendingWrites > 0 && now() - lastProgressAt > SSE_STALLED_CLIENT_TIMEOUT_MS;
|
|
119
139
|
const clientId = broker.addClient(channel, (event) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (!frame)
|
|
140
|
+
// skip: the stream is already being torn down
|
|
141
|
+
if (stream.aborted || stream.closed)
|
|
123
142
|
return;
|
|
124
|
-
|
|
143
|
+
// The broker loops over all clients synchronously: one bad client must not stop the others.
|
|
144
|
+
try {
|
|
145
|
+
const frame = decideWireFrame(event, user, options);
|
|
146
|
+
// skip: decideWireFrame withholds frames this user must not see
|
|
147
|
+
if (!frame)
|
|
148
|
+
return;
|
|
149
|
+
if (pendingWrites >= SSE_MAX_PENDING_WRITES_PER_CLIENT || isStalled()) {
|
|
150
|
+
// abort (not close) cancels the pending writes; close would queue behind them
|
|
151
|
+
stream.abort();
|
|
152
|
+
// skip: this client is gone, the frame has no audience
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const data = JSON.stringify(frame.data);
|
|
156
|
+
if (pendingWrites === 0)
|
|
157
|
+
lastProgressAt = now();
|
|
158
|
+
pendingWrites += 1;
|
|
159
|
+
stream
|
|
160
|
+
.writeSSE({ event: frame.name, data })
|
|
161
|
+
.catch(closeStream)
|
|
162
|
+
.finally(() => {
|
|
163
|
+
pendingWrites -= 1;
|
|
164
|
+
lastProgressAt = now();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// skip: an unserializable frame ends this client's stream, not the broadcast
|
|
169
|
+
closeStream();
|
|
170
|
+
}
|
|
125
171
|
}, closeStream);
|
|
126
172
|
const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
|
|
127
173
|
const expiryTimer = scheduleTokenExpiry(tokenExpiresAtSec, closeStream);
|
|
@@ -142,6 +188,10 @@ export function createSseRoute(broker, options) {
|
|
|
142
188
|
// header für die Layer-für-Layer-Begründung der 15s-Cadence.
|
|
143
189
|
try {
|
|
144
190
|
while (!stream.closed && !stream.aborted) {
|
|
191
|
+
if (isStalled()) {
|
|
192
|
+
stream.abort();
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
145
195
|
await stream.writeSSE({ event: "ping", data: "" });
|
|
146
196
|
await Promise.race([stream.sleep(SSE_HEARTBEAT_INTERVAL_MS), ended]);
|
|
147
197
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
1
2
|
import type { Registry } from "../engine/index.ts";
|
|
2
3
|
import type { SchemaTable } from "./dialect.ts";
|
|
3
4
|
import { buildEntityTable } from "./table-builder.ts";
|
|
4
|
-
export declare function entityTableFromRegistry(registry: Registry, entityName: string, entity: Parameters<typeof buildEntityTable>[1]): SchemaTable;
|
|
5
|
+
export declare function entityTableFromRegistry(registry: Registry, entityName: string, entity: Parameters<typeof buildEntityTable>[1]): SchemaTable & ExecutorOnly;
|
|
@@ -13,8 +13,6 @@ export declare function resolveTableName(entityName: string, entity: EntityDefin
|
|
|
13
13
|
* escape hatch (#1208).
|
|
14
14
|
*/
|
|
15
15
|
export declare function deriveEntityTableMeta(entityName: string, entity: EntityDefinition, options?: BuildEntityTableMetaOptions): EntityTableMeta;
|
|
16
|
-
/** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
|
|
17
|
-
export declare const buildEntityTableMeta: typeof deriveEntityTableMeta;
|
|
18
16
|
export declare function assertBackingTableSuperset(entityName: string, fieldMeta: EntityTableMeta, tableMeta: EntityTableMeta): void;
|
|
19
17
|
/**
|
|
20
18
|
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
@@ -219,7 +219,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
219
219
|
const tableName = resolveTableName(entityName, entity, options?.featureName);
|
|
220
220
|
const source = options?.source ?? "managed";
|
|
221
221
|
if (source === "unmanaged") {
|
|
222
|
-
assertUnmanagedTableName(tableName, "deriveEntityTableMeta
|
|
222
|
+
assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
|
|
223
223
|
}
|
|
224
224
|
const idType = entity.idType ?? "uuid";
|
|
225
225
|
if (source === "unmanaged" && idType === "serial") {
|
|
@@ -354,8 +354,6 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
354
354
|
...(entity.tenancy === "global" && { tenancy: "global" }),
|
|
355
355
|
};
|
|
356
356
|
}
|
|
357
|
-
/** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
|
|
358
|
-
export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
359
357
|
function sqlExpressionText(where) {
|
|
360
358
|
return isSqlExpression(where) ? where.text : undefined;
|
|
361
359
|
}
|
|
@@ -15,8 +15,13 @@ import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, own
|
|
|
15
15
|
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
16
16
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
17
17
|
import { runInSavepointIfSupported } from "./query.js";
|
|
18
|
-
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
|
|
18
|
+
import { assertPersonalDataWrite, tableNameOf, withExecutorManagedWrites } from "./tenant-db.js";
|
|
19
19
|
import { tenantDbWriteRunner } from "./tenant-db-runner.js";
|
|
20
|
+
// The echoed event carries entity PII and must never reach a handler's JSON response, while
|
|
21
|
+
// hooks and projections still read it by property. Non-enumerable keeps it out of spread/stringify.
|
|
22
|
+
function withoutEventInJson(result) {
|
|
23
|
+
return Object.defineProperty(result, "event", { value: result.event, enumerable: false });
|
|
24
|
+
}
|
|
20
25
|
// Custom projections for a write that did not go through the dispatcher (hook or
|
|
21
26
|
// job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
|
|
22
27
|
// projection fails the write and the caller's transaction rolls it back.
|
|
@@ -338,7 +343,7 @@ export function createWriteVerbs(ctx) {
|
|
|
338
343
|
// failed INSERT back; the error still propagates and stays catchable.
|
|
339
344
|
let result;
|
|
340
345
|
try {
|
|
341
|
-
result = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
346
|
+
result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
342
347
|
}
|
|
343
348
|
catch (e) {
|
|
344
349
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
@@ -362,7 +367,7 @@ export function createWriteVerbs(ctx) {
|
|
|
362
367
|
await projectEntityEvent(db, runner, echoEvent);
|
|
363
368
|
return {
|
|
364
369
|
isSuccess: true,
|
|
365
|
-
data: {
|
|
370
|
+
data: withoutEventInJson({
|
|
366
371
|
kind: "save",
|
|
367
372
|
id: aggregateId,
|
|
368
373
|
data: projection,
|
|
@@ -373,7 +378,7 @@ export function createWriteVerbs(ctx) {
|
|
|
373
378
|
// Persisted event carries ciphertext by design — the caller-facing
|
|
374
379
|
// echo must be plaintext like every other response field (#820).
|
|
375
380
|
event: echoEvent,
|
|
376
|
-
},
|
|
381
|
+
}),
|
|
377
382
|
};
|
|
378
383
|
},
|
|
379
384
|
async update(payload, user, db, updateOptions) {
|
|
@@ -560,7 +565,7 @@ export function createWriteVerbs(ctx) {
|
|
|
560
565
|
// failed nested one throws 500 instead of failing cleanly.
|
|
561
566
|
let result;
|
|
562
567
|
try {
|
|
563
|
-
result = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
568
|
+
result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
564
569
|
}
|
|
565
570
|
catch (e) {
|
|
566
571
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
@@ -586,7 +591,7 @@ export function createWriteVerbs(ctx) {
|
|
|
586
591
|
await projectEntityEvent(db, runner, echoEvent);
|
|
587
592
|
return {
|
|
588
593
|
isSuccess: true,
|
|
589
|
-
data: {
|
|
594
|
+
data: withoutEventInJson({
|
|
590
595
|
kind: "save",
|
|
591
596
|
id: data["id"], // @cast-boundary engine-payload
|
|
592
597
|
data,
|
|
@@ -595,7 +600,7 @@ export function createWriteVerbs(ctx) {
|
|
|
595
600
|
isNew: false,
|
|
596
601
|
entityName,
|
|
597
602
|
event: echoEvent,
|
|
598
|
-
},
|
|
603
|
+
}),
|
|
599
604
|
};
|
|
600
605
|
}
|
|
601
606
|
catch (e) {
|
|
@@ -688,7 +693,7 @@ export function createWriteVerbs(ctx) {
|
|
|
688
693
|
throw e;
|
|
689
694
|
}
|
|
690
695
|
// Live==Rebuild via applyEntityEvent. Savepoint like create/update.
|
|
691
|
-
const deleteResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
696
|
+
const deleteResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
692
697
|
if (deleteResult.kind !== "applied") {
|
|
693
698
|
return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
|
|
694
699
|
}
|
|
@@ -699,14 +704,14 @@ export function createWriteVerbs(ctx) {
|
|
|
699
704
|
await projectEntityEvent(db, runner, echoEvent);
|
|
700
705
|
return {
|
|
701
706
|
isSuccess: true,
|
|
702
|
-
data: {
|
|
707
|
+
data: withoutEventInJson({
|
|
703
708
|
kind: "delete",
|
|
704
709
|
id: payload.id,
|
|
705
710
|
data: existing,
|
|
706
711
|
entityName,
|
|
707
712
|
event: echoEvent,
|
|
708
713
|
...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
|
|
709
|
-
},
|
|
714
|
+
}),
|
|
710
715
|
};
|
|
711
716
|
},
|
|
712
717
|
// Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
|
|
@@ -772,7 +777,7 @@ export function createWriteVerbs(ctx) {
|
|
|
772
777
|
}
|
|
773
778
|
throw e;
|
|
774
779
|
}
|
|
775
|
-
const forgetResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
780
|
+
const forgetResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
776
781
|
if (forgetResult.kind !== "applied") {
|
|
777
782
|
return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
|
|
778
783
|
}
|
|
@@ -783,14 +788,14 @@ export function createWriteVerbs(ctx) {
|
|
|
783
788
|
await projectEntityEvent(db, runner, echoEvent);
|
|
784
789
|
return {
|
|
785
790
|
isSuccess: true,
|
|
786
|
-
data: {
|
|
791
|
+
data: withoutEventInJson({
|
|
787
792
|
kind: "delete",
|
|
788
793
|
id: payload.id,
|
|
789
794
|
data: existing,
|
|
790
795
|
entityName,
|
|
791
796
|
event: echoEvent,
|
|
792
797
|
lockGuardBypassed: true,
|
|
793
|
-
},
|
|
798
|
+
}),
|
|
794
799
|
};
|
|
795
800
|
},
|
|
796
801
|
async restore(payload, user, db, options) {
|
|
@@ -840,7 +845,7 @@ export function createWriteVerbs(ctx) {
|
|
|
840
845
|
// Soft-delete unique indexes are partial (WHERE is_deleted = false), so a live row may have re-taken the value this restore brings back.
|
|
841
846
|
let restoreResult;
|
|
842
847
|
try {
|
|
843
|
-
restoreResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
848
|
+
restoreResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
844
849
|
}
|
|
845
850
|
catch (e) {
|
|
846
851
|
const mapped = tryMapUniqueViolation(e, entityName);
|
|
@@ -865,7 +870,7 @@ export function createWriteVerbs(ctx) {
|
|
|
865
870
|
await projectEntityEvent(db, runner, echoEvent);
|
|
866
871
|
return {
|
|
867
872
|
isSuccess: true,
|
|
868
|
-
data: {
|
|
873
|
+
data: withoutEventInJson({
|
|
869
874
|
kind: "save",
|
|
870
875
|
id: payload.id,
|
|
871
876
|
data: restoredHydrated,
|
|
@@ -874,7 +879,7 @@ export function createWriteVerbs(ctx) {
|
|
|
874
879
|
isNew: false,
|
|
875
880
|
entityName,
|
|
876
881
|
event: echoEvent,
|
|
877
|
-
},
|
|
882
|
+
}),
|
|
878
883
|
};
|
|
879
884
|
},
|
|
880
885
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { AccessDeniedError } from "../errors/index.ts";
|
|
2
|
+
export declare function markExecutorManagedTable(table: object): void;
|
|
3
|
+
export declare function isExecutorManagedTable(table: unknown): boolean;
|
|
4
|
+
export declare function registerExecutorManagedWriteRunner(runner: object): void;
|
|
5
|
+
export declare function hasExecutorManagedWriteCapability(runner: unknown): boolean;
|
|
6
|
+
export declare function executorManagedWriteDenied(runner: unknown, table: unknown, operation: string): AccessDeniedError | undefined;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AccessDeniedError } from "../errors/index.js";
|
|
2
|
+
import { asEntityTableMeta, tableNameOf } from "./table-introspection.js";
|
|
3
|
+
const executorManagedTables = new WeakSet();
|
|
4
|
+
const executorManagedWriteRunners = new WeakSet();
|
|
5
|
+
export function markExecutorManagedTable(table) {
|
|
6
|
+
executorManagedTables.add(table);
|
|
7
|
+
const meta = asEntityTableMeta(table);
|
|
8
|
+
if (meta !== undefined)
|
|
9
|
+
executorManagedTables.add(meta);
|
|
10
|
+
}
|
|
11
|
+
export function isExecutorManagedTable(table) {
|
|
12
|
+
if (typeof table !== "object" || table === null)
|
|
13
|
+
return false;
|
|
14
|
+
if (executorManagedTables.has(table))
|
|
15
|
+
return true;
|
|
16
|
+
const meta = asEntityTableMeta(table);
|
|
17
|
+
return meta !== undefined && executorManagedTables.has(meta);
|
|
18
|
+
}
|
|
19
|
+
export function registerExecutorManagedWriteRunner(runner) {
|
|
20
|
+
executorManagedWriteRunners.add(runner);
|
|
21
|
+
}
|
|
22
|
+
// WeakSet.has never reads a property off runner, so a fail-closed guard Proxy stays untouched.
|
|
23
|
+
// postgres.js clients are callable (tagged templates), so a runner can be a function.
|
|
24
|
+
export function hasExecutorManagedWriteCapability(runner) {
|
|
25
|
+
if (typeof runner !== "object" && typeof runner !== "function")
|
|
26
|
+
return false;
|
|
27
|
+
return runner !== null && executorManagedWriteRunners.has(runner);
|
|
28
|
+
}
|
|
29
|
+
export function executorManagedWriteDenied(runner, table, operation) {
|
|
30
|
+
if (!isExecutorManagedTable(table))
|
|
31
|
+
return undefined;
|
|
32
|
+
if (hasExecutorManagedWriteCapability(runner))
|
|
33
|
+
return undefined;
|
|
34
|
+
const tableName = tableNameOf(table);
|
|
35
|
+
return new AccessDeniedError({
|
|
36
|
+
message: `${operation}(${tableName}): "${tableName}" is fed by events (r.entity / r.projection). ` +
|
|
37
|
+
"Write it through the entity executor (r.crud, ctx.write, createEventStoreExecutor) or inside a " +
|
|
38
|
+
"projection apply; a direct write drifts from the event stream and a rebuild drops or resurrects the row.",
|
|
39
|
+
});
|
|
40
|
+
}
|
package/dist/db/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { applyEntityEvent } from "./apply-entity-event.ts";
|
|
1
2
|
export { assertExistsIn } from "./assert-exists-in.ts";
|
|
2
3
|
export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.ts";
|
|
3
4
|
export { collectTableMetas } from "./collect-table-metas.ts";
|
|
@@ -14,7 +15,7 @@ export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences,
|
|
|
14
15
|
export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.ts";
|
|
15
16
|
export { entityTableFromRegistry } from "./entity-table-from-registry.ts";
|
|
16
17
|
export type { BuildEntityTableMetaOptions, ColumnMeta, CompositePrimaryKeyMeta, EntityTableMeta, IndexMeta, PgType, UnmanagedTableInput, } from "./entity-table-meta.ts";
|
|
17
|
-
export {
|
|
18
|
+
export { defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.ts";
|
|
18
19
|
export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, LockGuardOption, WritePurpose, } from "./event-store-executor.ts";
|
|
19
20
|
export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.ts";
|
|
20
21
|
export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.ts";
|
|
@@ -24,6 +25,7 @@ export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineM
|
|
|
24
25
|
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.ts";
|
|
25
26
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.ts";
|
|
26
27
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.ts";
|
|
28
|
+
export { readDatabaseNow } from "./queries/database-now.ts";
|
|
27
29
|
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.ts";
|
|
28
30
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.ts";
|
|
29
31
|
export type { DeleteManyBatchedOptions, DeleteManyBatchedResult, IncrementCounterOptions, InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, UpsertOnConflictOptions, WhereObject, WhereOperator, WhereValue, } from "./query.ts";
|
|
@@ -36,4 +38,4 @@ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFramework
|
|
|
36
38
|
export { tableExists } from "./schema-inspection.ts";
|
|
37
39
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
|
|
38
40
|
export type { CreateTenantDbOptions, OutsideTransactionContext, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
|
|
39
|
-
export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
|
|
41
|
+
export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
|
package/dist/db/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { applyEntityEvent } from "./apply-entity-event.js";
|
|
1
2
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
3
|
export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
4
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
@@ -9,7 +10,7 @@ export { bigint, bigserial, boolean, extractTableName, index, instant, instantTo
|
|
|
9
10
|
export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "./eagerload.js";
|
|
10
11
|
export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.js";
|
|
11
12
|
export { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
12
|
-
export {
|
|
13
|
+
export { defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
|
|
13
14
|
export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
|
|
14
15
|
export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
|
|
15
16
|
export { flattenLocatedTimestamp, rehydrateLocatedTimestamp, } from "./located-timestamp.js";
|
|
@@ -18,6 +19,7 @@ export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, Migr
|
|
|
18
19
|
export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
19
20
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
|
|
20
21
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
22
|
+
export { readDatabaseNow } from "./queries/database-now.js";
|
|
21
23
|
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
22
24
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
23
25
|
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
|
@@ -28,4 +30,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
|
|
|
28
30
|
export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
29
31
|
export { tableExists } from "./schema-inspection.js";
|
|
30
32
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
31
|
-
export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
|
33
|
+
export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
package/dist/db/money.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type MoneyRead = {
|
|
|
12
12
|
readonly currency: string;
|
|
13
13
|
readonly amountScaled: number;
|
|
14
14
|
/**
|
|
15
|
-
* @deprecated Use `amountScaled` — this name implies ISO-4217 minor units
|
|
15
|
+
* @deprecated Removed in 0.390.0. Use `amountScaled` — this name implies ISO-4217 minor units
|
|
16
16
|
* (cents), which is wrong once a currency needing a different scale than
|
|
17
17
|
* the current flat MINOR_UNIT_SCALE=100 lands (e.g. JPY, 0 decimals).
|
|
18
18
|
* Alias of `amountScaled`, kept until #1976/4 and #1976/5 migrate their
|
|
@@ -62,7 +62,7 @@ import { quoteIdent } from "../../crypto/ciphertext-pattern.js";
|
|
|
62
62
|
import { configuredEventPiiCatalog } from "../../crypto/event-pii.js";
|
|
63
63
|
import { isLocalKeyKmsAdapter, KeyErasedError, KeyNotFoundError, } from "../../crypto/kms-adapter.js";
|
|
64
64
|
import { isPlainUserOwnedField } from "../../crypto/owner-transition-rekey.js";
|
|
65
|
-
import { configuredPiiSubjectKms, decryptPiiValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, piiCiphertextSubject, } from "../../crypto/pii-field-encryption.js";
|
|
65
|
+
import { configuredPiiSubjectKms, decryptPiiValueForSubject, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, piiCiphertextSubject, } from "../../crypto/pii-field-encryption.js";
|
|
66
66
|
import { collectPiiEncryptedFields, nonEmptyString, resolveEventSubject, resolveSubjectForField, SubjectResolutionError, } from "../../crypto/subject-resolver.js";
|
|
67
67
|
import { resolveTableName } from "../entity-table-meta.js";
|
|
68
68
|
import { asRawClient } from "../query.js";
|
|
@@ -343,11 +343,11 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
343
343
|
const value = section[field];
|
|
344
344
|
if (value === null || value === undefined)
|
|
345
345
|
continue;
|
|
346
|
-
// jsonb
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (typeof value !== "string")
|
|
346
|
+
// A jsonb field holds any JSON value; a string that is neither ciphertext nor the
|
|
347
|
+
// sentinel is a plaintext scalar and gets the JSON format (the v2 text format must
|
|
348
|
+
// not reach a jsonb column).
|
|
349
|
+
const isJsonbField = target.entity.fields[field]?.type === "jsonb";
|
|
350
|
+
if (!isJsonbField && typeof value !== "string")
|
|
351
351
|
continue;
|
|
352
352
|
// Already-ciphertext/sentinel fields must never enter the encrypt/erase
|
|
353
353
|
// owner resolution — an unresolvable owner on an already-handled field
|
|
@@ -374,7 +374,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
374
374
|
}
|
|
375
375
|
if (resolution.viaProjection)
|
|
376
376
|
counters.ownerFromProjection++;
|
|
377
|
-
const outcome = await encryptField(section, field, resolution.subject);
|
|
377
|
+
const outcome = await encryptField(section, field, resolution.subject, isJsonbField);
|
|
378
378
|
bump(outcome);
|
|
379
379
|
}
|
|
380
380
|
}
|
|
@@ -405,13 +405,11 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
405
405
|
counters.erased++;
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
|
-
async function encryptField(section, field, subject) {
|
|
408
|
+
async function encryptField(section, field, subject, isJsonbField = false) {
|
|
409
409
|
const value = section[field];
|
|
410
410
|
if (value === null || value === undefined)
|
|
411
411
|
return "unchanged";
|
|
412
|
-
if (
|
|
413
|
-
return "unchanged";
|
|
414
|
-
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL)
|
|
412
|
+
if (!isBackfillCandidate(value, isJsonbField))
|
|
415
413
|
return "unchanged";
|
|
416
414
|
if (await isForgottenSubject(subject, row.aggregate_id)) {
|
|
417
415
|
section[field] = PII_ERASED_SENTINEL;
|
|
@@ -420,7 +418,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
420
418
|
if (options.dryRun)
|
|
421
419
|
return predictEncryptOutcome(subject);
|
|
422
420
|
try {
|
|
423
|
-
section[field] = await
|
|
421
|
+
section[field] = await encryptBackfillValue(subjectKms, subject, value, kmsCtx, field, isJsonbField);
|
|
424
422
|
return "encrypted";
|
|
425
423
|
}
|
|
426
424
|
catch (e) {
|
|
@@ -469,6 +467,18 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
469
467
|
return scanCache.destroyedTenants;
|
|
470
468
|
}
|
|
471
469
|
}
|
|
470
|
+
// Text fields only hold strings; a jsonb field holds any JSON value, scalars included.
|
|
471
|
+
// Ciphertext and the erased sentinel are already handled.
|
|
472
|
+
function isBackfillCandidate(value, isJsonbField) {
|
|
473
|
+
if (typeof value === "string")
|
|
474
|
+
return !isPiiCiphertext(value) && value !== PII_ERASED_SENTINEL;
|
|
475
|
+
return isJsonbField || (typeof value === "object" && value !== null);
|
|
476
|
+
}
|
|
477
|
+
function encryptBackfillValue(subjectKms, subject, value, kmsCtx, field, isJsonbField) {
|
|
478
|
+
return typeof value === "string" && !isJsonbField
|
|
479
|
+
? encryptPiiValueForSubject(subjectKms, subject, value, kmsCtx, field)
|
|
480
|
+
: encryptPiiJsonValueForSubject(subjectKms, subject, value, kmsCtx, field);
|
|
481
|
+
}
|
|
472
482
|
// aggregate_id is only unique per tenant: another tenant's projection row must not name the owner.
|
|
473
483
|
function projectionOwnerRowOfTenant(owner, tenantId) {
|
|
474
484
|
if (owner === undefined)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AnyDb } from "../query.ts";
|
|
2
|
+
import type { TenantDb } from "../tenant-db.ts";
|
|
3
|
+
export declare function queryDatabaseNow(runner: AnyDb): Promise<Date>;
|
|
4
|
+
/** Postgres clock; inside a transaction `now()` is the transaction start, so repeated reads agree. */
|
|
5
|
+
export declare function readDatabaseNow(db: TenantDb): Promise<Date>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { executeRawQueryRead } from "./raw-sql.js";
|
|
2
|
+
// Called by createTenantDb with its inner runner; keeps raw SQL out of consumers.
|
|
3
|
+
export async function queryDatabaseNow(runner) {
|
|
4
|
+
const [row] = await executeRawQueryRead(runner, "SELECT now() AS now");
|
|
5
|
+
if (!row)
|
|
6
|
+
throw new Error("SELECT now() returned no row");
|
|
7
|
+
return row.now;
|
|
8
|
+
}
|
|
9
|
+
/** Postgres clock; inside a transaction `now()` is the transaction start, so repeated reads agree. */
|
|
10
|
+
export function readDatabaseNow(db) {
|
|
11
|
+
return db.databaseNow();
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantId } from "../../engine/types/index.ts";
|
|
2
|
+
import { type AnyDb } from "../query.ts";
|
|
3
|
+
export type DeleteRowsWithoutReplayableStreamArgs = {
|
|
4
|
+
readonly table: unknown;
|
|
5
|
+
readonly ids: readonly string[];
|
|
6
|
+
readonly tenantId?: TenantId;
|
|
7
|
+
};
|
|
8
|
+
export declare function deleteRowsWithoutReplayableStream(db: AnyDb, args: DeleteRowsWithoutReplayableStreamArgs): Promise<readonly string[]>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { asRawClient } from "../query.js";
|
|
2
|
+
import { tableNameOf } from "../table-introspection.js";
|
|
3
|
+
import { isBoundTenantDb, tenantDbWriteRunner } from "../tenant-db-runner.js";
|
|
4
|
+
import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
|
|
5
|
+
import { quoteTableIdent } from "./table-ops.js";
|
|
6
|
+
// Deletes only rows a projection rebuild would not recreate: the row's stream has no events, or is archived.
|
|
7
|
+
// One statement, so a stream cannot gain events between check and delete.
|
|
8
|
+
export async function deleteRowsWithoutReplayableStream(db, args) {
|
|
9
|
+
if (args.ids.length === 0)
|
|
10
|
+
return [];
|
|
11
|
+
const tenantBound = isBoundTenantDb(db);
|
|
12
|
+
const tenantId = tenantBound ? db.tenantId : args.tenantId;
|
|
13
|
+
const runner = tenantBound ? tenantDbWriteRunner(db, "deleteRowsWithoutReplayableStream") : db;
|
|
14
|
+
const params = [toPostgresArrayLiteral(args.ids)];
|
|
15
|
+
let tenantFilter = "";
|
|
16
|
+
if (tenantId !== undefined) {
|
|
17
|
+
params.push(tenantId);
|
|
18
|
+
tenantFilter = `AND t."tenant_id" = $2`;
|
|
19
|
+
}
|
|
20
|
+
const rows = await asRawClient(runner).unsafe(`DELETE FROM ${quoteTableIdent(tableNameOf(args.table))} t
|
|
21
|
+
WHERE t."id" = ANY($1::uuid[]) ${tenantFilter}
|
|
22
|
+
AND NOT EXISTS (
|
|
23
|
+
SELECT 1 FROM "kumiko_events" e
|
|
24
|
+
WHERE e."tenant_id" = t."tenant_id" AND e."aggregate_id" = t."id"
|
|
25
|
+
AND NOT EXISTS (
|
|
26
|
+
SELECT 1 FROM "kumiko_archived_streams" a
|
|
27
|
+
WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
RETURNING t."id"`, params);
|
|
31
|
+
return rows.map((row) => row.id);
|
|
32
|
+
}
|