@cosmicdrift/kumiko-framework 0.399.0 → 0.401.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -3
- package/agents/api/framework-engine.md +16 -8
- package/agents/api/framework-ui-types.md +10 -5
- package/agents/api/renderer-web.md +2 -1
- package/agents/api/testing-stack.md +1 -0
- package/agents/api/types.md +256 -80
- package/agents/api.md +3 -3
- package/agents/examples/derived-fields-sql.md +251 -0
- package/agents/examples/documents.md +161 -0
- package/agents/examples/inspection-checklist.md +807 -0
- package/agents/examples/relations.md +13 -3
- package/agents/examples.md +3 -0
- package/agents/features-index.md +2 -0
- package/agents/features.md +8 -0
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +46 -2
- package/dist/api/server-boot-checks.js +2 -2
- package/dist/api/server-routes.js +3 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/db/derived-field-sql.d.ts +23 -0
- package/dist/db/derived-field-sql.js +115 -0
- package/dist/db/derived-related-sql.d.ts +31 -0
- package/dist/db/derived-related-sql.js +137 -0
- package/dist/db/driver-row-coercion.d.ts +1 -0
- package/dist/db/driver-row-coercion.js +13 -0
- package/dist/db/eagerload.d.ts +3 -1
- package/dist/db/eagerload.js +4 -6
- package/dist/db/event-store-executor-context.js +1 -1
- package/dist/db/event-store-executor-read-paging.js +1 -1
- package/dist/db/event-store-executor-read-search.d.ts +3 -0
- package/dist/db/event-store-executor-read-search.js +17 -9
- package/dist/db/event-store-executor-read-sort.d.ts +9 -2
- package/dist/db/event-store-executor-read-sort.js +27 -14
- package/dist/db/event-store-executor-read-where.d.ts +0 -5
- package/dist/db/event-store-executor-read-where.js +3 -9
- package/dist/db/event-store-executor-read.js +23 -4
- package/dist/db/event-store-executor-write-steps.d.ts +6 -1
- package/dist/db/event-store-executor-write-steps.js +20 -1
- package/dist/db/event-store-executor-write.js +8 -8
- package/dist/db/money.d.ts +4 -0
- package/dist/db/money.js +34 -29
- package/dist/db/parent-ref-clause.d.ts +1 -0
- package/dist/db/parent-ref-clause.js +18 -8
- package/dist/db/read-gate-clause.d.ts +7 -0
- package/dist/db/read-gate-clause.js +201 -0
- package/dist/db/read-ownership.d.ts +9 -0
- package/dist/db/read-ownership.js +12 -0
- package/dist/engine/boot-validator/boot-check.d.ts +3 -1
- package/dist/engine/boot-validator/boot-check.js +7 -11
- package/dist/engine/boot-validator/boot-context.d.ts +26 -0
- package/dist/engine/boot-validator/boot-context.js +57 -0
- package/dist/engine/boot-validator/boot-options.d.ts +38 -0
- package/dist/engine/boot-validator/boot-options.js +1 -0
- package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
- package/dist/engine/boot-validator/boot-phases-app.js +107 -0
- package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
- package/dist/engine/boot-validator/boot-phases.js +140 -0
- package/dist/engine/boot-validator/boot-report.d.ts +6 -0
- package/dist/engine/boot-validator/boot-report.js +47 -0
- package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
- package/dist/engine/boot-validator/boot-rule.js +14 -0
- package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
- package/dist/engine/boot-validator/boot-warnings.js +20 -0
- package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
- package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
- package/dist/engine/boot-validator/entity-handler.js +6 -3
- package/dist/engine/boot-validator/entity-list-screens.js +3 -1
- package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
- package/dist/engine/boot-validator/field-conditions.js +196 -0
- package/dist/engine/boot-validator/i18n-keys.d.ts +3 -2
- package/dist/engine/boot-validator/i18n-keys.js +18 -4
- package/dist/engine/boot-validator/index.d.ts +5 -34
- package/dist/engine/boot-validator/index.js +24 -204
- package/dist/engine/boot-validator/pii-retention.js +15 -9
- package/dist/engine/boot-validator/projection-list-screens.js +19 -1
- package/dist/engine/boot-validator/read-gate.d.ts +2 -0
- package/dist/engine/boot-validator/read-gate.js +59 -0
- package/dist/engine/boot-validator/screens-forms.js +19 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
- package/dist/engine/boot-validator/screens-lists.js +8 -0
- package/dist/engine/boot-validator/zod-shape.js +4 -2
- package/dist/engine/build-app-schema.js +11 -5
- package/dist/engine/embedded-derived.d.ts +8 -6
- package/dist/engine/embedded-derived.js +25 -7
- package/dist/engine/entity-handlers.d.ts +13 -6
- package/dist/engine/entity-handlers.js +17 -18
- package/dist/engine/entity-list-runtime-options.d.ts +2 -1
- package/dist/engine/entity-list-runtime-options.js +86 -5
- package/dist/engine/factories.d.ts +18 -6
- package/dist/engine/factories.js +21 -5
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +48 -1
- package/dist/engine/field-state.d.ts +12 -0
- package/dist/engine/field-state.js +67 -0
- package/dist/engine/handler-helpers.d.ts +3 -0
- package/dist/engine/handler-helpers.js +7 -1
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +3 -3
- package/dist/engine/ownership.d.ts +1 -1
- package/dist/engine/ownership.js +9 -5
- package/dist/engine/registry-state.d.ts +1 -0
- package/dist/engine/registry-state.js +1 -1
- package/dist/engine/schema-builder.js +3 -0
- package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
- package/dist/engine/strip-unreadable-related-lists.js +80 -0
- package/dist/engine/user-entity-reference.d.ts +2 -0
- package/dist/engine/user-entity-reference.js +7 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
- package/dist/files/bind-pending-file-refs.d.ts +3 -2
- package/dist/files/bind-pending-file-refs.js +11 -12
- package/dist/files/bind-referenced-files.write.js +5 -5
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +2 -0
- package/dist/files/create-file-ref.d.ts +2 -0
- package/dist/files/create-file-ref.js +39 -22
- package/dist/files/dispose-removed-file-refs.js +5 -8
- package/dist/files/file-field-slots.d.ts +10 -0
- package/dist/files/file-field-slots.js +53 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-table.d.ts +1 -1
- package/dist/files/file-routes.js +12 -3
- package/dist/files/index.d.ts +3 -0
- package/dist/files/index.js +2 -0
- package/dist/files/load-save-file-refs.js +3 -4
- package/dist/files/rebind-file-ref.d.ts +41 -0
- package/dist/files/rebind-file-ref.js +170 -0
- package/dist/files/record-read-check.js +2 -30
- package/dist/files/record-write-check.d.ts +2 -0
- package/dist/files/record-write-check.js +18 -3
- package/dist/files/upload-field-resolution.d.ts +2 -1
- package/dist/files/upload-field-resolution.js +33 -13
- package/dist/i18n/required-surface-keys.d.ts +6 -0
- package/dist/i18n/required-surface-keys.js +61 -0
- package/dist/pipeline/dispatch-batch.js +6 -5
- package/dist/pipeline/dispatch-write.js +2 -1
- package/dist/pipeline/write-result-masking.js +2 -1
- package/dist/schema-cli/validate-boot-input.js +2 -0
- package/dist/schema-cli/validate-command.js +46 -12
- package/dist/schema-cli.js +1 -1
- package/dist/ui-types/index.d.ts +2 -1
- package/dist/ui-types/index.js +2 -1
- package/package.json +3 -3
- package/src/changes.json +68 -0
|
@@ -217,7 +217,12 @@ describe("cascade: deleting member removes tasks", () => {
|
|
|
217
217
|
|
|
218
218
|
```ts
|
|
219
219
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
220
|
-
import {
|
|
220
|
+
import {
|
|
221
|
+
createEntity,
|
|
222
|
+
createReferenceField,
|
|
223
|
+
createTextField,
|
|
224
|
+
defineFeature,
|
|
225
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
221
226
|
|
|
222
227
|
export const teamEntity = createEntity({
|
|
223
228
|
table: "read_sample_teams",
|
|
@@ -230,7 +235,12 @@ export const memberEntity = createEntity({
|
|
|
230
235
|
table: "read_sample_members",
|
|
231
236
|
fields: {
|
|
232
237
|
name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
233
|
-
teamId:
|
|
238
|
+
teamId: createReferenceField({
|
|
239
|
+
entity: "team",
|
|
240
|
+
personal: false,
|
|
241
|
+
reason: "is_system_identifier",
|
|
242
|
+
required: true,
|
|
243
|
+
}),
|
|
234
244
|
role: createTextField({ personal: false, reason: "is_business_data" }),
|
|
235
245
|
},
|
|
236
246
|
});
|
|
@@ -239,7 +249,7 @@ export const taskEntity = createEntity({
|
|
|
239
249
|
table: "read_sample_member_tasks",
|
|
240
250
|
fields: {
|
|
241
251
|
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
242
|
-
memberId:
|
|
252
|
+
memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
|
|
243
253
|
},
|
|
244
254
|
});
|
|
245
255
|
|
package/agents/examples.md
CHANGED
|
@@ -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` |
|
package/agents/features-index.md
CHANGED
|
@@ -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.
|
package/agents/features.md
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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 {
|
|
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 (
|
|
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
|
|
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);
|
package/dist/config/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type AppPorts = {
|
|
|
20
20
|
readonly infra?: AppInfraPorts;
|
|
21
21
|
};
|
|
22
22
|
/** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
|
|
23
|
-
export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "warnOnUniqueAccessRoles">;
|
|
23
|
+
export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "requireFieldValueTranslations" | "warnOnUniqueAccessRoles">;
|
|
24
24
|
export type AppConfig = {
|
|
25
25
|
/** Features mounted in every environment: dev, tests, prod. */
|
|
26
26
|
readonly features: readonly FeatureDefinition[];
|
|
@@ -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
|
+
}
|
|
@@ -162,6 +162,19 @@ function driverValueCoercerFor(pgType) {
|
|
|
162
162
|
return coercer;
|
|
163
163
|
return pgType.startsWith("numeric") ? numberUnlessNaN : undefined;
|
|
164
164
|
}
|
|
165
|
+
const STANDALONE_VALUE_INFO = {
|
|
166
|
+
name: "",
|
|
167
|
+
columnOf: (field) => field,
|
|
168
|
+
pgTypeOf: () => undefined,
|
|
169
|
+
bigintJsModeOf: () => undefined,
|
|
170
|
+
fieldOf: (column) => column,
|
|
171
|
+
hasColumn: () => false,
|
|
172
|
+
};
|
|
173
|
+
// For computed values that have no table column: coerces by pg type exactly as a stored column of that type.
|
|
174
|
+
export function coerceDriverValueOfPgType(value, pgType, column) {
|
|
175
|
+
const coercer = driverValueCoercerFor(pgType);
|
|
176
|
+
return coercer ? coercer(value, column, STANDALONE_VALUE_INFO) : value;
|
|
177
|
+
}
|
|
165
178
|
export function coerceDriverRow(row, info) {
|
|
166
179
|
const out = {};
|
|
167
180
|
let changed = false;
|
package/dist/db/eagerload.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
2
2
|
import type { SessionUser } from "../engine/types/index.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
4
|
+
import { buildEntityTable } from "./table-builder.ts";
|
|
4
5
|
import type { TenantDb } from "./tenant-db.ts";
|
|
5
6
|
export type EagerLoadEntityResolver = (entityName: string) => EntityDefinition | undefined;
|
|
6
7
|
export type EagerloadedRow<T extends Record<string, unknown> = Record<string, unknown>> = T & {
|
|
@@ -15,6 +16,7 @@ export declare function collectReferenceFields(entity: EntityDefinition): readon
|
|
|
15
16
|
export type EagerLoadViewer = {
|
|
16
17
|
readonly user: SessionUser;
|
|
17
18
|
readonly parentVisibility: ParentVisibilityOption;
|
|
19
|
+
readonly resolveTable?: (entityName: string) => ReturnType<typeof buildEntityTable>;
|
|
18
20
|
};
|
|
19
21
|
/** Eagerloads a list of rows without mutating them; returns shallow copies
|
|
20
22
|
* with an added `_refs` property. */
|
package/dist/db/eagerload.js
CHANGED
|
@@ -21,15 +21,15 @@ import { requestContext } from "../api/request-context.js";
|
|
|
21
21
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
|
|
22
22
|
import { selectMany } from "../db/query.js";
|
|
23
23
|
import { filterReadFields } from "../engine/field-access.js";
|
|
24
|
-
import {
|
|
24
|
+
import { shiftParams, tableNameOf } from "../engine/ownership.js";
|
|
25
25
|
import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
26
26
|
import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
27
27
|
import { scrubLogValue } from "../logging/scrub.js";
|
|
28
28
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
29
29
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
30
|
-
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
31
30
|
import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
32
31
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
32
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
33
33
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
34
34
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
35
35
|
const log = createFallbackLogger("eagerload");
|
|
@@ -71,9 +71,7 @@ async function decryptReferencedRow(row, piiFields, encryptedFields, kms) {
|
|
|
71
71
|
// entity. Raw SQL because ownership/parentRef clauses are SQL fragments; the
|
|
72
72
|
// table is unaliased since where-rules qualify columns with the table name.
|
|
73
73
|
async function resolveRefAccessScope(refEntity, refTable, refTableName, ids, db, viewer) {
|
|
74
|
-
const access =
|
|
75
|
-
includeDeleted: false,
|
|
76
|
-
}));
|
|
74
|
+
const access = resolveReadOwnership({ entity: refEntity, table: refTable }, refTableName, viewer.user, db, viewer.parentVisibility, { includeDeleted: false, narrowTypes: undefined });
|
|
77
75
|
if (access.kind === "empty")
|
|
78
76
|
return { kind: "none" };
|
|
79
77
|
if (access.kind === "pass")
|
|
@@ -155,7 +153,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
155
153
|
// to the UUID instead of crashing.
|
|
156
154
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
|
|
157
155
|
}
|
|
158
|
-
const refTable = buildEntityTable(rf.refEntityName, refEntity);
|
|
156
|
+
const refTable = viewer.resolveTable?.(rf.refEntityName) ?? buildEntityTable(rf.refEntityName, refEntity);
|
|
159
157
|
const refTableName = tableNameOf(refTable);
|
|
160
158
|
const usesUuidIds = refEntity.idType === undefined || refEntity.idType === "uuid";
|
|
161
159
|
// A malformed ref value must not 500 the whole list via the uuid cast.
|
|
@@ -10,8 +10,8 @@ import { isStreamArchived } from "../event-store/index.js";
|
|
|
10
10
|
import { assertUnreachable } from "../utils/index.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
13
|
-
import { resolveReadOwnership } from "./event-store-executor-read-where.js";
|
|
14
13
|
import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
14
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
15
15
|
import { toSnakeCase } from "./table-builder.js";
|
|
16
16
|
import { columnHandleName, schemaTableName } from "./table-introspection.js";
|
|
17
17
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -81,7 +81,7 @@ export function appendCursorBoundary(where, encodedCursor, sort) {
|
|
|
81
81
|
export function encodeNextCursor(cursorId, sort, lastRaw, lastSortLabel, table) {
|
|
82
82
|
const sortText = sort.kind === "unsorted"
|
|
83
83
|
? undefined
|
|
84
|
-
: toCursorSortText(sort.kind === "
|
|
84
|
+
: toCursorSortText(sort.kind === "expression"
|
|
85
85
|
? lastSortLabel
|
|
86
86
|
: lastRaw[physicalColumnName(table, sort.field)]);
|
|
87
87
|
return sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
@@ -5,8 +5,10 @@ import type { SearchAdapter } from "../search/types.ts";
|
|
|
5
5
|
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
6
6
|
import type { Table } from "./event-store-executor-context.ts";
|
|
7
7
|
import { type ListWhere } from "./event-store-executor-read-filter.ts";
|
|
8
|
+
import type { ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
8
9
|
import type { TenantDb } from "./tenant-db.ts";
|
|
9
10
|
type ReferenceSearchOption = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSearch"];
|
|
11
|
+
export declare function referenceTargetOwnership(targetEntity: EntityDefinition, targetTable: Table, targetTableName: string, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined): OwnershipClause;
|
|
10
12
|
export declare function isSoftDeletedTargetHidden(targetEntity: EntityDefinition, targetTable: Table): boolean;
|
|
11
13
|
export type ListSearchRequest = {
|
|
12
14
|
readonly searchTerm: string;
|
|
@@ -14,6 +16,7 @@ export type ListSearchRequest = {
|
|
|
14
16
|
readonly entityName: string;
|
|
15
17
|
readonly table: Table;
|
|
16
18
|
readonly referenceSearch: ReferenceSearchOption;
|
|
19
|
+
readonly parentVisibility: ParentVisibilityOption | undefined;
|
|
17
20
|
readonly fieldReadClause: (field: string) => OwnershipClause;
|
|
18
21
|
readonly streamTenantFor: (user: SessionUser) => TenantId;
|
|
19
22
|
readonly user: SessionUser;
|