@cosmicdrift/kumiko-framework 0.352.0 → 0.354.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +39 -6
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +7 -1
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +36 -4
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +78 -42
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +10 -2
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +24 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +37 -5
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +21 -1
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +53 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +136 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +33 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +70 -40
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +44 -4
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +13 -3
- package/dist/engine/index.d.ts +9 -3
- package/dist/engine/index.js +7 -2
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +11 -3
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +49 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.js +13 -7
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/files/types.js +21 -1
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +228 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
package/dist/db/eagerload.js
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Server-side eagerload for reference fields.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// `_refs.<fieldName>` (single) bzw. `_refs.<fieldName>: Row[]`
|
|
8
|
-
// (multiple) an die Original-Rows.
|
|
3
|
+
// After `executor.list`/`detail` we scan the returned rows for reference
|
|
4
|
+
// values, collect the UUIDs per reference (deduped), run one
|
|
5
|
+
// WHERE id IN (...) lookup per referenced entity, and attach the resolved rows
|
|
6
|
+
// as `_refs.<fieldName>` (single) or `_refs.<fieldName>: Row[]` (multiple).
|
|
9
7
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
8
|
+
// Access scope: `_refs` only returns what the viewer could read on the target
|
|
9
|
+
// entity itself (tenant, access.read, parentRef gate, field-level read). An
|
|
10
|
+
// unreadable ref is absent exactly like a missing one, so there is no
|
|
11
|
+
// existence oracle; the renderer falls back to the UUID. The target detail
|
|
12
|
+
// handler's handler-level `access` is not evaluated (same as reference
|
|
13
|
+
// search/sort).
|
|
15
14
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// O(n) pro Page (bei pageSize:50 mit 2 ref-Spalten = max 100 IDs).
|
|
19
|
-
// Render-Side limit:200-Workaround entfällt damit komplett.
|
|
15
|
+
// No explicit limit on the lookup SELECT: it asks for exactly the UUIDs present
|
|
16
|
+
// in the main rows, so it is O(n) per page.
|
|
20
17
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// keine framework-engine-Internals und kann auch von custom
|
|
24
|
-
// query-handlern manuell aufgerufen werden.
|
|
18
|
+
// Custom handlers must pass the viewer explicitly so the parentRef gate is
|
|
19
|
+
// never skipped silently.
|
|
25
20
|
import { requestContext } from "../api/request-context.js";
|
|
26
21
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
|
|
27
22
|
import { selectMany } from "../db/query.js";
|
|
28
|
-
import {
|
|
23
|
+
import { filterReadFields } from "../engine/field-access.js";
|
|
24
|
+
import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
|
|
29
25
|
import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
26
|
+
import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
30
27
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
31
|
-
import {
|
|
28
|
+
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
29
|
+
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
30
|
+
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
31
|
+
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
32
32
|
function isReferenceField(field) {
|
|
33
33
|
return field.type === "reference";
|
|
34
34
|
}
|
|
@@ -46,43 +46,12 @@ export function collectReferenceFields(entity) {
|
|
|
46
46
|
return out;
|
|
47
47
|
}
|
|
48
48
|
// Referenced rows are read via a raw selectMany, not the referenced entity's
|
|
49
|
-
// own executor context (
|
|
50
|
-
//
|
|
51
|
-
// would need table/searchAdapter/entityCache wiring for no reason). Mirrors
|
|
49
|
+
// own executor context (that would need table/searchAdapter/entityCache wiring
|
|
50
|
+
// per ref). Access is enforced separately by readableRefIds. Mirrors
|
|
52
51
|
// event-store-executor-context's decryptForRead ordering: PII is the outer
|
|
53
52
|
// layer, peel it before the envelope-encrypted fields, or the envelope
|
|
54
53
|
// cipher chokes on a still-PII-wrapped string.
|
|
55
|
-
|
|
56
|
-
// Ownership guard (fw#1671): the ref lookup below is tenant-scoped only, not
|
|
57
|
-
// ownership-scoped — this function has no SessionUser to evaluate
|
|
58
|
-
// refEntity.access.read against. If the ref entity declares row-level
|
|
59
|
-
// ownership at all, decrypting here would hand a same-tenant User A the
|
|
60
|
-
// plaintext PII of a User B row they merely reference (e.g. a freely-settable
|
|
61
|
-
// reference UUID), even though refEntity.access.read says "own" — the caller
|
|
62
|
-
// never gets to run that ownership check. Fail closed: strip PII/encrypted
|
|
63
|
-
// fields entirely instead of decrypting (or leaking ciphertext) when that
|
|
64
|
-
// guarantee can't be evaluated here.
|
|
65
|
-
function hasOwnershipScopedRead(refEntity) {
|
|
66
|
-
const readMap = refEntity.access?.read;
|
|
67
|
-
if (readMap === undefined)
|
|
68
|
-
return false;
|
|
69
|
-
// "all" means that role sees every row unrestricted — a map where every
|
|
70
|
-
// rule is "all" carries no ownership restriction at all, so stripping
|
|
71
|
-
// here would just silently drop PII/encrypted fields #1667 wants
|
|
72
|
-
// decrypted, for no security benefit.
|
|
73
|
-
return Object.values(readMap).some((rule) => rule !== "all");
|
|
74
|
-
}
|
|
75
|
-
async function decryptReferencedRow(row, refEntity, piiFields, encryptedFields, kms) {
|
|
76
|
-
if (hasOwnershipScopedRead(refEntity)) {
|
|
77
|
-
if (piiFields.length === 0 && encryptedFields.size === 0)
|
|
78
|
-
return row;
|
|
79
|
-
const out = { ...row };
|
|
80
|
-
for (const field of piiFields)
|
|
81
|
-
delete out[field];
|
|
82
|
-
for (const field of encryptedFields)
|
|
83
|
-
delete out[field];
|
|
84
|
-
return out;
|
|
85
|
-
}
|
|
54
|
+
async function decryptReferencedRow(row, piiFields, encryptedFields, kms) {
|
|
86
55
|
let out = row;
|
|
87
56
|
if (piiFields.length > 0 && kms) {
|
|
88
57
|
out = await decryptPiiFieldValues(out, piiFields, kms, {
|
|
@@ -94,20 +63,45 @@ async function decryptReferencedRow(row, refEntity, piiFields, encryptedFields,
|
|
|
94
63
|
}
|
|
95
64
|
return out;
|
|
96
65
|
}
|
|
66
|
+
// One probe query per ref field: the ids the viewer may read on the target
|
|
67
|
+
// entity. Raw SQL because ownership/parentRef clauses are SQL fragments; the
|
|
68
|
+
// table is unaliased since where-rules qualify columns with the table name.
|
|
69
|
+
async function resolveRefAccessScope(refEntity, refTable, refTableName, ids, db, viewer) {
|
|
70
|
+
const access = combineClauses(buildOwnershipClause(viewer.user, refEntity.access?.read, refTable), buildParentRefClause(refEntity, refTable, refTableName, viewer.user, db, viewer.parentVisibility, {
|
|
71
|
+
includeDeleted: false,
|
|
72
|
+
}));
|
|
73
|
+
if (access.kind === "empty")
|
|
74
|
+
return { kind: "none" };
|
|
75
|
+
if (access.kind === "pass")
|
|
76
|
+
return { kind: "unrestricted" };
|
|
77
|
+
// No read path exists for non-uuid ids, so nothing can be access-checked.
|
|
78
|
+
if (refEntity.idType !== undefined && refEntity.idType !== "uuid")
|
|
79
|
+
return { kind: "none" };
|
|
80
|
+
const params = [ids];
|
|
81
|
+
let tenantClause = "";
|
|
82
|
+
if (refTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
83
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
84
|
+
tenantClause = ` AND "${physicalColumnName(refTable, "tenantId")}" IN ($2, $3)`;
|
|
85
|
+
}
|
|
86
|
+
const shifted = shiftParams({ sqlText: access.sqlText, params: access.params }, params.length);
|
|
87
|
+
params.push(...shifted.params);
|
|
88
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), `SELECT "id" FROM "${refTableName}" WHERE "id" = ANY($1::uuid[])${tenantClause} AND ${shifted.sqlText}`, params);
|
|
89
|
+
return { kind: "restricted", readableIds: new Set(rows.map((r) => r.id)) };
|
|
90
|
+
}
|
|
97
91
|
// Per-row, not Promise.all: a single legacy/backfilled row without a valid
|
|
98
92
|
// envelope (decryptEntityFieldValues throws hard on malformed ciphertext)
|
|
99
93
|
// must not 500 the whole list request — the main rows the caller asked for
|
|
100
94
|
// are unrelated to this one broken reference. Drop just that row from the
|
|
101
95
|
// map; the renderer falls back to the raw UUID.
|
|
102
96
|
//
|
|
103
|
-
// piiFields/encryptedFields/kms are constant per refEntity
|
|
97
|
+
// piiFields/encryptedFields/kms are constant per refEntity — the
|
|
104
98
|
// caller computes them once and passes them in instead of recomputing per row.
|
|
105
|
-
async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, piiFields, encryptedFields, kms) {
|
|
99
|
+
async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, viewer, piiFields, encryptedFields, kms) {
|
|
106
100
|
const map = new Map();
|
|
107
101
|
for (const r of rawRefRows) {
|
|
108
102
|
let decrypted;
|
|
109
103
|
try {
|
|
110
|
-
decrypted = await decryptReferencedRow(r,
|
|
104
|
+
decrypted = await decryptReferencedRow(r, piiFields, encryptedFields, kms);
|
|
111
105
|
}
|
|
112
106
|
catch (e) {
|
|
113
107
|
console.warn(`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -115,13 +109,25 @@ async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, piiFields
|
|
|
115
109
|
}
|
|
116
110
|
const id = decrypted["id"];
|
|
117
111
|
if (typeof id === "string")
|
|
118
|
-
map.set(id,
|
|
112
|
+
map.set(id, filterReadFields(refEntity, decrypted, viewer.user));
|
|
119
113
|
}
|
|
120
114
|
return map;
|
|
121
115
|
}
|
|
116
|
+
function collectRefIds(rows, rf) {
|
|
117
|
+
const ids = new Set();
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
const v = row[rf.fieldName];
|
|
120
|
+
const values = rf.multiple ? (Array.isArray(v) ? v : []) : [v];
|
|
121
|
+
for (const item of values) {
|
|
122
|
+
if (typeof item === "string" && item.length > 0)
|
|
123
|
+
ids.add(item);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return ids;
|
|
127
|
+
}
|
|
122
128
|
/** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
|
|
123
129
|
* flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
|
|
124
|
-
export async function enrichWithReferences(rows, entity, resolveEntity, db) {
|
|
130
|
+
export async function enrichWithReferences(rows, entity, resolveEntity, db, viewer) {
|
|
125
131
|
const refFields = collectReferenceFields(entity);
|
|
126
132
|
if (refFields.length === 0 || rows.length === 0) {
|
|
127
133
|
return rows.map((r) => ({ ...r }));
|
|
@@ -130,21 +136,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db) {
|
|
|
130
136
|
// einziger SELECT WHERE id IN (...). Maps werden parallel gebaut
|
|
131
137
|
// damit die Lookups nicht serialisieren (Promise.all).
|
|
132
138
|
const lookupMaps = await Promise.all(refFields.map(async (rf) => {
|
|
133
|
-
const ids =
|
|
134
|
-
for (const row of rows) {
|
|
135
|
-
const v = row[rf.fieldName];
|
|
136
|
-
if (rf.multiple) {
|
|
137
|
-
if (Array.isArray(v)) {
|
|
138
|
-
for (const item of v) {
|
|
139
|
-
if (typeof item === "string" && item.length > 0)
|
|
140
|
-
ids.add(item);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
else if (typeof v === "string" && v.length > 0) {
|
|
145
|
-
ids.add(v);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
139
|
+
const ids = collectRefIds(rows, rf);
|
|
148
140
|
if (ids.size === 0)
|
|
149
141
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
|
|
150
142
|
const refEntity = resolveEntity(rf.refEntityName);
|
|
@@ -156,12 +148,24 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db) {
|
|
|
156
148
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
|
|
157
149
|
}
|
|
158
150
|
const refTable = buildEntityTable(rf.refEntityName, refEntity);
|
|
159
|
-
const
|
|
160
|
-
const
|
|
151
|
+
const refTableName = tableNameOf(refTable);
|
|
152
|
+
const usesUuidIds = refEntity.idType === undefined || refEntity.idType === "uuid";
|
|
153
|
+
// A malformed ref value must not 500 the whole list via the uuid cast.
|
|
154
|
+
const idArray = usesUuidIds ? [...ids].filter(isUuid) : [...ids];
|
|
155
|
+
const emptyLookup = { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
|
|
156
|
+
if (idArray.length === 0)
|
|
157
|
+
return emptyLookup;
|
|
158
|
+
const scope = await resolveRefAccessScope(refEntity, refTable, refTableName, idArray, db, viewer);
|
|
159
|
+
if (scope.kind === "none")
|
|
160
|
+
return emptyLookup;
|
|
161
|
+
const lookedUpRows = (await selectMany(db, refTable, { id: idArray }));
|
|
162
|
+
const rawRefRows = scope.kind === "restricted"
|
|
163
|
+
? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
|
|
164
|
+
: lookedUpRows;
|
|
161
165
|
const piiFields = collectPiiSubjectFields(refEntity);
|
|
162
166
|
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
163
167
|
const kms = configuredPiiSubjectKms();
|
|
164
|
-
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, piiFields, encryptedFields, kms);
|
|
168
|
+
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
|
|
165
169
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map };
|
|
166
170
|
}));
|
|
167
171
|
return rows.map((row) => {
|
|
@@ -186,7 +190,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db) {
|
|
|
186
190
|
});
|
|
187
191
|
}
|
|
188
192
|
/** Single-Row-Variante für detail-Calls. */
|
|
189
|
-
export async function enrichRowWithReferences(row, entity, resolveEntity, db) {
|
|
190
|
-
const enriched = await enrichWithReferences([row], entity, resolveEntity, db);
|
|
193
|
+
export async function enrichRowWithReferences(row, entity, resolveEntity, db, viewer) {
|
|
194
|
+
const enriched = await enrichWithReferences([row], entity, resolveEntity, db, viewer);
|
|
191
195
|
return enriched[0] ?? { ...row, _refs: {} };
|
|
192
196
|
}
|
|
@@ -222,6 +222,17 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
222
222
|
assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
|
|
223
223
|
}
|
|
224
224
|
const idType = entity.idType ?? "uuid";
|
|
225
|
+
if (source === "unmanaged" && idType === "serial") {
|
|
226
|
+
// Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
|
|
227
|
+
// feature.entities), yet encryptForDirectWrite still keys recordOwned fields as
|
|
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);
|
|
232
|
+
if (recordOwnedField) {
|
|
233
|
+
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
|
+
}
|
|
235
|
+
}
|
|
225
236
|
// Base columns first, then user-fields. User-fields with the same
|
|
226
237
|
// pg-name as a base-column OVERRIDE the base-column (last-wins, same
|
|
227
238
|
// behavior as drizzle's `{ ...base, ...fields }` spread in the table
|
|
@@ -34,6 +34,18 @@ export type ExecutorContext = {
|
|
|
34
34
|
sqlText: string;
|
|
35
35
|
params: readonly unknown[];
|
|
36
36
|
}) => Promise<Record<string, unknown>[]>;
|
|
37
|
+
readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
|
|
38
|
+
kind: "pass";
|
|
39
|
+
} | {
|
|
40
|
+
kind: "empty";
|
|
41
|
+
} | {
|
|
42
|
+
kind: "sql";
|
|
43
|
+
sqlText: string;
|
|
44
|
+
params: readonly unknown[];
|
|
45
|
+
}, streamTenantId: TenantId, aggregateId: string) => Promise<{
|
|
46
|
+
readonly row: Record<string, unknown> | null;
|
|
47
|
+
readonly streamVersion: number;
|
|
48
|
+
}>;
|
|
37
49
|
readonly loadExpectSnapshot: (db: TenantDb, id: EntityId, streamTenantId: TenantId, expectKeys: readonly string[]) => Promise<{
|
|
38
50
|
readonly row: Record<string, unknown> | null;
|
|
39
51
|
readonly streamVersion: number;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
2
|
import { requestContext } from "../api/request-context.js";
|
|
3
|
-
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, } from "../crypto/index.js";
|
|
3
|
+
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
5
|
import { shiftParams } from "../engine/ownership.js";
|
|
6
6
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
7
|
-
import { UniqueViolationError, writeFailure } from "../errors/index.js";
|
|
7
|
+
import { InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
|
|
8
8
|
import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
|
|
9
9
|
import { assertUnreachable } from "../utils/index.js";
|
|
10
10
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
@@ -175,12 +175,19 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
175
175
|
}
|
|
176
176
|
const kms = piiKms();
|
|
177
177
|
if (hasPiiFields && kms) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
178
|
+
try {
|
|
179
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
180
|
+
tenantId: user.tenantId,
|
|
181
|
+
entityName,
|
|
182
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
183
|
+
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
if (e instanceof KeyErasedError)
|
|
188
|
+
throw subjectErasedConflict(e);
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
184
191
|
}
|
|
185
192
|
return out;
|
|
186
193
|
}
|
|
@@ -264,6 +271,16 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
264
271
|
// ownership has raw SQL — splice it into a raw query alongside the
|
|
265
272
|
// idFilter + tenant-filter that TenantDb would have added.
|
|
266
273
|
const tableName = String(table[KUMIKO_NAME_SYMBOL]);
|
|
274
|
+
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
275
|
+
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
276
|
+
return [
|
|
277
|
+
...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
// Tenant filter + idFilter + ownership predicate as raw SQL, mirroring what
|
|
281
|
+
// TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
|
|
282
|
+
// `isDeleted`, so no null/IS NULL branch is needed.
|
|
283
|
+
function buildRawIdWhere(db, idWhere, ownership) {
|
|
267
284
|
const colSql = (field) => `"${table[field]?.name ?? toSnakeCase(field)}"`;
|
|
268
285
|
const whereParts = [];
|
|
269
286
|
const params = [];
|
|
@@ -280,40 +297,59 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
280
297
|
whereParts.push(`${colSql(field)} = $${params.length}`);
|
|
281
298
|
}
|
|
282
299
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
];
|
|
300
|
+
if (ownership.kind === "sql") {
|
|
301
|
+
const shifted = shiftParams(ownership, params.length);
|
|
302
|
+
whereParts.push(shifted.sqlText);
|
|
303
|
+
for (const p of shifted.params)
|
|
304
|
+
params.push(p);
|
|
305
|
+
}
|
|
306
|
+
return { whereParts, params };
|
|
291
307
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
308
|
+
function streamVersionSubquerySql(aggregateIdParam, tenantIdParam) {
|
|
309
|
+
return (`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${aggregateIdParam} ` +
|
|
310
|
+
`AND "tenant_id" = $${tenantIdParam}) AS "__streamVersion"`);
|
|
311
|
+
}
|
|
312
|
+
// Row and MAX(version) in ONE statement: two reads can pair a post-write
|
|
313
|
+
// version with a pre-write row, and update({version: detail.version}) would
|
|
314
|
+
// then pass the optimistic lock and overwrite the newer write (lost update).
|
|
315
|
+
async function loadDetailSnapshot(db, idWhere, ownership, streamTenantId, aggregateId) {
|
|
316
|
+
if (ownership.kind === "empty")
|
|
317
|
+
return { row: null, streamVersion: 0 };
|
|
318
|
+
const tableName = String(table[KUMIKO_NAME_SYMBOL]);
|
|
319
|
+
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
320
|
+
params.push(aggregateId, streamTenantId);
|
|
321
|
+
const sqlText = `SELECT *, ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
|
|
322
|
+
`FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
323
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
|
|
324
|
+
const first = rows[0];
|
|
325
|
+
if (!first)
|
|
326
|
+
return { row: null, streamVersion: 0 };
|
|
327
|
+
const { __streamVersion, ...row } = first;
|
|
328
|
+
return {
|
|
329
|
+
row,
|
|
330
|
+
streamVersion: typeof __streamVersion === "number" ? __streamVersion : 0,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// Reads the `expect:` fields and the aggregate's MAX(version) in ONE statement:
|
|
334
|
+
// projection rows are written after their event commits, so two reads can
|
|
335
|
+
// pair a post-write version with a pre-write row.
|
|
336
|
+
// row.version can't stand in for MAX(version) — raw-seeded/legacy rows carry
|
|
337
|
+
// a default version with no matching events, so the append would target a
|
|
338
|
+
// non-existent predecessor.
|
|
339
|
+
// Raw column values, no decrypt pass: plain (non-pii, non-encrypted) columns only.
|
|
316
340
|
async function loadExpectSnapshot(db, id, streamTenantId, expectKeys) {
|
|
341
|
+
for (const key of expectKeys) {
|
|
342
|
+
if (table[key] === undefined) {
|
|
343
|
+
throw new InternalError({
|
|
344
|
+
message: `expect: "${key}" is not a column of entity "${entityName}"`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (encryptedFields.has(key) || piiSubjectFields.includes(key)) {
|
|
348
|
+
throw new InternalError({
|
|
349
|
+
message: `expect: "${key}" on entity "${entityName}" is stored encrypted and cannot be compared as plaintext`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
317
353
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
318
354
|
const columnOf = (field) => quote(table[field]?.name ?? toSnakeCase(field));
|
|
319
355
|
const tableName = String(table[KUMIKO_NAME_SYMBOL]);
|
|
@@ -328,8 +364,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
328
364
|
const streamAggregateIdx = params.length - 1;
|
|
329
365
|
const streamTenantIdx = params.length;
|
|
330
366
|
const sqlText = `SELECT ${selectCols.join(", ")}, ` +
|
|
331
|
-
|
|
332
|
-
`AND "tenant_id" = $${streamTenantIdx}) AS "__streamVersion" ` +
|
|
367
|
+
`${streamVersionSubquerySql(streamAggregateIdx, streamTenantIdx)} ` +
|
|
333
368
|
`FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
334
369
|
const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
|
|
335
370
|
const row = rows[0];
|
|
@@ -353,6 +388,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
353
388
|
loadById,
|
|
354
389
|
assertStreamWritable,
|
|
355
390
|
loadWithOwnership,
|
|
391
|
+
loadDetailSnapshot,
|
|
356
392
|
loadExpectSnapshot,
|
|
357
393
|
encryptForStorage,
|
|
358
394
|
decryptForRead,
|
|
@@ -227,6 +227,12 @@ function deriveParentNarrowTypes(entity, payload) {
|
|
|
227
227
|
// targets match" would silently hide rows a full-cardinality match would
|
|
228
228
|
// have found. Native text search on the entity's own fields still applies.
|
|
229
229
|
const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
230
|
+
// The hit ids become an `id IN (...)` filter beneath the SQL cursor/filters/sort, so the
|
|
231
|
+
// adapter must return the whole candidate set, not a page; matches Meilisearch's default
|
|
232
|
+
// maxTotalHits, which caps a response at exactly this many hits however high a larger limit is
|
|
233
|
+
// asked. A response that reaches the cap is therefore flagged `searchTruncated` rather than
|
|
234
|
+
// treated as complete (a cap of N+1 could never be observed).
|
|
235
|
+
const MAX_SEARCH_CANDIDATE_IDS = 1000;
|
|
230
236
|
// Registry-declared `searchable` reference fields, plus the implicit
|
|
231
237
|
// tenantId→tenant.name row-meta reference (fw#2660) — both resolve a text
|
|
232
238
|
// search term against a target row's label instead of the raw FK column.
|
|
@@ -312,16 +318,28 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
|
|
|
312
318
|
if (ownership.kind === "empty")
|
|
313
319
|
return undefined;
|
|
314
320
|
const candidateIds = results.map((r) => String(r.entityId));
|
|
315
|
-
|
|
321
|
+
// The index is only trusted to name candidates: a stale or foreign-tenant document must
|
|
322
|
+
// never become a filter value, so tenant-scoped targets are always re-read from the DB.
|
|
323
|
+
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
324
|
+
if (ownership.kind === "pass" && !tenantScoped) {
|
|
316
325
|
return { ownColumn: descriptor.ownColumn, ids: candidateIds };
|
|
317
326
|
}
|
|
318
327
|
const params = [...candidateIds];
|
|
319
328
|
const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
329
|
+
let tenantClause = "";
|
|
330
|
+
if (tenantScoped) {
|
|
331
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
332
|
+
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($${params.length - 1}, $${params.length})`;
|
|
333
|
+
}
|
|
334
|
+
let ownershipClause = "";
|
|
335
|
+
if (ownership.kind === "sql") {
|
|
336
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
337
|
+
for (const p of shifted.params)
|
|
338
|
+
params.push(p);
|
|
339
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
340
|
+
}
|
|
341
|
+
const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")})` +
|
|
342
|
+
`${tenantClause}${ownershipClause}`;
|
|
325
343
|
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, params);
|
|
326
344
|
if (rows.length === 0)
|
|
327
345
|
return undefined;
|
|
@@ -374,7 +392,7 @@ const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
|
374
392
|
// undefined means "cannot order by this label", which drops the sort to plain
|
|
375
393
|
// id order; falling back to the UUID column would look deliberate to the user
|
|
376
394
|
// while being arbitrary, which the issue calls worse than no sorting.
|
|
377
|
-
function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db) {
|
|
395
|
+
function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
|
|
378
396
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
379
397
|
if (targetEntity === undefined) {
|
|
380
398
|
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
@@ -391,6 +409,13 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
391
409
|
return undefined;
|
|
392
410
|
}
|
|
393
411
|
const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
412
|
+
// The order of labels the viewer may not read would leak them bit by bit, same as the search path.
|
|
413
|
+
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
414
|
+
if (labelAccess.kind !== "pass")
|
|
415
|
+
return undefined;
|
|
416
|
+
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
417
|
+
if (ownership.kind === "empty")
|
|
418
|
+
return undefined;
|
|
394
419
|
const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
|
|
395
420
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
396
421
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
@@ -408,12 +433,19 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
408
433
|
params.push(tenantId, SYSTEM_TENANT_ID);
|
|
409
434
|
tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
|
|
410
435
|
}
|
|
436
|
+
let ownershipClause = "";
|
|
437
|
+
if (ownership.kind === "sql") {
|
|
438
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
439
|
+
for (const p of shifted.params)
|
|
440
|
+
params.push(p);
|
|
441
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
442
|
+
}
|
|
411
443
|
return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
412
|
-
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
|
|
444
|
+
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
|
|
413
445
|
};
|
|
414
446
|
}
|
|
415
447
|
export function createReadVerbs(ctx) {
|
|
416
|
-
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, decryptForRead, encryptForStorage, } = ctx;
|
|
448
|
+
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
|
|
417
449
|
return {
|
|
418
450
|
// list + detail are unchanged from crud-executor — projections are the
|
|
419
451
|
// read-model and serve these queries directly.
|
|
@@ -453,6 +485,7 @@ export function createReadVerbs(ctx) {
|
|
|
453
485
|
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
454
486
|
// beim Definition-Time noch keinen Server-Context hat).
|
|
455
487
|
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
488
|
+
let searchTruncated = false;
|
|
456
489
|
if (payload.search) {
|
|
457
490
|
const searchTerm = payload.search;
|
|
458
491
|
// #2032 — a search term with no adapter wired must fail loud, not
|
|
@@ -471,7 +504,9 @@ export function createReadVerbs(ctx) {
|
|
|
471
504
|
const searchTenantId = streamTenantFor(user);
|
|
472
505
|
const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
|
|
473
506
|
filterType: entityName,
|
|
507
|
+
limit: MAX_SEARCH_CANDIDATE_IDS,
|
|
474
508
|
});
|
|
509
|
+
searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
|
|
475
510
|
filterIds = results.map((r) => r.entityId);
|
|
476
511
|
// fw#2660 — union in reference-column label matches: a searchable
|
|
477
512
|
// reference field (or the implicit tenantId→tenant.name row-meta
|
|
@@ -522,7 +557,7 @@ export function createReadVerbs(ctx) {
|
|
|
522
557
|
const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
|
|
523
558
|
referenceSortExpr =
|
|
524
559
|
referenceSort && descriptor
|
|
525
|
-
? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db)
|
|
560
|
+
? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db, user)
|
|
526
561
|
: undefined;
|
|
527
562
|
if (referenceSortExpr !== undefined)
|
|
528
563
|
sortField = sortCandidate;
|
|
@@ -657,7 +692,12 @@ export function createReadVerbs(ctx) {
|
|
|
657
692
|
const countRows = await executeRawQueryRead(runner, countSql, whereParams);
|
|
658
693
|
total = countRows[0]?.count ?? 0;
|
|
659
694
|
}
|
|
660
|
-
return {
|
|
695
|
+
return {
|
|
696
|
+
rows,
|
|
697
|
+
nextCursor,
|
|
698
|
+
...(total !== undefined && { total }),
|
|
699
|
+
...(searchTruncated && { searchTruncated: true }),
|
|
700
|
+
};
|
|
661
701
|
},
|
|
662
702
|
async detail(payload, user, db, options) {
|
|
663
703
|
const tableName = String(table[KUMIKO_NAME_SYMBOL]);
|
|
@@ -676,10 +716,9 @@ export function createReadVerbs(ctx) {
|
|
|
676
716
|
// out the stale row.version dooms the next CRUD update built on it
|
|
677
717
|
// (entityEdit loads detail.version as its optimistic-lock base) to a
|
|
678
718
|
// guaranteed version_conflict.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
};
|
|
719
|
+
// A cache hit only counts when its version equals the live stream version:
|
|
720
|
+
// a late entityCache.set() can resurrect an old row after the writer's del().
|
|
721
|
+
const withVersion = (row, streamVersion) => (streamVersion > 0 ? { ...row, version: streamVersion } : row);
|
|
683
722
|
if (entityCache && entityName) {
|
|
684
723
|
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
685
724
|
if (cached) {
|
|
@@ -692,25 +731,28 @@ export function createReadVerbs(ctx) {
|
|
|
692
731
|
if (checkRows.length === 0)
|
|
693
732
|
return null;
|
|
694
733
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
734
|
+
const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
|
|
735
|
+
if (!(streamVersion > 0 && cached["version"] !== streamVersion)) {
|
|
736
|
+
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
737
|
+
// `encrypted` field's plaintext never sits in a second at-rest
|
|
738
|
+
// store (Redis) the field-encryption feature doesn't cover.
|
|
739
|
+
return withVersion(await decryptForRead(cached), streamVersion);
|
|
740
|
+
}
|
|
699
741
|
}
|
|
700
742
|
}
|
|
701
|
-
const
|
|
702
|
-
const raw =
|
|
743
|
+
const snapshot = await loadDetailSnapshot(db, idWhere, ownership, streamTenantFor(user), String(payload.id));
|
|
744
|
+
const raw = snapshot.row;
|
|
703
745
|
if (!raw)
|
|
704
746
|
return null;
|
|
705
747
|
// Same coerce-before-rehydrate/decrypt ordering as list() above — raw
|
|
706
|
-
// is snake_case
|
|
707
|
-
// coerceRow is a no-op on the already-camelCase "pass" branch rows.
|
|
748
|
+
// is snake_case (raw SQL); coerceRow maps it to camelCase.
|
|
708
749
|
const rowInfo = extractTableInfo(table);
|
|
709
750
|
const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
|
|
751
|
+
const result = withVersion(coerced, snapshot.streamVersion);
|
|
710
752
|
if (entityCache && entityName) {
|
|
711
|
-
await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(
|
|
753
|
+
await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
|
|
712
754
|
}
|
|
713
|
-
return
|
|
755
|
+
return result;
|
|
714
756
|
},
|
|
715
757
|
};
|
|
716
758
|
}
|