@cosmicdrift/kumiko-framework 0.378.0 → 0.380.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/apply-entity-event.d.ts +3 -2
- package/dist/db/apply-entity-event.js +1 -1
- package/dist/db/dialect.d.ts +2 -1
- package/dist/db/dialect.js +2 -2
- package/dist/db/entity-table-from-registry.js +0 -2
- package/dist/db/entity-table-meta.d.ts +5 -3
- package/dist/db/entity-table-meta.js +6 -15
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +8 -0
- package/dist/db/executor-managed-tables.js +48 -0
- package/dist/db/index.d.ts +6 -4
- 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.d.ts +22 -11
- package/dist/db/query.js +41 -36
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.d.ts +2 -3
- package/dist/db/table-builder.js +2 -0
- package/dist/db/table-introspection.d.ts +2 -0
- package/dist/db/table-introspection.js +4 -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 +9 -2
- package/dist/db/tenant-db.js +82 -25
- 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/extensions/tenant-data.d.ts +2 -2
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/projection-helpers.d.ts +3 -3
- 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 +4 -2
- 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/es-ops/operations-schema.d.ts +1 -1
- package/dist/event-store/archive.d.ts +1 -1
- package/dist/event-store/events-schema.d.ts +1 -1
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
- package/dist/event-store/snapshot.d.ts +1 -1
- package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
- 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/files/storage-tracking.d.ts +1 -1
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/internal/testing.d.ts +7 -0
- package/dist/internal/testing.js +14 -1
- package/dist/migrations/pending-rebuilds.d.ts +1 -1
- package/dist/migrations/pii-event-backfill.d.ts +1 -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/event-consumer-state.d.ts +1 -1
- package/dist/pipeline/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- package/dist/pipeline/projection-state.d.ts +1 -1
- 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 +117 -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,6 +1,7 @@
|
|
|
1
|
+
import type { ExecutorManagedRunner } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
1
2
|
import type { EntityDefinition } from "../engine/types/index.ts";
|
|
2
3
|
import type { StoredEvent } from "../event-store/index.ts";
|
|
3
|
-
import type { DbRow
|
|
4
|
+
import type { DbRow } from "./connection.ts";
|
|
4
5
|
import type { TableColumns } from "./dialect.ts";
|
|
5
6
|
type Table = TableColumns<any>;
|
|
6
7
|
export type AutoVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
|
|
@@ -17,5 +18,5 @@ export declare function parseAutoVerb(event: StoredEvent): AutoVerb | null;
|
|
|
17
18
|
/** Idempotente Anwendung eines Auto-Events auf die Entity-Tabelle.
|
|
18
19
|
* Wird sowohl beim Live-Append (innerhalb der Write-TX) als auch beim
|
|
19
20
|
* Rebuild (innerhalb der Rebuild-TX) gerufen — identische Logik. */
|
|
20
|
-
export declare function applyEntityEvent(event: StoredEvent, table: Table, entity: EntityDefinition, tx:
|
|
21
|
+
export declare function applyEntityEvent(event: StoredEvent, table: Table, entity: EntityDefinition, tx: ExecutorManagedRunner): Promise<ApplyResult>;
|
|
21
22
|
export {};
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// (DEK erased → Wert unlesbar, bidx NULL). Load-bearing Test:
|
|
15
15
|
// db/__tests__/implicit-projection-equivalence.integration.test.ts.
|
|
16
16
|
//
|
|
17
|
-
// Tenant isolation: applyEntityEvent expects
|
|
17
|
+
// Tenant isolation: applyEntityEvent expects an ExecutorManagedRunner (tx or pool), NOT
|
|
18
18
|
// a TenantDb wrapper. Two sources keep it safe:
|
|
19
19
|
// 1. The live path reads the target row through the tenant-scoped TenantDb
|
|
20
20
|
// before writing — loadById() for update/delete/forget, db.fetchOne() for
|
package/dist/db/dialect.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WritableSchemaTable } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
1
2
|
import { type ColumnHandle, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
4
|
import type { PgType } from "./entity-table-meta.ts";
|
|
@@ -94,7 +95,7 @@ export type ColumnMap = Record<string, ColumnBuilder<unknown>>;
|
|
|
94
95
|
type IndexOrPk = IndexBuilderWithCols | PrimaryKeyDescriptor;
|
|
95
96
|
export declare function table<TCols extends ColumnMap>(tableName: string, cols: TCols, optsFn?: (t: {
|
|
96
97
|
[K in keyof TCols]: ColumnHandle;
|
|
97
|
-
}) => Record<string, IndexOrPk> | ReadonlyArray<IndexOrPk>):
|
|
98
|
+
}) => Record<string, IndexOrPk> | ReadonlyArray<IndexOrPk>): WritableSchemaTable;
|
|
98
99
|
/** Reads the `kumiko:schema:Name` symbol from a table object.
|
|
99
100
|
* Throws with `context` in the message so callers don't have to duplicate the guard. */
|
|
100
101
|
export declare function extractTableName(table: unknown, context?: string): string;
|
package/dist/db/dialect.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// crashed on timestamptz writes without it (#1480).
|
|
19
19
|
import { KUMIKO_COLUMNS_SYMBOL, KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
20
20
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
21
|
-
import { schemaTableName } from "./table-introspection.js";
|
|
21
|
+
import { brandWritable, schemaTableName } from "./table-introspection.js";
|
|
22
22
|
function isNumericPgType(t) {
|
|
23
23
|
return t.startsWith("numeric(");
|
|
24
24
|
}
|
|
@@ -376,7 +376,7 @@ export function table(tableName, cols, optsFn) {
|
|
|
376
376
|
[KUMIKO_COLUMNS_SYMBOL]: handles,
|
|
377
377
|
[KUMIKO_META_SYMBOL]: base,
|
|
378
378
|
});
|
|
379
|
-
return out;
|
|
379
|
+
return brandWritable(out);
|
|
380
380
|
}
|
|
381
381
|
/** Reads the `kumiko:schema:Name` symbol from a table object.
|
|
382
382
|
* Throws with `context` in the message so callers don't have to duplicate the guard. */
|
|
@@ -17,8 +17,6 @@ export function entityTableFromRegistry(registry, entityName, entity) {
|
|
|
17
17
|
continue;
|
|
18
18
|
if (projection.source !== entityName)
|
|
19
19
|
continue;
|
|
20
|
-
// @cast-boundary projection-table — ProjectionDefinition.table is the
|
|
21
|
-
// untyped table object every projection registrar accepts.
|
|
22
20
|
return projection.table;
|
|
23
21
|
}
|
|
24
22
|
return buildEntityTable(entityName, entity);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Writable } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
1
2
|
import type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
2
3
|
import type { EntityDefinition } from "../engine/types/index.ts";
|
|
3
4
|
import type { BuildEntityTableMetaOptions, EntityTableMeta, UnmanagedTableInput } from "./entity-table-meta-types.ts";
|
|
@@ -12,9 +13,10 @@ export declare function resolveTableName(entityName: string, entity: EntityDefin
|
|
|
12
13
|
* Named `derive*` (not `build*Meta`) so it is not mistaken for the unmanaged
|
|
13
14
|
* escape hatch (#1208).
|
|
14
15
|
*/
|
|
16
|
+
export declare function deriveEntityTableMeta(entityName: string, entity: EntityDefinition, options: BuildEntityTableMetaOptions & {
|
|
17
|
+
readonly source: "unmanaged";
|
|
18
|
+
}): EntityTableMeta & Writable;
|
|
15
19
|
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
20
|
export declare function assertBackingTableSuperset(entityName: string, fieldMeta: EntityTableMeta, tableMeta: EntityTableMeta): void;
|
|
19
21
|
/**
|
|
20
22
|
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
@@ -27,4 +29,4 @@ export declare function assertBackingTableSuperset(entityName: string, fieldMeta
|
|
|
27
29
|
*/
|
|
28
30
|
export declare function defineUnmanagedTable<const T extends EntityTenancy = "tenant">(input: UnmanagedTableInput & {
|
|
29
31
|
readonly tenancy?: T;
|
|
30
|
-
}): EntityTableMeta & TenancyBrand<T
|
|
32
|
+
}): EntityTableMeta & TenancyBrand<T> & Writable;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { collectPiiEncryptedFields, isRecordKeyedField } from "../crypto/index.js";
|
|
19
19
|
import { isSqlExpression } from "./dialect.js";
|
|
20
20
|
import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
21
|
+
import { brandWritable } from "./table-introspection.js";
|
|
21
22
|
// Standard base columns for event-sourced read-model tables. Mirrors
|
|
22
23
|
// `buildBaseColumns()` from table-builder.ts (drizzle variant).
|
|
23
24
|
function fullBaseColumns(idType, softDelete) {
|
|
@@ -206,20 +207,11 @@ export function resolveTableName(entityName, entity, featureName) {
|
|
|
206
207
|
}
|
|
207
208
|
return `${featureName}_${baseName}`;
|
|
208
209
|
}
|
|
209
|
-
/**
|
|
210
|
-
* Derive EntityTableMeta from an EntityDefinition (base columns + field DDL).
|
|
211
|
-
* Defaults to `source: "managed"` (rebuildable projection). For direct-write
|
|
212
|
-
* stores pass `{ source: "unmanaged" }` and use a non-`read_` table name
|
|
213
|
-
* (convention: `store_*`) — or build columns by hand with `defineUnmanagedTable`.
|
|
214
|
-
*
|
|
215
|
-
* Named `derive*` (not `build*Meta`) so it is not mistaken for the unmanaged
|
|
216
|
-
* escape hatch (#1208).
|
|
217
|
-
*/
|
|
218
210
|
export function deriveEntityTableMeta(entityName, entity, options) {
|
|
219
211
|
const tableName = resolveTableName(entityName, entity, options?.featureName);
|
|
220
212
|
const source = options?.source ?? "managed";
|
|
221
213
|
if (source === "unmanaged") {
|
|
222
|
-
assertUnmanagedTableName(tableName, "deriveEntityTableMeta
|
|
214
|
+
assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
|
|
223
215
|
}
|
|
224
216
|
const idType = entity.idType ?? "uuid";
|
|
225
217
|
if (source === "unmanaged" && idType === "serial") {
|
|
@@ -345,7 +337,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
345
337
|
}
|
|
346
338
|
}
|
|
347
339
|
const piiSubjectFields = collectPiiEncryptedFields(entity);
|
|
348
|
-
|
|
340
|
+
const meta = {
|
|
349
341
|
tableName,
|
|
350
342
|
columns,
|
|
351
343
|
indexes,
|
|
@@ -353,9 +345,8 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
353
345
|
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
354
346
|
...(entity.tenancy === "global" && { tenancy: "global" }),
|
|
355
347
|
};
|
|
348
|
+
return source === "unmanaged" ? brandWritable(meta) : meta;
|
|
356
349
|
}
|
|
357
|
-
/** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
|
|
358
|
-
export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
359
350
|
function sqlExpressionText(where) {
|
|
360
351
|
return isSqlExpression(where) ? where.text : undefined;
|
|
361
352
|
}
|
|
@@ -407,7 +398,7 @@ export function defineUnmanagedTable(input) {
|
|
|
407
398
|
"have a tenant_id column — a global table's rows carry the system tenant, not a per-row " +
|
|
408
399
|
"tenant identity.");
|
|
409
400
|
}
|
|
410
|
-
return {
|
|
401
|
+
return brandWritable({
|
|
411
402
|
tableName: input.tableName,
|
|
412
403
|
columns: input.columns,
|
|
413
404
|
indexes: input.indexes ?? [],
|
|
@@ -417,7 +408,7 @@ export function defineUnmanagedTable(input) {
|
|
|
417
408
|
source: "unmanaged",
|
|
418
409
|
...(input.tenancy === "global" && { tenancy: "global" }),
|
|
419
410
|
// @cast-boundary phantom-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
|
|
420
|
-
};
|
|
411
|
+
});
|
|
421
412
|
}
|
|
422
413
|
function assertUnmanagedTableName(tableName, via) {
|
|
423
414
|
if (tableName.startsWith("read_")) {
|
|
@@ -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,8 @@
|
|
|
1
|
+
import type { ExecutorManagedWriteCapability, Writable } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
2
|
+
import { AccessDeniedError } from "../errors/index.ts";
|
|
3
|
+
export declare function markExecutorManagedTable(table: object): void;
|
|
4
|
+
export declare function isExecutorManagedTable(table: unknown): boolean;
|
|
5
|
+
export declare function registerExecutorManagedWriteRunner(runner: object): void;
|
|
6
|
+
export declare function hasExecutorManagedWriteCapability(runner: unknown): runner is ExecutorManagedWriteCapability;
|
|
7
|
+
export declare function executorManagedWriteDenied(runner: unknown, table: unknown, operation: string): AccessDeniedError | undefined;
|
|
8
|
+
export declare function assertWritableTable<T extends object>(table: T): asserts table is T & Writable;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AccessDeniedError, InternalError } 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
|
+
}
|
|
41
|
+
// Unmanaged tables are the Writable ones; callers invoke this only after executorManagedWriteDenied passed without a capable runner.
|
|
42
|
+
export function assertWritableTable(table) {
|
|
43
|
+
if (isExecutorManagedTable(table)) {
|
|
44
|
+
throw new InternalError({
|
|
45
|
+
message: `assertWritableTable: "${tableNameOf(table)}" is executor-managed and the runner holds no write capability.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
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";
|
|
@@ -34,6 +36,6 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.ts";
|
|
|
34
36
|
export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.ts";
|
|
35
37
|
export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
|
|
36
38
|
export { tableExists } from "./schema-inspection.ts";
|
|
37
|
-
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
|
|
38
|
-
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";
|
|
39
|
+
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, type ExecutorManagedRunner, type ExecutorManagedWriteCapability, physicalColumnName, toSnakeCase, toTableName, type Writable, type WritableSchemaTable, type WritableTable, } from "./table-builder.ts";
|
|
40
|
+
export type { CreateTenantDbOptions, ExecutorManagedTenantDb, OutsideTransactionContext, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } 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>;
|