@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
package/dist/db/dialect.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type SqlExpression = {
|
|
|
85
85
|
readonly params: readonly unknown[];
|
|
86
86
|
readonly [SQL_EXPR_BRAND]: true;
|
|
87
87
|
};
|
|
88
|
+
export declare function isSqlExpression(value: unknown): value is SqlExpression;
|
|
88
89
|
export declare function sql(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlExpression;
|
|
89
90
|
export declare namespace sql {
|
|
90
91
|
var raw: (text: string) => SqlExpression;
|
package/dist/db/dialect.js
CHANGED
|
@@ -18,6 +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
22
|
function isNumericPgType(t) {
|
|
22
23
|
return t.startsWith("numeric(");
|
|
23
24
|
}
|
|
@@ -76,9 +77,8 @@ function buildColumn(sqlName, pgType, opts) {
|
|
|
76
77
|
return value ? "true" : "false";
|
|
77
78
|
if (typeof value === "bigint")
|
|
78
79
|
return value.toString();
|
|
79
|
-
if (value
|
|
80
|
+
if (isSqlExpression(value))
|
|
80
81
|
return value.text;
|
|
81
|
-
}
|
|
82
82
|
if (typeof value === "function")
|
|
83
83
|
return null; // function-defaults stay JS-side
|
|
84
84
|
// Object/array → jsonb literal
|
|
@@ -256,6 +256,13 @@ export function primaryKey(opts) {
|
|
|
256
256
|
// `{ kind: "sql-expr" }` from a client-controlled schema definition is never
|
|
257
257
|
// spliced into SQL text anywhere.
|
|
258
258
|
export const SQL_EXPR_BRAND = Symbol("sql-expr");
|
|
259
|
+
export function isSqlExpression(value) {
|
|
260
|
+
return (typeof value === "object" &&
|
|
261
|
+
value !== null &&
|
|
262
|
+
SQL_EXPR_BRAND in value &&
|
|
263
|
+
"text" in value &&
|
|
264
|
+
typeof value.text === "string");
|
|
265
|
+
}
|
|
259
266
|
export function sql(strings, ...values) {
|
|
260
267
|
const parts = [];
|
|
261
268
|
const params = [];
|
|
@@ -263,7 +270,7 @@ export function sql(strings, ...values) {
|
|
|
263
270
|
parts.push(strings[i] ?? "");
|
|
264
271
|
if (i < values.length) {
|
|
265
272
|
const v = values[i];
|
|
266
|
-
if (v
|
|
273
|
+
if (isSqlExpression(v)) {
|
|
267
274
|
parts.push(v.text);
|
|
268
275
|
}
|
|
269
276
|
else {
|
|
@@ -279,6 +286,15 @@ sql.raw = (text) => ({
|
|
|
279
286
|
params: [],
|
|
280
287
|
[SQL_EXPR_BRAND]: true,
|
|
281
288
|
});
|
|
289
|
+
function isIndexOrPkList(opts) {
|
|
290
|
+
return Array.isArray(opts);
|
|
291
|
+
}
|
|
292
|
+
function isPrimaryKeyDescriptor(value) {
|
|
293
|
+
return "__pk" in value && value.__pk === true;
|
|
294
|
+
}
|
|
295
|
+
function isIndexBuilderWithCols(value) {
|
|
296
|
+
return "__index" in value && value.__index === true;
|
|
297
|
+
}
|
|
282
298
|
export function table(tableName, cols, optsFn) {
|
|
283
299
|
// Finalise columns + build the column-handle map.
|
|
284
300
|
const handles = {};
|
|
@@ -316,25 +332,24 @@ export function table(tableName, cols, optsFn) {
|
|
|
316
332
|
if (optsFn) {
|
|
317
333
|
const tHandle = handles;
|
|
318
334
|
const opts = optsFn(tHandle);
|
|
319
|
-
const entries =
|
|
335
|
+
const entries = isIndexOrPkList(opts)
|
|
320
336
|
? opts.map((v, i) => [String(i), v])
|
|
321
337
|
: Object.entries(opts);
|
|
322
338
|
for (const [key, value] of entries) {
|
|
323
339
|
if (!value)
|
|
324
340
|
continue;
|
|
325
|
-
if (
|
|
341
|
+
if (isPrimaryKeyDescriptor(value)) {
|
|
326
342
|
compositePrimaryKey = {
|
|
327
343
|
name: value.name ?? `${tableName}_pk`,
|
|
328
344
|
columns: value.columns,
|
|
329
345
|
};
|
|
330
346
|
}
|
|
331
|
-
else if (
|
|
332
|
-
const idx = value;
|
|
347
|
+
else if (isIndexBuilderWithCols(value)) {
|
|
333
348
|
indexes.push({
|
|
334
|
-
name:
|
|
335
|
-
columns:
|
|
336
|
-
...(
|
|
337
|
-
...(
|
|
349
|
+
name: value.name,
|
|
350
|
+
columns: value.columns,
|
|
351
|
+
...(value.unique && { unique: true }),
|
|
352
|
+
...(value.whereSql !== undefined && { whereSql: value.whereSql }),
|
|
338
353
|
});
|
|
339
354
|
}
|
|
340
355
|
else {
|
|
@@ -367,8 +382,8 @@ export function extractTableName(table, context = "extractTableName") {
|
|
|
367
382
|
if (typeof table !== "object" || table === null) {
|
|
368
383
|
throw new Error(`${context}: table is not an object`);
|
|
369
384
|
}
|
|
370
|
-
const name = table
|
|
371
|
-
if (
|
|
385
|
+
const name = schemaTableName(table);
|
|
386
|
+
if (name === undefined) {
|
|
372
387
|
throw new Error(`${context}: table missing kumiko:schema:Name symbol`);
|
|
373
388
|
}
|
|
374
389
|
return name;
|
package/dist/db/eagerload.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// Custom handlers must pass the viewer explicitly so the parentRef gate is
|
|
19
19
|
// never skipped silently.
|
|
20
20
|
import { requestContext } from "../api/request-context.js";
|
|
21
|
-
import {
|
|
21
|
+
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
|
|
22
22
|
import { selectMany } from "../db/query.js";
|
|
23
23
|
import { filterReadFields } from "../engine/field-access.js";
|
|
24
24
|
import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
|
|
@@ -162,7 +162,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
162
162
|
const rawRefRows = scope.kind === "restricted"
|
|
163
163
|
? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
|
|
164
164
|
: lookedUpRows;
|
|
165
|
-
const piiFields =
|
|
165
|
+
const piiFields = collectPiiSubjectFields(refEntity);
|
|
166
166
|
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
167
167
|
const kms = configuredPiiSubjectKms();
|
|
168
168
|
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
|
|
@@ -17,7 +17,7 @@ export function entityTableFromRegistry(registry, entityName, entity) {
|
|
|
17
17
|
continue;
|
|
18
18
|
if (projection.source !== entityName)
|
|
19
19
|
continue;
|
|
20
|
-
// @cast-boundary
|
|
20
|
+
// @cast-boundary projection-table — ProjectionDefinition.table is the
|
|
21
21
|
// untyped table object every projection registrar accepts.
|
|
22
22
|
return projection.table;
|
|
23
23
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// hand-built path; app author owns tenant scoping / versioning. Prefer a
|
|
17
17
|
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
18
18
|
import { collectPiiEncryptedFields } from "../crypto/index.js";
|
|
19
|
-
import {
|
|
19
|
+
import { isSqlExpression } from "./dialect.js";
|
|
20
20
|
import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
21
21
|
// Standard base columns for event-sourced read-model tables. Mirrors
|
|
22
22
|
// `buildBaseColumns()` from table-builder.ts (drizzle variant).
|
|
@@ -226,9 +226,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
226
226
|
// Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
|
|
227
227
|
// feature.entities), yet encryptForDirectWrite still keys recordOwned fields as
|
|
228
228
|
// `record:<entity>:<id>`; forgetSubject requires a UUID, so the value could never be shredded.
|
|
229
|
-
const recordOwnedField = Object.entries(entity.fields).find(
|
|
230
|
-
// @cast-boundary schema-walk — PII flags live on the resolved field annotation
|
|
231
|
-
([, field]) => field.recordOwned === true);
|
|
229
|
+
const recordOwnedField = Object.entries(entity.fields).find(([, field]) => "recordOwned" in field && field.recordOwned === true);
|
|
232
230
|
if (recordOwnedField) {
|
|
233
231
|
throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
|
|
234
232
|
}
|
|
@@ -302,7 +300,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
302
300
|
// Explicitly declared indexes (EntityIndexDef). `def.where` is a
|
|
303
301
|
// SqlExpression (`sql\`…\`` from @cosmicdrift/kumiko-framework/db) —
|
|
304
302
|
// renderable via `.text`. Unknown where-shapes stay needsManualWhere.
|
|
305
|
-
for (const def of
|
|
303
|
+
for (const def of entity.indexes ?? []) {
|
|
306
304
|
const cols = def.columns.map((fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName));
|
|
307
305
|
const suffix = def.unique === true ? "unique" : "idx";
|
|
308
306
|
const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
|
|
@@ -358,13 +356,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
358
356
|
/** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
|
|
359
357
|
export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
360
358
|
function sqlExpressionText(where) {
|
|
361
|
-
|
|
362
|
-
where !== null &&
|
|
363
|
-
SQL_EXPR_BRAND in where &&
|
|
364
|
-
typeof where.text === "string") {
|
|
365
|
-
return where.text;
|
|
366
|
-
}
|
|
367
|
-
return undefined;
|
|
359
|
+
return isSqlExpression(where) ? where.text : undefined;
|
|
368
360
|
}
|
|
369
361
|
// Validates that a backing Drizzle table (declared via `r.entity(name, def,
|
|
370
362
|
// { table })`) is a SUPERSET of the field-derived meta: every column the
|
|
@@ -423,7 +415,7 @@ export function defineUnmanagedTable(input) {
|
|
|
423
415
|
}),
|
|
424
416
|
source: "unmanaged",
|
|
425
417
|
...(input.tenancy === "global" && { tenancy: "global" }),
|
|
426
|
-
// @cast-boundary
|
|
418
|
+
// @cast-boundary phantom-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
|
|
427
419
|
};
|
|
428
420
|
}
|
|
429
421
|
function assertUnmanagedTableName(tableName, via) {
|
|
@@ -33,15 +33,14 @@ export type ExecutorContext = {
|
|
|
33
33
|
readonly includeDeleted?: boolean;
|
|
34
34
|
}) => Promise<Record<string, unknown> | null>;
|
|
35
35
|
readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
|
|
36
|
-
readonly
|
|
37
|
-
kind: "pass";
|
|
38
|
-
} | {
|
|
39
|
-
kind: "empty";
|
|
40
|
-
} | {
|
|
36
|
+
readonly probeOwnershipWithStreamVersion: (db: TenantDb, idWhere: WhereObject, ownership: {
|
|
41
37
|
kind: "sql";
|
|
42
38
|
sqlText: string;
|
|
43
39
|
params: readonly unknown[];
|
|
44
|
-
}) => Promise<
|
|
40
|
+
}, streamTenantId: TenantId, aggregateId: string) => Promise<{
|
|
41
|
+
readonly owned: boolean;
|
|
42
|
+
readonly streamVersion: number;
|
|
43
|
+
}>;
|
|
45
44
|
readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
|
|
46
45
|
kind: "pass";
|
|
47
46
|
} | {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
1
|
import { requestContext } from "../api/request-context.js";
|
|
3
|
-
import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
2
|
+
import { collectPiiEncryptedFields, collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
4
3
|
import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
|
|
5
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
6
5
|
import { shiftParams } from "../engine/ownership.js";
|
|
@@ -12,6 +11,7 @@ import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
|
12
11
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
13
12
|
import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
14
13
|
import { toSnakeCase } from "./table-builder.js";
|
|
14
|
+
import { columnHandleName, schemaTableName } from "./table-introspection.js";
|
|
15
15
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
16
16
|
// Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
|
|
17
17
|
// Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
|
|
@@ -173,6 +173,11 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
173
173
|
const hasEncryptedFields = encryptedFields.size > 0;
|
|
174
174
|
const piiSubjectFields = collectPiiEncryptedFields(entity);
|
|
175
175
|
const hasPiiFields = piiSubjectFields.length > 0;
|
|
176
|
+
// Decrypt keeps file-reference fields: rows and event payloads written by
|
|
177
|
+
// the old backfill may hold ciphertext there, and decrypt passes plaintext
|
|
178
|
+
// through untouched.
|
|
179
|
+
const piiDecryptFields = collectPiiSubjectFields(entity);
|
|
180
|
+
const hasPiiDecryptFields = piiDecryptFields.length > 0;
|
|
176
181
|
function fieldCipher() {
|
|
177
182
|
if (options.encryption)
|
|
178
183
|
return options.encryption;
|
|
@@ -230,8 +235,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
230
235
|
async function decryptForRead(row, opts) {
|
|
231
236
|
let out = row;
|
|
232
237
|
const kms = piiKms();
|
|
233
|
-
if (
|
|
234
|
-
out = await decryptPiiFieldValues(out,
|
|
238
|
+
if (hasPiiDecryptFields && kms) {
|
|
239
|
+
out = await decryptPiiFieldValues(out, piiDecryptFields, kms, kmsContextFor());
|
|
235
240
|
}
|
|
236
241
|
if (hasEncryptedFields) {
|
|
237
242
|
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
|
|
@@ -301,29 +306,11 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
301
306
|
throw new ArchivedStreamError(tenantId, String(id));
|
|
302
307
|
}
|
|
303
308
|
}
|
|
304
|
-
// SELECT a row by id with the ownership clause applied at the DB layer.
|
|
305
|
-
// Detail() uses this both on cold path and as a cache-revalidation probe.
|
|
306
|
-
async function loadWithOwnership(db, idWhere, ownership) {
|
|
307
|
-
if (ownership.kind === "empty")
|
|
308
|
-
return [];
|
|
309
|
-
if (ownership.kind === "pass") {
|
|
310
|
-
const row = await db.fetchOne(table, idWhere);
|
|
311
|
-
return row ? [row] : [];
|
|
312
|
-
}
|
|
313
|
-
// ownership has raw SQL — splice it into a raw query alongside the
|
|
314
|
-
// idFilter + tenant-filter that TenantDb would have added.
|
|
315
|
-
const tableName = String(table[KUMIKO_NAME_SYMBOL]);
|
|
316
|
-
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
317
|
-
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
318
|
-
return [
|
|
319
|
-
...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
|
|
320
|
-
];
|
|
321
|
-
}
|
|
322
309
|
// Tenant filter + idFilter + ownership predicate as raw SQL, mirroring what
|
|
323
310
|
// TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
|
|
324
311
|
// `isDeleted`, so no null/IS NULL branch is needed.
|
|
325
312
|
function buildRawIdWhere(db, idWhere, ownership) {
|
|
326
|
-
const colSql = (field) => `"${table
|
|
313
|
+
const colSql = (field) => `"${columnHandleName(table, field) ?? toSnakeCase(field)}"`;
|
|
327
314
|
const whereParts = [];
|
|
328
315
|
const params = [];
|
|
329
316
|
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
@@ -351,13 +338,28 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
351
338
|
return (`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${aggregateIdParam} ` +
|
|
352
339
|
`AND "tenant_id" = $${tenantIdParam}) AS "__streamVersion"`);
|
|
353
340
|
}
|
|
341
|
+
// Cache-hit revalidation: ownership predicate and live stream version in ONE
|
|
342
|
+
// statement, so a cache hit costs the same single roundtrip as the cold path.
|
|
343
|
+
async function probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantId, aggregateId) {
|
|
344
|
+
const tableName = String(schemaTableName(table));
|
|
345
|
+
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
346
|
+
params.push(aggregateId, streamTenantId);
|
|
347
|
+
const sqlText = `SELECT 1 AS "__owned", ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
|
|
348
|
+
`FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
349
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
|
|
350
|
+
const first = rows[0];
|
|
351
|
+
if (!first)
|
|
352
|
+
return { owned: false, streamVersion: 0 };
|
|
353
|
+
const version = first["__streamVersion"];
|
|
354
|
+
return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
|
|
355
|
+
}
|
|
354
356
|
// Row and MAX(version) in ONE statement: two reads can pair a post-write
|
|
355
357
|
// version with a pre-write row, and update({version: detail.version}) would
|
|
356
358
|
// then pass the optimistic lock and overwrite the newer write (lost update).
|
|
357
359
|
async function loadDetailSnapshot(db, idWhere, ownership, streamTenantId, aggregateId) {
|
|
358
360
|
if (ownership.kind === "empty")
|
|
359
361
|
return { row: null, streamVersion: 0 };
|
|
360
|
-
const tableName = String(table
|
|
362
|
+
const tableName = String(schemaTableName(table));
|
|
361
363
|
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
362
364
|
params.push(aggregateId, streamTenantId);
|
|
363
365
|
const sqlText = `SELECT *, ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
|
|
@@ -393,8 +395,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
393
395
|
}
|
|
394
396
|
}
|
|
395
397
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
396
|
-
const columnOf = (field) => quote(table
|
|
397
|
-
const tableName = String(table
|
|
398
|
+
const columnOf = (field) => quote(columnHandleName(table, field) ?? toSnakeCase(field));
|
|
399
|
+
const tableName = String(schemaTableName(table));
|
|
398
400
|
const selectCols = expectKeys.map((key) => `${columnOf(key)} AS ${quote(key)}`);
|
|
399
401
|
const whereParts = [`${columnOf("id")} = $1`];
|
|
400
402
|
const params = [id];
|
|
@@ -431,7 +433,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
431
433
|
idFilter,
|
|
432
434
|
loadById,
|
|
433
435
|
assertStreamWritable,
|
|
434
|
-
|
|
436
|
+
probeOwnershipWithStreamVersion,
|
|
435
437
|
loadDetailSnapshot,
|
|
436
438
|
loadExpectSnapshot,
|
|
437
439
|
encryptForStorage,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
2
|
import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
|
|
3
3
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
@@ -6,7 +6,7 @@ import { coerceRow, extractTableInfo } from "../db/query.js";
|
|
|
6
6
|
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
8
8
|
import { UnprocessableError } from "../errors/index.js";
|
|
9
|
-
import { getStreamVersion
|
|
9
|
+
import { getStreamVersion } from "../event-store/index.js";
|
|
10
10
|
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
@@ -14,6 +14,7 @@ import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-en
|
|
|
14
14
|
import { buildFilterWhere, } from "./event-store-executor-context.js";
|
|
15
15
|
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
16
16
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
17
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
17
18
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
18
19
|
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
19
20
|
// of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
|
|
@@ -57,7 +58,7 @@ function toCursorSortText(value) {
|
|
|
57
58
|
if (value instanceof Date)
|
|
58
59
|
return value.toISOString();
|
|
59
60
|
if (typeof value === "object") {
|
|
60
|
-
const tag = value
|
|
61
|
+
const tag = Reflect.get(value, Symbol.toStringTag);
|
|
61
62
|
if (typeof tag === "string" && tag.startsWith("Temporal."))
|
|
62
63
|
return String(value);
|
|
63
64
|
return JSON.stringify(value);
|
|
@@ -154,9 +155,8 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
|
154
155
|
});
|
|
155
156
|
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
156
157
|
}
|
|
157
|
-
else if (
|
|
158
|
-
|
|
159
|
-
if (valueObj["ne"] === null && Object.keys(valueObj).length === 1) {
|
|
158
|
+
else if (isPlainObject(value)) {
|
|
159
|
+
if (value["ne"] === null && Object.keys(value).length === 1) {
|
|
160
160
|
whereSql.push(`${colSql(field)} IS NOT NULL`);
|
|
161
161
|
continue;
|
|
162
162
|
}
|
|
@@ -362,7 +362,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
362
362
|
return undefined;
|
|
363
363
|
}
|
|
364
364
|
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
365
|
-
const targetTableName = String(targetTable
|
|
365
|
+
const targetTableName = String(schemaTableName(targetTable));
|
|
366
366
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
367
367
|
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
368
368
|
}
|
|
@@ -387,6 +387,7 @@ function referenceTargetTable(name, entity) {
|
|
|
387
387
|
// Projected alongside the row when a reference sort is active, so the keyset
|
|
388
388
|
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
389
389
|
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
390
|
+
const STREAM_VERSION_ALIAS = "__streamVersion";
|
|
390
391
|
// fw#2741 — resolve a `sortable: true` reference field's label inline for
|
|
391
392
|
// ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
|
|
392
393
|
// row per entity row, the read model stays untouched, and the lookup rides the
|
|
@@ -419,7 +420,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
419
420
|
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
420
421
|
if (ownership.kind === "empty")
|
|
421
422
|
return undefined;
|
|
422
|
-
const targetTableName = String(targetTable
|
|
423
|
+
const targetTableName = String(schemaTableName(targetTable));
|
|
423
424
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
424
425
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
425
426
|
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
@@ -448,7 +449,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
448
449
|
};
|
|
449
450
|
}
|
|
450
451
|
export function createReadVerbs(ctx) {
|
|
451
|
-
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter,
|
|
452
|
+
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, probeOwnershipWithStreamVersion, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
|
|
452
453
|
return {
|
|
453
454
|
// list + detail are unchanged from crud-executor — projections are the
|
|
454
455
|
// read-model and serve these queries directly.
|
|
@@ -456,7 +457,7 @@ export function createReadVerbs(ctx) {
|
|
|
456
457
|
const runner = tenantDbRunner(db);
|
|
457
458
|
const { limit, offset } = resolveListPagination(payload);
|
|
458
459
|
const totalCount = payload.totalCount === true;
|
|
459
|
-
const tableName = String(table
|
|
460
|
+
const tableName = String(schemaTableName(table));
|
|
460
461
|
// H.2 — entity-level read ownership, AND-ed with the parent-ref
|
|
461
462
|
// read-gate (join-row entities derive their visibility from a host
|
|
462
463
|
// row's own read path — see db/parent-ref-clause.ts). Decide before
|
|
@@ -644,15 +645,28 @@ export function createReadVerbs(ctx) {
|
|
|
644
645
|
const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
|
|
645
646
|
// Project the resolved label too, so the keyset cursor below carries the
|
|
646
647
|
// value that was ordered by rather than the FK column's UUID.
|
|
647
|
-
const
|
|
648
|
-
?
|
|
649
|
-
: "
|
|
648
|
+
const sortLabelProjection = referenceSortExpr !== undefined && sortSql !== undefined
|
|
649
|
+
? `, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
|
|
650
|
+
: "";
|
|
651
|
+
// Stream version in the same statement as the rows: a separate read could pair a
|
|
652
|
+
// post-write version with a pre-write row, and update({version}) would then pass
|
|
653
|
+
// the optimistic lock against a state the client never saw (see loadDetailSnapshot).
|
|
654
|
+
params.push(streamTenantFor(user));
|
|
655
|
+
const streamVersionProjection = `, (SELECT MAX("version") FROM "kumiko_events" ` +
|
|
656
|
+
`WHERE "aggregate_id" = "${tableName}"."id" AND "tenant_id" = $${params.length}) ` +
|
|
657
|
+
`AS "${STREAM_VERSION_ALIAS}"`;
|
|
658
|
+
const selectList = `*${sortLabelProjection}${streamVersionProjection}`;
|
|
650
659
|
const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
651
660
|
const rawRows = await executeRawQueryRead(runner, listSql, params);
|
|
652
661
|
const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
|
|
662
|
+
// Aliases are query artifacts, not columns of this entity — strip them before
|
|
663
|
+
// coercion so they never reach the caller or the entity cache.
|
|
664
|
+
const streamVersions = rawRows.map((r) => {
|
|
665
|
+
const v = r[STREAM_VERSION_ALIAS];
|
|
666
|
+
delete r[STREAM_VERSION_ALIAS];
|
|
667
|
+
return typeof v === "number" ? v : 0;
|
|
668
|
+
});
|
|
653
669
|
if (referenceSortExpr !== undefined) {
|
|
654
|
-
// The alias is a sort artifact, not a column of this entity — strip it
|
|
655
|
-
// before coercion so it never reaches the caller or the entity cache.
|
|
656
670
|
for (const row of rawRows)
|
|
657
671
|
delete row[SORT_LABEL_ALIAS];
|
|
658
672
|
}
|
|
@@ -668,10 +682,7 @@ export function createReadVerbs(ctx) {
|
|
|
668
682
|
// Stream version is authoritative (see detail()): ctx.appendEvent bumps the
|
|
669
683
|
// stream without touching row.version, so the raw row version would hand
|
|
670
684
|
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
671
|
-
const
|
|
672
|
-
? await getStreamVersions(runner, decryptedRows.map((r) => String(r["id"])), streamTenantFor(user))
|
|
673
|
-
: new Map();
|
|
674
|
-
const rows = decryptedRows.map((r) => withStreamVersion(r, streamVersions.get(String(r["id"])) ?? 0));
|
|
685
|
+
const rows = decryptedRows.map((r, i) => withStreamVersion(r, streamVersions[i] ?? 0));
|
|
675
686
|
// Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
|
|
676
687
|
// detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
|
|
677
688
|
// a cached version that differs from the stream.
|
|
@@ -709,7 +720,7 @@ export function createReadVerbs(ctx) {
|
|
|
709
720
|
};
|
|
710
721
|
},
|
|
711
722
|
async detail(payload, user, db, options) {
|
|
712
|
-
const tableName = String(table
|
|
723
|
+
const tableName = String(schemaTableName(table));
|
|
713
724
|
// H.2 — ownership check, AND-ed with the parent-ref read-gate (see
|
|
714
725
|
// db/parent-ref-clause.ts). `empty` → the user can never see this row
|
|
715
726
|
// regardless of its id. Return null (same shape as "not found", so a
|
|
@@ -730,16 +741,20 @@ export function createReadVerbs(ctx) {
|
|
|
730
741
|
if (entityCache && entityName) {
|
|
731
742
|
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
732
743
|
if (cached) {
|
|
744
|
+
let streamVersion;
|
|
733
745
|
if (ownership.kind === "sql") {
|
|
734
746
|
// Re-check ownership predicate (incl. the parent-ref gate) against
|
|
735
747
|
// the live row — the cache is keyed only by tenant + id, not by
|
|
736
|
-
// role, so a cache hit can't bypass either gate.
|
|
737
|
-
//
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
748
|
+
// role, so a cache hit can't bypass either gate. The probe carries
|
|
749
|
+
// the stream version in the same statement.
|
|
750
|
+
const probe = await probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantFor(user), String(payload.id));
|
|
751
|
+
if (!probe.owned)
|
|
740
752
|
return null;
|
|
753
|
+
streamVersion = probe.streamVersion;
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
|
|
741
757
|
}
|
|
742
|
-
const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
|
|
743
758
|
if (!(streamVersion > 0 && cached["version"] !== streamVersion)) {
|
|
744
759
|
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
745
760
|
// `encrypted` field's plaintext never sits in a second at-rest
|
|
@@ -3,7 +3,6 @@ import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
|
3
3
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
4
4
|
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
5
5
|
import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
|
|
6
|
-
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
7
6
|
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
8
7
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
9
8
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
@@ -12,10 +11,11 @@ import { generateId } from "../utils/index.js";
|
|
|
12
11
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
13
12
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
14
13
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
14
|
+
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.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";
|
|
18
|
-
import {
|
|
18
|
+
import { tenantDbWriteRunner } from "./tenant-db-runner.js";
|
|
19
19
|
// Custom projections for a write that did not go through the dispatcher (hook or
|
|
20
20
|
// job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
|
|
21
21
|
// projection fails the write and the caller's transaction rolls it back.
|
|
@@ -29,12 +29,6 @@ async function projectEntityEvent(db, runner, event) {
|
|
|
29
29
|
return;
|
|
30
30
|
await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
|
|
31
31
|
}
|
|
32
|
-
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
33
|
-
// per-role ownership map can never cover it, and a silent deny means the
|
|
34
|
-
// erasure never happened.
|
|
35
|
-
function isFrameworkSystemUser(user) {
|
|
36
|
-
return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
|
|
37
|
-
}
|
|
38
32
|
// The five write verbs (create/update/delete/forget/restore) of the event-
|
|
39
33
|
// store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
|
|
40
34
|
// behavior-preserving relocation, not a redesign: every closure below is
|
|
@@ -126,9 +120,7 @@ export function createWriteVerbs(ctx) {
|
|
|
126
120
|
// statutory deletion must reach locked rows, and forget (Art. 17) never checks the lock
|
|
127
121
|
// at all. The options are server-code only; no request field is ever copied into them.
|
|
128
122
|
function recordLockedFailure(row, id, user, options) {
|
|
129
|
-
if (options
|
|
130
|
-
(options?.writePurpose !== undefined && isFrameworkSystemUser(user)) ||
|
|
131
|
-
!isRecordLocked(entity.lockedWhen, row)) {
|
|
123
|
+
if (isLockGuardBypassed(user, options) || !isRecordLocked(entity.lockedWhen, row)) {
|
|
132
124
|
return undefined;
|
|
133
125
|
}
|
|
134
126
|
return writeFailure(new RecordLockedError({ entityName, entityId: id }));
|
|
@@ -180,7 +172,7 @@ export function createWriteVerbs(ctx) {
|
|
|
180
172
|
const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
|
|
181
173
|
if (streamTenantMismatch)
|
|
182
174
|
return streamTenantMismatch;
|
|
183
|
-
const runner =
|
|
175
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor create");
|
|
184
176
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
185
177
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
186
178
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
@@ -349,7 +341,7 @@ export function createWriteVerbs(ctx) {
|
|
|
349
341
|
};
|
|
350
342
|
},
|
|
351
343
|
async update(payload, user, db, updateOptions) {
|
|
352
|
-
const runner =
|
|
344
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor update");
|
|
353
345
|
if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
|
|
354
346
|
throw new AccessDeniedError({
|
|
355
347
|
message: `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
|
|
@@ -582,7 +574,7 @@ export function createWriteVerbs(ctx) {
|
|
|
582
574
|
if (isIrreversibleEntityVerb("delete", entity)) {
|
|
583
575
|
assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
|
|
584
576
|
}
|
|
585
|
-
const runner =
|
|
577
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor delete");
|
|
586
578
|
const existing = await loadById(payload.id, db);
|
|
587
579
|
if (!existing)
|
|
588
580
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -664,6 +656,7 @@ export function createWriteVerbs(ctx) {
|
|
|
664
656
|
data: existing,
|
|
665
657
|
entityName,
|
|
666
658
|
event: echoEvent,
|
|
659
|
+
...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
|
|
667
660
|
},
|
|
668
661
|
};
|
|
669
662
|
},
|
|
@@ -673,7 +666,7 @@ export function createWriteVerbs(ctx) {
|
|
|
673
666
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
674
667
|
async forget(payload, user, db, options) {
|
|
675
668
|
assertIrreversibleOperationAllowed(`${entityName}.forget`);
|
|
676
|
-
const runner =
|
|
669
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor forget");
|
|
677
670
|
const raw = await db.fetchOne(table, { id: payload.id });
|
|
678
671
|
if (!raw)
|
|
679
672
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -744,6 +737,7 @@ export function createWriteVerbs(ctx) {
|
|
|
744
737
|
data: existing,
|
|
745
738
|
entityName,
|
|
746
739
|
event: echoEvent,
|
|
740
|
+
lockGuardBypassed: true,
|
|
747
741
|
},
|
|
748
742
|
};
|
|
749
743
|
},
|
|
@@ -753,7 +747,7 @@ export function createWriteVerbs(ctx) {
|
|
|
753
747
|
i18nKey: "errors.softDeleteNotEnabled",
|
|
754
748
|
}));
|
|
755
749
|
}
|
|
756
|
-
const runner =
|
|
750
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
|
|
757
751
|
const streamTenantId = options?.streamTenantId;
|
|
758
752
|
const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
|
|
759
753
|
if ("failure" in target)
|
|
@@ -780,8 +774,13 @@ export function createWriteVerbs(ctx) {
|
|
|
780
774
|
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
781
775
|
// delete for symmetry. Projections that decremented on delete use
|
|
782
776
|
// `previous` to re-increment on restore without re-querying the entity
|
|
783
|
-
// table.
|
|
784
|
-
//
|
|
777
|
+
// table. Rows predating the subject-KMS rollout may still hold plaintext
|
|
778
|
+
// pii, and envelope encryption is not idempotent: decrypt, then
|
|
779
|
+
// re-encrypt as delete does.
|
|
780
|
+
const previousPlain = await decryptForRead(data);
|
|
781
|
+
const previousForStorage = await encryptForStorage(previousPlain, user, {
|
|
782
|
+
keyTenantId: streamTenantId,
|
|
783
|
+
});
|
|
785
784
|
let event;
|
|
786
785
|
try {
|
|
787
786
|
event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
@@ -791,7 +790,7 @@ export function createWriteVerbs(ctx) {
|
|
|
791
790
|
expectedVersion: currentVersion,
|
|
792
791
|
type: entityEventName(entityName, "restored"),
|
|
793
792
|
eventVersion,
|
|
794
|
-
payload: { previous:
|
|
793
|
+
payload: { previous: previousForStorage },
|
|
795
794
|
metadata: buildEventMetadata(user),
|
|
796
795
|
}));
|
|
797
796
|
}
|
|
@@ -835,7 +834,6 @@ export function createWriteVerbs(ctx) {
|
|
|
835
834
|
// same as every other executor method — `data`/`restored` are raw rows
|
|
836
835
|
// (db.fetchOne / applyEntityEvent), never decrypted before this point.
|
|
837
836
|
const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
|
|
838
|
-
const previousPlain = await decryptForRead(data);
|
|
839
837
|
const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
|
|
840
838
|
await projectEntityEvent(db, runner, echoEvent);
|
|
841
839
|
return {
|
package/dist/db/index.d.ts
CHANGED
|
@@ -35,4 +35,4 @@ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFramework
|
|
|
35
35
|
export { tableExists } from "./schema-inspection.js";
|
|
36
36
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
37
37
|
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
|
|
38
|
-
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
|
|
38
|
+
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|