@cosmicdrift/kumiko-framework 0.398.0 → 0.400.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 +33 -19
- package/agents/api/dev-server-testing.md +23 -0
- package/agents/api/framework-db.md +277 -0
- package/agents/api/framework-engine.md +1810 -0
- package/agents/api/framework-errors.md +119 -0
- package/agents/api/framework-event-store.md +99 -0
- package/agents/api/framework-seeding.md +26 -0
- package/agents/api/framework-ui-types.md +542 -0
- package/agents/api/renderer-web.md +1450 -0
- package/agents/api/testing-helpers.md +189 -0
- package/agents/api/testing-stack.md +144 -0
- package/agents/api/types.md +5124 -0
- package/agents/api.md +20 -0
- package/agents/examples/derived-fields-sql.md +251 -0
- package/agents/examples/documents.md +161 -0
- package/agents/examples/inspection-checklist.md +807 -0
- package/agents/examples.md +3 -0
- package/agents/features-index.md +178 -0
- package/agents/features.md +9 -1
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +46 -2
- package/dist/api/server-boot-checks.js +2 -2
- package/dist/api/server-routes.js +3 -1
- package/dist/db/derived-field-sql.d.ts +23 -0
- package/dist/db/derived-field-sql.js +115 -0
- package/dist/db/derived-related-sql.d.ts +31 -0
- package/dist/db/derived-related-sql.js +137 -0
- package/dist/db/driver-row-coercion.d.ts +1 -0
- package/dist/db/driver-row-coercion.js +13 -0
- package/dist/db/eagerload.d.ts +3 -1
- package/dist/db/eagerload.js +4 -6
- package/dist/db/event-store-executor-context.js +1 -1
- package/dist/db/event-store-executor-read-paging.js +1 -1
- package/dist/db/event-store-executor-read-search.d.ts +3 -0
- package/dist/db/event-store-executor-read-search.js +17 -9
- package/dist/db/event-store-executor-read-sort.d.ts +9 -2
- package/dist/db/event-store-executor-read-sort.js +27 -14
- package/dist/db/event-store-executor-read-where.d.ts +0 -5
- package/dist/db/event-store-executor-read-where.js +3 -9
- package/dist/db/event-store-executor-read.js +23 -4
- package/dist/db/event-store-executor-write-steps.d.ts +6 -1
- package/dist/db/event-store-executor-write-steps.js +20 -1
- package/dist/db/event-store-executor-write.js +8 -8
- package/dist/db/money.d.ts +4 -0
- package/dist/db/money.js +34 -29
- package/dist/db/parent-ref-clause.d.ts +1 -0
- package/dist/db/parent-ref-clause.js +18 -8
- package/dist/db/read-gate-clause.d.ts +7 -0
- package/dist/db/read-gate-clause.js +188 -0
- package/dist/db/read-ownership.d.ts +9 -0
- package/dist/db/read-ownership.js +12 -0
- package/dist/engine/boot-validator/boot-check.d.ts +3 -1
- package/dist/engine/boot-validator/boot-check.js +7 -11
- package/dist/engine/boot-validator/boot-context.d.ts +26 -0
- package/dist/engine/boot-validator/boot-context.js +57 -0
- package/dist/engine/boot-validator/boot-options.d.ts +33 -0
- package/dist/engine/boot-validator/boot-options.js +1 -0
- package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +103 -0
- package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
- package/dist/engine/boot-validator/boot-phases.js +140 -0
- package/dist/engine/boot-validator/boot-report.d.ts +6 -0
- package/dist/engine/boot-validator/boot-report.js +47 -0
- package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
- package/dist/engine/boot-validator/boot-rule.js +14 -0
- package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
- package/dist/engine/boot-validator/boot-warnings.js +20 -0
- package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
- package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
- package/dist/engine/boot-validator/entity-handler.js +6 -3
- package/dist/engine/boot-validator/entity-list-screens.js +3 -1
- package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
- package/dist/engine/boot-validator/field-conditions.js +178 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
- package/dist/engine/boot-validator/i18n-keys.js +1 -3
- package/dist/engine/boot-validator/index.d.ts +5 -34
- package/dist/engine/boot-validator/index.js +24 -204
- package/dist/engine/boot-validator/pii-retention.js +2 -7
- package/dist/engine/boot-validator/projection-list-screens.js +19 -1
- package/dist/engine/boot-validator/read-gate.d.ts +2 -0
- package/dist/engine/boot-validator/read-gate.js +54 -0
- package/dist/engine/boot-validator/screens-forms.js +19 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
- package/dist/engine/boot-validator/screens-lists.js +8 -0
- package/dist/engine/boot-validator/zod-shape.js +4 -2
- package/dist/engine/build-app-schema.js +11 -5
- package/dist/engine/embedded-derived.d.ts +8 -6
- package/dist/engine/embedded-derived.js +25 -7
- package/dist/engine/entity-handlers.d.ts +1 -1
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +5 -1
- package/dist/engine/entity-list-runtime-options.js +103 -4
- package/dist/engine/factories.d.ts +5 -5
- package/dist/engine/factories.js +3 -5
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +48 -1
- package/dist/engine/field-state.d.ts +12 -0
- package/dist/engine/field-state.js +67 -0
- package/dist/engine/handler-helpers.d.ts +3 -0
- package/dist/engine/handler-helpers.js +7 -1
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +2 -1
- package/dist/engine/ownership.d.ts +1 -1
- package/dist/engine/ownership.js +9 -5
- package/dist/engine/registry-state.d.ts +1 -0
- package/dist/engine/registry-state.js +1 -1
- package/dist/engine/schema-builder.js +3 -0
- package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
- package/dist/engine/strip-unreadable-related-lists.js +80 -0
- package/dist/entrypoint/index.js +1 -0
- package/dist/entrypoint/internals.d.ts +2 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
- package/dist/files/bind-pending-file-refs.d.ts +3 -2
- package/dist/files/bind-pending-file-refs.js +11 -12
- package/dist/files/bind-referenced-files.write.js +5 -5
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +2 -0
- package/dist/files/create-file-ref.d.ts +2 -0
- package/dist/files/create-file-ref.js +39 -22
- package/dist/files/dispose-removed-file-refs.js +5 -8
- package/dist/files/file-field-slots.d.ts +10 -0
- package/dist/files/file-field-slots.js +53 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-table.d.ts +1 -1
- package/dist/files/file-routes.js +30 -9
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/load-save-file-refs.js +3 -4
- package/dist/files/record-read-check.js +2 -30
- package/dist/files/record-write-check.d.ts +2 -0
- package/dist/files/record-write-check.js +18 -3
- package/dist/files/upload-field-resolution.d.ts +4 -1
- package/dist/files/upload-field-resolution.js +57 -12
- package/dist/pipeline/dispatch-batch.js +6 -5
- package/dist/pipeline/dispatch-write.js +2 -1
- package/dist/pipeline/write-result-masking.js +2 -1
- package/dist/schema-cli/validate-command.js +46 -12
- package/dist/schema-cli.js +1 -1
- package/dist/ui-types/index.d.ts +2 -1
- package/dist/ui-types/index.js +2 -1
- package/package.json +3 -3
- package/src/changes.json +67 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { buildOwnershipClause, normalizeAccessEntry } from "../engine/ownership.js";
|
|
2
|
+
import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
4
|
+
import { holdsCiphertextOrSecret } from "./entity-field-encryption.js";
|
|
5
|
+
import { bindPlaceholders, bindShiftedClause } from "./event-store-executor-read-filter.js";
|
|
6
|
+
import { isSoftDeletedTargetHidden } from "./event-store-executor-read-search.js";
|
|
7
|
+
import { buildReferenceSortExpr } from "./event-store-executor-read-sort.js";
|
|
8
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
9
|
+
import { physicalColumnName } from "./table-builder.js";
|
|
10
|
+
import { extractTableInfo } from "./table-info.js";
|
|
11
|
+
import { schemaTableName } from "./table-introspection.js";
|
|
12
|
+
// Own alias for the related table: a where-rule qualifies columns with the table name it is
|
|
13
|
+
// given, and the physical name could bind to an outer row of the same table (self-relation).
|
|
14
|
+
const RELATED_ALIAS = "__kumiko_rel";
|
|
15
|
+
const COMPARISON_SQL = { eq: "=", ne: "<>", lt: "<", gt: ">", lte: "<=", gte: ">=" };
|
|
16
|
+
function conditionFields(condition) {
|
|
17
|
+
return "activeOn" in condition
|
|
18
|
+
? [condition.activeOn.from, condition.activeOn.to]
|
|
19
|
+
: [condition.field];
|
|
20
|
+
}
|
|
21
|
+
function measureFields(measure) {
|
|
22
|
+
if (measure.kind === "count")
|
|
23
|
+
return [];
|
|
24
|
+
return measure.kind === "first" ? [measure.field, measure.orderBy] : [measure.field];
|
|
25
|
+
}
|
|
26
|
+
function usedRelatedFields(spec) {
|
|
27
|
+
return [
|
|
28
|
+
spec.on.field,
|
|
29
|
+
...measureFields(spec.measure),
|
|
30
|
+
...(spec.where ?? []).flatMap(conditionFields),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
function isRelatedFieldReadable(entity, table, user, field) {
|
|
34
|
+
if (!Object.hasOwn(table, field) || holdsCiphertextOrSecret(entity, field))
|
|
35
|
+
return false;
|
|
36
|
+
const access = normalizeAccessEntry(entity.fields[field]?.access?.read);
|
|
37
|
+
return buildOwnershipClause(user, access, table, 1, RELATED_ALIAS).kind === "pass";
|
|
38
|
+
}
|
|
39
|
+
function isPlaceholderValue(value) {
|
|
40
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
function conditionSql(condition, column, params, runtime) {
|
|
43
|
+
if ("activeOn" in condition) {
|
|
44
|
+
params.push(runtime.today);
|
|
45
|
+
const today = `$${params.length}`;
|
|
46
|
+
const from = column(condition.activeOn.from);
|
|
47
|
+
const to = column(condition.activeOn.to);
|
|
48
|
+
return `(${from} <= ${today} AND (${to} IS NULL OR ${to} >= ${today}))`;
|
|
49
|
+
}
|
|
50
|
+
const col = column(condition.field);
|
|
51
|
+
const { value } = condition;
|
|
52
|
+
if (condition.op === "in") {
|
|
53
|
+
return Array.isArray(value) && value.length > 0
|
|
54
|
+
? `${col} IN (${bindPlaceholders(params, value)})`
|
|
55
|
+
: "FALSE";
|
|
56
|
+
}
|
|
57
|
+
if (value === null) {
|
|
58
|
+
if (condition.op === "eq")
|
|
59
|
+
return `${col} IS NULL`;
|
|
60
|
+
return condition.op === "ne" ? `${col} IS NOT NULL` : "FALSE";
|
|
61
|
+
}
|
|
62
|
+
params.push(isPlaceholderValue(value) ? (value.today ? runtime.today : runtime.now) : value);
|
|
63
|
+
return `${col} ${COMPARISON_SQL[condition.op]} $${params.length}`;
|
|
64
|
+
}
|
|
65
|
+
// Correlated scalar subquery per row, like the reference-label sort: one result row per entity
|
|
66
|
+
// row, the read model untouched. undefined means "this viewer cannot see the value": the field
|
|
67
|
+
// projects NULL and is not sortable, so an order never leaks what the viewer is not allowed to read.
|
|
68
|
+
export function buildRelatedSubquery(input) {
|
|
69
|
+
const { spec, outer, user, db, runtime } = input;
|
|
70
|
+
const relatedName = parseRefTargetEntityName(spec.entity);
|
|
71
|
+
const related = runtime.resolveEntity(relatedName);
|
|
72
|
+
if (related === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (!runtime.canReadEntity(relatedName))
|
|
75
|
+
return undefined;
|
|
76
|
+
const relatedTable = runtime.resolveTable(relatedName);
|
|
77
|
+
const relatedTableName = String(schemaTableName(relatedTable));
|
|
78
|
+
const outerField = spec.on.outer ?? "id";
|
|
79
|
+
const fieldsReadable = usedRelatedFields(spec).every((f) => isRelatedFieldReadable(related, relatedTable, user, f)) &&
|
|
80
|
+
Object.hasOwn(outer.table, outerField) &&
|
|
81
|
+
outer.isFieldReadable(outerField);
|
|
82
|
+
if (!fieldsReadable)
|
|
83
|
+
return undefined;
|
|
84
|
+
const ownership = resolveReadOwnership({ entity: related, table: relatedTable }, RELATED_ALIAS, user, db, input.parentVisibility, { includeDeleted: false, narrowTypes: undefined });
|
|
85
|
+
if (ownership.kind === "empty")
|
|
86
|
+
return undefined;
|
|
87
|
+
const column = (field) => `"${RELATED_ALIAS}"."${physicalColumnName(relatedTable, field)}"`;
|
|
88
|
+
const selectSql = relatedSelect(spec.measure, column, related, input);
|
|
89
|
+
if (selectSql === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
const joinColumn = physicalColumnName(relatedTable, spec.on.field);
|
|
92
|
+
const outerColumn = physicalColumnName(outer.table, outerField);
|
|
93
|
+
const joinTypesDiffer = extractTableInfo(relatedTable).pgTypeOf(joinColumn) !==
|
|
94
|
+
extractTableInfo(outer.table).pgTypeOf(outerColumn);
|
|
95
|
+
const joinSql = joinTypesDiffer
|
|
96
|
+
? `${column(spec.on.field)}::text = "${outer.tableName}"."${outerColumn}"::text`
|
|
97
|
+
: `${column(spec.on.field)} = "${outer.tableName}"."${outerColumn}"`;
|
|
98
|
+
const tenantScoped = relatedTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
99
|
+
const softDeleteSql = isSoftDeletedTargetHidden(related, relatedTable)
|
|
100
|
+
? `${column("isDeleted")} = FALSE`
|
|
101
|
+
: undefined;
|
|
102
|
+
return (params) => {
|
|
103
|
+
const clauses = [joinSql];
|
|
104
|
+
if (tenantScoped && db.mode === "tenant") {
|
|
105
|
+
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
106
|
+
clauses.push(`${column("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
107
|
+
}
|
|
108
|
+
if (ownership.kind === "sql")
|
|
109
|
+
clauses.push(bindShiftedClause(params, ownership));
|
|
110
|
+
if (softDeleteSql !== undefined)
|
|
111
|
+
clauses.push(softDeleteSql);
|
|
112
|
+
for (const condition of spec.where ?? []) {
|
|
113
|
+
clauses.push(conditionSql(condition, column, params, runtime));
|
|
114
|
+
}
|
|
115
|
+
const order = spec.measure.kind === "first"
|
|
116
|
+
? ` ORDER BY ${column(spec.measure.orderBy)} ${spec.measure.direction === "desc" ? "DESC" : "ASC"} NULLS LAST, ${column("id")} ASC LIMIT 1`
|
|
117
|
+
: "";
|
|
118
|
+
return `(SELECT ${selectSql(params)} FROM "${relatedTableName}" AS "${RELATED_ALIAS}" WHERE ${clauses.join(" AND ")}${order})`;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function relatedSelect(measure, column, related, input) {
|
|
122
|
+
if (measure.kind === "count")
|
|
123
|
+
return () => "COUNT(*)::int";
|
|
124
|
+
if (measure.kind === "countDistinct")
|
|
125
|
+
return () => `COUNT(DISTINCT ${column(measure.field)})::int`;
|
|
126
|
+
if (measure.label !== true) {
|
|
127
|
+
const selected = input.currency === true ? `${measure.field}Currency` : measure.field;
|
|
128
|
+
return () => column(selected);
|
|
129
|
+
}
|
|
130
|
+
const def = related.fields[measure.field];
|
|
131
|
+
if (def?.type !== "reference" || def.labelField === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
const labelTarget = parseRefTargetEntityName(def.entity);
|
|
134
|
+
if (!input.runtime.canReadEntity(labelTarget))
|
|
135
|
+
return undefined;
|
|
136
|
+
return buildReferenceSortExpr(column(measure.field), { targetEntityName: labelTarget, labelField: def.labelField }, input.runtime.resolveEntity, input.db, input.user, input.runtime.resolveTable, input.parentVisibility);
|
|
137
|
+
}
|
|
@@ -162,6 +162,19 @@ function driverValueCoercerFor(pgType) {
|
|
|
162
162
|
return coercer;
|
|
163
163
|
return pgType.startsWith("numeric") ? numberUnlessNaN : undefined;
|
|
164
164
|
}
|
|
165
|
+
const STANDALONE_VALUE_INFO = {
|
|
166
|
+
name: "",
|
|
167
|
+
columnOf: (field) => field,
|
|
168
|
+
pgTypeOf: () => undefined,
|
|
169
|
+
bigintJsModeOf: () => undefined,
|
|
170
|
+
fieldOf: (column) => column,
|
|
171
|
+
hasColumn: () => false,
|
|
172
|
+
};
|
|
173
|
+
// For computed values that have no table column: coerces by pg type exactly as a stored column of that type.
|
|
174
|
+
export function coerceDriverValueOfPgType(value, pgType, column) {
|
|
175
|
+
const coercer = driverValueCoercerFor(pgType);
|
|
176
|
+
return coercer ? coercer(value, column, STANDALONE_VALUE_INFO) : value;
|
|
177
|
+
}
|
|
165
178
|
export function coerceDriverRow(row, info) {
|
|
166
179
|
const out = {};
|
|
167
180
|
let changed = false;
|
package/dist/db/eagerload.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
2
2
|
import type { SessionUser } from "../engine/types/index.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
4
|
+
import { buildEntityTable } from "./table-builder.ts";
|
|
4
5
|
import type { TenantDb } from "./tenant-db.ts";
|
|
5
6
|
export type EagerLoadEntityResolver = (entityName: string) => EntityDefinition | undefined;
|
|
6
7
|
export type EagerloadedRow<T extends Record<string, unknown> = Record<string, unknown>> = T & {
|
|
@@ -15,6 +16,7 @@ export declare function collectReferenceFields(entity: EntityDefinition): readon
|
|
|
15
16
|
export type EagerLoadViewer = {
|
|
16
17
|
readonly user: SessionUser;
|
|
17
18
|
readonly parentVisibility: ParentVisibilityOption;
|
|
19
|
+
readonly resolveTable?: (entityName: string) => ReturnType<typeof buildEntityTable>;
|
|
18
20
|
};
|
|
19
21
|
/** Eagerloads a list of rows without mutating them; returns shallow copies
|
|
20
22
|
* with an added `_refs` property. */
|
package/dist/db/eagerload.js
CHANGED
|
@@ -21,15 +21,15 @@ import { requestContext } from "../api/request-context.js";
|
|
|
21
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
|
-
import {
|
|
24
|
+
import { shiftParams, tableNameOf } from "../engine/ownership.js";
|
|
25
25
|
import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
26
26
|
import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
27
27
|
import { scrubLogValue } from "../logging/scrub.js";
|
|
28
28
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
29
29
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
30
|
-
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
31
30
|
import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
32
31
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
32
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
33
33
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
34
34
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
35
35
|
const log = createFallbackLogger("eagerload");
|
|
@@ -71,9 +71,7 @@ async function decryptReferencedRow(row, piiFields, encryptedFields, kms) {
|
|
|
71
71
|
// entity. Raw SQL because ownership/parentRef clauses are SQL fragments; the
|
|
72
72
|
// table is unaliased since where-rules qualify columns with the table name.
|
|
73
73
|
async function resolveRefAccessScope(refEntity, refTable, refTableName, ids, db, viewer) {
|
|
74
|
-
const access =
|
|
75
|
-
includeDeleted: false,
|
|
76
|
-
}));
|
|
74
|
+
const access = resolveReadOwnership({ entity: refEntity, table: refTable }, refTableName, viewer.user, db, viewer.parentVisibility, { includeDeleted: false, narrowTypes: undefined });
|
|
77
75
|
if (access.kind === "empty")
|
|
78
76
|
return { kind: "none" };
|
|
79
77
|
if (access.kind === "pass")
|
|
@@ -155,7 +153,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
155
153
|
// to the UUID instead of crashing.
|
|
156
154
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
|
|
157
155
|
}
|
|
158
|
-
const refTable = buildEntityTable(rf.refEntityName, refEntity);
|
|
156
|
+
const refTable = viewer.resolveTable?.(rf.refEntityName) ?? buildEntityTable(rf.refEntityName, refEntity);
|
|
159
157
|
const refTableName = tableNameOf(refTable);
|
|
160
158
|
const usesUuidIds = refEntity.idType === undefined || refEntity.idType === "uuid";
|
|
161
159
|
// A malformed ref value must not 500 the whole list via the uuid cast.
|
|
@@ -10,8 +10,8 @@ import { isStreamArchived } from "../event-store/index.js";
|
|
|
10
10
|
import { assertUnreachable } from "../utils/index.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
13
|
-
import { resolveReadOwnership } from "./event-store-executor-read-where.js";
|
|
14
13
|
import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
14
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
15
15
|
import { toSnakeCase } from "./table-builder.js";
|
|
16
16
|
import { columnHandleName, schemaTableName } from "./table-introspection.js";
|
|
17
17
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -81,7 +81,7 @@ export function appendCursorBoundary(where, encodedCursor, sort) {
|
|
|
81
81
|
export function encodeNextCursor(cursorId, sort, lastRaw, lastSortLabel, table) {
|
|
82
82
|
const sortText = sort.kind === "unsorted"
|
|
83
83
|
? undefined
|
|
84
|
-
: toCursorSortText(sort.kind === "
|
|
84
|
+
: toCursorSortText(sort.kind === "expression"
|
|
85
85
|
? lastSortLabel
|
|
86
86
|
: lastRaw[physicalColumnName(table, sort.field)]);
|
|
87
87
|
return sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
@@ -5,8 +5,10 @@ import type { SearchAdapter } from "../search/types.ts";
|
|
|
5
5
|
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
6
6
|
import type { Table } from "./event-store-executor-context.ts";
|
|
7
7
|
import { type ListWhere } from "./event-store-executor-read-filter.ts";
|
|
8
|
+
import type { ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
8
9
|
import type { TenantDb } from "./tenant-db.ts";
|
|
9
10
|
type ReferenceSearchOption = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSearch"];
|
|
11
|
+
export declare function referenceTargetOwnership(targetEntity: EntityDefinition, targetTable: Table, targetTableName: string, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined): OwnershipClause;
|
|
10
12
|
export declare function isSoftDeletedTargetHidden(targetEntity: EntityDefinition, targetTable: Table): boolean;
|
|
11
13
|
export type ListSearchRequest = {
|
|
12
14
|
readonly searchTerm: string;
|
|
@@ -14,6 +16,7 @@ export type ListSearchRequest = {
|
|
|
14
16
|
readonly entityName: string;
|
|
15
17
|
readonly table: Table;
|
|
16
18
|
readonly referenceSearch: ReferenceSearchOption;
|
|
19
|
+
readonly parentVisibility: ParentVisibilityOption | undefined;
|
|
17
20
|
readonly fieldReadClause: (field: string) => OwnershipClause;
|
|
18
21
|
readonly streamTenantFor: (user: SessionUser) => TenantId;
|
|
19
22
|
readonly user: SessionUser;
|
|
@@ -6,6 +6,7 @@ import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
|
6
6
|
import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-encryption.js";
|
|
7
7
|
import { bindPlaceholders } from "./event-store-executor-read-filter.js";
|
|
8
8
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
9
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
9
10
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
10
11
|
import { schemaTableName } from "./table-introspection.js";
|
|
11
12
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -42,6 +43,12 @@ function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
|
|
|
42
43
|
}
|
|
43
44
|
return descriptors;
|
|
44
45
|
}
|
|
46
|
+
// The target of a reference is gated like a direct read of it (access rule, parent-ref and
|
|
47
|
+
// readGate): a label of a row the viewer cannot open must not be findable by searching or
|
|
48
|
+
// readable by ordering.
|
|
49
|
+
export function referenceTargetOwnership(targetEntity, targetTable, targetTableName, user, db, parentVisibility) {
|
|
50
|
+
return resolveReadOwnership({ entity: targetEntity, table: targetTable }, targetTableName, user, db, parentVisibility, { includeDeleted: false, narrowTypes: undefined });
|
|
51
|
+
}
|
|
45
52
|
// Matches the main list's own default: soft-deleted rows are invisible, so a
|
|
46
53
|
// deleted target must neither match a search nor influence an ordering.
|
|
47
54
|
export function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
@@ -51,13 +58,13 @@ export function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
|
51
58
|
// unscoped SQL, a match in a foreign tenant must never surface a row here.
|
|
52
59
|
// Mirrors the main table's own tenant filter (see the `table["tenantId"]`
|
|
53
60
|
// check further down in list()).
|
|
54
|
-
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
|
|
61
|
+
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, parentVisibility) {
|
|
55
62
|
// The label must not leak through match counts: a viewer who cannot read the
|
|
56
63
|
// target row or its label field would otherwise probe names by searching.
|
|
57
64
|
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
58
65
|
if (labelAccess.kind !== "pass")
|
|
59
66
|
return undefined;
|
|
60
|
-
const ownership =
|
|
67
|
+
const ownership = referenceTargetOwnership(targetEntity, targetTable, targetTableName, user, db, parentVisibility);
|
|
61
68
|
if (ownership.kind === "empty")
|
|
62
69
|
return undefined;
|
|
63
70
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
@@ -91,7 +98,7 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEnt
|
|
|
91
98
|
}
|
|
92
99
|
// Same lookup, but the target's index is tenant-wide, not per-viewer, so
|
|
93
100
|
// candidate ids get re-checked against the target entity's own read access.
|
|
94
|
-
async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter) {
|
|
101
|
+
async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter, parentVisibility) {
|
|
95
102
|
const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
96
103
|
const results = await searchAdapter.search(searchTenantId, searchTerm, {
|
|
97
104
|
filterType: descriptor.targetEntityName,
|
|
@@ -101,7 +108,7 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
|
|
|
101
108
|
// skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
|
|
102
109
|
return undefined;
|
|
103
110
|
}
|
|
104
|
-
const ownership =
|
|
111
|
+
const ownership = referenceTargetOwnership(targetEntity, targetTable, targetTableName, user, db, parentVisibility);
|
|
105
112
|
if (ownership.kind === "empty")
|
|
106
113
|
return undefined;
|
|
107
114
|
const candidateIds = results.map((r) => String(r.entityId));
|
|
@@ -132,7 +139,7 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
|
|
|
132
139
|
return undefined;
|
|
133
140
|
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
134
141
|
}
|
|
135
|
-
async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db, user, searchAdapter) {
|
|
142
|
+
async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db, user, searchAdapter, resolveTable, parentVisibility) {
|
|
136
143
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
137
144
|
if (targetEntity === undefined) {
|
|
138
145
|
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
@@ -145,17 +152,18 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
145
152
|
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
146
153
|
return undefined;
|
|
147
154
|
}
|
|
148
|
-
const targetTable =
|
|
155
|
+
const targetTable = resolveTable?.(descriptor.targetEntityName) ??
|
|
156
|
+
referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
149
157
|
const targetTableName = String(schemaTableName(targetTable));
|
|
150
158
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
151
|
-
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
159
|
+
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, parentVisibility);
|
|
152
160
|
}
|
|
153
161
|
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
154
162
|
// skip: no plaintext anywhere to match — boot rejects this normally,
|
|
155
163
|
// this is defense-in-depth for a target entity resolved only at runtime.
|
|
156
164
|
return undefined;
|
|
157
165
|
}
|
|
158
|
-
return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter);
|
|
166
|
+
return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter, parentVisibility);
|
|
159
167
|
}
|
|
160
168
|
// Building the table is pure but not free, and runs on every search request;
|
|
161
169
|
// keyed by the definition object so a re-registered entity never sees a stale table.
|
|
@@ -216,6 +224,6 @@ async function resolveSearchableReferenceMatches(request, searchAdapter) {
|
|
|
216
224
|
// to infer its value by matching the target's label — same probe
|
|
217
225
|
// surface as filtering/sorting on it directly.
|
|
218
226
|
const descriptors = collectReferenceSearchDescriptors(request.table, referenceSearch, request.db.mode).filter((d) => request.fieldReadClause(d.ownColumn).kind === "pass");
|
|
219
|
-
const matches = await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, request.searchTerm, referenceSearch.resolveEntity, request.db, request.user, searchAdapter)));
|
|
227
|
+
const matches = await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, request.searchTerm, referenceSearch.resolveEntity, request.db, request.user, searchAdapter, referenceSearch.resolveTable, request.parentVisibility)));
|
|
220
228
|
return matches.filter((m) => m !== undefined);
|
|
221
229
|
}
|
|
@@ -4,6 +4,7 @@ import type { SessionUser } from "../engine/types/index.ts";
|
|
|
4
4
|
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
5
5
|
import type { Table } from "./event-store-executor-context.ts";
|
|
6
6
|
import type { ListWhere } from "./event-store-executor-read-filter.ts";
|
|
7
|
+
import type { ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
7
8
|
import type { TenantDb } from "./tenant-db.ts";
|
|
8
9
|
type ListPayload = Parameters<EventStoreExecutor["list"]>[0];
|
|
9
10
|
type ReferenceSortOption = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSort"];
|
|
@@ -15,16 +16,20 @@ export type ListSort = {
|
|
|
15
16
|
readonly descending: boolean;
|
|
16
17
|
readonly nulls: SortNulls | undefined;
|
|
17
18
|
} | {
|
|
18
|
-
readonly kind: "
|
|
19
|
+
readonly kind: "expression";
|
|
19
20
|
readonly field: string;
|
|
20
21
|
readonly descending: boolean;
|
|
21
22
|
readonly nulls: SortNulls | undefined;
|
|
22
|
-
readonly
|
|
23
|
+
readonly sql: (params: unknown[]) => string;
|
|
23
24
|
};
|
|
24
25
|
export type SortedListSort = Exclude<ListSort, {
|
|
25
26
|
readonly kind: "unsorted";
|
|
26
27
|
}>;
|
|
27
28
|
export declare function sortsNullsFirst(sort: SortedListSort): boolean;
|
|
29
|
+
export declare function buildReferenceSortExpr(outerColumnSql: string, descriptor: {
|
|
30
|
+
readonly targetEntityName: string;
|
|
31
|
+
readonly labelField: string;
|
|
32
|
+
}, resolveEntity: (entityName: string) => EntityDefinition | undefined, db: TenantDb, user: SessionUser, resolveTable: ((entityName: string) => Table) | undefined, parentVisibility: ParentVisibilityOption | undefined): ((params: unknown[]) => string) | undefined;
|
|
28
33
|
export declare function resolveListSort(request: {
|
|
29
34
|
readonly payload: Pick<ListPayload, "sort" | "sortDirection">;
|
|
30
35
|
readonly table: Table;
|
|
@@ -33,8 +38,10 @@ export declare function resolveListSort(request: {
|
|
|
33
38
|
readonly colSql: (field: string) => string;
|
|
34
39
|
readonly fieldReadClause: (field: string) => OwnershipClause;
|
|
35
40
|
readonly referenceSort: ReferenceSortOption;
|
|
41
|
+
readonly derivedSortExpr?: (field: string) => ((params: unknown[]) => string) | undefined;
|
|
36
42
|
readonly db: TenantDb;
|
|
37
43
|
readonly user: SessionUser;
|
|
44
|
+
readonly parentVisibility: ParentVisibilityOption | undefined;
|
|
38
45
|
}): ListSort;
|
|
39
46
|
export declare function renderSortSql(sort: SortedListSort, where: ListWhere): string;
|
|
40
47
|
export declare function renderListOrderBy(sort: ListSort, where: ListWhere): {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { buildOwnershipClause, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
2
2
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
3
3
|
import { isListSortAllowed, isSensitiveLabelField } from "./entity-field-encryption.js";
|
|
4
|
-
import { isSoftDeletedTargetHidden } from "./event-store-executor-read-search.js";
|
|
4
|
+
import { isSoftDeletedTargetHidden, referenceTargetOwnership, } from "./event-store-executor-read-search.js";
|
|
5
5
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
6
6
|
import { schemaTableName } from "./table-introspection.js";
|
|
7
7
|
// Projected alongside the row when a reference sort is active, so the keyset
|
|
8
8
|
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
9
9
|
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
10
|
+
// Where-rules qualify with the table name they get; the target may be the outer entity itself.
|
|
11
|
+
const TARGET_ALIAS = "t";
|
|
10
12
|
function declaredSortNulls(entity, field) {
|
|
11
13
|
const def = entity.fields[field];
|
|
12
14
|
return def !== undefined && "sortNulls" in def ? def.sortNulls : undefined;
|
|
@@ -28,7 +30,7 @@ function nullsClause(sort) {
|
|
|
28
30
|
// undefined means "cannot order by this label", which drops the sort to plain
|
|
29
31
|
// id order; falling back to the UUID column would look deliberate to the user
|
|
30
32
|
// while being arbitrary, which is worse than no sorting.
|
|
31
|
-
function buildReferenceSortExpr(
|
|
33
|
+
export function buildReferenceSortExpr(outerColumnSql, descriptor, resolveEntity, db, user, resolveTable, parentVisibility) {
|
|
32
34
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
33
35
|
if (targetEntity === undefined) {
|
|
34
36
|
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
@@ -44,15 +46,16 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
44
46
|
// arbitrary to the reader and would also rank rows by their stored secret
|
|
45
47
|
return undefined;
|
|
46
48
|
}
|
|
47
|
-
const targetTable =
|
|
49
|
+
const targetTable = resolveTable?.(descriptor.targetEntityName) ??
|
|
50
|
+
buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
48
51
|
// The order of labels the viewer may not read would leak them bit by bit, same as the search path.
|
|
49
|
-
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
52
|
+
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable, 1, TARGET_ALIAS);
|
|
50
53
|
if (labelAccess.kind !== "pass")
|
|
51
54
|
return undefined;
|
|
52
|
-
const
|
|
55
|
+
const targetTableName = String(schemaTableName(targetTable));
|
|
56
|
+
const ownership = referenceTargetOwnership(targetEntity, targetTable, TARGET_ALIAS, user, db, parentVisibility);
|
|
53
57
|
if (ownership.kind === "empty")
|
|
54
58
|
return undefined;
|
|
55
|
-
const targetTableName = String(schemaTableName(targetTable));
|
|
56
59
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
57
60
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
58
61
|
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
@@ -77,11 +80,21 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
77
80
|
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
78
81
|
}
|
|
79
82
|
return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
80
|
-
`WHERE t."id" =
|
|
83
|
+
`WHERE t."id" = ${outerColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
|
|
81
84
|
};
|
|
82
85
|
}
|
|
83
86
|
export function resolveListSort(request) {
|
|
84
87
|
const { payload, table, entity, referenceSort } = request;
|
|
88
|
+
const derivedSql = payload.sort ? request.derivedSortExpr?.(payload.sort) : undefined;
|
|
89
|
+
if (derivedSql !== undefined && payload.sort) {
|
|
90
|
+
return {
|
|
91
|
+
kind: "expression",
|
|
92
|
+
field: payload.sort,
|
|
93
|
+
descending: payload.sortDirection === "desc",
|
|
94
|
+
nulls: undefined,
|
|
95
|
+
sql: derivedSql,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
85
98
|
// A column sort must not 400 (the renderer sends one per header click), and the order of
|
|
86
99
|
// ciphertext carries no meaning for the user while leaking the order of the plaintext.
|
|
87
100
|
const field = payload.sort &&
|
|
@@ -101,15 +114,15 @@ export function resolveListSort(request) {
|
|
|
101
114
|
// directly: either the field opted into `sortable` and we order by the
|
|
102
115
|
// target row's label, or the sort is dropped to plain id order.
|
|
103
116
|
const descriptor = referenceSort?.fields.find((f) => f.fieldName === field);
|
|
104
|
-
const
|
|
105
|
-
? buildReferenceSortExpr(request.tableName
|
|
117
|
+
const sql = referenceSort && descriptor
|
|
118
|
+
? buildReferenceSortExpr(`"${request.tableName}".${request.colSql(field)}`, descriptor, referenceSort.resolveEntity, request.db, request.user, referenceSort.resolveTable, request.parentVisibility)
|
|
106
119
|
: undefined;
|
|
107
|
-
return
|
|
120
|
+
return sql === undefined
|
|
108
121
|
? { kind: "unsorted" }
|
|
109
|
-
: { kind: "
|
|
122
|
+
: { kind: "expression", field, descending, nulls, sql };
|
|
110
123
|
}
|
|
111
124
|
export function renderSortSql(sort, where) {
|
|
112
|
-
return sort.kind === "
|
|
125
|
+
return sort.kind === "expression" ? sort.sql(where.params) : where.colSql(sort.field);
|
|
113
126
|
}
|
|
114
127
|
export function renderListOrderBy(sort, where) {
|
|
115
128
|
const idAscending = `${where.colSql("id")} ASC`;
|
|
@@ -124,7 +137,7 @@ export function renderListOrderBy(sort, where) {
|
|
|
124
137
|
sortLabelProjection: "",
|
|
125
138
|
};
|
|
126
139
|
}
|
|
127
|
-
// The projected
|
|
140
|
+
// The projected alias is ordered by directly, so Postgres evaluates
|
|
128
141
|
// the correlated subquery once per row instead of once for ORDER BY and
|
|
129
142
|
// once for the SELECT list. Projecting it also lets the keyset cursor carry
|
|
130
143
|
// the value that was ordered by rather than the FK column's UUID.
|
|
@@ -137,7 +150,7 @@ export function renderListOrderBy(sort, where) {
|
|
|
137
150
|
// stripped before coercion; the last row's label feeds the keyset cursor.
|
|
138
151
|
export function takeLastSortLabel(rawRows, sort) {
|
|
139
152
|
const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
|
|
140
|
-
if (sort.kind === "
|
|
153
|
+
if (sort.kind === "expression") {
|
|
141
154
|
for (const row of rawRows)
|
|
142
155
|
delete row[SORT_LABEL_ALIAS];
|
|
143
156
|
}
|
|
@@ -3,15 +3,10 @@ import type { SessionUser } from "../engine/types/index.ts";
|
|
|
3
3
|
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
4
4
|
import type { ExecutorContext } from "./event-store-executor-context.ts";
|
|
5
5
|
import { type ListWhere } from "./event-store-executor-read-filter.ts";
|
|
6
|
-
import { type ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
7
6
|
import type { TenantDb } from "./tenant-db.ts";
|
|
8
7
|
export type ListRuntimeOptions = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>;
|
|
9
8
|
export type ListWherePayload = Pick<Parameters<EventStoreExecutor["list"]>[0], "search" | "filter" | "filters">;
|
|
10
9
|
export type FieldReadClause = (field: string) => OwnershipClause;
|
|
11
|
-
export declare function resolveReadOwnership(ctx: Pick<ExecutorContext, "entity" | "table">, tableName: string, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined, gate: {
|
|
12
|
-
readonly includeDeleted: boolean;
|
|
13
|
-
readonly narrowTypes: ReadonlySet<string> | undefined;
|
|
14
|
-
}): OwnershipClause;
|
|
15
10
|
export declare function createFieldReadClause(ctx: Pick<ExecutorContext, "entity" | "table">, user: SessionUser): FieldReadClause;
|
|
16
11
|
export type ListWhereBuild = {
|
|
17
12
|
readonly kind: "empty";
|
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
import { buildOwnershipClause,
|
|
1
|
+
import { buildOwnershipClause, normalizeAccessEntry, } from "../engine/ownership.js";
|
|
2
2
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
3
3
|
import { appendShiftedClause, applyListFilter, collectListFilters, deriveParentNarrowTypes, } from "./event-store-executor-read-filter.js";
|
|
4
4
|
import { applyListSearch } from "./event-store-executor-read-search.js";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
6
6
|
import { physicalColumnName } from "./table-builder.js";
|
|
7
7
|
import { schemaTableName } from "./table-introspection.js";
|
|
8
|
-
// H.2 — entity-level read ownership, AND-ed with the parent-ref read-gate
|
|
9
|
-
// (join-row entities derive their visibility from a host row's own read path —
|
|
10
|
-
// see db/parent-ref-clause.ts). `empty` means there's no row the user could ever see.
|
|
11
|
-
export function resolveReadOwnership(ctx, tableName, user, db, parentVisibility, gate) {
|
|
12
|
-
const parentClause = buildParentRefClause(ctx.entity, ctx.table, tableName, user, db, parentVisibility, gate);
|
|
13
|
-
return combineClauses(buildOwnershipClause(user, ctx.entity.access?.read, ctx.table), parentClause);
|
|
14
|
-
}
|
|
15
8
|
// Replicates TenantDb's readWhere semantics for the raw list SQL.
|
|
16
9
|
function appendTenantAndSoftDeleteScope(where, ctx, db, includeDeleted) {
|
|
17
10
|
if (ctx.table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
@@ -62,6 +55,7 @@ export async function buildListWhere(ctx, payload, user, db, runtimeOptions) {
|
|
|
62
55
|
entityName,
|
|
63
56
|
table,
|
|
64
57
|
referenceSearch: runtimeOptions?.referenceSearch,
|
|
58
|
+
parentVisibility: runtimeOptions?.parentVisibility,
|
|
65
59
|
fieldReadClause,
|
|
66
60
|
streamTenantFor,
|
|
67
61
|
user,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
2
2
|
import { extractTableInfo } from "../db/table-info.js";
|
|
3
3
|
import { getStreamVersion } from "../event-store/index.js";
|
|
4
|
+
import { buildDerivedSelect } from "./derived-field-sql.js";
|
|
4
5
|
import { aggregateList } from "./event-store-executor-read-aggregate.js";
|
|
5
6
|
import { appendCursorBoundary, countListRows, encodeNextCursor, resolveListPagination, } from "./event-store-executor-read-paging.js";
|
|
6
7
|
import { appendStreamVersionProjection, coerceAndRehydrateRow, takeStreamVersions, withStreamVersion, } from "./event-store-executor-read-rows.js";
|
|
7
8
|
import { renderListOrderBy, resolveListSort, takeLastSortLabel, } from "./event-store-executor-read-sort.js";
|
|
8
|
-
import { buildListWhere
|
|
9
|
+
import { buildListWhere } from "./event-store-executor-read-where.js";
|
|
10
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
9
11
|
import { schemaTableName } from "./table-introspection.js";
|
|
10
12
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
11
13
|
// The read verbs (list/aggregate/detail) of the event-store-executor. Split out
|
|
@@ -30,6 +32,15 @@ export function createReadVerbs(ctx) {
|
|
|
30
32
|
if (built.kind === "empty")
|
|
31
33
|
return emptyListPage(totalCount);
|
|
32
34
|
const { where, fieldReadClause, searchTruncated } = built;
|
|
35
|
+
const derived = buildDerivedSelect({
|
|
36
|
+
entity,
|
|
37
|
+
table,
|
|
38
|
+
tableName,
|
|
39
|
+
fieldReadClause,
|
|
40
|
+
user,
|
|
41
|
+
db,
|
|
42
|
+
runtime: runtimeOptions,
|
|
43
|
+
});
|
|
33
44
|
const sort = resolveListSort({
|
|
34
45
|
payload,
|
|
35
46
|
table,
|
|
@@ -38,8 +49,10 @@ export function createReadVerbs(ctx) {
|
|
|
38
49
|
colSql: where.colSql,
|
|
39
50
|
fieldReadClause,
|
|
40
51
|
referenceSort: runtimeOptions?.referenceSort,
|
|
52
|
+
derivedSortExpr: derived?.sortExpr,
|
|
41
53
|
db,
|
|
42
54
|
user,
|
|
55
|
+
parentVisibility: runtimeOptions?.parentVisibility,
|
|
43
56
|
});
|
|
44
57
|
if (payload.cursor)
|
|
45
58
|
appendCursorBoundary(where, payload.cursor, sort);
|
|
@@ -51,24 +64,30 @@ export function createReadVerbs(ctx) {
|
|
|
51
64
|
const offsetClause = !payload.cursor && offset > 0 ? ` OFFSET ${offset}` : "";
|
|
52
65
|
const whereClauseSqlText = where.whereSql.length > 0 ? ` WHERE ${where.whereSql.join(" AND ")}` : "";
|
|
53
66
|
const streamVersionProjection = appendStreamVersionProjection(where.params, tableName, streamTenantFor(user));
|
|
54
|
-
const
|
|
67
|
+
const derivedProjection = derived?.projections(where.params) ?? "";
|
|
68
|
+
const selectList = `*${sortLabelProjection}${streamVersionProjection}${derivedProjection}`;
|
|
55
69
|
const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
56
70
|
const rawRows = await executeRawQueryRead(runner, listSql, where.params);
|
|
57
71
|
const lastSortLabel = takeLastSortLabel(rawRows, sort);
|
|
58
72
|
const streamVersions = takeStreamVersions(rawRows);
|
|
73
|
+
const derivedValues = derived?.takeValues(rawRows);
|
|
59
74
|
const tableInfo = extractTableInfo(table);
|
|
60
75
|
const encryptedRows = rawRows.map((r) => coerceAndRehydrateRow(r, tableInfo, entity));
|
|
61
76
|
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
|
|
62
77
|
// Stream version is authoritative (see detail()): ctx.events.append bumps the
|
|
63
78
|
// stream without touching row.version, so the raw row version would hand
|
|
64
79
|
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
65
|
-
const
|
|
80
|
+
const storedRows = decryptedRows.map((r, i) => withStreamVersion(r, streamVersions[i] ?? 0));
|
|
66
81
|
// Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
|
|
67
82
|
// detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
|
|
68
83
|
// a cached version that differs from the stream.
|
|
69
|
-
if (entityCache && entityName &&
|
|
84
|
+
if (entityCache && entityName && storedRows.length > 0) {
|
|
70
85
|
await entityCache.mset(user.tenantId, entityName, encryptedRows.map((r) => ({ id: r["id"], data: r })));
|
|
71
86
|
}
|
|
87
|
+
// Viewer-dependent, so attached only after the cache write, as new row objects.
|
|
88
|
+
const rows = derivedValues === undefined
|
|
89
|
+
? storedRows
|
|
90
|
+
: storedRows.map((r, i) => ({ ...r, ...derivedValues[i] }));
|
|
72
91
|
const lastRow = rows[rows.length - 1];
|
|
73
92
|
const lastRaw = rawRows[rawRows.length - 1];
|
|
74
93
|
let nextCursor = null;
|