@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
|
@@ -6,6 +6,7 @@ import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
|
6
6
|
import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-encryption.js";
|
|
7
7
|
import { bindPlaceholders } from "./event-store-executor-read-filter.js";
|
|
8
8
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
9
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
9
10
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
10
11
|
import { schemaTableName } from "./table-introspection.js";
|
|
11
12
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -42,6 +43,12 @@ function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
|
|
|
42
43
|
}
|
|
43
44
|
return descriptors;
|
|
44
45
|
}
|
|
46
|
+
// The target of a reference is gated like a direct read of it (access rule, parent-ref and
|
|
47
|
+
// readGate): a label of a row the viewer cannot open must not be findable by searching or
|
|
48
|
+
// readable by ordering.
|
|
49
|
+
export function referenceTargetOwnership(targetEntity, targetTable, targetTableName, user, db, parentVisibility) {
|
|
50
|
+
return resolveReadOwnership({ entity: targetEntity, table: targetTable }, targetTableName, user, db, parentVisibility, { includeDeleted: false, narrowTypes: undefined });
|
|
51
|
+
}
|
|
45
52
|
// Matches the main list's own default: soft-deleted rows are invisible, so a
|
|
46
53
|
// deleted target must neither match a search nor influence an ordering.
|
|
47
54
|
export function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
@@ -51,13 +58,13 @@ export function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
|
51
58
|
// unscoped SQL, a match in a foreign tenant must never surface a row here.
|
|
52
59
|
// Mirrors the main table's own tenant filter (see the `table["tenantId"]`
|
|
53
60
|
// check further down in list()).
|
|
54
|
-
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
|
|
61
|
+
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, parentVisibility) {
|
|
55
62
|
// The label must not leak through match counts: a viewer who cannot read the
|
|
56
63
|
// target row or its label field would otherwise probe names by searching.
|
|
57
64
|
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
58
65
|
if (labelAccess.kind !== "pass")
|
|
59
66
|
return undefined;
|
|
60
|
-
const ownership =
|
|
67
|
+
const ownership = referenceTargetOwnership(targetEntity, targetTable, targetTableName, user, db, parentVisibility);
|
|
61
68
|
if (ownership.kind === "empty")
|
|
62
69
|
return undefined;
|
|
63
70
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
@@ -91,7 +98,7 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEnt
|
|
|
91
98
|
}
|
|
92
99
|
// Same lookup, but the target's index is tenant-wide, not per-viewer, so
|
|
93
100
|
// candidate ids get re-checked against the target entity's own read access.
|
|
94
|
-
async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter) {
|
|
101
|
+
async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter, parentVisibility) {
|
|
95
102
|
const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
|
|
96
103
|
const results = await searchAdapter.search(searchTenantId, searchTerm, {
|
|
97
104
|
filterType: descriptor.targetEntityName,
|
|
@@ -101,7 +108,7 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
|
|
|
101
108
|
// skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
|
|
102
109
|
return undefined;
|
|
103
110
|
}
|
|
104
|
-
const ownership =
|
|
111
|
+
const ownership = referenceTargetOwnership(targetEntity, targetTable, targetTableName, user, db, parentVisibility);
|
|
105
112
|
if (ownership.kind === "empty")
|
|
106
113
|
return undefined;
|
|
107
114
|
const candidateIds = results.map((r) => String(r.entityId));
|
|
@@ -132,7 +139,7 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
|
|
|
132
139
|
return undefined;
|
|
133
140
|
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
134
141
|
}
|
|
135
|
-
async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db, user, searchAdapter) {
|
|
142
|
+
async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db, user, searchAdapter, resolveTable, parentVisibility) {
|
|
136
143
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
137
144
|
if (targetEntity === undefined) {
|
|
138
145
|
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
@@ -145,17 +152,18 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
145
152
|
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
146
153
|
return undefined;
|
|
147
154
|
}
|
|
148
|
-
const targetTable =
|
|
155
|
+
const targetTable = resolveTable?.(descriptor.targetEntityName) ??
|
|
156
|
+
referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
149
157
|
const targetTableName = String(schemaTableName(targetTable));
|
|
150
158
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
151
|
-
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
159
|
+
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, parentVisibility);
|
|
152
160
|
}
|
|
153
161
|
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
154
162
|
// skip: no plaintext anywhere to match — boot rejects this normally,
|
|
155
163
|
// this is defense-in-depth for a target entity resolved only at runtime.
|
|
156
164
|
return undefined;
|
|
157
165
|
}
|
|
158
|
-
return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter);
|
|
166
|
+
return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter, parentVisibility);
|
|
159
167
|
}
|
|
160
168
|
// Building the table is pure but not free, and runs on every search request;
|
|
161
169
|
// keyed by the definition object so a re-registered entity never sees a stale table.
|
|
@@ -216,6 +224,6 @@ async function resolveSearchableReferenceMatches(request, searchAdapter) {
|
|
|
216
224
|
// to infer its value by matching the target's label — same probe
|
|
217
225
|
// surface as filtering/sorting on it directly.
|
|
218
226
|
const descriptors = collectReferenceSearchDescriptors(request.table, referenceSearch, request.db.mode).filter((d) => request.fieldReadClause(d.ownColumn).kind === "pass");
|
|
219
|
-
const matches = await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, request.searchTerm, referenceSearch.resolveEntity, request.db, request.user, searchAdapter)));
|
|
227
|
+
const matches = await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, request.searchTerm, referenceSearch.resolveEntity, request.db, request.user, searchAdapter, referenceSearch.resolveTable, request.parentVisibility)));
|
|
220
228
|
return matches.filter((m) => m !== undefined);
|
|
221
229
|
}
|
|
@@ -4,6 +4,7 @@ import type { SessionUser } from "../engine/types/index.ts";
|
|
|
4
4
|
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
5
5
|
import type { Table } from "./event-store-executor-context.ts";
|
|
6
6
|
import type { ListWhere } from "./event-store-executor-read-filter.ts";
|
|
7
|
+
import type { ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
7
8
|
import type { TenantDb } from "./tenant-db.ts";
|
|
8
9
|
type ListPayload = Parameters<EventStoreExecutor["list"]>[0];
|
|
9
10
|
type ReferenceSortOption = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSort"];
|
|
@@ -15,16 +16,20 @@ export type ListSort = {
|
|
|
15
16
|
readonly descending: boolean;
|
|
16
17
|
readonly nulls: SortNulls | undefined;
|
|
17
18
|
} | {
|
|
18
|
-
readonly kind: "
|
|
19
|
+
readonly kind: "expression";
|
|
19
20
|
readonly field: string;
|
|
20
21
|
readonly descending: boolean;
|
|
21
22
|
readonly nulls: SortNulls | undefined;
|
|
22
|
-
readonly
|
|
23
|
+
readonly sql: (params: unknown[]) => string;
|
|
23
24
|
};
|
|
24
25
|
export type SortedListSort = Exclude<ListSort, {
|
|
25
26
|
readonly kind: "unsorted";
|
|
26
27
|
}>;
|
|
27
28
|
export declare function sortsNullsFirst(sort: SortedListSort): boolean;
|
|
29
|
+
export declare function buildReferenceSortExpr(outerColumnSql: string, descriptor: {
|
|
30
|
+
readonly targetEntityName: string;
|
|
31
|
+
readonly labelField: string;
|
|
32
|
+
}, resolveEntity: (entityName: string) => EntityDefinition | undefined, db: TenantDb, user: SessionUser, resolveTable: ((entityName: string) => Table) | undefined, parentVisibility: ParentVisibilityOption | undefined): ((params: unknown[]) => string) | undefined;
|
|
28
33
|
export declare function resolveListSort(request: {
|
|
29
34
|
readonly payload: Pick<ListPayload, "sort" | "sortDirection">;
|
|
30
35
|
readonly table: Table;
|
|
@@ -33,8 +38,10 @@ export declare function resolveListSort(request: {
|
|
|
33
38
|
readonly colSql: (field: string) => string;
|
|
34
39
|
readonly fieldReadClause: (field: string) => OwnershipClause;
|
|
35
40
|
readonly referenceSort: ReferenceSortOption;
|
|
41
|
+
readonly derivedSortExpr?: (field: string) => ((params: unknown[]) => string) | undefined;
|
|
36
42
|
readonly db: TenantDb;
|
|
37
43
|
readonly user: SessionUser;
|
|
44
|
+
readonly parentVisibility: ParentVisibilityOption | undefined;
|
|
38
45
|
}): ListSort;
|
|
39
46
|
export declare function renderSortSql(sort: SortedListSort, where: ListWhere): string;
|
|
40
47
|
export declare function renderListOrderBy(sort: ListSort, where: ListWhere): {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { buildOwnershipClause, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
2
2
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
3
3
|
import { isListSortAllowed, isSensitiveLabelField } from "./entity-field-encryption.js";
|
|
4
|
-
import { isSoftDeletedTargetHidden } from "./event-store-executor-read-search.js";
|
|
4
|
+
import { isSoftDeletedTargetHidden, referenceTargetOwnership, } from "./event-store-executor-read-search.js";
|
|
5
5
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
6
6
|
import { schemaTableName } from "./table-introspection.js";
|
|
7
7
|
// Projected alongside the row when a reference sort is active, so the keyset
|
|
8
8
|
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
9
9
|
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
10
|
+
// Where-rules qualify with the table name they get; the target may be the outer entity itself.
|
|
11
|
+
const TARGET_ALIAS = "t";
|
|
10
12
|
function declaredSortNulls(entity, field) {
|
|
11
13
|
const def = entity.fields[field];
|
|
12
14
|
return def !== undefined && "sortNulls" in def ? def.sortNulls : undefined;
|
|
@@ -28,7 +30,7 @@ function nullsClause(sort) {
|
|
|
28
30
|
// undefined means "cannot order by this label", which drops the sort to plain
|
|
29
31
|
// id order; falling back to the UUID column would look deliberate to the user
|
|
30
32
|
// while being arbitrary, which is worse than no sorting.
|
|
31
|
-
function buildReferenceSortExpr(
|
|
33
|
+
export function buildReferenceSortExpr(outerColumnSql, descriptor, resolveEntity, db, user, resolveTable, parentVisibility) {
|
|
32
34
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
33
35
|
if (targetEntity === undefined) {
|
|
34
36
|
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
@@ -44,15 +46,16 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
44
46
|
// arbitrary to the reader and would also rank rows by their stored secret
|
|
45
47
|
return undefined;
|
|
46
48
|
}
|
|
47
|
-
const targetTable =
|
|
49
|
+
const targetTable = resolveTable?.(descriptor.targetEntityName) ??
|
|
50
|
+
buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
48
51
|
// The order of labels the viewer may not read would leak them bit by bit, same as the search path.
|
|
49
|
-
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
52
|
+
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable, 1, TARGET_ALIAS);
|
|
50
53
|
if (labelAccess.kind !== "pass")
|
|
51
54
|
return undefined;
|
|
52
|
-
const
|
|
55
|
+
const targetTableName = String(schemaTableName(targetTable));
|
|
56
|
+
const ownership = referenceTargetOwnership(targetEntity, targetTable, TARGET_ALIAS, user, db, parentVisibility);
|
|
53
57
|
if (ownership.kind === "empty")
|
|
54
58
|
return undefined;
|
|
55
|
-
const targetTableName = String(schemaTableName(targetTable));
|
|
56
59
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
57
60
|
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
58
61
|
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
@@ -77,11 +80,21 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
77
80
|
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
78
81
|
}
|
|
79
82
|
return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
80
|
-
`WHERE t."id" =
|
|
83
|
+
`WHERE t."id" = ${outerColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
|
|
81
84
|
};
|
|
82
85
|
}
|
|
83
86
|
export function resolveListSort(request) {
|
|
84
87
|
const { payload, table, entity, referenceSort } = request;
|
|
88
|
+
const derivedSql = payload.sort ? request.derivedSortExpr?.(payload.sort) : undefined;
|
|
89
|
+
if (derivedSql !== undefined && payload.sort) {
|
|
90
|
+
return {
|
|
91
|
+
kind: "expression",
|
|
92
|
+
field: payload.sort,
|
|
93
|
+
descending: payload.sortDirection === "desc",
|
|
94
|
+
nulls: undefined,
|
|
95
|
+
sql: derivedSql,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
85
98
|
// A column sort must not 400 (the renderer sends one per header click), and the order of
|
|
86
99
|
// ciphertext carries no meaning for the user while leaking the order of the plaintext.
|
|
87
100
|
const field = payload.sort &&
|
|
@@ -101,15 +114,15 @@ export function resolveListSort(request) {
|
|
|
101
114
|
// directly: either the field opted into `sortable` and we order by the
|
|
102
115
|
// target row's label, or the sort is dropped to plain id order.
|
|
103
116
|
const descriptor = referenceSort?.fields.find((f) => f.fieldName === field);
|
|
104
|
-
const
|
|
105
|
-
? buildReferenceSortExpr(request.tableName
|
|
117
|
+
const sql = referenceSort && descriptor
|
|
118
|
+
? buildReferenceSortExpr(`"${request.tableName}".${request.colSql(field)}`, descriptor, referenceSort.resolveEntity, request.db, request.user, referenceSort.resolveTable, request.parentVisibility)
|
|
106
119
|
: undefined;
|
|
107
|
-
return
|
|
120
|
+
return sql === undefined
|
|
108
121
|
? { kind: "unsorted" }
|
|
109
|
-
: { kind: "
|
|
122
|
+
: { kind: "expression", field, descending, nulls, sql };
|
|
110
123
|
}
|
|
111
124
|
export function renderSortSql(sort, where) {
|
|
112
|
-
return sort.kind === "
|
|
125
|
+
return sort.kind === "expression" ? sort.sql(where.params) : where.colSql(sort.field);
|
|
113
126
|
}
|
|
114
127
|
export function renderListOrderBy(sort, where) {
|
|
115
128
|
const idAscending = `${where.colSql("id")} ASC`;
|
|
@@ -124,7 +137,7 @@ export function renderListOrderBy(sort, where) {
|
|
|
124
137
|
sortLabelProjection: "",
|
|
125
138
|
};
|
|
126
139
|
}
|
|
127
|
-
// The projected
|
|
140
|
+
// The projected alias is ordered by directly, so Postgres evaluates
|
|
128
141
|
// the correlated subquery once per row instead of once for ORDER BY and
|
|
129
142
|
// once for the SELECT list. Projecting it also lets the keyset cursor carry
|
|
130
143
|
// the value that was ordered by rather than the FK column's UUID.
|
|
@@ -137,7 +150,7 @@ export function renderListOrderBy(sort, where) {
|
|
|
137
150
|
// stripped before coercion; the last row's label feeds the keyset cursor.
|
|
138
151
|
export function takeLastSortLabel(rawRows, sort) {
|
|
139
152
|
const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
|
|
140
|
-
if (sort.kind === "
|
|
153
|
+
if (sort.kind === "expression") {
|
|
141
154
|
for (const row of rawRows)
|
|
142
155
|
delete row[SORT_LABEL_ALIAS];
|
|
143
156
|
}
|
|
@@ -3,15 +3,10 @@ import type { SessionUser } from "../engine/types/index.ts";
|
|
|
3
3
|
import type { EventStoreExecutor } from "./event-store-executor.ts";
|
|
4
4
|
import type { ExecutorContext } from "./event-store-executor-context.ts";
|
|
5
5
|
import { type ListWhere } from "./event-store-executor-read-filter.ts";
|
|
6
|
-
import { type ParentVisibilityOption } from "./parent-ref-clause.ts";
|
|
7
6
|
import type { TenantDb } from "./tenant-db.ts";
|
|
8
7
|
export type ListRuntimeOptions = NonNullable<Parameters<EventStoreExecutor["list"]>[3]>;
|
|
9
8
|
export type ListWherePayload = Pick<Parameters<EventStoreExecutor["list"]>[0], "search" | "filter" | "filters">;
|
|
10
9
|
export type FieldReadClause = (field: string) => OwnershipClause;
|
|
11
|
-
export declare function resolveReadOwnership(ctx: Pick<ExecutorContext, "entity" | "table">, tableName: string, user: SessionUser, db: TenantDb, parentVisibility: ParentVisibilityOption | undefined, gate: {
|
|
12
|
-
readonly includeDeleted: boolean;
|
|
13
|
-
readonly narrowTypes: ReadonlySet<string> | undefined;
|
|
14
|
-
}): OwnershipClause;
|
|
15
10
|
export declare function createFieldReadClause(ctx: Pick<ExecutorContext, "entity" | "table">, user: SessionUser): FieldReadClause;
|
|
16
11
|
export type ListWhereBuild = {
|
|
17
12
|
readonly kind: "empty";
|
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
import { buildOwnershipClause,
|
|
1
|
+
import { buildOwnershipClause, normalizeAccessEntry, } from "../engine/ownership.js";
|
|
2
2
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
3
3
|
import { appendShiftedClause, applyListFilter, collectListFilters, deriveParentNarrowTypes, } from "./event-store-executor-read-filter.js";
|
|
4
4
|
import { applyListSearch } from "./event-store-executor-read-search.js";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
6
6
|
import { physicalColumnName } from "./table-builder.js";
|
|
7
7
|
import { schemaTableName } from "./table-introspection.js";
|
|
8
|
-
// H.2 — entity-level read ownership, AND-ed with the parent-ref read-gate
|
|
9
|
-
// (join-row entities derive their visibility from a host row's own read path —
|
|
10
|
-
// see db/parent-ref-clause.ts). `empty` means there's no row the user could ever see.
|
|
11
|
-
export function resolveReadOwnership(ctx, tableName, user, db, parentVisibility, gate) {
|
|
12
|
-
const parentClause = buildParentRefClause(ctx.entity, ctx.table, tableName, user, db, parentVisibility, gate);
|
|
13
|
-
return combineClauses(buildOwnershipClause(user, ctx.entity.access?.read, ctx.table), parentClause);
|
|
14
|
-
}
|
|
15
8
|
// Replicates TenantDb's readWhere semantics for the raw list SQL.
|
|
16
9
|
function appendTenantAndSoftDeleteScope(where, ctx, db, includeDeleted) {
|
|
17
10
|
if (ctx.table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
@@ -62,6 +55,7 @@ export async function buildListWhere(ctx, payload, user, db, runtimeOptions) {
|
|
|
62
55
|
entityName,
|
|
63
56
|
table,
|
|
64
57
|
referenceSearch: runtimeOptions?.referenceSearch,
|
|
58
|
+
parentVisibility: runtimeOptions?.parentVisibility,
|
|
65
59
|
fieldReadClause,
|
|
66
60
|
streamTenantFor,
|
|
67
61
|
user,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
2
2
|
import { extractTableInfo } from "../db/table-info.js";
|
|
3
3
|
import { getStreamVersion } from "../event-store/index.js";
|
|
4
|
+
import { buildDerivedSelect } from "./derived-field-sql.js";
|
|
4
5
|
import { aggregateList } from "./event-store-executor-read-aggregate.js";
|
|
5
6
|
import { appendCursorBoundary, countListRows, encodeNextCursor, resolveListPagination, } from "./event-store-executor-read-paging.js";
|
|
6
7
|
import { appendStreamVersionProjection, coerceAndRehydrateRow, takeStreamVersions, withStreamVersion, } from "./event-store-executor-read-rows.js";
|
|
7
8
|
import { renderListOrderBy, resolveListSort, takeLastSortLabel, } from "./event-store-executor-read-sort.js";
|
|
8
|
-
import { buildListWhere
|
|
9
|
+
import { buildListWhere } from "./event-store-executor-read-where.js";
|
|
10
|
+
import { resolveReadOwnership } from "./read-ownership.js";
|
|
9
11
|
import { schemaTableName } from "./table-introspection.js";
|
|
10
12
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
11
13
|
// The read verbs (list/aggregate/detail) of the event-store-executor. Split out
|
|
@@ -30,6 +32,15 @@ export function createReadVerbs(ctx) {
|
|
|
30
32
|
if (built.kind === "empty")
|
|
31
33
|
return emptyListPage(totalCount);
|
|
32
34
|
const { where, fieldReadClause, searchTruncated } = built;
|
|
35
|
+
const derived = buildDerivedSelect({
|
|
36
|
+
entity,
|
|
37
|
+
table,
|
|
38
|
+
tableName,
|
|
39
|
+
fieldReadClause,
|
|
40
|
+
user,
|
|
41
|
+
db,
|
|
42
|
+
runtime: runtimeOptions,
|
|
43
|
+
});
|
|
33
44
|
const sort = resolveListSort({
|
|
34
45
|
payload,
|
|
35
46
|
table,
|
|
@@ -38,8 +49,10 @@ export function createReadVerbs(ctx) {
|
|
|
38
49
|
colSql: where.colSql,
|
|
39
50
|
fieldReadClause,
|
|
40
51
|
referenceSort: runtimeOptions?.referenceSort,
|
|
52
|
+
derivedSortExpr: derived?.sortExpr,
|
|
41
53
|
db,
|
|
42
54
|
user,
|
|
55
|
+
parentVisibility: runtimeOptions?.parentVisibility,
|
|
43
56
|
});
|
|
44
57
|
if (payload.cursor)
|
|
45
58
|
appendCursorBoundary(where, payload.cursor, sort);
|
|
@@ -51,24 +64,30 @@ export function createReadVerbs(ctx) {
|
|
|
51
64
|
const offsetClause = !payload.cursor && offset > 0 ? ` OFFSET ${offset}` : "";
|
|
52
65
|
const whereClauseSqlText = where.whereSql.length > 0 ? ` WHERE ${where.whereSql.join(" AND ")}` : "";
|
|
53
66
|
const streamVersionProjection = appendStreamVersionProjection(where.params, tableName, streamTenantFor(user));
|
|
54
|
-
const
|
|
67
|
+
const derivedProjection = derived?.projections(where.params) ?? "";
|
|
68
|
+
const selectList = `*${sortLabelProjection}${streamVersionProjection}${derivedProjection}`;
|
|
55
69
|
const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
56
70
|
const rawRows = await executeRawQueryRead(runner, listSql, where.params);
|
|
57
71
|
const lastSortLabel = takeLastSortLabel(rawRows, sort);
|
|
58
72
|
const streamVersions = takeStreamVersions(rawRows);
|
|
73
|
+
const derivedValues = derived?.takeValues(rawRows);
|
|
59
74
|
const tableInfo = extractTableInfo(table);
|
|
60
75
|
const encryptedRows = rawRows.map((r) => coerceAndRehydrateRow(r, tableInfo, entity));
|
|
61
76
|
const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r, { degradeUndecryptable: () => { } })));
|
|
62
77
|
// Stream version is authoritative (see detail()): ctx.events.append bumps the
|
|
63
78
|
// stream without touching row.version, so the raw row version would hand
|
|
64
79
|
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
65
|
-
const
|
|
80
|
+
const storedRows = decryptedRows.map((r, i) => withStreamVersion(r, streamVersions[i] ?? 0));
|
|
66
81
|
// Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
|
|
67
82
|
// detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
|
|
68
83
|
// a cached version that differs from the stream.
|
|
69
|
-
if (entityCache && entityName &&
|
|
84
|
+
if (entityCache && entityName && storedRows.length > 0) {
|
|
70
85
|
await entityCache.mset(user.tenantId, entityName, encryptedRows.map((r) => ({ id: r["id"], data: r })));
|
|
71
86
|
}
|
|
87
|
+
// Viewer-dependent, so attached only after the cache write, as new row objects.
|
|
88
|
+
const rows = derivedValues === undefined
|
|
89
|
+
? storedRows
|
|
90
|
+
: storedRows.map((r, i) => ({ ...r, ...derivedValues[i] }));
|
|
72
91
|
const lastRow = rows[rows.length - 1];
|
|
73
92
|
const lastRaw = rawRows[rawRows.length - 1];
|
|
74
93
|
let nextCursor = null;
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
3
3
|
import type { ZodType } from "zod";
|
|
4
|
-
import type
|
|
4
|
+
import { type UnavailableSelectOption } from "../engine/screen-helpers.ts";
|
|
5
|
+
import type { FieldDefinition } from "../engine/types/fields.ts";
|
|
5
6
|
import type { UnprocessableOpts } from "../errors/classes.ts";
|
|
6
7
|
import { type WriteFailure, writeFailure } from "../errors/index.ts";
|
|
7
8
|
import type { StoredEvent } from "../event-store/index.ts";
|
|
8
9
|
import type { DbRow, DbRunner } from "./connection.ts";
|
|
9
10
|
export declare function projectEntityEvent(db: TenantDb, runner: DbRunner, event: StoredEvent): Promise<void>;
|
|
10
11
|
export declare function selectOptionNotAvailableFailure(entries: readonly UnavailableSelectOption[]): WriteFailure | undefined;
|
|
12
|
+
export declare function missingConditionalRequirementFailure(paths: readonly string[]): WriteFailure | undefined;
|
|
13
|
+
/** Value-consistency checks on the merged row (create: no `previous`): options a
|
|
14
|
+
* conditionalOptions rule no longer allows, and requiredWhen values still missing. */
|
|
15
|
+
export declare function fieldStateFailure(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): WriteFailure | undefined;
|
|
11
16
|
export type PreSaveFn = (changes: Record<string, unknown>, previous: Record<string, unknown>, isNew: boolean) => Promise<Record<string, unknown>>;
|
|
12
17
|
export declare function runPreSave(preSave: PreSaveFn | undefined, changes: Record<string, unknown>, previous: Record<string, unknown>, isNew: boolean, entityName: string, action: "create" | "update", outputSchema: () => ZodType): Promise<{
|
|
13
18
|
readonly data: DbRow;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { findMissingConditionalRequirements } from "../engine/field-state.js";
|
|
2
|
+
import { findUnavailableSelectOptions, } from "../engine/screen-helpers.js";
|
|
3
|
+
import { VersionConflictError as FrameworkVersionConflict, UnprocessableError, ValidationError, writeFailure, } from "../errors/index.js";
|
|
2
4
|
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
3
5
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
4
6
|
import { runInSavepointIfSupported } from "./query.js";
|
|
@@ -35,6 +37,23 @@ export function selectOptionNotAvailableFailure(entries) {
|
|
|
35
37
|
},
|
|
36
38
|
}));
|
|
37
39
|
}
|
|
40
|
+
export function missingConditionalRequirementFailure(paths) {
|
|
41
|
+
if (paths.length === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
return writeFailure(new ValidationError({
|
|
44
|
+
fields: paths.map((path) => ({
|
|
45
|
+
path,
|
|
46
|
+
code: "required",
|
|
47
|
+
i18nKey: "kumiko.validation.required",
|
|
48
|
+
})),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
/** Value-consistency checks on the merged row (create: no `previous`): options a
|
|
52
|
+
* conditionalOptions rule no longer allows, and requiredWhen values still missing. */
|
|
53
|
+
export function fieldStateFailure(fields, values, previous) {
|
|
54
|
+
return (selectOptionNotAvailableFailure(findUnavailableSelectOptions(fields, values, previous)) ??
|
|
55
|
+
missingConditionalRequirementFailure(findMissingConditionalRequirements(fields, values, previous)));
|
|
56
|
+
}
|
|
38
57
|
// preSave runs before ownership checks: authorization must evaluate the row
|
|
39
58
|
// as it will actually be persisted, including hook-derived fields. A throwing
|
|
40
59
|
// hook is an app-author bug (bad business rule, not a framework fault) — map it
|
|
@@ -3,7 +3,7 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
|
|
|
3
3
|
import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
4
4
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
5
5
|
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isRecordLocked, isUnchangedValue } from "../engine/screen-helpers.js";
|
|
7
7
|
import { IdempotentReplayError, PreconditionFailedError, RecordLockedError, } from "../errors/classes.js";
|
|
8
8
|
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, InternalError, NotFoundError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
9
9
|
import { isSubjectErasedConflict } from "../errors/subject-erased.js";
|
|
@@ -15,7 +15,7 @@ import { applyEntityEvent } from "./apply-entity-event.js";
|
|
|
15
15
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
16
16
|
import { WritePurposes, } from "./event-store-executor.js";
|
|
17
17
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
18
|
-
import { optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave,
|
|
18
|
+
import { fieldStateFailure, optimisticLockFailure, ownershipDeniedFailure, projectEntityEvent, runPreSave, } from "./event-store-executor-write-steps.js";
|
|
19
19
|
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
20
20
|
import { runInSavepointIfSupported } from "./query.js";
|
|
21
21
|
import { assertPersonalDataWrite, tableNameOf, withExecutorManagedWrites } from "./tenant-db.js";
|
|
@@ -241,9 +241,9 @@ export function createWriteVerbs(ctx) {
|
|
|
241
241
|
// Statutory retention/erasure writes may change a condition field (e.g. null a PII
|
|
242
242
|
// reference) and must not be blocked by the select rule — same exemption as the lock.
|
|
243
243
|
const isStatutorySystemWrite = options?.writePurpose !== undefined && isFrameworkSystemUser(user);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
if (isStatutorySystemWrite)
|
|
245
|
+
return undefined;
|
|
246
|
+
return fieldStateFailure(entity.fields, mergedNew, previous);
|
|
247
247
|
}
|
|
248
248
|
async function appendCreatedEvent(runner, user, streamTenantId, aggregateId, flatData) {
|
|
249
249
|
// 1. Append the event in the same transaction as the projection write — both must
|
|
@@ -446,9 +446,9 @@ export function createWriteVerbs(ctx) {
|
|
|
446
446
|
const ownershipDenied = createOwnershipFailure(user, streamTenantId, payloadWithoutId, data);
|
|
447
447
|
if (ownershipDenied)
|
|
448
448
|
return ownershipDenied;
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
451
|
-
return
|
|
449
|
+
const invalidFieldState = fieldStateFailure(entity.fields, data);
|
|
450
|
+
if (invalidFieldState)
|
|
451
|
+
return invalidFieldState;
|
|
452
452
|
// All compound types (locatedTimestamp, money, ...) use the same pipeline: the
|
|
453
453
|
// caller sends the combined API form, the framework stores the flat DB form (see
|
|
454
454
|
// db/compound-types.ts).
|
package/dist/db/money.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
|
|
2
2
|
import { toMinorUnits } from "../engine/minor-units.ts";
|
|
3
|
+
export declare const FRAMEWORK_DEFAULT_CURRENCY: "EUR";
|
|
3
4
|
export { toMinorUnits };
|
|
4
5
|
export declare function moneyPayloadToMinorUnits(raw: unknown): number | undefined;
|
|
5
6
|
export declare function flattenMoney(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
|
|
@@ -29,3 +30,6 @@ export type MoneyRead = {
|
|
|
29
30
|
* Pure — does not mutate its input.
|
|
30
31
|
*/
|
|
31
32
|
export declare function rehydrateMoney(row: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
|
|
33
|
+
/** One money value from its raw amount/currency columns; shared with SQL derived money so both
|
|
34
|
+
* produce the identical read shape. Throws on a corrupt amount. */
|
|
35
|
+
export declare function readMoney(name: string, amountRaw: unknown, currencyRaw: unknown, fallbackCurrency: string): MoneyRead;
|
package/dist/db/money.js
CHANGED
|
@@ -32,7 +32,7 @@ import { DEFAULT_CURRENCIES } from "../engine/field-helpers.js";
|
|
|
32
32
|
// amount column (legacy DB convention for money — `SUM(buying_price)` remains
|
|
33
33
|
// idiomatic). `<name>Currency` is the additional column.
|
|
34
34
|
import { MINOR_UNIT_SCALE, toMinorUnits } from "../engine/minor-units.js";
|
|
35
|
-
const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
35
|
+
export const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
36
36
|
export { toMinorUnits };
|
|
37
37
|
function toMajorUnits(amountScaled) {
|
|
38
38
|
return amountScaled / MINOR_UNIT_SCALE;
|
|
@@ -116,35 +116,40 @@ export function rehydrateMoney(row, entity) {
|
|
|
116
116
|
delete result[name];
|
|
117
117
|
continue;
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
throw new Error(`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`);
|
|
135
|
-
}
|
|
119
|
+
result[name] = readMoney(name, amountRaw, currencyRaw, fallbackCurrency);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/** One money value from its raw amount/currency columns; shared with SQL derived money so both
|
|
124
|
+
* produce the identical read shape. Throws on a corrupt amount. */
|
|
125
|
+
export function readMoney(name, amountRaw, currencyRaw, fallbackCurrency) {
|
|
126
|
+
let amountScaled;
|
|
127
|
+
if (typeof amountRaw === "number") {
|
|
128
|
+
amountScaled = amountRaw;
|
|
129
|
+
}
|
|
130
|
+
else if (typeof amountRaw === "bigint") {
|
|
131
|
+
amountScaled = Number(amountRaw);
|
|
132
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
133
|
+
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
|
|
136
134
|
}
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
}
|
|
136
|
+
else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
137
|
+
// The PG driver sometimes returns BIGINT as a string (when it exceeds the
|
|
138
|
+
// safe integer range of 2^53).
|
|
139
|
+
amountScaled = Number(amountRaw);
|
|
140
|
+
if (!Number.isSafeInteger(amountScaled)) {
|
|
141
|
+
throw new Error(`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`);
|
|
139
142
|
}
|
|
140
|
-
const currency = typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
141
|
-
// amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
|
|
142
|
-
result[name] = {
|
|
143
|
-
amount: toMajorUnits(amountScaled),
|
|
144
|
-
currency,
|
|
145
|
-
amountScaled,
|
|
146
|
-
amountMinor: amountScaled,
|
|
147
|
-
};
|
|
148
143
|
}
|
|
149
|
-
|
|
144
|
+
else {
|
|
145
|
+
throw new Error(`rehydrateMoney: field "${name}" amount has unexpected type ${typeof amountRaw}`);
|
|
146
|
+
}
|
|
147
|
+
const currency = typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
148
|
+
// amountMinor: deprecated alias, same value as amountScaled — see MoneyRead.
|
|
149
|
+
return {
|
|
150
|
+
amount: toMajorUnits(amountScaled),
|
|
151
|
+
currency,
|
|
152
|
+
amountScaled,
|
|
153
|
+
amountMinor: amountScaled,
|
|
154
|
+
};
|
|
150
155
|
}
|
|
@@ -8,4 +8,5 @@ export type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-sto
|
|
|
8
8
|
export declare function buildParentRefClause(entity: EntityDefinition, table: Table, tableName: string, user: SessionUser, db: TenantDb, option: ParentVisibilityOption | undefined, opts: {
|
|
9
9
|
readonly includeDeleted?: boolean;
|
|
10
10
|
readonly narrowTypes?: ReadonlySet<string>;
|
|
11
|
+
readonly requireReadHandler?: boolean;
|
|
11
12
|
}): OwnershipClause;
|