@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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 +6 -1
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +1 -0
- package/dist/api/client-error-route.js +8 -2
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +203 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +293 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +236 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1251
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-route.js +16 -12
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +3 -1
- package/dist/db/aggregate-sql.d.ts +21 -0
- package/dist/db/aggregate-sql.js +155 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +95 -0
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +99 -0
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.js +103 -110
- package/dist/db/event-store-executor-context.d.ts +2 -1
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +112 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +203 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +48 -701
- package/dist/db/event-store-executor-write-steps.d.ts +20 -0
- package/dist/db/event-store-executor-write-steps.js +100 -0
- package/dist/db/event-store-executor-write.js +395 -499
- package/dist/db/index.d.ts +6 -2
- package/dist/db/index.js +5 -1
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/migrate-generator.js +71 -86
- package/dist/db/migrate-runner.js +4 -1
- package/dist/db/query.d.ts +2 -15
- package/dist/db/query.js +8 -761
- package/dist/db/reference-data.js +6 -1
- package/dist/db/table-builder.js +113 -166
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db.js +39 -9
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +2 -1
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/detail-screens.js +2 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/i18n-keys.js +5 -3
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +8 -1
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +2 -6
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +12 -9
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +99 -860
- package/dist/engine/build-app-schema.js +56 -96
- package/dist/engine/define-feature.js +9 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +10 -21
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/patch.js +4 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
- package/dist/engine/feature-ast/patterns.d.ts +9 -0
- package/dist/engine/feature-ast/patterns.js +43 -51
- package/dist/engine/feature-ast/render.js +48 -87
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +6 -4
- package/dist/engine/registry-ingest.js +1 -1
- package/dist/engine/schema-builder.js +228 -212
- package/dist/engine/screen-helpers.js +1 -1
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +4 -1
- package/dist/entrypoint/index.js +7 -1
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/es-ops/context.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/event-store.js +10 -4
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +4 -1
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.js +11 -8
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.js +7 -0
- package/dist/i18n/required-surface-keys.js +182 -206
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +61 -2
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +47 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +12 -1
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +58 -710
- package/dist/pipeline/dispatch-write.js +296 -189
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -5
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
- package/dist/pipeline/handler-context-dispatch.js +89 -0
- package/dist/pipeline/handler-context-events.d.ts +43 -0
- package/dist/pipeline/handler-context-events.js +237 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +2 -1
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -604
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/upgrade-cli.js +149 -121
- package/package.json +4 -4
- package/src/changes.json +310 -0
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +175 -145
- package/src/scripts/codemod/testing-injection.ts +122 -42
- package/src/scripts/codemod/testing-move.ts +29 -7
|
@@ -1,455 +1,23 @@
|
|
|
1
|
-
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
-
import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
|
|
3
|
-
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
|
|
4
1
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
|
-
import {
|
|
6
|
-
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
7
|
-
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
8
|
-
import { UnprocessableError, ValidationError } from "../errors/index.js";
|
|
2
|
+
import { extractTableInfo } from "../db/table-info.js";
|
|
9
3
|
import { getStreamVersion } from "../event-store/index.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
16
|
-
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
4
|
+
import { aggregateList } from "./event-store-executor-read-aggregate.js";
|
|
5
|
+
import { appendCursorBoundary, countListRows, encodeNextCursor, resolveListPagination, } from "./event-store-executor-read-paging.js";
|
|
6
|
+
import { appendStreamVersionProjection, coerceAndRehydrateRow, takeStreamVersions, withStreamVersion, } from "./event-store-executor-read-rows.js";
|
|
7
|
+
import { renderListOrderBy, resolveListSort, takeLastSortLabel, } from "./event-store-executor-read-sort.js";
|
|
8
|
+
import { buildListWhere, resolveReadOwnership } from "./event-store-executor-read-where.js";
|
|
17
9
|
import { schemaTableName } from "./table-introspection.js";
|
|
18
10
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
19
|
-
// The
|
|
11
|
+
// The read verbs (list/aggregate/detail) of the event-store-executor. Split out
|
|
20
12
|
// of event-store-executor.ts — behavior-preserving relocation, not a redesign:
|
|
21
13
|
// unchanged from the original, now behind an explicit ExecutorContext instead
|
|
22
|
-
// of the factory's local scope.
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// would otherwise be interpolated raw into `LIMIT ${limit}` SQL text.
|
|
27
|
-
const MAX_LIST_LIMIT = 200; // keep in sync with engine/entity-handlers.ts MAX_LIST_LIMIT
|
|
28
|
-
export function resolveListPagination(payload) {
|
|
29
|
-
const limit = payload.limit ?? 50;
|
|
30
|
-
const offset = payload.offset ?? 0;
|
|
31
|
-
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
|
|
32
|
-
throw new UnprocessableError("invalid_list_limit", {
|
|
33
|
-
details: { hint: "limit must be a non-negative integer" },
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
|
|
37
|
-
throw new UnprocessableError("invalid_list_offset", {
|
|
38
|
-
details: { hint: "offset must be a non-negative integer" },
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
|
|
42
|
-
}
|
|
43
|
-
// Cursor sort values travel as text and bind as plain string params — Postgres
|
|
44
|
-
// infers the parameter type from the column they are compared against, the same
|
|
45
|
-
// way prepareValue binds a timestamptz as an ISO string. undefined means the
|
|
46
|
-
// driver value has no faithful text form, which downgrades the page to the
|
|
47
|
-
// legacy id-only boundary instead of emitting a wrong one.
|
|
48
|
-
function toCursorSortText(value) {
|
|
49
|
-
if (value === undefined)
|
|
50
|
-
return undefined;
|
|
51
|
-
if (value === null)
|
|
52
|
-
return null;
|
|
53
|
-
if (typeof value === "string")
|
|
54
|
-
return value;
|
|
55
|
-
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
|
56
|
-
return String(value);
|
|
57
|
-
}
|
|
58
|
-
if (value instanceof Date)
|
|
59
|
-
return value.toISOString();
|
|
60
|
-
if (typeof value === "object") {
|
|
61
|
-
const tag = Reflect.get(value, Symbol.toStringTag);
|
|
62
|
-
if (typeof tag === "string" && tag.startsWith("Temporal."))
|
|
63
|
-
return String(value);
|
|
64
|
-
return JSON.stringify(value);
|
|
65
|
-
}
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
function withStreamVersion(row, streamVersion) {
|
|
69
|
-
return streamVersion > 0 ? { ...row, version: streamVersion } : row;
|
|
70
|
-
}
|
|
71
|
-
// Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
|
|
72
|
-
// null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
|
|
73
|
-
// would change the visible order and cost the sort its index.
|
|
74
|
-
function keysetBoundarySql(sortCol, idCol, cursor, descending, params) {
|
|
75
|
-
params.push(cursor.id);
|
|
76
|
-
const afterId = `${idCol} > $${params.length}`;
|
|
77
|
-
const nullsComeFirst = descending;
|
|
78
|
-
if (cursor.sortValue === null) {
|
|
79
|
-
return nullsComeFirst
|
|
80
|
-
? `(${sortCol} IS NOT NULL OR ${afterId})`
|
|
81
|
-
: `(${sortCol} IS NULL AND ${afterId})`;
|
|
82
|
-
}
|
|
83
|
-
params.push(cursor.sortValue);
|
|
84
|
-
const sortParam = `$${params.length}`;
|
|
85
|
-
const beyond = `${sortCol} ${descending ? "<" : ">"} ${sortParam}`;
|
|
86
|
-
const tie = `(${sortCol} = ${sortParam} AND ${afterId})`;
|
|
87
|
-
return nullsComeFirst
|
|
88
|
-
? `(${sortCol} IS NOT NULL AND (${beyond} OR ${tie}))`
|
|
89
|
-
: `(${sortCol} IS NULL OR ${beyond} OR ${tie})`;
|
|
90
|
-
}
|
|
91
|
-
// multiSelect stores its options as a jsonb array — a filter value is one
|
|
92
|
-
// option, not the whole array, so eq/ne/in must check array containment
|
|
93
|
-
// (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column.
|
|
94
|
-
// lt/gt/lte/gte have no containment analogue; the boot-validator already
|
|
95
|
-
// blocks them for screen-declared filters (screen-filter-ops.ts
|
|
96
|
-
// EQUALITY_ONLY), but a client-supplied facet filter reaches here
|
|
97
|
-
// unvalidated, so treat it as unsatisfiable rather than emitting SQL
|
|
98
|
-
// Postgres would reject.
|
|
99
|
-
function applyMultiSelectFilter(colSql, whereSql, params, f) {
|
|
100
|
-
if (f.op === "lt" || f.op === "gt" || f.op === "lte" || f.op === "gte") {
|
|
101
|
-
whereSql.push("FALSE");
|
|
102
|
-
// skip: lt/gt/lte/gte is unsatisfiable on a multiSelect column
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
if (f.op === "in") {
|
|
106
|
-
if (!Array.isArray(f.value) || f.value.length === 0) {
|
|
107
|
-
whereSql.push("FALSE");
|
|
108
|
-
// skip: empty/non-array `in` is unsatisfiable, mirrors buildFilterWhere's "in" short-circuit
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
const parts = f.value.map((v) => {
|
|
112
|
-
params.push(JSON.stringify([v]));
|
|
113
|
-
return `${colSql(f.field)} @> $${params.length}::text::jsonb`;
|
|
114
|
-
});
|
|
115
|
-
whereSql.push(`(${parts.join(" OR ")})`);
|
|
116
|
-
// skip: containment condition already pushed — don't fall through to the eq/ne branch below
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
const candidates = Array.isArray(f.value) ? f.value : [f.value];
|
|
120
|
-
// `@> '[]'` is true for every non-null row, so an empty array would turn
|
|
121
|
-
// the filter into a silent match-all.
|
|
122
|
-
if (candidates.length === 0) {
|
|
123
|
-
whereSql.push("FALSE");
|
|
124
|
-
// skip: empty candidate list is unsatisfiable
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
// Bound as text then cast: a JS array makes postgres.js infer boolean[]/int4[]
|
|
128
|
-
// for client-supplied non-string values, and `boolean[]::jsonb` is rejected
|
|
129
|
-
// (500 on client input). An array value means "contains all of these".
|
|
130
|
-
params.push(JSON.stringify(candidates));
|
|
131
|
-
const containment = `${colSql(f.field)} @> $${params.length}::text::jsonb`;
|
|
132
|
-
// A NULL column must count as "does not contain" — plain NOT(...) yields
|
|
133
|
-
// NULL and would drop those rows.
|
|
134
|
-
whereSql.push(f.op === "ne" ? `(${colSql(f.field)} IS NULL OR NOT (${containment}))` : containment);
|
|
135
|
-
}
|
|
136
|
-
// Falls through to the screen-filter WHERE builder shared with
|
|
137
|
-
// screen-declared filters — buildFilterWhere flattens the op into a
|
|
138
|
-
// WhereObject, which this then lowers to raw SQL fragments.
|
|
139
|
-
function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
140
|
-
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
141
|
-
if (screen === null) {
|
|
142
|
-
whereSql.push("FALSE");
|
|
143
|
-
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
for (const [field, value] of Object.entries(screen)) {
|
|
147
|
-
// `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/query.ts.
|
|
148
|
-
if (value === null) {
|
|
149
|
-
whereSql.push(`${colSql(field)} IS NULL`);
|
|
150
|
-
}
|
|
151
|
-
else if (Array.isArray(value)) {
|
|
152
|
-
const placeholders = value.map((v) => {
|
|
153
|
-
params.push(v);
|
|
154
|
-
return `$${params.length}`;
|
|
155
|
-
});
|
|
156
|
-
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
157
|
-
}
|
|
158
|
-
else if (isPlainObject(value)) {
|
|
159
|
-
if (value["ne"] === null && Object.keys(value).length === 1) {
|
|
160
|
-
whereSql.push(`${colSql(field)} IS NOT NULL`);
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const opMap = {
|
|
164
|
-
gt: ">",
|
|
165
|
-
gte: ">=",
|
|
166
|
-
lt: "<",
|
|
167
|
-
lte: "<=",
|
|
168
|
-
ne: "<>",
|
|
169
|
-
};
|
|
170
|
-
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
171
|
-
if (!(opKey in value))
|
|
172
|
-
continue;
|
|
173
|
-
params.push(value[opKey]);
|
|
174
|
-
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
// Blind-index OR rewrite, lock-step with buildWhereClause
|
|
179
|
-
// in db/query.ts — equality on lookupable fields matches the
|
|
180
|
-
// plaintext arm OR the HMAC arm.
|
|
181
|
-
const bidxKey = configuredBlindIndexKey();
|
|
182
|
-
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
183
|
-
params.push(value, computeBlindIndex(bidxKey, value));
|
|
184
|
-
whereSql.push(`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`);
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
params.push(value);
|
|
188
|
-
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
// A `filter`/`filters` entry that already pins the parentRef's entityType
|
|
194
|
-
// field is a real subset of the parent-ref clause's own OR-branches — the
|
|
195
|
-
// client-supplied filter can't widen the result, only narrow which branches
|
|
196
|
-
// the SQL even needs to consider. Skipped for multiSelect fields, where `eq`
|
|
197
|
-
// means containment (see applyMultiSelectFilter) rather than equality.
|
|
198
|
-
function deriveParentNarrowTypes(entity, payload) {
|
|
199
|
-
const parentRef = entity.parentRef;
|
|
200
|
-
if (parentRef === undefined)
|
|
201
|
-
return undefined;
|
|
202
|
-
if (entity.fields[parentRef.entityTypeField]?.type === "multiSelect")
|
|
203
|
-
return undefined;
|
|
204
|
-
const typeFilters = [
|
|
205
|
-
...(payload.filter ? [payload.filter] : []),
|
|
206
|
-
...(payload.filters ?? []),
|
|
207
|
-
].filter((f) => f.field === parentRef.entityTypeField);
|
|
208
|
-
let narrowed;
|
|
209
|
-
for (const f of typeFilters) {
|
|
210
|
-
let values;
|
|
211
|
-
if (f.op === "eq" && typeof f.value === "string") {
|
|
212
|
-
values = [f.value];
|
|
213
|
-
}
|
|
214
|
-
else if (f.op === "in" &&
|
|
215
|
-
Array.isArray(f.value) &&
|
|
216
|
-
f.value.every((v) => typeof v === "string")) {
|
|
217
|
-
values = f.value;
|
|
218
|
-
}
|
|
219
|
-
if (values === undefined)
|
|
220
|
-
continue;
|
|
221
|
-
narrowed =
|
|
222
|
-
narrowed === undefined
|
|
223
|
-
? new Set(values)
|
|
224
|
-
: new Set([...narrowed].filter((v) => values.includes(v)));
|
|
225
|
-
}
|
|
226
|
-
return narrowed;
|
|
227
|
-
}
|
|
228
|
-
// Above this many target-row matches, drop the reference clause
|
|
229
|
-
// entirely instead of truncating: an arbitrary 200-row slice of "which
|
|
230
|
-
// targets match" would silently hide rows a full-cardinality match would
|
|
231
|
-
// have found. Native text search on the entity's own fields still applies.
|
|
232
|
-
const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
233
|
-
// The hit ids become an `id IN (...)` filter beneath the SQL cursor/filters/sort, so the
|
|
234
|
-
// adapter must return the whole candidate set, not a page; matches Meilisearch's default
|
|
235
|
-
// maxTotalHits, which caps a response at exactly this many hits however high a larger limit is
|
|
236
|
-
// asked. A response that reaches the cap is therefore flagged `searchTruncated` rather than
|
|
237
|
-
// treated as complete (a cap of N+1 could never be observed).
|
|
238
|
-
const MAX_SEARCH_CANDIDATE_IDS = 1000;
|
|
239
|
-
// Registry-declared `searchable` reference fields, plus the implicit
|
|
240
|
-
// tenantId→tenant.name row-meta reference — both resolve a text
|
|
241
|
-
// search term against a target row's label instead of the raw FK column.
|
|
242
|
-
function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
|
|
243
|
-
if (!referenceSearch)
|
|
244
|
-
return [];
|
|
245
|
-
const descriptors = referenceSearch.fields.map((f) => ({
|
|
246
|
-
ownColumn: f.fieldName,
|
|
247
|
-
targetEntityName: f.targetEntityName,
|
|
248
|
-
labelField: f.labelField,
|
|
249
|
-
}));
|
|
250
|
-
for (const [ownColumn, ref] of Object.entries(LIST_ROW_META_REFERENCES)) {
|
|
251
|
-
if (table[ownColumn] === undefined)
|
|
252
|
-
continue;
|
|
253
|
-
// In "tenant" mode the outer tenant filter already pins tenantId to one
|
|
254
|
-
// of exactly two values, so a by-name lookup here can never narrow that further.
|
|
255
|
-
if (ownColumn === "tenantId" && dbMode === "tenant")
|
|
256
|
-
continue;
|
|
257
|
-
descriptors.push({ ownColumn, targetEntityName: ref.refEntity, labelField: ref.refLabelField });
|
|
258
|
-
}
|
|
259
|
-
return descriptors;
|
|
260
|
-
}
|
|
261
|
-
// Matches the main list's own default: soft-deleted rows are invisible, so a
|
|
262
|
-
// deleted target must neither match a search nor influence an ordering.
|
|
263
|
-
function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
264
|
-
return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
|
|
265
|
-
}
|
|
266
|
-
// Tenant-scoped ILIKE lookup against one reference target — never raw/
|
|
267
|
-
// unscoped SQL, a match in a foreign tenant must never surface a row here.
|
|
268
|
-
// Mirrors the main table's own tenant filter (see the `table["tenantId"]`
|
|
269
|
-
// check further down in list()).
|
|
270
|
-
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
|
|
271
|
-
// The label must not leak through match counts: a viewer who cannot read the
|
|
272
|
-
// target row or its label field would otherwise probe names by searching.
|
|
273
|
-
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
274
|
-
if (labelAccess.kind !== "pass")
|
|
275
|
-
return undefined;
|
|
276
|
-
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
277
|
-
if (ownership.kind === "empty")
|
|
278
|
-
return undefined;
|
|
279
|
-
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
280
|
-
const subParams = [`%${escapeLikePattern(searchTerm)}%`];
|
|
281
|
-
let tenantClause = "";
|
|
282
|
-
if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
283
|
-
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
284
|
-
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
285
|
-
}
|
|
286
|
-
let ownershipClause = "";
|
|
287
|
-
if (ownership.kind === "sql") {
|
|
288
|
-
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, subParams.length);
|
|
289
|
-
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
290
|
-
for (const p of shifted.params)
|
|
291
|
-
subParams.push(p);
|
|
292
|
-
}
|
|
293
|
-
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
294
|
-
? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
295
|
-
: "";
|
|
296
|
-
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
297
|
-
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
298
|
-
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${ownershipClause}${deletedClause} ` +
|
|
299
|
-
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
300
|
-
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
|
|
301
|
-
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
302
|
-
// skip: no match, or over the cap — drop the clause rather than
|
|
303
|
-
// truncate to an arbitrary slice of matching targets
|
|
304
|
-
return undefined;
|
|
305
|
-
}
|
|
306
|
-
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
307
|
-
}
|
|
308
|
-
// Same lookup, but the target's index is tenant-wide, not per-viewer, so
|
|
309
|
-
// candidate ids get re-checked against the target entity's own read access.
|
|
310
|
-
async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter) {
|
|
311
|
-
const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
312
|
-
const results = await searchAdapter.search(searchTenantId, searchTerm, {
|
|
313
|
-
filterType: descriptor.targetEntityName,
|
|
314
|
-
limit: MAX_REFERENCE_SEARCH_IDS + 1,
|
|
315
|
-
});
|
|
316
|
-
if (results.length === 0 || results.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
317
|
-
// skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
|
|
318
|
-
return undefined;
|
|
319
|
-
}
|
|
320
|
-
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
321
|
-
if (ownership.kind === "empty")
|
|
322
|
-
return undefined;
|
|
323
|
-
const candidateIds = results.map((r) => String(r.entityId));
|
|
324
|
-
// The index is only trusted to name candidates: a stale or foreign-tenant document must
|
|
325
|
-
// never become a filter value, so tenant-scoped targets are always re-read from the DB.
|
|
326
|
-
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
327
|
-
if (ownership.kind === "pass" && !tenantScoped) {
|
|
328
|
-
return { ownColumn: descriptor.ownColumn, ids: candidateIds };
|
|
329
|
-
}
|
|
330
|
-
const params = [...candidateIds];
|
|
331
|
-
const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
|
|
332
|
-
let tenantClause = "";
|
|
333
|
-
if (tenantScoped) {
|
|
334
|
-
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
335
|
-
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($${params.length - 1}, $${params.length})`;
|
|
336
|
-
}
|
|
337
|
-
let ownershipClause = "";
|
|
338
|
-
if (ownership.kind === "sql") {
|
|
339
|
-
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
340
|
-
for (const p of shifted.params)
|
|
341
|
-
params.push(p);
|
|
342
|
-
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
343
|
-
}
|
|
344
|
-
const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")})` +
|
|
345
|
-
`${tenantClause}${ownershipClause}`;
|
|
346
|
-
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, params);
|
|
347
|
-
if (rows.length === 0)
|
|
348
|
-
return undefined;
|
|
349
|
-
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
350
|
-
}
|
|
351
|
-
async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db, user, searchAdapter) {
|
|
352
|
-
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
353
|
-
if (targetEntity === undefined) {
|
|
354
|
-
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
355
|
-
// should have caught; fail open (no clause) rather than 500 the request
|
|
356
|
-
return undefined;
|
|
357
|
-
}
|
|
358
|
-
if (targetEntity.fields[descriptor.labelField] === undefined) {
|
|
359
|
-
// skip: labelField isn't a real (declared) column on the target entity —
|
|
360
|
-
// row-meta references (e.g. tenant.name) always resolve here since
|
|
361
|
-
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
362
|
-
return undefined;
|
|
363
|
-
}
|
|
364
|
-
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
365
|
-
const targetTableName = String(schemaTableName(targetTable));
|
|
366
|
-
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
367
|
-
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
368
|
-
}
|
|
369
|
-
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
370
|
-
// skip: no plaintext anywhere to match — boot rejects this normally,
|
|
371
|
-
// this is defense-in-depth for a target entity resolved only at runtime.
|
|
372
|
-
return undefined;
|
|
373
|
-
}
|
|
374
|
-
return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter);
|
|
375
|
-
}
|
|
376
|
-
// Building the table is pure but not free, and runs on every search request;
|
|
377
|
-
// keyed by the definition object so a re-registered entity never sees a stale table.
|
|
378
|
-
const referenceTargetTables = new WeakMap();
|
|
379
|
-
function referenceTargetTable(name, entity) {
|
|
380
|
-
const cached = referenceTargetTables.get(entity);
|
|
381
|
-
if (cached?.name === name)
|
|
382
|
-
return cached.table;
|
|
383
|
-
const table = buildEntityTable(name, entity);
|
|
384
|
-
referenceTargetTables.set(entity, { name, table });
|
|
385
|
-
return table;
|
|
386
|
-
}
|
|
387
|
-
// Projected alongside the row when a reference sort is active, so the keyset
|
|
388
|
-
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
389
|
-
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
390
|
-
const STREAM_VERSION_ALIAS = "__streamVersion";
|
|
391
|
-
// Resolve a `sortable: true` reference field's label inline for
|
|
392
|
-
// ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
|
|
393
|
-
// row per entity row, the read model stays untouched, and the lookup rides the
|
|
394
|
-
// target's primary key. Tenant-scoped exactly like the search path — a foreign
|
|
395
|
-
// tenant's label must never influence this tenant's ordering.
|
|
396
|
-
// undefined means "cannot order by this label", which drops the sort to plain
|
|
397
|
-
// id order; falling back to the UUID column would look deliberate to the user
|
|
398
|
-
// while being arbitrary, which is worse than no sorting.
|
|
399
|
-
function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
|
|
400
|
-
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
401
|
-
if (targetEntity === undefined) {
|
|
402
|
-
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
403
|
-
// should have caught; drop the sort rather than 500 the request
|
|
404
|
-
return undefined;
|
|
405
|
-
}
|
|
406
|
-
if (targetEntity.fields[descriptor.labelField] === undefined) {
|
|
407
|
-
// skip: labelField isn't a real (declared) column on the target entity
|
|
408
|
-
return undefined;
|
|
409
|
-
}
|
|
410
|
-
if (isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
411
|
-
// skip: encrypted/PII labelField — ciphertext orders by its bytes, which is
|
|
412
|
-
// arbitrary to the reader and would also rank rows by their stored secret
|
|
413
|
-
return undefined;
|
|
414
|
-
}
|
|
415
|
-
const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
416
|
-
// The order of labels the viewer may not read would leak them bit by bit, same as the search path.
|
|
417
|
-
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
418
|
-
if (labelAccess.kind !== "pass")
|
|
419
|
-
return undefined;
|
|
420
|
-
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
421
|
-
if (ownership.kind === "empty")
|
|
422
|
-
return undefined;
|
|
423
|
-
const targetTableName = String(schemaTableName(targetTable));
|
|
424
|
-
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
425
|
-
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
426
|
-
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
427
|
-
const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
|
|
428
|
-
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
429
|
-
? ` AND t."${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
430
|
-
: "";
|
|
431
|
-
// No ::text cast (unlike the ILIKE search path): a non-text label must keep
|
|
432
|
-
// its native collation/numeric order, and the keyset param infers its type
|
|
433
|
-
// from the comparison anyway.
|
|
434
|
-
return (params) => {
|
|
435
|
-
let tenantClause = "";
|
|
436
|
-
if (tenantScoped) {
|
|
437
|
-
params.push(tenantId, SYSTEM_TENANT_ID);
|
|
438
|
-
tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
|
|
439
|
-
}
|
|
440
|
-
let ownershipClause = "";
|
|
441
|
-
if (ownership.kind === "sql") {
|
|
442
|
-
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
443
|
-
for (const p of shifted.params)
|
|
444
|
-
params.push(p);
|
|
445
|
-
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
446
|
-
}
|
|
447
|
-
return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
448
|
-
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
|
|
449
|
-
};
|
|
14
|
+
// of the factory's local scope. The list steps (filter, search, sort, paging,
|
|
15
|
+
// row post-processing) live in the event-store-executor-read-*.ts neighbours.
|
|
16
|
+
function emptyListPage(totalCount) {
|
|
17
|
+
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
450
18
|
}
|
|
451
19
|
export function createReadVerbs(ctx) {
|
|
452
|
-
const { table, entity, entityName, entityCache,
|
|
20
|
+
const { table, entity, entityName, entityCache, streamTenantFor, idFilter, probeOwnershipWithStreamVersion, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
|
|
453
21
|
return {
|
|
454
22
|
// list + detail are unchanged from crud-executor — projections are the
|
|
455
23
|
// read-model and serve these queries directly.
|
|
@@ -458,246 +26,38 @@ export function createReadVerbs(ctx) {
|
|
|
458
26
|
const { limit, offset } = resolveListPagination(payload);
|
|
459
27
|
const totalCount = payload.totalCount === true;
|
|
460
28
|
const tableName = String(schemaTableName(table));
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const physicalCol = (field) => physicalColumnName(table, field);
|
|
479
|
-
const colSql = (field) => `"${physicalCol(field)}"`;
|
|
480
|
-
// Field-level read access gates filtering and sorting the same way it
|
|
481
|
-
// gates the projected row: a caller who cannot read a field must not be
|
|
482
|
-
// able to probe its values through result counts or ordering.
|
|
483
|
-
// Hoisted above the search block — a searchable-reference clause is the
|
|
484
|
-
// same probe surface as a filter/sort and must be gated the same way,
|
|
485
|
-
// before the reference-match query even runs.
|
|
486
|
-
const fieldReadClause = (field) => buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
|
|
487
|
-
// The build-time options.searchAdapter takes precedence; use the runtime override
|
|
488
|
-
// as a fallback because defaultEntityQueryHandler receives the search adapter only
|
|
489
|
-
// at runtime, when createEventStoreExecutor has no server context yet.
|
|
490
|
-
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
491
|
-
let searchTruncated = false;
|
|
492
|
-
if (payload.search) {
|
|
493
|
-
const searchTerm = payload.search;
|
|
494
|
-
// A search term with no adapter wired must fail loud, not
|
|
495
|
-
// silently return the unfiltered list dressed up as a search result.
|
|
496
|
-
if (!effectiveSearchAdapter) {
|
|
497
|
-
throw new UnprocessableError("search_adapter_not_wired", {
|
|
498
|
-
details: {
|
|
499
|
-
entity: entityName,
|
|
500
|
-
hint: "Wire a SearchAdapter for this entity, or remove `searchable` from the field/screen.",
|
|
501
|
-
},
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
// Same choke-point as writes/index: systemStream docs live under
|
|
505
|
-
// SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
|
|
506
|
-
// must not invent a second predicate.
|
|
507
|
-
const searchTenantId = streamTenantFor(user);
|
|
508
|
-
const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
|
|
509
|
-
filterType: entityName,
|
|
510
|
-
limit: MAX_SEARCH_CANDIDATE_IDS,
|
|
511
|
-
});
|
|
512
|
-
searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
|
|
513
|
-
filterIds = results.map((r) => r.entityId);
|
|
514
|
-
// Union in reference-column label matches: a searchable
|
|
515
|
-
// reference field (or the implicit tenantId→tenant.name row-meta
|
|
516
|
-
// reference) whose target row's labelField matches the search term
|
|
517
|
-
// also counts as a hit on this entity, not just a raw text-field hit.
|
|
518
|
-
const referenceSearch = runtimeOptions?.referenceSearch;
|
|
519
|
-
// A caller who cannot read the reference column itself must not be able
|
|
520
|
-
// to infer its value by matching the target's label — same probe
|
|
521
|
-
// surface as filtering/sorting on it directly.
|
|
522
|
-
const descriptors = collectReferenceSearchDescriptors(table, referenceSearch, db.mode).filter((d) => fieldReadClause(d.ownColumn).kind === "pass");
|
|
523
|
-
const referenceMatches = referenceSearch
|
|
524
|
-
? (await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db, user, effectiveSearchAdapter)))).filter((m) => m !== undefined)
|
|
525
|
-
: [];
|
|
526
|
-
if (filterIds.length === 0 && referenceMatches.length === 0) {
|
|
527
|
-
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
528
|
-
}
|
|
529
|
-
const orParts = [];
|
|
530
|
-
if (filterIds.length > 0) {
|
|
531
|
-
const placeholders = filterIds.map((id) => {
|
|
532
|
-
params.push(id);
|
|
533
|
-
return `$${params.length}`;
|
|
534
|
-
});
|
|
535
|
-
orParts.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
|
|
536
|
-
}
|
|
537
|
-
for (const match of referenceMatches) {
|
|
538
|
-
const placeholders = match.ids.map((id) => {
|
|
539
|
-
params.push(id);
|
|
540
|
-
return `$${params.length}`;
|
|
541
|
-
});
|
|
542
|
-
orParts.push(`${colSql(match.ownColumn)} IN (${placeholders.join(", ")})`);
|
|
543
|
-
}
|
|
544
|
-
// Parenthesized as one whereSql entry — whereSql is joined with
|
|
545
|
-
// " AND " below, an unparenthesized OR here would leak past the
|
|
546
|
-
// tenant filter that follows.
|
|
547
|
-
whereSql.push(`(${orParts.join(" OR ")})`);
|
|
548
|
-
}
|
|
549
|
-
// A column sort must not 400 (the renderer sends one per header click), and the order of
|
|
550
|
-
// ciphertext carries no meaning for the user while leaking the order of the plaintext.
|
|
551
|
-
const sortCandidate = payload.sort &&
|
|
552
|
-
table[payload.sort] &&
|
|
553
|
-
fieldReadClause(payload.sort).kind === "pass" &&
|
|
554
|
-
isListSortAllowed(entity, payload.sort)
|
|
555
|
-
? payload.sort
|
|
556
|
-
: undefined;
|
|
557
|
-
const sortDescending = payload.sortDirection === "desc";
|
|
558
|
-
// A reference column stores a UUID, so it is never sorted by
|
|
559
|
-
// directly: either the field opted into `sortable` and we order by the
|
|
560
|
-
// target row's label, or the sort is dropped to plain id order.
|
|
561
|
-
let sortField;
|
|
562
|
-
let referenceSortExpr;
|
|
563
|
-
if (sortCandidate !== undefined && entity.fields[sortCandidate]?.type === "reference") {
|
|
564
|
-
const referenceSort = runtimeOptions?.referenceSort;
|
|
565
|
-
const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
|
|
566
|
-
referenceSortExpr =
|
|
567
|
-
referenceSort && descriptor
|
|
568
|
-
? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db, user)
|
|
569
|
-
: undefined;
|
|
570
|
-
if (referenceSortExpr !== undefined)
|
|
571
|
-
sortField = sortCandidate;
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
sortField = sortCandidate;
|
|
575
|
-
}
|
|
576
|
-
// Tenant-Filter (replicates TenantDb's readWhere semantics).
|
|
577
|
-
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
578
|
-
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
579
|
-
whereSql.push(`${colSql("tenantId")} IN ($${params.length - 1}, $${params.length})`);
|
|
580
|
-
}
|
|
581
|
-
if (softDelete && table["isDeleted"] && runtimeOptions?.includeDeleted !== true) {
|
|
582
|
-
whereSql.push(`${colSql("isDeleted")} = FALSE`);
|
|
583
|
-
}
|
|
584
|
-
if (payload.cursor) {
|
|
585
|
-
const cursor = decodeKeysetCursor(payload.cursor);
|
|
586
|
-
if (sortField === undefined || cursor.sortValue === undefined) {
|
|
587
|
-
params.push(cursor.id);
|
|
588
|
-
whereSql.push(`${colSql("id")} > $${params.length}`);
|
|
589
|
-
}
|
|
590
|
-
else {
|
|
591
|
-
whereSql.push(keysetBoundarySql(referenceSortExpr !== undefined ? referenceSortExpr(params) : colSql(sortField), colSql("id"), { id: cursor.id, sortValue: cursor.sortValue }, sortDescending, params));
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
if (ownership.kind === "sql") {
|
|
595
|
-
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
|
|
596
|
-
whereSql.push(shifted.sqlText);
|
|
597
|
-
for (const p of shifted.params)
|
|
598
|
-
params.push(p);
|
|
599
|
-
}
|
|
600
|
-
const applyFilter = (f) => {
|
|
601
|
-
if (table[f.field] === undefined) {
|
|
602
|
-
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
const readClause = fieldReadClause(f.field);
|
|
606
|
-
if (readClause.kind === "empty") {
|
|
607
|
-
whereSql.push("FALSE");
|
|
608
|
-
// skip: no role grants read on this field, so no row may be probed
|
|
609
|
-
// through it — an unsatisfiable filter beats an error, which would
|
|
610
|
-
// itself confirm the field exists (field-access.ts strips silently)
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
if (!isListFilterAllowed(entity, f.field, f)) {
|
|
614
|
-
throw new ValidationError({
|
|
615
|
-
fields: [
|
|
616
|
-
{
|
|
617
|
-
path: `filter.${f.field}`,
|
|
618
|
-
code: "field_not_filterable",
|
|
619
|
-
i18nKey: "errors.validation.invalid_value",
|
|
620
|
-
params: {
|
|
621
|
-
field: f.field,
|
|
622
|
-
op: f.op,
|
|
623
|
-
hint: "Encrypted, personal and secret fields cannot be filtered; only equality on a field with find: exact or fuzzy.",
|
|
624
|
-
},
|
|
625
|
-
},
|
|
626
|
-
],
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
if (readClause.kind === "sql") {
|
|
630
|
-
const shifted = shiftParams({ sqlText: readClause.sqlText, params: readClause.params }, params.length);
|
|
631
|
-
whereSql.push(shifted.sqlText);
|
|
632
|
-
for (const p of shifted.params)
|
|
633
|
-
params.push(p);
|
|
634
|
-
}
|
|
635
|
-
if (entity.fields[f.field]?.type === "multiSelect") {
|
|
636
|
-
applyMultiSelectFilter(colSql, whereSql, params, f);
|
|
637
|
-
// skip: multiSelect already handled — don't fall through to applyScreenFilter
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
applyScreenFilter(table, colSql, whereSql, params, f);
|
|
641
|
-
};
|
|
642
|
-
if (payload.filter !== undefined)
|
|
643
|
-
applyFilter(payload.filter);
|
|
644
|
-
if (payload.filters !== undefined)
|
|
645
|
-
for (const f of payload.filters)
|
|
646
|
-
applyFilter(f);
|
|
29
|
+
const built = await buildListWhere(ctx, payload, user, db, runtimeOptions);
|
|
30
|
+
if (built.kind === "empty")
|
|
31
|
+
return emptyListPage(totalCount);
|
|
32
|
+
const { where, fieldReadClause, searchTruncated } = built;
|
|
33
|
+
const sort = resolveListSort({
|
|
34
|
+
payload,
|
|
35
|
+
table,
|
|
36
|
+
entity,
|
|
37
|
+
tableName,
|
|
38
|
+
colSql: where.colSql,
|
|
39
|
+
fieldReadClause,
|
|
40
|
+
referenceSort: runtimeOptions?.referenceSort,
|
|
41
|
+
db,
|
|
42
|
+
user,
|
|
43
|
+
});
|
|
44
|
+
if (payload.cursor)
|
|
45
|
+
appendCursorBoundary(where, payload.cursor, sort);
|
|
647
46
|
// The COUNT below reuses the WHERE text only, so it must bind exactly the
|
|
648
47
|
// params that text references — Postgres rejects a statement supplied
|
|
649
48
|
// with more parameters than it uses, and ORDER BY may add its own.
|
|
650
|
-
const whereParams = params.slice();
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
: colSql(sortField);
|
|
656
|
-
// The projected label alias is ordered by directly, so Postgres evaluates
|
|
657
|
-
// the correlated subquery once per row instead of once for ORDER BY and
|
|
658
|
-
// once for the SELECT list.
|
|
659
|
-
const orderBySortSql = referenceSortExpr !== undefined ? `"${SORT_LABEL_ALIAS}"` : sortSql;
|
|
660
|
-
const orderByClause = orderBySortSql !== undefined
|
|
661
|
-
? ` ORDER BY ${orderBySortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
|
|
662
|
-
: ` ORDER BY ${colSql("id")} ASC`;
|
|
663
|
-
const useOffset = !payload.cursor && offset > 0;
|
|
664
|
-
const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
|
|
665
|
-
const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
|
|
666
|
-
// Project the resolved label too, so the keyset cursor below carries the
|
|
667
|
-
// value that was ordered by rather than the FK column's UUID.
|
|
668
|
-
const sortLabelProjection = referenceSortExpr !== undefined && sortSql !== undefined
|
|
669
|
-
? `, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
|
|
670
|
-
: "";
|
|
671
|
-
// Stream version in the same statement as the rows: a separate read could pair a
|
|
672
|
-
// post-write version with a pre-write row, and update({version}) would then pass
|
|
673
|
-
// the optimistic lock against a state the client never saw (see loadDetailSnapshot).
|
|
674
|
-
params.push(streamTenantFor(user));
|
|
675
|
-
const streamVersionProjection = `, (SELECT MAX("version") FROM "kumiko_events" ` +
|
|
676
|
-
`WHERE "aggregate_id" = "${tableName}"."id" AND "tenant_id" = $${params.length}) ` +
|
|
677
|
-
`AS "${STREAM_VERSION_ALIAS}"`;
|
|
49
|
+
const whereParams = where.params.slice();
|
|
50
|
+
const { orderByClause, sortLabelProjection } = renderListOrderBy(sort, where);
|
|
51
|
+
const offsetClause = !payload.cursor && offset > 0 ? ` OFFSET ${offset}` : "";
|
|
52
|
+
const whereClauseSqlText = where.whereSql.length > 0 ? ` WHERE ${where.whereSql.join(" AND ")}` : "";
|
|
53
|
+
const streamVersionProjection = appendStreamVersionProjection(where.params, tableName, streamTenantFor(user));
|
|
678
54
|
const selectList = `*${sortLabelProjection}${streamVersionProjection}`;
|
|
679
55
|
const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
680
|
-
const rawRows = await executeRawQueryRead(runner, listSql, params);
|
|
681
|
-
const lastSortLabel = rawRows
|
|
682
|
-
|
|
683
|
-
// coercion so they never reach the caller or the entity cache.
|
|
684
|
-
const streamVersions = rawRows.map((r) => {
|
|
685
|
-
const v = r[STREAM_VERSION_ALIAS];
|
|
686
|
-
delete r[STREAM_VERSION_ALIAS];
|
|
687
|
-
return typeof v === "number" ? v : 0;
|
|
688
|
-
});
|
|
689
|
-
if (referenceSortExpr !== undefined) {
|
|
690
|
-
for (const row of rawRows)
|
|
691
|
-
delete row[SORT_LABEL_ALIAS];
|
|
692
|
-
}
|
|
693
|
-
// Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
|
|
694
|
-
// Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
|
|
695
|
-
// column names, while compound-type lookups (rehydrateMoney et al.) and the
|
|
696
|
-
// encrypted/pii field lists are all camelCase — running either first on a
|
|
697
|
-
// still-snake_case row silently no-ops (money) or skips every multi-word
|
|
698
|
-
// field (ciphertext leaked to the caller).
|
|
56
|
+
const rawRows = await executeRawQueryRead(runner, listSql, where.params);
|
|
57
|
+
const lastSortLabel = takeLastSortLabel(rawRows, sort);
|
|
58
|
+
const streamVersions = takeStreamVersions(rawRows);
|
|
699
59
|
const tableInfo = extractTableInfo(table);
|
|
700
|
-
const encryptedRows = rawRows.map((r) =>
|
|
60
|
+
const encryptedRows = rawRows.map((r) => coerceAndRehydrateRow(r, tableInfo, entity));
|
|
701
61
|
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
|
|
702
62
|
// Stream version is authoritative (see detail()): ctx.appendEvent bumps the
|
|
703
63
|
// stream without touching row.version, so the raw row version would hand
|
|
@@ -714,24 +74,11 @@ export function createReadVerbs(ctx) {
|
|
|
714
74
|
let nextCursor = null;
|
|
715
75
|
if (rows.length === limit && lastRow && lastRaw) {
|
|
716
76
|
const cursorId = lastRow["id"]; // @cast-boundary engine-payload
|
|
717
|
-
|
|
718
|
-
? undefined
|
|
719
|
-
: toCursorSortText(referenceSortExpr !== undefined ? lastSortLabel : lastRaw[physicalCol(sortField)]);
|
|
720
|
-
nextCursor =
|
|
721
|
-
sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
722
|
-
}
|
|
723
|
-
// total: extra COUNT(*) — only when explicitly requested (pager UI).
|
|
724
|
-
// Postgres cost is O(table-scan) without a filter, with a filter as
|
|
725
|
-
// expensive as the corresponding WHERE — cheap enough on indexed columns.
|
|
726
|
-
// Always counted over the full WHERE: search ids, reference matches, tenant,
|
|
727
|
-
// soft-delete, cursor, ownership and filters all narrow the result, so the
|
|
728
|
-
// search hit count alone is never the total.
|
|
729
|
-
let total;
|
|
730
|
-
if (totalCount) {
|
|
731
|
-
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
732
|
-
const countRows = await executeRawQueryRead(runner, countSql, whereParams);
|
|
733
|
-
total = countRows[0]?.count ?? 0;
|
|
77
|
+
nextCursor = encodeNextCursor(cursorId, sort, lastRaw, lastSortLabel, table);
|
|
734
78
|
}
|
|
79
|
+
const total = totalCount
|
|
80
|
+
? await countListRows(runner, tableName, whereClauseSqlText, whereParams)
|
|
81
|
+
: undefined;
|
|
735
82
|
return {
|
|
736
83
|
rows,
|
|
737
84
|
nextCursor,
|
|
@@ -739,14 +86,17 @@ export function createReadVerbs(ctx) {
|
|
|
739
86
|
...(searchTruncated && { searchTruncated: true }),
|
|
740
87
|
};
|
|
741
88
|
},
|
|
89
|
+
aggregate: (payload, user, db, runtimeOptions) => aggregateList(ctx, payload, user, db, runtimeOptions),
|
|
742
90
|
async detail(payload, user, db, options) {
|
|
743
91
|
const tableName = String(schemaTableName(table));
|
|
744
92
|
// H.2 — ownership check, AND-ed with the parent-ref read-gate (see
|
|
745
93
|
// db/parent-ref-clause.ts). `empty` → the user can never see this row
|
|
746
94
|
// regardless of its id. Return null (same shape as "not found", so a
|
|
747
95
|
// probing attacker can't distinguish "no access" from "doesn't exist").
|
|
748
|
-
const
|
|
749
|
-
|
|
96
|
+
const ownership = resolveReadOwnership(ctx, tableName, user, db, options?.parentVisibility, {
|
|
97
|
+
includeDeleted: false,
|
|
98
|
+
narrowTypes: undefined,
|
|
99
|
+
});
|
|
750
100
|
if (ownership.kind === "empty")
|
|
751
101
|
return null;
|
|
752
102
|
const idWhere = idFilter(payload.id);
|
|
@@ -787,11 +137,8 @@ export function createReadVerbs(ctx) {
|
|
|
787
137
|
const raw = snapshot.row;
|
|
788
138
|
if (!raw)
|
|
789
139
|
return null;
|
|
790
|
-
// Same coerce-before-rehydrate/decrypt ordering as list() above — raw
|
|
791
|
-
// is snake_case (raw SQL); coerceRow maps it to camelCase.
|
|
792
|
-
const rowInfo = extractTableInfo(table);
|
|
793
140
|
const undecryptableFields = [];
|
|
794
|
-
const coerced = await decryptForRead(
|
|
141
|
+
const coerced = await decryptForRead(coerceAndRehydrateRow(raw, extractTableInfo(table), entity), { degradeUndecryptable: (field) => undecryptableFields.push(field) });
|
|
795
142
|
const result = withStreamVersion(coerced, snapshot.streamVersion);
|
|
796
143
|
// A degraded row must not be cached: re-encrypting its null would
|
|
797
144
|
// replay the missing value on later cache hits without any error log.
|