@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1
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/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/routes.js +4 -2
- package/dist/api/server.js +28 -3
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +2 -6
- 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 +29 -2
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +59 -18
- package/dist/db/event-store-executor-read.js +81 -31
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +11 -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 +5 -0
- package/dist/db/queries/event-store.js +51 -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 +32 -3
- 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 +5 -0
- 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 +40 -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 +98 -7
- 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 +3 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +55 -34
- 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-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +41 -4
- 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 +2 -3
- package/dist/engine/index.d.ts +8 -2
- package/dist/engine/index.js +6 -1
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/project-app-schema-for-roles.js +3 -1
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +35 -2
- 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/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.js +45 -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/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +14 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-routes.js +10 -4
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- 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/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/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/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 +209 -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
|
}
|
|
@@ -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 { InternalError, 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,14 +297,38 @@ 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 };
|
|
307
|
+
}
|
|
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
|
+
};
|
|
291
332
|
}
|
|
292
333
|
// Reads the `expect:` fields and the aggregate's MAX(version) in ONE statement:
|
|
293
334
|
// projection rows are written after their event commits, so two reads can
|
|
@@ -323,8 +364,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
323
364
|
const streamAggregateIdx = params.length - 1;
|
|
324
365
|
const streamTenantIdx = params.length;
|
|
325
366
|
const sqlText = `SELECT ${selectCols.join(", ")}, ` +
|
|
326
|
-
|
|
327
|
-
`AND "tenant_id" = $${streamTenantIdx}) AS "__streamVersion" ` +
|
|
367
|
+
`${streamVersionSubquerySql(streamAggregateIdx, streamTenantIdx)} ` +
|
|
328
368
|
`FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
329
369
|
const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
|
|
330
370
|
const row = rows[0];
|
|
@@ -348,6 +388,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
348
388
|
loadById,
|
|
349
389
|
assertStreamWritable,
|
|
350
390
|
loadWithOwnership,
|
|
391
|
+
loadDetailSnapshot,
|
|
351
392
|
loadExpectSnapshot,
|
|
352
393
|
encryptForStorage,
|
|
353
394
|
decryptForRead,
|
|
@@ -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 } from "../event-store/index.js";
|
|
9
|
+
import { getStreamVersion, getStreamVersions } 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";
|
|
@@ -64,6 +64,9 @@ function toCursorSortText(value) {
|
|
|
64
64
|
}
|
|
65
65
|
return undefined;
|
|
66
66
|
}
|
|
67
|
+
function withStreamVersion(row, streamVersion) {
|
|
68
|
+
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
69
|
+
}
|
|
67
70
|
// Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
|
|
68
71
|
// null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
|
|
69
72
|
// would change the visible order and cost the sort its index.
|
|
@@ -227,6 +230,12 @@ function deriveParentNarrowTypes(entity, payload) {
|
|
|
227
230
|
// targets match" would silently hide rows a full-cardinality match would
|
|
228
231
|
// have found. Native text search on the entity's own fields still applies.
|
|
229
232
|
const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
233
|
+
// The hit ids become an `id IN (...)` filter beneath the SQL cursor/filters/sort, so the
|
|
234
|
+
// adapter must return the whole candidate set, not a page; matches Meilisearch's default
|
|
235
|
+
// maxTotalHits, which caps a response at exactly this many hits however high a larger limit is
|
|
236
|
+
// asked. A response that reaches the cap is therefore flagged `searchTruncated` rather than
|
|
237
|
+
// treated as complete (a cap of N+1 could never be observed).
|
|
238
|
+
const MAX_SEARCH_CANDIDATE_IDS = 1000;
|
|
230
239
|
// Registry-declared `searchable` reference fields, plus the implicit
|
|
231
240
|
// tenantId→tenant.name row-meta reference (fw#2660) — both resolve a text
|
|
232
241
|
// search term against a target row's label instead of the raw FK column.
|
|
@@ -312,16 +321,28 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
|
|
|
312
321
|
if (ownership.kind === "empty")
|
|
313
322
|
return undefined;
|
|
314
323
|
const candidateIds = results.map((r) => String(r.entityId));
|
|
315
|
-
|
|
324
|
+
// The index is only trusted to name candidates: a stale or foreign-tenant document must
|
|
325
|
+
// never become a filter value, so tenant-scoped targets are always re-read from the DB.
|
|
326
|
+
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
327
|
+
if (ownership.kind === "pass" && !tenantScoped) {
|
|
316
328
|
return { ownColumn: descriptor.ownColumn, ids: candidateIds };
|
|
317
329
|
}
|
|
318
330
|
const params = [...candidateIds];
|
|
319
331
|
const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
let tenantClause = "";
|
|
333
|
+
if (tenantScoped) {
|
|
334
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
335
|
+
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($${params.length - 1}, $${params.length})`;
|
|
336
|
+
}
|
|
337
|
+
let ownershipClause = "";
|
|
338
|
+
if (ownership.kind === "sql") {
|
|
339
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
340
|
+
for (const p of shifted.params)
|
|
341
|
+
params.push(p);
|
|
342
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
343
|
+
}
|
|
344
|
+
const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")})` +
|
|
345
|
+
`${tenantClause}${ownershipClause}`;
|
|
325
346
|
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, params);
|
|
326
347
|
if (rows.length === 0)
|
|
327
348
|
return undefined;
|
|
@@ -374,7 +395,7 @@ const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
|
374
395
|
// undefined means "cannot order by this label", which drops the sort to plain
|
|
375
396
|
// id order; falling back to the UUID column would look deliberate to the user
|
|
376
397
|
// while being arbitrary, which the issue calls worse than no sorting.
|
|
377
|
-
function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db) {
|
|
398
|
+
function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
|
|
378
399
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
379
400
|
if (targetEntity === undefined) {
|
|
380
401
|
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
@@ -391,6 +412,13 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
391
412
|
return undefined;
|
|
392
413
|
}
|
|
393
414
|
const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
415
|
+
// The order of labels the viewer may not read would leak them bit by bit, same as the search path.
|
|
416
|
+
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
417
|
+
if (labelAccess.kind !== "pass")
|
|
418
|
+
return undefined;
|
|
419
|
+
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
420
|
+
if (ownership.kind === "empty")
|
|
421
|
+
return undefined;
|
|
394
422
|
const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
|
|
395
423
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
396
424
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
@@ -408,12 +436,19 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
408
436
|
params.push(tenantId, SYSTEM_TENANT_ID);
|
|
409
437
|
tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
|
|
410
438
|
}
|
|
439
|
+
let ownershipClause = "";
|
|
440
|
+
if (ownership.kind === "sql") {
|
|
441
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
442
|
+
for (const p of shifted.params)
|
|
443
|
+
params.push(p);
|
|
444
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
445
|
+
}
|
|
411
446
|
return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
412
|
-
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
|
|
447
|
+
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
|
|
413
448
|
};
|
|
414
449
|
}
|
|
415
450
|
export function createReadVerbs(ctx) {
|
|
416
|
-
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, decryptForRead, encryptForStorage, } = ctx;
|
|
451
|
+
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
|
|
417
452
|
return {
|
|
418
453
|
// list + detail are unchanged from crud-executor — projections are the
|
|
419
454
|
// read-model and serve these queries directly.
|
|
@@ -453,6 +488,7 @@ export function createReadVerbs(ctx) {
|
|
|
453
488
|
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
454
489
|
// beim Definition-Time noch keinen Server-Context hat).
|
|
455
490
|
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
491
|
+
let searchTruncated = false;
|
|
456
492
|
if (payload.search) {
|
|
457
493
|
const searchTerm = payload.search;
|
|
458
494
|
// #2032 — a search term with no adapter wired must fail loud, not
|
|
@@ -471,7 +507,9 @@ export function createReadVerbs(ctx) {
|
|
|
471
507
|
const searchTenantId = streamTenantFor(user);
|
|
472
508
|
const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
|
|
473
509
|
filterType: entityName,
|
|
510
|
+
limit: MAX_SEARCH_CANDIDATE_IDS,
|
|
474
511
|
});
|
|
512
|
+
searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
|
|
475
513
|
filterIds = results.map((r) => r.entityId);
|
|
476
514
|
// fw#2660 — union in reference-column label matches: a searchable
|
|
477
515
|
// reference field (or the implicit tenantId→tenant.name row-meta
|
|
@@ -522,7 +560,7 @@ export function createReadVerbs(ctx) {
|
|
|
522
560
|
const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
|
|
523
561
|
referenceSortExpr =
|
|
524
562
|
referenceSort && descriptor
|
|
525
|
-
? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db)
|
|
563
|
+
? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db, user)
|
|
526
564
|
: undefined;
|
|
527
565
|
if (referenceSortExpr !== undefined)
|
|
528
566
|
sortField = sortCandidate;
|
|
@@ -626,11 +664,17 @@ export function createReadVerbs(ctx) {
|
|
|
626
664
|
// field (ciphertext leaked to the caller).
|
|
627
665
|
const tableInfo = extractTableInfo(table);
|
|
628
666
|
const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
|
|
629
|
-
const
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
667
|
+
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
668
|
+
// Stream version is authoritative (see detail()): ctx.appendEvent bumps the
|
|
669
|
+
// stream without touching row.version, so the raw row version would hand
|
|
670
|
+
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
671
|
+
const streamVersions = decryptedRows.length > 0
|
|
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));
|
|
675
|
+
// Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
|
|
676
|
+
// detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
|
|
677
|
+
// a cached version that differs from the stream.
|
|
634
678
|
if (entityCache && entityName && rows.length > 0) {
|
|
635
679
|
await entityCache.mset(user.tenantId, entityName, encryptedRows.map((r) => ({ id: r["id"], data: r })));
|
|
636
680
|
}
|
|
@@ -657,7 +701,12 @@ export function createReadVerbs(ctx) {
|
|
|
657
701
|
const countRows = await executeRawQueryRead(runner, countSql, whereParams);
|
|
658
702
|
total = countRows[0]?.count ?? 0;
|
|
659
703
|
}
|
|
660
|
-
return {
|
|
704
|
+
return {
|
|
705
|
+
rows,
|
|
706
|
+
nextCursor,
|
|
707
|
+
...(total !== undefined && { total }),
|
|
708
|
+
...(searchTruncated && { searchTruncated: true }),
|
|
709
|
+
};
|
|
661
710
|
},
|
|
662
711
|
async detail(payload, user, db, options) {
|
|
663
712
|
const tableName = String(table[KUMIKO_NAME_SYMBOL]);
|
|
@@ -676,10 +725,8 @@ export function createReadVerbs(ctx) {
|
|
|
676
725
|
// out the stale row.version dooms the next CRUD update built on it
|
|
677
726
|
// (entityEdit loads detail.version as its optimistic-lock base) to a
|
|
678
727
|
// guaranteed version_conflict.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
682
|
-
};
|
|
728
|
+
// A cache hit only counts when its version equals the live stream version:
|
|
729
|
+
// a late entityCache.set() can resurrect an old row after the writer's del().
|
|
683
730
|
if (entityCache && entityName) {
|
|
684
731
|
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
685
732
|
if (cached) {
|
|
@@ -692,25 +739,28 @@ export function createReadVerbs(ctx) {
|
|
|
692
739
|
if (checkRows.length === 0)
|
|
693
740
|
return null;
|
|
694
741
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
742
|
+
const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
|
|
743
|
+
if (!(streamVersion > 0 && cached["version"] !== streamVersion)) {
|
|
744
|
+
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
745
|
+
// `encrypted` field's plaintext never sits in a second at-rest
|
|
746
|
+
// store (Redis) the field-encryption feature doesn't cover.
|
|
747
|
+
return withStreamVersion(await decryptForRead(cached), streamVersion);
|
|
748
|
+
}
|
|
699
749
|
}
|
|
700
750
|
}
|
|
701
|
-
const
|
|
702
|
-
const raw =
|
|
751
|
+
const snapshot = await loadDetailSnapshot(db, idWhere, ownership, streamTenantFor(user), String(payload.id));
|
|
752
|
+
const raw = snapshot.row;
|
|
703
753
|
if (!raw)
|
|
704
754
|
return null;
|
|
705
755
|
// 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.
|
|
756
|
+
// is snake_case (raw SQL); coerceRow maps it to camelCase.
|
|
708
757
|
const rowInfo = extractTableInfo(table);
|
|
709
758
|
const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
|
|
759
|
+
const result = withStreamVersion(coerced, snapshot.streamVersion);
|
|
710
760
|
if (entityCache && entityName) {
|
|
711
|
-
await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(
|
|
761
|
+
await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
|
|
712
762
|
}
|
|
713
|
-
return
|
|
763
|
+
return result;
|
|
714
764
|
},
|
|
715
765
|
};
|
|
716
766
|
}
|