@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.
Files changed (143) hide show
  1. package/AGENTS.md +33 -19
  2. package/agents/api/dev-server-testing.md +23 -0
  3. package/agents/api/framework-db.md +277 -0
  4. package/agents/api/framework-engine.md +1810 -0
  5. package/agents/api/framework-errors.md +119 -0
  6. package/agents/api/framework-event-store.md +99 -0
  7. package/agents/api/framework-seeding.md +26 -0
  8. package/agents/api/framework-ui-types.md +542 -0
  9. package/agents/api/renderer-web.md +1450 -0
  10. package/agents/api/testing-helpers.md +189 -0
  11. package/agents/api/testing-stack.md +144 -0
  12. package/agents/api/types.md +5124 -0
  13. package/agents/api.md +20 -0
  14. package/agents/examples/derived-fields-sql.md +251 -0
  15. package/agents/examples/documents.md +161 -0
  16. package/agents/examples/inspection-checklist.md +807 -0
  17. package/agents/examples.md +3 -0
  18. package/agents/features-index.md +178 -0
  19. package/agents/features.md +9 -1
  20. package/dist/api/entity-read-access.d.ts +2 -0
  21. package/dist/api/entity-read-access.js +46 -2
  22. package/dist/api/server-boot-checks.js +2 -2
  23. package/dist/api/server-routes.js +3 -1
  24. package/dist/db/derived-field-sql.d.ts +23 -0
  25. package/dist/db/derived-field-sql.js +115 -0
  26. package/dist/db/derived-related-sql.d.ts +31 -0
  27. package/dist/db/derived-related-sql.js +137 -0
  28. package/dist/db/driver-row-coercion.d.ts +1 -0
  29. package/dist/db/driver-row-coercion.js +13 -0
  30. package/dist/db/eagerload.d.ts +3 -1
  31. package/dist/db/eagerload.js +4 -6
  32. package/dist/db/event-store-executor-context.js +1 -1
  33. package/dist/db/event-store-executor-read-paging.js +1 -1
  34. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  35. package/dist/db/event-store-executor-read-search.js +17 -9
  36. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  37. package/dist/db/event-store-executor-read-sort.js +27 -14
  38. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  39. package/dist/db/event-store-executor-read-where.js +3 -9
  40. package/dist/db/event-store-executor-read.js +23 -4
  41. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  42. package/dist/db/event-store-executor-write-steps.js +20 -1
  43. package/dist/db/event-store-executor-write.js +8 -8
  44. package/dist/db/money.d.ts +4 -0
  45. package/dist/db/money.js +34 -29
  46. package/dist/db/parent-ref-clause.d.ts +1 -0
  47. package/dist/db/parent-ref-clause.js +18 -8
  48. package/dist/db/read-gate-clause.d.ts +7 -0
  49. package/dist/db/read-gate-clause.js +188 -0
  50. package/dist/db/read-ownership.d.ts +9 -0
  51. package/dist/db/read-ownership.js +12 -0
  52. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  53. package/dist/engine/boot-validator/boot-check.js +7 -11
  54. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  55. package/dist/engine/boot-validator/boot-context.js +57 -0
  56. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  57. package/dist/engine/boot-validator/boot-options.js +1 -0
  58. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  59. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  60. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  61. package/dist/engine/boot-validator/boot-phases.js +140 -0
  62. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  63. package/dist/engine/boot-validator/boot-report.js +47 -0
  64. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  65. package/dist/engine/boot-validator/boot-rule.js +14 -0
  66. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  67. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  68. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  69. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  70. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  71. package/dist/engine/boot-validator/entity-handler.js +6 -3
  72. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  73. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  74. package/dist/engine/boot-validator/field-conditions.js +178 -0
  75. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  76. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  77. package/dist/engine/boot-validator/index.d.ts +5 -34
  78. package/dist/engine/boot-validator/index.js +24 -204
  79. package/dist/engine/boot-validator/pii-retention.js +2 -7
  80. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  81. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  82. package/dist/engine/boot-validator/read-gate.js +54 -0
  83. package/dist/engine/boot-validator/screens-forms.js +19 -6
  84. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  85. package/dist/engine/boot-validator/screens-lists.js +8 -0
  86. package/dist/engine/boot-validator/zod-shape.js +4 -2
  87. package/dist/engine/build-app-schema.js +11 -5
  88. package/dist/engine/embedded-derived.d.ts +8 -6
  89. package/dist/engine/embedded-derived.js +25 -7
  90. package/dist/engine/entity-handlers.d.ts +1 -1
  91. package/dist/engine/entity-handlers.js +17 -18
  92. package/dist/engine/entity-list-runtime-options.d.ts +5 -1
  93. package/dist/engine/entity-list-runtime-options.js +103 -4
  94. package/dist/engine/factories.d.ts +5 -5
  95. package/dist/engine/factories.js +3 -5
  96. package/dist/engine/field-access.d.ts +2 -0
  97. package/dist/engine/field-access.js +48 -1
  98. package/dist/engine/field-state.d.ts +12 -0
  99. package/dist/engine/field-state.js +67 -0
  100. package/dist/engine/handler-helpers.d.ts +3 -0
  101. package/dist/engine/handler-helpers.js +7 -1
  102. package/dist/engine/index.d.ts +2 -1
  103. package/dist/engine/index.js +2 -1
  104. package/dist/engine/ownership.d.ts +1 -1
  105. package/dist/engine/ownership.js +9 -5
  106. package/dist/engine/registry-state.d.ts +1 -0
  107. package/dist/engine/registry-state.js +1 -1
  108. package/dist/engine/schema-builder.js +3 -0
  109. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  110. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  111. package/dist/entrypoint/index.js +1 -0
  112. package/dist/entrypoint/internals.d.ts +2 -0
  113. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  114. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  115. package/dist/files/bind-pending-file-refs.js +11 -12
  116. package/dist/files/bind-referenced-files.write.js +5 -5
  117. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  118. package/dist/files/create-file-ref-from-bytes.js +2 -0
  119. package/dist/files/create-file-ref.d.ts +2 -0
  120. package/dist/files/create-file-ref.js +39 -22
  121. package/dist/files/dispose-removed-file-refs.js +5 -8
  122. package/dist/files/file-field-slots.d.ts +10 -0
  123. package/dist/files/file-field-slots.js +53 -0
  124. package/dist/files/file-ref-entity.d.ts +1 -1
  125. package/dist/files/file-ref-table.d.ts +1 -1
  126. package/dist/files/file-routes.js +30 -9
  127. package/dist/files/index.d.ts +1 -0
  128. package/dist/files/index.js +1 -0
  129. package/dist/files/load-save-file-refs.js +3 -4
  130. package/dist/files/record-read-check.js +2 -30
  131. package/dist/files/record-write-check.d.ts +2 -0
  132. package/dist/files/record-write-check.js +18 -3
  133. package/dist/files/upload-field-resolution.d.ts +4 -1
  134. package/dist/files/upload-field-resolution.js +57 -12
  135. package/dist/pipeline/dispatch-batch.js +6 -5
  136. package/dist/pipeline/dispatch-write.js +2 -1
  137. package/dist/pipeline/write-result-masking.js +2 -1
  138. package/dist/schema-cli/validate-command.js +46 -12
  139. package/dist/schema-cli.js +1 -1
  140. package/dist/ui-types/index.d.ts +2 -1
  141. package/dist/ui-types/index.js +2 -1
  142. package/package.json +3 -3
  143. 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
+ }
@@ -1,2 +1,3 @@
1
1
  import type { TableInfo } from "./table-info.ts";
2
+ export declare function coerceDriverValueOfPgType(value: unknown, pgType: string, column: string): unknown;
2
3
  export declare function coerceDriverRow(row: Record<string, unknown>, info: TableInfo): Record<string, unknown>;
@@ -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;
@@ -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 { type ParentVisibilityOption } from "./parent-ref-clause.ts";
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. */
@@ -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 { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
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 = combineClauses(buildOwnershipClause(viewer.user, refEntity.access?.read, refTable), buildParentRefClause(refEntity, refTable, refTableName, viewer.user, db, viewer.parentVisibility, {
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 === "referenceLabel"
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 = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
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 = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
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 = referenceTargetTable(descriptor.targetEntityName, targetEntity);
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: "referenceLabel";
19
+ readonly kind: "expression";
19
20
  readonly field: string;
20
21
  readonly descending: boolean;
21
22
  readonly nulls: SortNulls | undefined;
22
- readonly labelSql: (params: unknown[]) => string;
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(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
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 = buildEntityTable(descriptor.targetEntityName, targetEntity);
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 ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
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" = "${outerTableName}".${ownColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
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 labelSql = referenceSort && descriptor
105
- ? buildReferenceSortExpr(request.tableName, request.colSql(field), descriptor, referenceSort.resolveEntity, request.db, request.user)
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 labelSql === undefined
120
+ return sql === undefined
108
121
  ? { kind: "unsorted" }
109
- : { kind: "referenceLabel", field, descending, nulls, labelSql };
122
+ : { kind: "expression", field, descending, nulls, sql };
110
123
  }
111
124
  export function renderSortSql(sort, where) {
112
- return sort.kind === "referenceLabel" ? sort.labelSql(where.params) : where.colSql(sort.field);
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 label alias is ordered by directly, so Postgres evaluates
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 === "referenceLabel") {
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, combineClauses, normalizeAccessEntry, } from "../engine/ownership.js";
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 { buildParentRefClause } from "./parent-ref-clause.js";
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, resolveReadOwnership } from "./event-store-executor-read-where.js";
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 selectList = `*${sortLabelProjection}${streamVersionProjection}`;
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 rows = decryptedRows.map((r, i) => withStreamVersion(r, streamVersions[i] ?? 0));
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 && rows.length > 0) {
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;