@ultimat3/entity 29.3.2 → 30.0.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/CLAUDE.md +66 -96
- package/README.md +125 -8
- package/package.json +15 -5
- package/src/append-only.ts +7 -0
- package/src/batch-read.ts +12 -4
- package/src/bulk-write-send.ts +49 -0
- package/src/bulk-write.ts +48 -11
- package/src/column.ts +29 -4
- package/src/counter.ts +109 -0
- package/src/cross-tenant.ts +13 -0
- package/src/cursor.ts +17 -1
- package/src/database.ts +16 -10
- package/src/describe-tenancy.ts +25 -0
- package/src/describe.ts +146 -3
- package/src/entity-error.ts +33 -0
- package/src/entity-parse.ts +89 -0
- package/src/entity-patch.ts +42 -0
- package/src/entity.ts +95 -98
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -0
- package/src/index-identity.ts +46 -0
- package/src/index-init.ts +12 -0
- package/src/index.ts +20 -1
- package/src/jit-preload.ts +35 -25
- package/src/lifecycle.ts +100 -0
- package/src/lock-key.ts +27 -0
- package/src/lock-unsupported.ts +11 -0
- package/src/memory-aggregates.ts +99 -0
- package/src/memory-batch.ts +69 -0
- package/src/memory-journal.ts +68 -0
- package/src/memory-lifecycle.ts +106 -0
- package/src/memory-lock.ts +144 -0
- package/src/memory-observed.ts +121 -0
- package/src/memory-repo.ts +199 -186
- package/src/memory-same-tenant.ts +83 -0
- package/src/memory-seek.ts +44 -0
- package/src/memory-transactor.ts +94 -0
- package/src/memory-unique.ts +19 -4
- package/src/n-plus-one.ts +28 -10
- package/src/numeric-compare.ts +43 -0
- package/src/pg-aggregates.ts +185 -0
- package/src/pg-driver.ts +79 -166
- package/src/pg-lock.ts +50 -0
- package/src/pg-row.ts +29 -2
- package/src/pg-sql-aggregate.ts +31 -0
- package/src/pg-sql.ts +17 -1
- package/src/query.ts +26 -105
- package/src/read-aggregates.ts +138 -0
- package/src/read-capture.ts +22 -0
- package/src/registry.ts +49 -1
- package/src/repo.ts +34 -0
- package/src/retain.ts +71 -0
- package/src/row-observer.ts +22 -4
- package/src/row-security-settings.ts +20 -0
- package/src/sealed-repo.ts +15 -0
- package/src/seed.ts +43 -7
- package/src/series-unsupported.ts +11 -0
- package/src/table-locks.ts +41 -0
- package/src/test-driver.ts +90 -0
- package/src/time-bucket.ts +147 -0
- package/src/touch.ts +33 -0
- package/src/types.ts +22 -0
- package/src/view-entity.ts +78 -0
- package/src/view-issue.ts +47 -0
- package/src/view-repo.ts +38 -0
- package/src/view.ts +41 -27
package/src/describe.ts
CHANGED
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
// Money is the one place the projection is not one-to-one: one property becomes the two
|
|
6
6
|
// physical columns that back it.
|
|
7
7
|
|
|
8
|
-
import { columnName, moneyColumns, referenceBinding } from './column';
|
|
8
|
+
import { type Binding, bindingOf, columnName, moneyColumns, referenceBinding } from './column';
|
|
9
9
|
import { currencyCheck, scaleCheck } from './columns';
|
|
10
|
+
import { EntityError, invariantViolated } from './errors';
|
|
10
11
|
import type { Invariant } from './invariants';
|
|
11
|
-
import type {
|
|
12
|
+
import type { LifecycleDescription } from './lifecycle';
|
|
13
|
+
import type {
|
|
14
|
+
ColumnDescription,
|
|
15
|
+
EntityDescription,
|
|
16
|
+
ReferenceDescription,
|
|
17
|
+
SameTenantKey,
|
|
18
|
+
} from './registry';
|
|
12
19
|
import type { SearchVector } from './search';
|
|
13
20
|
import type { AnyColumn, ColumnMeta, IndexDef } from './types';
|
|
21
|
+
import type { ViewQuery } from './view-entity';
|
|
14
22
|
|
|
15
23
|
export interface DescribeInput<Row> {
|
|
16
24
|
readonly name: string;
|
|
@@ -28,6 +36,16 @@ export interface DescribeInput<Row> {
|
|
|
28
36
|
readonly search?: SearchVector | null;
|
|
29
37
|
/** `entity({ appendOnly: true })` — projected only when true. */
|
|
30
38
|
readonly appendOnly?: boolean;
|
|
39
|
+
/** `entity({ retired: [...] })` — physical names, projected only when there are any. */
|
|
40
|
+
readonly retired?: readonly string[] | undefined;
|
|
41
|
+
/** `entity({ shared: '<why>' })` — projected only when declared. */
|
|
42
|
+
readonly shared?: string | undefined;
|
|
43
|
+
/** `entity({ scan: '<why>' })` — projected only when declared. */
|
|
44
|
+
readonly scan?: string | undefined;
|
|
45
|
+
/** `entity({ view })` — projected only when declared. */
|
|
46
|
+
readonly view?: ViewQuery | null | undefined;
|
|
47
|
+
/** `retain`, `history`, `countedIn` counters — each projected only when declared (`lifecycle.ts`). */
|
|
48
|
+
readonly lifecycle?: LifecycleDescription | undefined;
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
/**
|
|
@@ -54,6 +72,42 @@ const describeSearchColumn = (search: SearchVector): ColumnDescription => ({
|
|
|
54
72
|
generated: search.expression,
|
|
55
73
|
});
|
|
56
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Whether this edge holds its two ends to one tenant. Decided here because a thunk can only be
|
|
77
|
+
* answered once both modules evaluated, and from the bindings because they alone know each side's
|
|
78
|
+
* physical tenant column.
|
|
79
|
+
*
|
|
80
|
+
* Both ends scoped, and neither end IS the tenant column: `orgId -> orgs.id` is the tenant's own
|
|
81
|
+
* root, where a composite would pair a column with itself. A `crossTenant` reason on an edge that
|
|
82
|
+
* could never be same-tenant is refused, or it would state a reason for nothing and read as a
|
|
83
|
+
* decision someone took.
|
|
84
|
+
*/
|
|
85
|
+
const sameTenantOf = (
|
|
86
|
+
entityName: string,
|
|
87
|
+
property: string,
|
|
88
|
+
meta: ColumnMeta,
|
|
89
|
+
own: Binding | undefined,
|
|
90
|
+
target: Binding,
|
|
91
|
+
): SameTenantKey | null => {
|
|
92
|
+
const column = own?.tenant ?? null;
|
|
93
|
+
const targetColumn = target.tenant;
|
|
94
|
+
const eligible =
|
|
95
|
+
own !== undefined &&
|
|
96
|
+
column !== null &&
|
|
97
|
+
targetColumn !== null &&
|
|
98
|
+
own.name !== column &&
|
|
99
|
+
target.name !== targetColumn;
|
|
100
|
+
if (!eligible) {
|
|
101
|
+
if (meta.crossTenant === undefined) return null;
|
|
102
|
+
throw new EntityError({
|
|
103
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
104
|
+
cause: `${entityName}.${property} states crossTenant on an edge that is not same-tenant: ${target.table} and ${entityName} do not both carry a tenant, or the column is the tenant column itself`,
|
|
105
|
+
fix: `references(() => ${target.table}.${target.property}) — in the columns of ${entityName}, with the crossTenant option removed`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return meta.crossTenant === undefined ? { column, targetColumn } : null;
|
|
109
|
+
};
|
|
110
|
+
|
|
57
111
|
/**
|
|
58
112
|
* The foreign keys an entity declares, resolved through the one binding resolver. Money is
|
|
59
113
|
* skipped for the reason the DDL projection drops a reference there too: one property is two
|
|
@@ -75,9 +129,11 @@ export const describeReferences = (
|
|
|
75
129
|
column: columnName(property, meta),
|
|
76
130
|
nullable: !meta.notNull,
|
|
77
131
|
targetEntity: target.table,
|
|
132
|
+
targetTable: target.physicalTable,
|
|
78
133
|
targetProperty: target.property,
|
|
79
134
|
targetColumn: target.name,
|
|
80
135
|
onDelete: meta.onDelete ?? null,
|
|
136
|
+
sameTenant: sameTenantOf(entityName, property, meta, bindingOf(column), target),
|
|
81
137
|
},
|
|
82
138
|
];
|
|
83
139
|
});
|
|
@@ -108,6 +164,75 @@ export const sqlTypeOf = (meta: ColumnMeta): string => {
|
|
|
108
164
|
return meta.kind;
|
|
109
165
|
};
|
|
110
166
|
|
|
167
|
+
/**
|
|
168
|
+
* At declaration, on the physical names: the same word in both lists is a column read by the release
|
|
169
|
+
* that says it stopped, and `x db gen` could only answer later and further away. Never through
|
|
170
|
+
* `describeEntity`: that answers a `references()` thunk, and a self-reference or a schema cycle is
|
|
171
|
+
* still in its temporal dead zone at this point.
|
|
172
|
+
*/
|
|
173
|
+
export const assertNotRetired = (
|
|
174
|
+
entityName: string,
|
|
175
|
+
entries: readonly (readonly [string, AnyColumn])[],
|
|
176
|
+
retired: readonly string[] | undefined,
|
|
177
|
+
): void => {
|
|
178
|
+
for (const [property, column] of entries) {
|
|
179
|
+
const parts = column.$meta.kind === 'money' ? moneyColumns(property, column.$meta) : null;
|
|
180
|
+
const physical =
|
|
181
|
+
parts === null
|
|
182
|
+
? [columnName(property, column.$meta)]
|
|
183
|
+
: [parts.minor, parts.currency, ...(parts.scale === null ? [] : [parts.scale])];
|
|
184
|
+
const held = physical.find((each) => retired?.includes(each) === true);
|
|
185
|
+
if (held !== undefined) {
|
|
186
|
+
throw invariantViolated(
|
|
187
|
+
entityName,
|
|
188
|
+
held,
|
|
189
|
+
'is listed under retired and still declared in columns; a column is one or the other',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `shared` is a reason, so a blank one is no reason: refused where it was written, never read as a
|
|
197
|
+
* declaration someone took. On an entity WITH a tenant column it states a reason for nothing — the
|
|
198
|
+
* stance `sameTenantOf` takes on a `crossTenant` reason for an edge that cannot be same-tenant.
|
|
199
|
+
*/
|
|
200
|
+
export const assertShared = (
|
|
201
|
+
entityName: string,
|
|
202
|
+
table: string,
|
|
203
|
+
shared: string | undefined,
|
|
204
|
+
tenantColumn: string | null,
|
|
205
|
+
): void => {
|
|
206
|
+
if (shared === undefined) return;
|
|
207
|
+
if (shared.trim() === '') {
|
|
208
|
+
throw new EntityError({
|
|
209
|
+
code: 'X_ENTITY_TENANT_UNDECLARED',
|
|
210
|
+
cause: `${entityName} declares shared with no reason: a blank reason says nothing about why its rows belong to no tenant`,
|
|
211
|
+
fix: `entity('${entityName}', { shared: '<why it belongs to no tenant>' }) — or drop shared, add orgId: uuid().tenant() to its columns, then x db gen "${table} tenant"`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (tenantColumn !== null) {
|
|
215
|
+
throw new EntityError({
|
|
216
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
217
|
+
cause: `${entityName} declares shared and has the tenant column ${tenantColumn}: its rows belong to a tenant, so the reason explains nothing`,
|
|
218
|
+
fix: `entity('${entityName}', { … }) — with the shared option removed`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `scan` is a reason, like `shared`: a blank one is no reason, and a table a plan check skips with
|
|
225
|
+
* no argument is a silenced finding nobody can tell from a considered one.
|
|
226
|
+
*/
|
|
227
|
+
export const assertScan = (entityName: string, scan: string | undefined): void => {
|
|
228
|
+
if (scan === undefined || scan.trim() !== '') return;
|
|
229
|
+
throw new EntityError({
|
|
230
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
231
|
+
cause: `${entityName} declares scan with no reason: a blank reason says nothing about why the table stays small enough to read whole`,
|
|
232
|
+
fix: `entity('${entityName}', { scan: '<why it stays small, e.g. a closed catalog of 8 rows>' }) — or drop scan and add the index x exercise --json names`,
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
111
236
|
const describeColumn = <Row>(
|
|
112
237
|
input: DescribeInput<Row>,
|
|
113
238
|
property: string,
|
|
@@ -179,10 +304,12 @@ const describeColumn = <Row>(
|
|
|
179
304
|
// Rendered from the resolved record, so the string a migration reads and the record a
|
|
180
305
|
// traversal reads can never disagree about what a `references()` points at.
|
|
181
306
|
references:
|
|
182
|
-
reference === undefined ? null : `${reference.
|
|
307
|
+
reference === undefined ? null : `${reference.targetTable}.${reference.targetColumn}`,
|
|
183
308
|
// Off the resolved reference, never off `meta` again: a rule with no key is not a thing, and
|
|
184
309
|
// reading the option twice is two places for the pair to disagree.
|
|
185
310
|
onDelete: reference?.onDelete ?? null,
|
|
311
|
+
// `true`-or-absent's cousin: absent, so no entity without such an edge moves the hash.
|
|
312
|
+
...(reference?.sameTenant == null ? {} : { sameTenant: reference.sameTenant }),
|
|
186
313
|
},
|
|
187
314
|
];
|
|
188
315
|
};
|
|
@@ -231,6 +358,8 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
231
358
|
// Spread, never `?? null`: absent is what `@ultimat3/db` reads as the btree it always was,
|
|
232
359
|
// and a written-out `null` would be a field no existing snapshot carries.
|
|
233
360
|
...(index.using === undefined ? {} : { using: index.using }),
|
|
361
|
+
// `true` or absent, never `false`, for the same reason: `x verify` hashes this projection.
|
|
362
|
+
...(index.concurrently === true ? { concurrently: true as const } : {}),
|
|
234
363
|
})),
|
|
235
364
|
tags: input.tags,
|
|
236
365
|
cacheTag: input.cacheTag,
|
|
@@ -239,5 +368,19 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
239
368
|
// `true` or absent, never `false`: `x verify`'s source drift hashes this projection as JSON,
|
|
240
369
|
// and an entity that never said `appendOnly` must hash exactly what it hashed before.
|
|
241
370
|
...(input.appendOnly === true ? { appendOnly: true as const } : {}),
|
|
371
|
+
...((input.retired ?? []).length === 0 ? {} : { retired: input.retired }),
|
|
372
|
+
// Absent unless declared, like `appendOnly`: no entity that never said it moves the hash.
|
|
373
|
+
...(input.shared === undefined ? {} : { shared: input.shared }),
|
|
374
|
+
...(input.scan === undefined ? {} : { scan: input.scan }),
|
|
375
|
+
// Absent on every table, so no entity that never said `view` moves the hash.
|
|
376
|
+
...(input.view === null || input.view === undefined
|
|
377
|
+
? {}
|
|
378
|
+
: {
|
|
379
|
+
view: {
|
|
380
|
+
sql: input.view.sql,
|
|
381
|
+
...(input.view.materialized ? { materialized: true as const } : {}),
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
...input.lifecycle,
|
|
242
385
|
};
|
|
243
386
|
};
|
package/src/entity-error.ts
CHANGED
|
@@ -40,6 +40,14 @@ export const ENTITY_ERROR_CODES = [
|
|
|
40
40
|
'X_ENTITY_SEALED_IN_VIEW',
|
|
41
41
|
'X_ENTITY_APPEND_ONLY',
|
|
42
42
|
'X_ENTITY_OPERATOR_UNKNOWN',
|
|
43
|
+
'X_LOCK_UNSUPPORTED',
|
|
44
|
+
'X_TEST_DRIVER_CLIENT_CHANGED',
|
|
45
|
+
'X_ENTITY_TENANT_UNDECLARED',
|
|
46
|
+
'X_ENTITY_SERIES_UNSUPPORTED',
|
|
47
|
+
'X_ENTITY_SERIES_INVALID',
|
|
48
|
+
'X_ENTITY_VIEW_READ_ONLY',
|
|
49
|
+
'X_ENTITY_RETAIN_UNINDEXED',
|
|
50
|
+
'X_ENTITY_COUNTS_INVALID',
|
|
43
51
|
] as const;
|
|
44
52
|
|
|
45
53
|
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
@@ -74,6 +82,15 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
|
|
|
74
82
|
X_ENTITY_SEALED_IN_VIEW: 'a sealed column is named in a view',
|
|
75
83
|
X_ENTITY_APPEND_ONLY: 'an append-only entity was asked to rewrite or remove a row',
|
|
76
84
|
X_ENTITY_OPERATOR_UNKNOWN: 'a predicate names an operator outside the vocabulary',
|
|
85
|
+
X_LOCK_UNSUPPORTED: 'a repository has no lock',
|
|
86
|
+
X_TEST_DRIVER_CLIENT_CHANGED: 'The installed client changed inside a testDriver() transaction',
|
|
87
|
+
X_ENTITY_TENANT_UNDECLARED: 'a child of a tenant-owned table has no tenant',
|
|
88
|
+
X_ENTITY_SERIES_UNSUPPORTED: 'a repository has no series',
|
|
89
|
+
X_ENTITY_SERIES_INVALID: 'a time series asked for something no bucket can answer',
|
|
90
|
+
X_ENTITY_VIEW_READ_ONLY: 'a view entity was asked to write a row',
|
|
91
|
+
X_ENTITY_RETAIN_UNINDEXED: 'entity({ retain }) names a column no index leads with',
|
|
92
|
+
X_ENTITY_COUNTS_INVALID:
|
|
93
|
+
'references(…, { countedIn }) names no integer of its parent, or is no key to its primary key',
|
|
77
94
|
};
|
|
78
95
|
|
|
79
96
|
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
@@ -147,6 +164,22 @@ export const invariantViolated = (
|
|
|
147
164
|
fix: `x entities show ${entityName} --json # shows the invariant and its SQL CHECK`,
|
|
148
165
|
});
|
|
149
166
|
|
|
167
|
+
/**
|
|
168
|
+
* A `$view` key naming no column. Its own fix, never `describeCommand`: `x entities show` loads the
|
|
169
|
+
* app, and this throw is what stops the load, so that line re-failed with this finding. The file
|
|
170
|
+
* is the loader's to name (`Finding.at`): a view is built with no idea where it was called.
|
|
171
|
+
*/
|
|
172
|
+
export const viewKeyUnknown = (
|
|
173
|
+
entityName: string,
|
|
174
|
+
key: string,
|
|
175
|
+
columns: readonly string[],
|
|
176
|
+
): EntityError =>
|
|
177
|
+
new EntityError({
|
|
178
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
179
|
+
cause: `${entityName}.view: $view(['${key}']) names no column — pick from: ${columns.join(', ')}`,
|
|
180
|
+
fix: `$view(['${columns[0] ?? ''}']) # edit the call on ${entityName}, in the file this finding names; pick from: ${columns.join(', ')}`,
|
|
181
|
+
});
|
|
182
|
+
|
|
150
183
|
export const entityDuplicate = (name: string, existingTable: string): EntityError =>
|
|
151
184
|
new EntityError({
|
|
152
185
|
code: 'X_ENTITY_DUPLICATE',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Single responsibility: an entity's two row parsers — `$parse`, the whole row with declared
|
|
2
|
+
// defaults filled, and the wire parser `$schema` validates with, which neither requires nor copies a
|
|
3
|
+
// sealed column. Split from `entity.ts` at its line ceiling; both close over the same column list.
|
|
4
|
+
|
|
5
|
+
import { describeValue } from '@ultimat3/schema';
|
|
6
|
+
import { entityNow } from './clock';
|
|
7
|
+
import { newId } from './columns';
|
|
8
|
+
import { invariantViolated } from './errors';
|
|
9
|
+
import { sealedMissing } from './sealed-errors';
|
|
10
|
+
import type { AnyColumn, ColumnMeta } from './types';
|
|
11
|
+
|
|
12
|
+
const defaultValue = (meta: ColumnMeta): unknown => {
|
|
13
|
+
const declared = meta.default;
|
|
14
|
+
if (declared === undefined) return undefined;
|
|
15
|
+
if (declared.kind === 'value') return declared.value;
|
|
16
|
+
return declared.by === 'uuid-v7' ? newId() : entityNow();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const rowParsers = <Row, Wire>(
|
|
20
|
+
name: string,
|
|
21
|
+
entries: readonly (readonly [string, AnyColumn])[],
|
|
22
|
+
sealedKeys: ReadonlySet<string>,
|
|
23
|
+
): { readonly parse: (value: unknown) => Row; readonly parseWire: (value: unknown) => Wire } => {
|
|
24
|
+
/** The columns that leave the server: every one that is not sealed. */
|
|
25
|
+
const wireEntries = entries.filter(([property]) => !sealedKeys.has(property));
|
|
26
|
+
|
|
27
|
+
const parse = (value: unknown): Row => {
|
|
28
|
+
if (typeof value !== 'object' || value === null) {
|
|
29
|
+
// `describeValue`, never `String(value)`: this message is a `$parse` failure, which reaches
|
|
30
|
+
// the caller and the log line — and the whole row is the last value in the framework that
|
|
31
|
+
// may be echoed there. Same renderer as the column builders (`columns.ts`) use.
|
|
32
|
+
throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
|
|
33
|
+
}
|
|
34
|
+
const input = value as Readonly<Record<string, unknown>>;
|
|
35
|
+
const row: Record<string, unknown> = {};
|
|
36
|
+
for (const [property, column] of entries) {
|
|
37
|
+
// `=== undefined`, never `??`: an explicit `null` is the caller CLEARING a nullable column,
|
|
38
|
+
// and `??` read it as absence and wrote the column's declared default straight back — so
|
|
39
|
+
// `update(id, { status: null })` reported success and stored `'draft'`, with nothing on any
|
|
40
|
+
// surface to say otherwise. A present `undefined` still means absence, which is what a
|
|
41
|
+
// spread of an omitted optional key produces and is the shape every existing caller has.
|
|
42
|
+
const raw = input[property];
|
|
43
|
+
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
44
|
+
if (given === undefined || given === null) {
|
|
45
|
+
if (column.$meta.notNull) {
|
|
46
|
+
// A required sealed column ABSENT from a row is nearly always a spread of a repository
|
|
47
|
+
// row, which does not enumerate it — so the refusal names that, and the rewrite.
|
|
48
|
+
throw given === undefined && sealedKeys.has(property)
|
|
49
|
+
? sealedMissing(name, property)
|
|
50
|
+
: invariantViolated(name, property, 'is required and has no default');
|
|
51
|
+
}
|
|
52
|
+
row[property] = null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
row[property] = column.$parse(given);
|
|
56
|
+
}
|
|
57
|
+
// Every property was validated by its own column above, so the shape is the derived row.
|
|
58
|
+
return row as Row;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The row as `$schema` validates it: the sealed columns are neither required nor returned, so a
|
|
63
|
+
* handler's row — which holds their plaintext — parses to a value that does not.
|
|
64
|
+
*/
|
|
65
|
+
const parseWire = (value: unknown): Wire => {
|
|
66
|
+
if (sealedKeys.size === 0) return parse(value) as unknown as Wire;
|
|
67
|
+
if (typeof value !== 'object' || value === null) {
|
|
68
|
+
throw invariantViolated(name, 'row', `expected an object, got ${describeValue(value)}`);
|
|
69
|
+
}
|
|
70
|
+
const input = value as Readonly<Record<string, unknown>>;
|
|
71
|
+
const row: Record<string, unknown> = {};
|
|
72
|
+
for (const [property, column] of wireEntries) {
|
|
73
|
+
const raw = input[property];
|
|
74
|
+
const given = raw === undefined ? defaultValue(column.$meta) : raw;
|
|
75
|
+
if (given === undefined || given === null) {
|
|
76
|
+
if (column.$meta.notNull) {
|
|
77
|
+
throw invariantViolated(name, property, 'is required and has no default');
|
|
78
|
+
}
|
|
79
|
+
row[property] = null;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
row[property] = column.$parse(given);
|
|
83
|
+
}
|
|
84
|
+
// Every wire property was validated by its own column, and no sealed one was copied.
|
|
85
|
+
return row as Wire;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return { parse, parseWire };
|
|
89
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Single responsibility: a patch judged by the columns it names, with the same issues `$parse`
|
|
2
|
+
// raises for a whole row. Absent stays absent: a patch fills no default, or `update(id, { a })`
|
|
3
|
+
// would rewrite every column it did not name.
|
|
4
|
+
|
|
5
|
+
import { invariantViolated } from './errors';
|
|
6
|
+
import { copyRow } from './sealed';
|
|
7
|
+
import type { AnyColumn } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The patch, each named column parsed. A property present with `undefined` is absent, the reading
|
|
11
|
+
* `namedColumns` and `bindValues` both give it. `copyRow`, because a row passed back whole keeps
|
|
12
|
+
* its server-only (sealed) properties: they are named by own key, enumerable or not, and parsed
|
|
13
|
+
* like the rest. A key no column declares stays for the statement builder's own refusal, and an
|
|
14
|
+
* empty patch reaches `X_PATCH_EMPTY` as it did.
|
|
15
|
+
*/
|
|
16
|
+
export const parseEntityPatch = <Patch>(
|
|
17
|
+
name: string,
|
|
18
|
+
columns: Readonly<Record<string, AnyColumn>>,
|
|
19
|
+
patch: Patch,
|
|
20
|
+
): Patch => {
|
|
21
|
+
if (typeof patch !== 'object' || patch === null) return patch;
|
|
22
|
+
const parsed = copyRow(patch as object);
|
|
23
|
+
for (const [property, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(parsed))) {
|
|
24
|
+
const column = Object.hasOwn(columns, property) ? columns[property] : undefined;
|
|
25
|
+
const value: unknown = descriptor.get === undefined ? descriptor.value : descriptor.get();
|
|
26
|
+
if (column === undefined || value === undefined) continue;
|
|
27
|
+
if (value === null) {
|
|
28
|
+
if (column.$meta.notNull) {
|
|
29
|
+
throw invariantViolated(name, property, 'is required and has no default');
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const { enumerable } = descriptor;
|
|
34
|
+
Object.defineProperty(parsed, property, {
|
|
35
|
+
value: column.$parse(value),
|
|
36
|
+
enumerable: enumerable === true,
|
|
37
|
+
writable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return parsed as Patch;
|
|
42
|
+
};
|