@cosmicdrift/kumiko-framework 0.399.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 (133) hide show
  1. package/agents/api/framework-engine.md +9 -3
  2. package/agents/api/framework-ui-types.md +10 -5
  3. package/agents/api/renderer-web.md +2 -1
  4. package/agents/api/types.md +242 -80
  5. package/agents/api.md +2 -2
  6. package/agents/examples/derived-fields-sql.md +251 -0
  7. package/agents/examples/documents.md +161 -0
  8. package/agents/examples/inspection-checklist.md +807 -0
  9. package/agents/examples.md +3 -0
  10. package/agents/features-index.md +2 -0
  11. package/agents/features.md +8 -0
  12. package/dist/api/entity-read-access.d.ts +2 -0
  13. package/dist/api/entity-read-access.js +46 -2
  14. package/dist/api/server-boot-checks.js +2 -2
  15. package/dist/api/server-routes.js +3 -1
  16. package/dist/db/derived-field-sql.d.ts +23 -0
  17. package/dist/db/derived-field-sql.js +115 -0
  18. package/dist/db/derived-related-sql.d.ts +31 -0
  19. package/dist/db/derived-related-sql.js +137 -0
  20. package/dist/db/driver-row-coercion.d.ts +1 -0
  21. package/dist/db/driver-row-coercion.js +13 -0
  22. package/dist/db/eagerload.d.ts +3 -1
  23. package/dist/db/eagerload.js +4 -6
  24. package/dist/db/event-store-executor-context.js +1 -1
  25. package/dist/db/event-store-executor-read-paging.js +1 -1
  26. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  27. package/dist/db/event-store-executor-read-search.js +17 -9
  28. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  29. package/dist/db/event-store-executor-read-sort.js +27 -14
  30. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  31. package/dist/db/event-store-executor-read-where.js +3 -9
  32. package/dist/db/event-store-executor-read.js +23 -4
  33. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  34. package/dist/db/event-store-executor-write-steps.js +20 -1
  35. package/dist/db/event-store-executor-write.js +8 -8
  36. package/dist/db/money.d.ts +4 -0
  37. package/dist/db/money.js +34 -29
  38. package/dist/db/parent-ref-clause.d.ts +1 -0
  39. package/dist/db/parent-ref-clause.js +18 -8
  40. package/dist/db/read-gate-clause.d.ts +7 -0
  41. package/dist/db/read-gate-clause.js +188 -0
  42. package/dist/db/read-ownership.d.ts +9 -0
  43. package/dist/db/read-ownership.js +12 -0
  44. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  45. package/dist/engine/boot-validator/boot-check.js +7 -11
  46. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  47. package/dist/engine/boot-validator/boot-context.js +57 -0
  48. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  49. package/dist/engine/boot-validator/boot-options.js +1 -0
  50. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  51. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  52. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  53. package/dist/engine/boot-validator/boot-phases.js +140 -0
  54. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  55. package/dist/engine/boot-validator/boot-report.js +47 -0
  56. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  57. package/dist/engine/boot-validator/boot-rule.js +14 -0
  58. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  59. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  60. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  61. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  62. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  63. package/dist/engine/boot-validator/entity-handler.js +6 -3
  64. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  65. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  66. package/dist/engine/boot-validator/field-conditions.js +178 -0
  67. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  68. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  69. package/dist/engine/boot-validator/index.d.ts +5 -34
  70. package/dist/engine/boot-validator/index.js +24 -204
  71. package/dist/engine/boot-validator/pii-retention.js +2 -7
  72. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  73. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  74. package/dist/engine/boot-validator/read-gate.js +54 -0
  75. package/dist/engine/boot-validator/screens-forms.js +19 -6
  76. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  77. package/dist/engine/boot-validator/screens-lists.js +8 -0
  78. package/dist/engine/boot-validator/zod-shape.js +4 -2
  79. package/dist/engine/build-app-schema.js +11 -5
  80. package/dist/engine/embedded-derived.d.ts +8 -6
  81. package/dist/engine/embedded-derived.js +25 -7
  82. package/dist/engine/entity-handlers.d.ts +1 -1
  83. package/dist/engine/entity-handlers.js +17 -18
  84. package/dist/engine/entity-list-runtime-options.d.ts +2 -1
  85. package/dist/engine/entity-list-runtime-options.js +86 -5
  86. package/dist/engine/factories.d.ts +5 -5
  87. package/dist/engine/factories.js +3 -5
  88. package/dist/engine/field-access.d.ts +2 -0
  89. package/dist/engine/field-access.js +48 -1
  90. package/dist/engine/field-state.d.ts +12 -0
  91. package/dist/engine/field-state.js +67 -0
  92. package/dist/engine/handler-helpers.d.ts +3 -0
  93. package/dist/engine/handler-helpers.js +7 -1
  94. package/dist/engine/index.d.ts +2 -2
  95. package/dist/engine/index.js +2 -2
  96. package/dist/engine/ownership.d.ts +1 -1
  97. package/dist/engine/ownership.js +9 -5
  98. package/dist/engine/registry-state.d.ts +1 -0
  99. package/dist/engine/registry-state.js +1 -1
  100. package/dist/engine/schema-builder.js +3 -0
  101. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  102. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  103. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  104. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  105. package/dist/files/bind-pending-file-refs.js +11 -12
  106. package/dist/files/bind-referenced-files.write.js +5 -5
  107. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  108. package/dist/files/create-file-ref-from-bytes.js +2 -0
  109. package/dist/files/create-file-ref.d.ts +2 -0
  110. package/dist/files/create-file-ref.js +39 -22
  111. package/dist/files/dispose-removed-file-refs.js +5 -8
  112. package/dist/files/file-field-slots.d.ts +10 -0
  113. package/dist/files/file-field-slots.js +53 -0
  114. package/dist/files/file-ref-entity.d.ts +1 -1
  115. package/dist/files/file-ref-table.d.ts +1 -1
  116. package/dist/files/file-routes.js +12 -3
  117. package/dist/files/index.d.ts +1 -0
  118. package/dist/files/index.js +1 -0
  119. package/dist/files/load-save-file-refs.js +3 -4
  120. package/dist/files/record-read-check.js +2 -30
  121. package/dist/files/record-write-check.d.ts +2 -0
  122. package/dist/files/record-write-check.js +18 -3
  123. package/dist/files/upload-field-resolution.d.ts +2 -1
  124. package/dist/files/upload-field-resolution.js +33 -13
  125. package/dist/pipeline/dispatch-batch.js +6 -5
  126. package/dist/pipeline/dispatch-write.js +2 -1
  127. package/dist/pipeline/write-result-masking.js +2 -1
  128. package/dist/schema-cli/validate-command.js +46 -12
  129. package/dist/schema-cli.js +1 -1
  130. package/dist/ui-types/index.d.ts +2 -1
  131. package/dist/ui-types/index.js +2 -1
  132. package/package.json +3 -3
  133. package/src/changes.json +55 -0
@@ -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;
@@ -1,13 +1,18 @@
1
1
  import type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
3
  import type { ZodType } from "zod";
4
- import type { UnavailableSelectOption } from "../engine/screen-helpers.ts";
4
+ import { type UnavailableSelectOption } from "../engine/screen-helpers.ts";
5
+ import type { FieldDefinition } from "../engine/types/fields.ts";
5
6
  import type { UnprocessableOpts } from "../errors/classes.ts";
6
7
  import { type WriteFailure, writeFailure } from "../errors/index.ts";
7
8
  import type { StoredEvent } from "../event-store/index.ts";
8
9
  import type { DbRow, DbRunner } from "./connection.ts";
9
10
  export declare function projectEntityEvent(db: TenantDb, runner: DbRunner, event: StoredEvent): Promise<void>;
10
11
  export declare function selectOptionNotAvailableFailure(entries: readonly UnavailableSelectOption[]): WriteFailure | undefined;
12
+ export declare function missingConditionalRequirementFailure(paths: readonly string[]): WriteFailure | undefined;
13
+ /** Value-consistency checks on the merged row (create: no `previous`): options a
14
+ * conditionalOptions rule no longer allows, and requiredWhen values still missing. */
15
+ export declare function fieldStateFailure(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): WriteFailure | undefined;
11
16
  export type PreSaveFn = (changes: Record<string, unknown>, previous: Record<string, unknown>, isNew: boolean) => Promise<Record<string, unknown>>;
12
17
  export declare function runPreSave(preSave: PreSaveFn | undefined, changes: Record<string, unknown>, previous: Record<string, unknown>, isNew: boolean, entityName: string, action: "create" | "update", outputSchema: () => ZodType): Promise<{
13
18
  readonly data: DbRow;
@@ -1,4 +1,6 @@
1
- import { VersionConflictError as FrameworkVersionConflict, UnprocessableError, writeFailure, } from "../errors/index.js";
1
+ import { findMissingConditionalRequirements } from "../engine/field-state.js";
2
+ import { findUnavailableSelectOptions, } from "../engine/screen-helpers.js";
3
+ import { VersionConflictError as FrameworkVersionConflict, UnprocessableError, ValidationError, writeFailure, } from "../errors/index.js";
2
4
  import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
3
5
  import { projectionRegistryOf } from "./projection-registry-binding.js";
4
6
  import { runInSavepointIfSupported } from "./query.js";
@@ -35,6 +37,23 @@ export function selectOptionNotAvailableFailure(entries) {
35
37
  },
36
38
  }));
37
39
  }
40
+ export function missingConditionalRequirementFailure(paths) {
41
+ if (paths.length === 0)
42
+ return undefined;
43
+ return writeFailure(new ValidationError({
44
+ fields: paths.map((path) => ({
45
+ path,
46
+ code: "required",
47
+ i18nKey: "kumiko.validation.required",
48
+ })),
49
+ }));
50
+ }
51
+ /** Value-consistency checks on the merged row (create: no `previous`): options a
52
+ * conditionalOptions rule no longer allows, and requiredWhen values still missing. */
53
+ export function fieldStateFailure(fields, values, previous) {
54
+ return (selectOptionNotAvailableFailure(findUnavailableSelectOptions(fields, values, previous)) ??
55
+ missingConditionalRequirementFailure(findMissingConditionalRequirements(fields, values, previous)));
56
+ }
38
57
  // preSave runs before ownership checks: authorization must evaluate the row
39
58
  // as it will actually be persisted, including hook-derived fields. A throwing
40
59
  // hook is an app-author bug (bad business rule, not a framework fault) — map it
@@ -3,7 +3,7 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
3
3
  import { instructionFieldNames } from "../engine/instruction-fields.js";
4
4
  import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
5
5
  import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
6
- import { findUnavailableSelectOptions, isRecordLocked, isUnchangedValue, } from "../engine/screen-helpers.js";
6
+ import { isRecordLocked, isUnchangedValue } from "../engine/screen-helpers.js";
7
7
  import { IdempotentReplayError, PreconditionFailedError, RecordLockedError, } from "../errors/classes.js";
8
8
  import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, InternalError, NotFoundError, UnprocessableError, writeFailure, } from "../errors/index.js";
9
9
  import { isSubjectErasedConflict } from "../errors/subject-erased.js";
@@ -15,7 +15,7 @@ import { applyEntityEvent } from "./apply-entity-event.js";
15
15
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
16
16
  import { WritePurposes, } from "./event-store-executor.js";
17
17
  import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
18
- import { optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave, selectOptionNotAvailableFailure, } from "./event-store-executor-write-steps.js";
18
+ import { fieldStateFailure, optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave, } from "./event-store-executor-write-steps.js";
19
19
  import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
20
20
  import { runInSavepointIfSupported } from "./query.js";
21
21
  import { assertPersonalDataWrite, tableNameOf, withExecutorManagedWrites } from "./tenant-db.js";
@@ -241,9 +241,9 @@ export function createWriteVerbs(ctx) {
241
241
  // Statutory retention/erasure writes may change a condition field (e.g. null a PII
242
242
  // reference) and must not be blocked by the select rule — same exemption as the lock.
243
243
  const isStatutorySystemWrite = options?.writePurpose !== undefined && isFrameworkSystemUser(user);
244
- return isStatutorySystemWrite
245
- ? undefined
246
- : selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
244
+ if (isStatutorySystemWrite)
245
+ return undefined;
246
+ return fieldStateFailure(entity.fields, mergedNew, previous);
247
247
  }
248
248
  async function appendCreatedEvent(runner, user, streamTenantId, aggregateId, flatData) {
249
249
  // 1. Append the event in the same transaction as the projection write — both must
@@ -446,9 +446,9 @@ export function createWriteVerbs(ctx) {
446
446
  const ownershipDenied = createOwnershipFailure(user, streamTenantId, payloadWithoutId, data);
447
447
  if (ownershipDenied)
448
448
  return ownershipDenied;
449
- const unavailableOnCreate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, data));
450
- if (unavailableOnCreate)
451
- return unavailableOnCreate;
449
+ const invalidFieldState = fieldStateFailure(entity.fields, data);
450
+ if (invalidFieldState)
451
+ return invalidFieldState;
452
452
  // All compound types (locatedTimestamp, money, ...) use the same pipeline: the
453
453
  // caller sends the combined API form, the framework stores the flat DB form (see
454
454
  // db/compound-types.ts).
@@ -1,5 +1,6 @@
1
1
  import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
2
  import { toMinorUnits } from "../engine/minor-units.ts";
3
+ export declare const FRAMEWORK_DEFAULT_CURRENCY: "EUR";
3
4
  export { toMinorUnits };
4
5
  export declare function moneyPayloadToMinorUnits(raw: unknown): number | undefined;
5
6
  export declare function flattenMoney(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
@@ -29,3 +30,6 @@ export type MoneyRead = {
29
30
  * Pure — does not mutate its input.
30
31
  */
31
32
  export declare function rehydrateMoney(row: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
33
+ /** One money value from its raw amount/currency columns; shared with SQL derived money so both
34
+ * produce the identical read shape. Throws on a corrupt amount. */
35
+ export declare function readMoney(name: string, amountRaw: unknown, currencyRaw: unknown, fallbackCurrency: string): MoneyRead;
package/dist/db/money.js CHANGED
@@ -32,7 +32,7 @@ import { DEFAULT_CURRENCIES } from "../engine/field-helpers.js";
32
32
  // amount column (legacy DB convention for money — `SUM(buying_price)` remains
33
33
  // idiomatic). `<name>Currency` is the additional column.
34
34
  import { MINOR_UNIT_SCALE, toMinorUnits } from "../engine/minor-units.js";
35
- const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
35
+ export const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
36
36
  export { toMinorUnits };
37
37
  function toMajorUnits(amountScaled) {
38
38
  return amountScaled / MINOR_UNIT_SCALE;
@@ -116,35 +116,40 @@ export function rehydrateMoney(row, entity) {
116
116
  delete result[name];
117
117
  continue;
118
118
  }
119
- let amountScaled;
120
- if (typeof amountRaw === "number") {
121
- amountScaled = amountRaw;
122
- }
123
- else if (typeof amountRaw === "bigint") {
124
- amountScaled = Number(amountRaw);
125
- if (!Number.isSafeInteger(amountScaled)) {
126
- throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
127
- }
128
- }
129
- else if (typeof amountRaw === "string" && amountRaw !== "") {
130
- // The PG driver sometimes returns BIGINT as a string (when it exceeds the
131
- // safe integer range of 2^53).
132
- amountScaled = Number(amountRaw);
133
- if (!Number.isSafeInteger(amountScaled)) {
134
- throw new Error(`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`);
135
- }
119
+ result[name] = readMoney(name, amountRaw, currencyRaw, fallbackCurrency);
120
+ }
121
+ return result;
122
+ }
123
+ /** One money value from its raw amount/currency columns; shared with SQL derived money so both
124
+ * produce the identical read shape. Throws on a corrupt amount. */
125
+ export function readMoney(name, amountRaw, currencyRaw, fallbackCurrency) {
126
+ let amountScaled;
127
+ if (typeof amountRaw === "number") {
128
+ amountScaled = amountRaw;
129
+ }
130
+ else if (typeof amountRaw === "bigint") {
131
+ amountScaled = Number(amountRaw);
132
+ if (!Number.isSafeInteger(amountScaled)) {
133
+ throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
136
134
  }
137
- else {
138
- throw new Error(`rehydrateMoney: field "${name}" amount has unexpected type ${typeof amountRaw}`);
135
+ }
136
+ else if (typeof amountRaw === "string" && amountRaw !== "") {
137
+ // The PG driver sometimes returns BIGINT as a string (when it exceeds the
138
+ // safe integer range of 2^53).
139
+ amountScaled = Number(amountRaw);
140
+ if (!Number.isSafeInteger(amountScaled)) {
141
+ throw new Error(`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`);
139
142
  }
140
- const currency = typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
141
- // amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
142
- result[name] = {
143
- amount: toMajorUnits(amountScaled),
144
- currency,
145
- amountScaled,
146
- amountMinor: amountScaled,
147
- };
148
143
  }
149
- return result;
144
+ else {
145
+ throw new Error(`rehydrateMoney: field "${name}" amount has unexpected type ${typeof amountRaw}`);
146
+ }
147
+ const currency = typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
148
+ // amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
149
+ return {
150
+ amount: toMajorUnits(amountScaled),
151
+ currency,
152
+ amountScaled,
153
+ amountMinor: amountScaled,
154
+ };
150
155
  }
@@ -8,4 +8,5 @@ export type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-sto
8
8
  export declare function buildParentRefClause(entity: EntityDefinition, table: Table, tableName: string, user: SessionUser, db: TenantDb, option: ParentVisibilityOption | undefined, opts: {
9
9
  readonly includeDeleted?: boolean;
10
10
  readonly narrowTypes?: ReadonlySet<string>;
11
+ readonly requireReadHandler?: boolean;
11
12
  }): OwnershipClause;