@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
@@ -27,7 +27,9 @@
27
27
  | `custom-handlers` | Beyond CRUD — when your domain refuses to fit a table. | `examples/custom-handlers.md` |
28
28
  | `dashboard-stats` | Key figures on a dashboard: stat panels backed by one counting query. | `examples/dashboard-stats.md` |
29
29
  | `delivery-notifications` | Delivery flows plus notifications. Package tracking, minus the tracking. | `examples/delivery-notifications.md` |
30
+ | `derived-fields-sql` | SQL derived fields on a list: coalesce, related count with a where, related first (text and money), sortable columns and a defaultSort on a derived value. | `examples/derived-fields-sql.md` |
30
31
  | `designer-demo` | Kumiko sample feature — scaffolded by `bun kumiko create` | `examples/designer-demo.md` |
32
+ | `documents` | A document register on top of existing records: captured files, expiry dates and per-record visibility. | `examples/documents.md` |
31
33
  | `e2e-generator` | generateE2ESpec + withBootValidatorFixture — Registry → JSON-Specs für externen Playwright-Worker. Zeigt die 4 Test-Kinds, Feldtyp→Interaktion-Mapping und welche Feldtypen heute noch nicht generisch unterstützt sind. | `examples/e2e-generator.md` |
32
34
  | `embedded` | Embedded fields on entities. Structured JSON without the JSON part. | `examples/embedded.md` |
33
35
  | `embedded-entity-form` | RenderEdit hosted in a Drawer, no screen or route — externally-sourced prefill, controlled mode, and a custom write handler instead of the built-in CRUD create. | `examples/embedded-entity-form.md` |
@@ -43,6 +45,7 @@
43
45
  | `i18n` | Feature-scoped translations. 'Hallo' and 'Hello' in the same app. | `examples/i18n.md` |
44
46
  | `idempotency` | Same request, same result. Dedupes on request ID. | `examples/idempotency.md` |
45
47
  | `inbound-mail-triage` | Inbound-mail consumer pattern: inbound-mail-foundation + in-memory provider, a worker-lane triage job attached to the ingest handler, verified end-to-end through createAllInOneEntrypoint. | `examples/inbound-mail-triage.md` |
48
+ | `inspection-checklist` | Embedded list as a phone inspection checklist: conditional fields, derived pass/fail, photos per row. | `examples/inspection-checklist.md` |
46
49
  | `lane-routing` | runIn lane-routing: r.job pinned to api/worker deploy-lanes, event-chain via ctx.events.append, verified end-to-end through createAllInOneEntrypoint. | `examples/lane-routing.md` |
47
50
  | `ledger-banking` | Bank accounts + transfers on the ledger primitive — balances are pure queries, never stored. | `examples/ledger-banking.md` |
48
51
  | `ledger-invoicing` | Accrual invoicing on the ledger primitive — revenue at invoice time, VAT as a liability, receivable cleared on payment. | `examples/ledger-invoicing.md` |
@@ -62,6 +62,8 @@ One entry per feature a package ships: purpose, import of its factory, required
62
62
  `import { createDocumentDesignerFeature } from "@cosmicdrift/kumiko-document-designer"`; requires `template-resolver`
63
63
  - `document-ingest-foundation`: Shared PDF/Scan/Image → normalized-text ingest primitive.
64
64
  `import { createDocumentIngestFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/document-ingest-foundation"`; requires `config`, `tenant-lifecycle`
65
+ - `documents`: Filed documents with title, category, issue and expiry date and one file, shown on the records they are linked to.
66
+ `import { createDocumentsFeature } from "@cosmicdrift/kumiko-bundled-features/documents"`; requires `files`
65
67
  - `feature-toggles`: Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying.
66
68
  `import { createFeatureTogglesFeature } from "@cosmicdrift/kumiko-bundled-features/feature-toggles"`
67
69
  - `file-derivatives`: Declares the `derivativeRenderer` extension point.
@@ -253,6 +253,14 @@ Config keys:
253
253
  | `max-pages-per-file` | number | tenant | `50` |
254
254
  | `ocr-language` | text | tenant | `deu+eng` |
255
255
 
256
+ ## documents
257
+
258
+ Filed documents with title, category, issue and expiry date and one file, shown on the records they are linked to. A document is visible only through a linked record the viewer can read, and only while the record that owns its file is readable too, so linking never widens access to the file. Documents arrive through capture rules (a file saved into a configured field) or through the system-only import for migrations, which keeps the caller's id, refuses an id another record already uses, and stamps createdAt with the import time (issuedAt carries the business date; the event store does not backdate); users link, unlink, edit and delete them. Read a record's documents with for-record, count expired and expiring ones with expiry-overview, list them with an expiry state and an expiring filter with overview (the documents list screen), and add the documents tab to a record screen with documentsTab().
259
+
260
+ Category: data
261
+
262
+ Requires: `files`
263
+
256
264
  ## feature-toggles
257
265
 
258
266
  Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "shared"` syncs the snapshot across instances whenever a `toggle-set` event is appended.
@@ -2,5 +2,7 @@ import type { AccessRule } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import type { OwnershipMap } from "@cosmicdrift/kumiko-types/ownership";
3
3
  import type { Registry } from "../engine/types/index.ts";
4
4
  export declare function assertNoAggregateTypeKeyCollisions(registry: Registry): void;
5
+ export declare function collectEntityDetailQueries(registry: Registry): ReadonlyMap<string, string>;
6
+ export declare function collectEntityDetailAccess(registry: Registry): ReadonlyMap<string, AccessRule>;
5
7
  export declare function collectEntityReadAccess(registry: Registry): ReadonlyMap<string, readonly AccessRule[]>;
6
8
  export declare function collectEntityRowReadMaps(registry: Registry): ReadonlyMap<string, OwnershipMap>;
@@ -14,6 +14,47 @@ export function assertNoAggregateTypeKeyCollisions(registry) {
14
14
  }
15
15
  }
16
16
  }
17
+ // Only the "<entity>:detail" handler counts: other "<entity>:...:detail"
18
+ // queries map to the same entity but may return a different shape or scope.
19
+ // Entity names are globally unique and only the owning feature maps handlers
20
+ // to its entity, so at most one QN matches per entity.
21
+ function isEntityDetailQuery(qn, entityName) {
22
+ return qn.endsWith(`:query:${toKebab(entityName)}:detail`);
23
+ }
24
+ export function collectEntityDetailQueries(registry) {
25
+ const detailQueryByEntity = new Map();
26
+ for (const qn of registry.getAllQueryHandlers().keys()) {
27
+ const entityName = registry.getHandlerEntity(qn);
28
+ if (entityName !== undefined && isEntityDetailQuery(qn, entityName)) {
29
+ detailQueryByEntity.set(entityName, qn);
30
+ }
31
+ }
32
+ for (const projection of registry.getAllProjections().values()) {
33
+ const entityName = projection.source;
34
+ if (typeof entityName !== "string")
35
+ continue;
36
+ const owner = registry.getReadModelEntityOwner(entityName);
37
+ if (owner === undefined)
38
+ continue;
39
+ const qn = `${owner}:query:${toKebab(entityName)}:detail`;
40
+ if (registry.getAllQueryHandlers().has(qn))
41
+ detailQueryByEntity.set(entityName, qn);
42
+ }
43
+ return detailQueryByEntity;
44
+ }
45
+ // The access rule of each entity's own detail query. The file route and the read gate both ask
46
+ // "may this viewer open the record?" through this one rule, so a liveEntities or openToAll query
47
+ // that merely names an entity never widens what a gated row exposes.
48
+ export function collectEntityDetailAccess(registry) {
49
+ const handlers = registry.getAllQueryHandlers();
50
+ const accessByEntity = new Map();
51
+ for (const [entityName, qn] of collectEntityDetailQueries(registry)) {
52
+ const handler = handlers.get(qn);
53
+ if (handler !== undefined)
54
+ accessByEntity.set(entityName, handler.access);
55
+ }
56
+ return accessByEntity;
57
+ }
17
58
  // Access rules of the query handlers that let a role read an entity (the
18
59
  // handler's own entity or one it declares via liveEntities). A live signal
19
60
  // carries the id of every row of the entity, so a signed-in connection gets it
@@ -45,8 +86,11 @@ export function collectEntityRowReadMaps(registry) {
45
86
  assertNoAggregateTypeKeyCollisions(registry);
46
87
  const mapsByEntity = new Map();
47
88
  for (const [entityName, entity] of registry.getAllEntities()) {
48
- const readMap = entity.access?.read;
49
- if (readMap === undefined || Object.keys(readMap).length === 0)
89
+ // A read gate narrows rows for every role, so no role reads every row: signals stay anonymous.
90
+ const readMap = entity.readGate !== undefined ? {} : entity.access?.read;
91
+ if (readMap === undefined)
92
+ continue;
93
+ if (entity.readGate === undefined && Object.keys(readMap).length === 0)
50
94
  continue;
51
95
  for (const aggregateType of new Set([entityName, toKebab(entityName)])) {
52
96
  mapsByEntity.set(aggregateType, readMap);
@@ -1,5 +1,5 @@
1
1
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
2
- import { isFileField } from "../engine/field-helpers.js";
2
+ import { holdsFileRefs } from "../files/file-field-slots.js";
3
3
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
4
4
  import { generateId } from "../utils/index.js";
5
5
  // A payload-bucket handler without a resolver would answer every call with a
@@ -125,7 +125,7 @@ function registryDeclaresFileFields(registry) {
125
125
  for (const feature of registry.features.values()) {
126
126
  for (const entity of Object.values(feature.entities ?? {})) {
127
127
  for (const field of Object.values(entity.fields)) {
128
- if (isFileField(field))
128
+ if (holdsFileRefs(field))
129
129
  return true;
130
130
  }
131
131
  }
@@ -2,6 +2,7 @@ import { createProjectingTenantDb } from "../db/tenant-db.js";
2
2
  import { buildAppSchema } from "../engine/build-app-schema.js";
3
3
  import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles.js";
4
4
  import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from "../engine/strip-disabled-features-from-app-schema.js";
5
+ import { stripUnreadableRelatedLists } from "../engine/strip-unreadable-related-lists.js";
5
6
  import { createAnonymousUser, isAnonymousSessionUser } from "../engine/system-user.js";
6
7
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
7
8
  import { createRecordReadCheck } from "../files/record-read-check.js";
@@ -109,7 +110,8 @@ export function mountSchemaRoute(app, options) {
109
110
  searchAdapterMissing: !options.context.searchAdapter,
110
111
  });
111
112
  }
112
- const projected = projectAppSchemaForRoles(stripDisabledFeaturesFromAppSchema(fullSchema, options.registry, disabledFeatureNames), user?.roles ?? []);
113
+ const roles = user?.roles ?? [];
114
+ const projected = stripUnreadableRelatedLists(projectAppSchemaForRoles(stripDisabledFeaturesFromAppSchema(fullSchema, options.registry, disabledFeatureNames), roles), roles, (queryQn) => options.registry.getQueryHandler(queryQn)?.access);
113
115
  const json = JSON.stringify(projected);
114
116
  cached = { json, etag: computeStrongEtag(json) };
115
117
  projectedSchemaCache.set(roleKey, cached);
@@ -0,0 +1,23 @@
1
+ import type { ListRuntimeOptions } from "@cosmicdrift/kumiko-types/event-store-executor-types";
2
+ import { type EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
3
+ import type { SessionUser } from "../engine/types/index.ts";
4
+ import type { Table } from "./event-store-executor-context.ts";
5
+ import type { FieldReadClause } from "./event-store-executor-read-where.ts";
6
+ import type { TenantDb } from "./tenant-db.ts";
7
+ type SqlBuilder = (params: unknown[]) => string;
8
+ export type DerivedSelect = {
9
+ readonly projections: (params: unknown[]) => string;
10
+ readonly sortExpr: (name: string) => SqlBuilder | undefined;
11
+ readonly takeValues: (rawRows: readonly Record<string, unknown>[]) => readonly Record<string, unknown>[];
12
+ };
13
+ type DerivedSelectInput = {
14
+ readonly entity: EntityDefinition;
15
+ readonly table: Table;
16
+ readonly tableName: string;
17
+ readonly fieldReadClause: FieldReadClause;
18
+ readonly user: SessionUser;
19
+ readonly db: TenantDb;
20
+ readonly runtime: ListRuntimeOptions | undefined;
21
+ };
22
+ export declare function buildDerivedSelect(input: DerivedSelectInput): DerivedSelect | undefined;
23
+ export {};
@@ -0,0 +1,115 @@
1
+ import { isSqlDerivedField, } from "@cosmicdrift/kumiko-types/fields";
2
+ import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
3
+ import { buildRelatedSubquery } from "./derived-related-sql.js";
4
+ import { coerceDriverValueOfPgType } from "./driver-row-coercion.js";
5
+ import { holdsCiphertextOrSecret } from "./entity-field-encryption.js";
6
+ import { FRAMEWORK_DEFAULT_CURRENCY, readMoney } from "./money.js";
7
+ import { physicalColumnName } from "./table-builder.js";
8
+ const DERIVED_ALIAS_PREFIX = "__kumiko_derived_";
9
+ // Driver value coercion by the pg type a stored column of this value type would have.
10
+ const PG_TYPE_OF_VALUE_TYPE = {
11
+ text: "text",
12
+ number: "integer",
13
+ decimal: "numeric",
14
+ boolean: "boolean",
15
+ date: "date",
16
+ timestamp: "timestamptz",
17
+ money: "bigint",
18
+ };
19
+ function buildCoalesceExpression(fields, input) {
20
+ const { entity, table, tableName, fieldReadClause } = input;
21
+ const allReadable = fields.every((f) => Object.hasOwn(table, f) &&
22
+ fieldReadClause(f).kind === "pass" &&
23
+ !holdsCiphertextOrSecret(entity, f));
24
+ if (!allReadable)
25
+ return undefined;
26
+ const columns = fields.map((f) => `"${tableName}"."${physicalColumnName(table, f)}"`);
27
+ const sql = `COALESCE(${columns.join(", ")})`;
28
+ return () => sql;
29
+ }
30
+ function relatedDefaultCurrency(spec, input) {
31
+ const related = spec.kind === "related"
32
+ ? input.runtime?.derivedSql?.resolveEntity(parseRefTargetEntityName(spec.entity))
33
+ : undefined;
34
+ return related?.defaultCurrency ?? FRAMEWORK_DEFAULT_CURRENCY;
35
+ }
36
+ function buildExpression(spec, input, currency = false) {
37
+ if (spec.kind === "coalesce")
38
+ return buildCoalesceExpression(spec.fields, input);
39
+ const runtime = input.runtime?.derivedSql;
40
+ if (runtime === undefined)
41
+ return undefined;
42
+ const outer = {
43
+ entity: input.entity,
44
+ table: input.table,
45
+ tableName: input.tableName,
46
+ isFieldReadable: (field) => input.fieldReadClause(field).kind === "pass",
47
+ };
48
+ return buildRelatedSubquery({
49
+ spec,
50
+ outer,
51
+ user: input.user,
52
+ db: input.db,
53
+ runtime,
54
+ parentVisibility: input.runtime?.parentVisibility,
55
+ currency,
56
+ });
57
+ }
58
+ export function buildDerivedSelect(input) {
59
+ if (input.runtime?.derivedSql === undefined)
60
+ return undefined;
61
+ const plans = [];
62
+ for (const [name, def] of Object.entries(input.entity.derivedFields ?? {})) {
63
+ if (!isSqlDerivedField(def))
64
+ continue;
65
+ const alias = `${DERIVED_ALIAS_PREFIX}${plans.length}`;
66
+ plans.push({
67
+ name,
68
+ alias,
69
+ valueType: def.valueType,
70
+ sortable: def.sortable === true,
71
+ expression: buildExpression(def.sql, input),
72
+ currency: def.valueType === "money"
73
+ ? {
74
+ alias: `${alias}_currency`,
75
+ expression: buildExpression(def.sql, input, true),
76
+ fallback: relatedDefaultCurrency(def.sql, input),
77
+ }
78
+ : undefined,
79
+ });
80
+ }
81
+ if (plans.length === 0)
82
+ return undefined;
83
+ return {
84
+ projections: (params) => plans
85
+ .flatMap((plan) => [
86
+ { alias: plan.alias, expression: plan.expression },
87
+ ...(plan.currency === undefined ? [] : [plan.currency]),
88
+ ])
89
+ .map(({ alias, expression }) => `, ${expression?.(params) ?? "NULL"} AS "${alias}"`)
90
+ .join(""),
91
+ sortExpr: (name) => {
92
+ const plan = plans.find((p) => p.name === name);
93
+ return plan?.sortable === true ? plan.expression : undefined;
94
+ },
95
+ // Stripped before row coercion and the entity cache: the values depend on the viewer.
96
+ takeValues: (rawRows) => rawRows.map((raw) => {
97
+ const values = {};
98
+ for (const plan of plans) {
99
+ if (plan.currency !== undefined) {
100
+ const amount = raw[plan.alias];
101
+ // A stored money field without an amount has no key on the row; match that.
102
+ if (amount !== null && amount !== undefined) {
103
+ values[plan.name] = readMoney(plan.name, amount, raw[plan.currency.alias], plan.currency.fallback);
104
+ }
105
+ delete raw[plan.alias];
106
+ delete raw[plan.currency.alias];
107
+ continue;
108
+ }
109
+ values[plan.name] = coerceDriverValueOfPgType(raw[plan.alias], PG_TYPE_OF_VALUE_TYPE[plan.valueType], plan.alias);
110
+ delete raw[plan.alias];
111
+ }
112
+ return values;
113
+ }),
114
+ };
115
+ }
@@ -0,0 +1,31 @@
1
+ import type { DerivedSqlSpec, EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
2
+ import type { SessionUser } from "../engine/types/index.ts";
3
+ import type { Table } from "./event-store-executor-context.ts";
4
+ import type { ParentVisibilityOption } from "./parent-ref-clause.ts";
5
+ import type { TenantDb } from "./tenant-db.ts";
6
+ export type RelatedDerivedSpec = Extract<DerivedSqlSpec, {
7
+ readonly kind: "related";
8
+ }>;
9
+ export type DerivedSqlRuntime = {
10
+ readonly resolveEntity: (entityName: string) => EntityDefinition | undefined;
11
+ readonly resolveTable: (entityName: string) => Table;
12
+ readonly canReadEntity: (entityName: string) => boolean;
13
+ readonly today: string;
14
+ readonly now: string;
15
+ };
16
+ export type OuterRow = {
17
+ readonly entity: EntityDefinition;
18
+ readonly table: Table;
19
+ readonly tableName: string;
20
+ readonly isFieldReadable: (field: string) => boolean;
21
+ };
22
+ export declare function buildRelatedSubquery(input: {
23
+ readonly spec: RelatedDerivedSpec;
24
+ readonly outer: OuterRow;
25
+ readonly user: SessionUser;
26
+ readonly db: TenantDb;
27
+ readonly runtime: DerivedSqlRuntime;
28
+ readonly parentVisibility: ParentVisibilityOption | undefined;
29
+ /** Select the measured money field's currency column instead of its amount. */
30
+ readonly currency?: boolean;
31
+ }): ((params: unknown[]) => string) | undefined;
@@ -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;