@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -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 +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -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 +215 -0
- package/agents/examples/ledger-invoicing.md +236 -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 +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -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 +250 -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 +418 -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 +75 -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 +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- 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/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- 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/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- 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 +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -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 +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- 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 +8 -8
- 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/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -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 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- 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 +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- 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 +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- 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/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -15,6 +15,7 @@ import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-p
|
|
|
15
15
|
import { isSelfPiiField } from "../crypto/is-self-pii-field.js";
|
|
16
16
|
import { toSnakeCase } from "../utils/case.js";
|
|
17
17
|
import { resolveTableName } from "./entity-table-meta.js";
|
|
18
|
+
import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
18
19
|
import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
19
20
|
import { columnNamesOf, tableExists } from "./schema-inspection.js";
|
|
20
21
|
export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
@@ -55,7 +56,7 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
|
55
56
|
async function existingTableNames(db, tableNames) {
|
|
56
57
|
if (tableNames.length === 0)
|
|
57
58
|
return new Set();
|
|
58
|
-
const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [tableNames]);
|
|
59
|
+
const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [toPostgresArrayLiteral(tableNames)]);
|
|
59
60
|
return new Set(rows.map((r) => r.name));
|
|
60
61
|
}
|
|
61
62
|
// Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
|
package/dist/db/bun-provider.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// Bun.SQL
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// derselben Connection.
|
|
1
|
+
// Bun.SQL provider, opt-in via DB_PROVIDER=bun; postgres-js stays the default.
|
|
2
|
+
// Known bug (Bun <= 1.4.3, oven-sh/bun#29484): a cached plan is not re-prepared
|
|
3
|
+
// after DDL, so e.g. `SELECT *` after ADD COLUMN fails on that connection with
|
|
4
|
+
// 0A000 "cached plan must not change result type". prepare:false is no fix,
|
|
5
|
+
// it changes how jsonb params are bound.
|
|
7
6
|
//
|
|
8
|
-
// Bun.SQL
|
|
9
|
-
|
|
7
|
+
// LISTEN runs on Bun.SQL's own dedicated connection, which is exempt from the
|
|
8
|
+
// pool's idleTimeout and maxLifetime and re-LISTENs after a drop.
|
|
10
9
|
import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS, } from "./api.js";
|
|
11
10
|
export function createBunConnection(url, options = {}) {
|
|
12
11
|
const bunOpts = {};
|
|
@@ -17,21 +16,13 @@ export function createBunConnection(url, options = {}) {
|
|
|
17
16
|
if (options.connectTimeoutSeconds !== undefined)
|
|
18
17
|
bunOpts.connectionTimeout = options.connectTimeoutSeconds;
|
|
19
18
|
const db = new Bun.SQL(url, bunOpts);
|
|
20
|
-
// LISTEN peer — 1 connection reicht für NOTIFY-Wakeups
|
|
21
|
-
const pgOpts = { max: 1 };
|
|
22
|
-
if (options.idleTimeoutSeconds !== undefined)
|
|
23
|
-
pgOpts.idle_timeout = options.idleTimeoutSeconds;
|
|
24
|
-
if (options.connectTimeoutSeconds !== undefined)
|
|
25
|
-
pgOpts.connect_timeout = options.connectTimeoutSeconds;
|
|
26
|
-
const listenClient = postgres(url, pgOpts);
|
|
27
19
|
const closeTimeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
|
|
28
20
|
return {
|
|
29
21
|
db,
|
|
30
|
-
client:
|
|
31
|
-
listenClient,
|
|
22
|
+
client: db,
|
|
23
|
+
listenClient: db,
|
|
32
24
|
close: async () => {
|
|
33
25
|
await db.end({ timeout: closeTimeout });
|
|
34
|
-
await listenClient.end({ timeout: closeTimeout });
|
|
35
26
|
},
|
|
36
27
|
};
|
|
37
28
|
}
|
|
@@ -15,6 +15,15 @@ function canonicalColumnsKey(meta) {
|
|
|
15
15
|
.map((c) => `${c.name}|${c.pgType}|${c.notNull}|${c.defaultSql ?? ""}|${c.primaryKey ?? false}|${c.identity ?? false}|${c.bigintJsMode ?? ""}`)
|
|
16
16
|
.join("\n");
|
|
17
17
|
}
|
|
18
|
+
function registerPass1Meta(metas, byName, meta, origin) {
|
|
19
|
+
const existing = byName.get(meta.tableName);
|
|
20
|
+
if (existing) {
|
|
21
|
+
throw new Error(`collectTableMetas: table "${meta.tableName}" is registered by ${origin} and ${existing.origin}. ` +
|
|
22
|
+
"Give one of them a different table name.");
|
|
23
|
+
}
|
|
24
|
+
metas.push(meta);
|
|
25
|
+
byName.set(meta.tableName, { meta, origin });
|
|
26
|
+
}
|
|
18
27
|
export function collectTableMetas(features) {
|
|
19
28
|
const metas = [];
|
|
20
29
|
const byName = new Map();
|
|
@@ -37,15 +46,10 @@ export function collectTableMetas(features) {
|
|
|
37
46
|
assertBackingTableSuperset(name, fieldMeta, tableMeta);
|
|
38
47
|
meta = tableMeta;
|
|
39
48
|
}
|
|
40
|
-
metas.
|
|
41
|
-
byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
|
|
49
|
+
registerPass1Meta(metas, byName, meta, `entity "${name}" (${feature.name})`);
|
|
42
50
|
}
|
|
43
51
|
for (const entry of Object.values(feature.storeTables)) {
|
|
44
|
-
metas.
|
|
45
|
-
byName.set(entry.meta.tableName, {
|
|
46
|
-
meta: entry.meta,
|
|
47
|
-
origin: `storeTable "${entry.name}" (${feature.name})`,
|
|
48
|
-
});
|
|
52
|
+
registerPass1Meta(metas, byName, entry.meta, `storeTable "${entry.name}" (${feature.name})`);
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
// Pass 2: table-tragende Registrierungen. Zwei Pässe, damit eine Entity-
|
package/dist/db/dialect.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export declare function instant(name: string, opts?: {
|
|
|
54
54
|
}): ColumnBuilder<Temporal.Instant>;
|
|
55
55
|
export declare function plainDate(name: string): ColumnBuilder<Temporal.PlainDate>;
|
|
56
56
|
export declare const decimalColumn: (name: string, precision: number, scale: number) => ColumnBuilder<number>;
|
|
57
|
-
export declare const moneyAmount: (name: string) => ColumnBuilder<
|
|
57
|
+
export declare const moneyAmount: (name: string) => ColumnBuilder<bigint>;
|
|
58
58
|
export type IndexBuilder = {
|
|
59
59
|
readonly __index: true;
|
|
60
60
|
on(...cols: ColumnHandle[]): IndexBuilderWithCols;
|
package/dist/db/dialect.js
CHANGED
|
@@ -58,6 +58,8 @@ export function pgTypeToSqlType(pgType) {
|
|
|
58
58
|
function _toSnakeCase(name) {
|
|
59
59
|
return name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`).replace(/^_/, "");
|
|
60
60
|
}
|
|
61
|
+
// TValue is never inferred from the factory's declared return type, so a factory that forgets its
|
|
62
|
+
// type argument fails to compile instead of silently typing the column from its own annotation.
|
|
61
63
|
function buildColumn(sqlName, pgType, opts) {
|
|
62
64
|
let notNull = false;
|
|
63
65
|
let primaryKey = false;
|
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";
|
|
@@ -26,6 +26,7 @@ import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
|
26
26
|
import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
27
27
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
28
28
|
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
29
|
+
import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
29
30
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
30
31
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
31
32
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -77,7 +78,7 @@ async function resolveRefAccessScope(refEntity, refTable, refTableName, ids, db,
|
|
|
77
78
|
// No read path exists for non-uuid ids, so nothing can be access-checked.
|
|
78
79
|
if (refEntity.idType !== undefined && refEntity.idType !== "uuid")
|
|
79
80
|
return { kind: "none" };
|
|
80
|
-
const params = [ids];
|
|
81
|
+
const params = [toPostgresArrayLiteral(ids)];
|
|
81
82
|
let tenantClause = "";
|
|
82
83
|
if (refTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
83
84
|
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
@@ -162,7 +163,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
162
163
|
const rawRefRows = scope.kind === "restricted"
|
|
163
164
|
? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
|
|
164
165
|
: lookedUpRows;
|
|
165
|
-
const piiFields =
|
|
166
|
+
const piiFields = collectPiiSubjectFields(refEntity);
|
|
166
167
|
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
167
168
|
const kms = configuredPiiSubjectKms();
|
|
168
169
|
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
|
|
@@ -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
|
} | {
|
|
@@ -63,6 +62,7 @@ export type ExecutorContext = {
|
|
|
63
62
|
subjectSource?: Record<string, unknown>;
|
|
64
63
|
keyTenantId?: TenantId | undefined;
|
|
65
64
|
ownerFallback?: Record<string, unknown>;
|
|
65
|
+
onErasedSubject?: "sentinel";
|
|
66
66
|
}) => Promise<Record<string, unknown>>;
|
|
67
67
|
readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
|
|
68
68
|
readonly ownerTransitionRekeys: (previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>) => Record<string, unknown>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context.js";
|
|
2
|
-
import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
2
|
+
import { collectPiiEncryptedFields, collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
3
3
|
import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
5
|
import { shiftParams } from "../engine/ownership.js";
|
|
@@ -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;
|
|
@@ -210,6 +215,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
210
215
|
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
211
216
|
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
212
217
|
...(opts?.ownerFallback !== undefined && { ownerFallback: opts.ownerFallback }),
|
|
218
|
+
...(opts?.onErasedSubject !== undefined && { onErasedSubject: opts.onErasedSubject }),
|
|
213
219
|
});
|
|
214
220
|
}
|
|
215
221
|
catch (e) {
|
|
@@ -230,8 +236,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
230
236
|
async function decryptForRead(row, opts) {
|
|
231
237
|
let out = row;
|
|
232
238
|
const kms = piiKms();
|
|
233
|
-
if (
|
|
234
|
-
out = await decryptPiiFieldValues(out,
|
|
239
|
+
if (hasPiiDecryptFields && kms) {
|
|
240
|
+
out = await decryptPiiFieldValues(out, piiDecryptFields, kms, kmsContextFor());
|
|
235
241
|
}
|
|
236
242
|
if (hasEncryptedFields) {
|
|
237
243
|
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
|
|
@@ -301,24 +307,6 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
301
307
|
throw new ArchivedStreamError(tenantId, String(id));
|
|
302
308
|
}
|
|
303
309
|
}
|
|
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(schemaTableName(table));
|
|
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
310
|
// Tenant filter + idFilter + ownership predicate as raw SQL, mirroring what
|
|
323
311
|
// TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
|
|
324
312
|
// `isDeleted`, so no null/IS NULL branch is needed.
|
|
@@ -351,6 +339,21 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
351
339
|
return (`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${aggregateIdParam} ` +
|
|
352
340
|
`AND "tenant_id" = $${tenantIdParam}) AS "__streamVersion"`);
|
|
353
341
|
}
|
|
342
|
+
// Cache-hit revalidation: ownership predicate and live stream version in ONE
|
|
343
|
+
// statement, so a cache hit costs the same single roundtrip as the cold path.
|
|
344
|
+
async function probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantId, aggregateId) {
|
|
345
|
+
const tableName = String(schemaTableName(table));
|
|
346
|
+
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
347
|
+
params.push(aggregateId, streamTenantId);
|
|
348
|
+
const sqlText = `SELECT 1 AS "__owned", ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
|
|
349
|
+
`FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
350
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
|
|
351
|
+
const first = rows[0];
|
|
352
|
+
if (!first)
|
|
353
|
+
return { owned: false, streamVersion: 0 };
|
|
354
|
+
const version = first["__streamVersion"];
|
|
355
|
+
return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
|
|
356
|
+
}
|
|
354
357
|
// Row and MAX(version) in ONE statement: two reads can pair a post-write
|
|
355
358
|
// version with a pre-write row, and update({version: detail.version}) would
|
|
356
359
|
// then pass the optimistic lock and overwrite the newer write (lost update).
|
|
@@ -431,7 +434,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
431
434
|
idFilter,
|
|
432
435
|
loadById,
|
|
433
436
|
assertStreamWritable,
|
|
434
|
-
|
|
437
|
+
probeOwnershipWithStreamVersion,
|
|
435
438
|
loadDetailSnapshot,
|
|
436
439
|
loadExpectSnapshot,
|
|
437
440
|
encryptForStorage,
|
|
@@ -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";
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EventStoreExecutor } from "./event-store-executor.js";
|
|
2
2
|
import { type ExecutorContext } from "./event-store-executor-context.js";
|
|
3
3
|
export declare function createWriteVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore">;
|