@ultimat3/entity 20.2.1 → 22.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 +226 -1135
- package/README.md +56 -1
- package/package.json +7 -6
- package/src/aggregate-decode.ts +2 -1
- package/src/columns-data.ts +28 -2
- package/src/columns-scalar.ts +79 -0
- package/src/columns.ts +15 -46
- package/src/entity-error.ts +126 -0
- package/src/entity.ts +30 -24
- package/src/errors.ts +13 -120
- package/src/index.ts +15 -9
- package/src/jit-preload.ts +19 -1
- package/src/memory-repo.ts +74 -7
- package/src/memory-unique.ts +51 -0
- package/src/persisted-types.ts +21 -0
- package/src/pg-driver.ts +9 -25
- package/src/pg-row.ts +5 -1
- package/src/pg-sql-aggregate.ts +132 -0
- package/src/pg-sql.ts +1 -118
- package/src/pg-transactor.ts +23 -0
- package/src/record-key.ts +59 -0
- package/src/record-projection.ts +88 -0
- package/src/record-table.ts +46 -0
- package/src/record.ts +10 -0
- package/src/registry.ts +19 -1
- package/src/repo.ts +5 -0
- package/src/row-observer.ts +51 -12
- package/src/row-schema.ts +63 -0
- package/src/rows-of.ts +132 -0
- package/src/seed.ts +7 -1
- package/src/transition.ts +6 -1
- package/src/write-tag.ts +75 -0
package/src/errors.ts
CHANGED
|
@@ -1,102 +1,20 @@
|
|
|
1
1
|
// The entity layer's stable error codes. Each factory produces the exact command
|
|
2
2
|
// that fixes the situation — `X_DB_DRIFT` is the flagship: it names the table, the
|
|
3
|
-
// column and the generator invocation.
|
|
4
|
-
|
|
3
|
+
// column and the generator invocation. The code registry, the class, `invariantViolated` and
|
|
4
|
+
// `entityDuplicate` live in `entity-error.ts`, which imports no `@ultimat3/db`; re-exported here.
|
|
5
5
|
import { shellInertIdentifier } from '@ultimat3/db';
|
|
6
|
+
import { EntityError } from './entity-error';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'X_PATCH_EMPTY',
|
|
18
|
-
'X_PRELOAD_UNKNOWN_RELATION',
|
|
19
|
-
'X_N_PLUS_ONE_QUERY',
|
|
20
|
-
'X_N_PLUS_ONE_WRITE',
|
|
21
|
-
'X_REPO_CLIENT_PINNED',
|
|
22
|
-
'X_AGGREGATE_UNSUPPORTED',
|
|
23
|
-
'X_AGGREGATE_MIXED_CURRENCY',
|
|
24
|
-
'X_APPROXIMATE_COUNT_FILTERED',
|
|
25
|
-
'X_SEARCH_UNDECLARED',
|
|
26
|
-
'X_SEARCH_IN_MEMORY',
|
|
27
|
-
'X_STATE_UNDECLARED',
|
|
28
|
-
'X_STATE_TRANSITION_ILLEGAL',
|
|
29
|
-
'X_STATE_CONFLICT',
|
|
30
|
-
] as const;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
|
|
34
|
-
* rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
|
|
35
|
-
* second copy of the title is what lets the two packages disagree about what the code means.
|
|
36
|
-
*/
|
|
37
|
-
export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
|
|
38
|
-
|
|
39
|
-
/** Every code entity can throw: the ones it owns plus the one it borrows. */
|
|
40
|
-
export const ENTITY_ERROR_CODES = [
|
|
41
|
-
...ENTITY_OWNED_ERROR_CODES,
|
|
42
|
-
...ENTITY_BORROWED_ERROR_CODES,
|
|
43
|
-
] as const;
|
|
44
|
-
|
|
45
|
-
export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
|
|
46
|
-
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
47
|
-
|
|
48
|
-
export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>> = {
|
|
49
|
-
X_ENTITY_DUPLICATE: 'two entities claim the same name',
|
|
50
|
-
X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
|
|
51
|
-
X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
|
|
52
|
-
// "call", not "query": the same code covers a predicate that names another tenant and a row or
|
|
53
|
-
// patch that writes one, because they are one mistake made in two places.
|
|
54
|
-
X_TENANCY_ACTOR_MISMATCH: "a call named a tenant other than the actor's",
|
|
55
|
-
X_TENANCY_ACTOR_ORG_REQUIRED: 'the acting actor carries no tenant',
|
|
56
|
-
X_TENANCY_CROSS_DENIED: 'a cross-tenant read was entered without the capability',
|
|
57
|
-
X_NOT_FOUND: 'no row for that id',
|
|
58
|
-
X_WRITE_UNFILTERED: 'a filtered write named no filter columns',
|
|
59
|
-
X_PATCH_EMPTY: 'a filtered update named no columns to write',
|
|
60
|
-
X_PRELOAD_UNKNOWN_RELATION: 'no relation of that name on this entity',
|
|
61
|
-
X_N_PLUS_ONE_QUERY: 'a read repeated once per row',
|
|
62
|
-
X_N_PLUS_ONE_WRITE: 'a write repeated once per row',
|
|
63
|
-
X_REPO_CLIENT_PINNED: 'a repository pinned to its own client cannot join the open transaction',
|
|
64
|
-
X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
|
|
65
|
-
X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
|
|
66
|
-
X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
|
|
67
|
-
X_SEARCH_UNDECLARED: 'this entity has no searchable column',
|
|
68
|
-
X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match',
|
|
69
|
-
X_STATE_UNDECLARED: 'that column declares no state machine',
|
|
70
|
-
X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
|
|
71
|
-
X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
75
|
-
// code and every surface renders a title this package never wrote; with a presence guard, a second
|
|
76
|
-
// package claiming one of these codes would silently win instead of throwing X_ERROR_CODE_DUPLICATE.
|
|
77
|
-
registerErrorCodes(
|
|
78
|
-
Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Base for every error this package throws. No `docs:` — `UltimateError` fills it from
|
|
83
|
-
* `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
|
|
84
|
-
* code, never one per code, because `wiki/` is the framework's only public documentation surface
|
|
85
|
-
* and a code lives there in a TABLE ROW, which has no anchor. The
|
|
86
|
-
* `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
|
|
87
|
-
* included, on every refusal it has ever raised.
|
|
88
|
-
*/
|
|
89
|
-
export class EntityError extends UltimateError {
|
|
90
|
-
override readonly name = 'EntityError';
|
|
91
|
-
|
|
92
|
-
constructor(init: { code: EntityErrorCode; cause: string; fix: string }) {
|
|
93
|
-
super({
|
|
94
|
-
code: init.code,
|
|
95
|
-
cause: init.cause,
|
|
96
|
-
fix: init.fix,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
}
|
|
8
|
+
export type { EntityErrorCode, EntityOwnedErrorCode } from './entity-error';
|
|
9
|
+
export {
|
|
10
|
+
ENTITY_BORROWED_ERROR_CODES,
|
|
11
|
+
ENTITY_ERROR_CODES,
|
|
12
|
+
ENTITY_ERROR_TITLES,
|
|
13
|
+
ENTITY_OWNED_ERROR_CODES,
|
|
14
|
+
EntityError,
|
|
15
|
+
entityDuplicate,
|
|
16
|
+
invariantViolated,
|
|
17
|
+
} from './entity-error';
|
|
100
18
|
|
|
101
19
|
/**
|
|
102
20
|
* A value from an app, rendered for a `cause` — and it may not throw, whatever the app put there.
|
|
@@ -130,31 +48,6 @@ const renderValue = (value: unknown): string => {
|
|
|
130
48
|
const asLiteral = (value: unknown, placeholder: string): string =>
|
|
131
49
|
typeof value === 'string' ? JSON.stringify(value) : placeholder;
|
|
132
50
|
|
|
133
|
-
export const entityDuplicate = (name: string, existingTable: string): EntityError =>
|
|
134
|
-
new EntityError({
|
|
135
|
-
code: 'X_ENTITY_DUPLICATE',
|
|
136
|
-
cause: `entity "${name}" is already registered for table "${existingTable}"`,
|
|
137
|
-
fix: `x entities list --json # then rename one of the two entity({ name }) declarations`,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
|
|
142
|
-
* given. A literal is an entity that does not exist, and this fix then hands the reader
|
|
143
|
-
* `x entities describe column --json`, which answers `X_DECLARATION_UNKNOWN` (issue #290). A
|
|
144
|
-
* refusal raised before any entity exists belongs in `refuse.ts`, where the caller supplies the
|
|
145
|
-
* edit; `refuse.test.ts` fails on a literal here.
|
|
146
|
-
*/
|
|
147
|
-
export const invariantViolated = (
|
|
148
|
-
entityName: string,
|
|
149
|
-
invariantName: string,
|
|
150
|
-
message: string,
|
|
151
|
-
): EntityError =>
|
|
152
|
-
new EntityError({
|
|
153
|
-
code: 'X_INVARIANT_VIOLATED',
|
|
154
|
-
cause: `${entityName}.${invariantName}: ${message}`,
|
|
155
|
-
fix: `x entities describe ${entityName} --json # shows the invariant and its SQL CHECK`,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
51
|
/**
|
|
159
52
|
* One code, two situations, and they do not share a repair — so the cause and the fix branch on
|
|
160
53
|
* which one it is rather than one wording claiming the other's facts.
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,6 @@ export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
|
|
|
30
30
|
export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
|
|
31
31
|
export { database, defaultDriver, memoryDriver } from './database';
|
|
32
32
|
export type { DescribeInput } from './describe';
|
|
33
|
-
export { sqlTypeOf } from './describe';
|
|
34
33
|
export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
|
|
35
34
|
export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
36
35
|
// The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
|
|
@@ -80,14 +79,25 @@ export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
|
80
79
|
export { memoryRepo, memoryTransactor } from './memory-repo';
|
|
81
80
|
export type { StatementLoop } from './n-plus-one';
|
|
82
81
|
export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
|
|
82
|
+
export { persistedRecordTypes } from './persisted-types';
|
|
83
83
|
export type { PostgresDriverOptions } from './pg-driver';
|
|
84
|
-
export { postgresDriver, postgresRepo
|
|
84
|
+
export { postgresDriver, postgresRepo } from './pg-driver';
|
|
85
|
+
// For a change feed holding a table name and raw columns: `entityForTable` + `decodeRow` is the row
|
|
86
|
+
// the app declared, money and all. Exported for `@ultimat3/realtime` (plan 101, 06 i).
|
|
87
|
+
export { decodeRow } from './pg-row';
|
|
88
|
+
export { postgresTransactor } from './pg-transactor';
|
|
85
89
|
// The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
|
|
86
90
|
// its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
|
|
87
91
|
export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
|
|
88
92
|
export type { RelatedTable, RelatedTables } from './preload';
|
|
89
93
|
export type { Preloaded, ReadBuilder, Table } from './query';
|
|
90
94
|
export { tableFor } from './query';
|
|
95
|
+
// The client projection of an entity (plan 101): record type, record key, and the brand on
|
|
96
|
+
// `$schema` that lets an action or query derive its record envelope from its output schema.
|
|
97
|
+
// Value-light — no Postgres driver behind any of them — so a browser store may import them here.
|
|
98
|
+
export type { ProjectedEntity, RecordProjection } from './record-projection';
|
|
99
|
+
export { ENTITY_BRAND, recordProjection } from './record-projection';
|
|
100
|
+
export { recordProjectionForTable, recordTypeForTable } from './record-table';
|
|
91
101
|
export type {
|
|
92
102
|
ColumnDescription,
|
|
93
103
|
EntityDescription,
|
|
@@ -99,6 +109,7 @@ export type {
|
|
|
99
109
|
export {
|
|
100
110
|
clearRegistry,
|
|
101
111
|
describeEntities,
|
|
112
|
+
entityForTable,
|
|
102
113
|
entityNames,
|
|
103
114
|
getEntity,
|
|
104
115
|
registerEntity,
|
|
@@ -119,6 +130,8 @@ export type {
|
|
|
119
130
|
} from './repo';
|
|
120
131
|
export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
|
|
121
132
|
export { observedRepo, rowObserver, setRowObserver } from './row-observer';
|
|
133
|
+
export type { RecordsByKey, RecordsByType } from './rows-of';
|
|
134
|
+
export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
|
|
122
135
|
// Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
|
|
123
136
|
// `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
|
|
124
137
|
export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
|
|
@@ -129,9 +142,7 @@ export {
|
|
|
129
142
|
isSearchLanguage,
|
|
130
143
|
isSearchWeight,
|
|
131
144
|
SEARCH_LANGUAGES,
|
|
132
|
-
SEARCH_PROPERTY,
|
|
133
145
|
SEARCH_WEIGHTS,
|
|
134
|
-
searchExpression,
|
|
135
146
|
} from './search';
|
|
136
147
|
export type {
|
|
137
148
|
Seed,
|
|
@@ -159,14 +170,9 @@ export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './t
|
|
|
159
170
|
export {
|
|
160
171
|
assertRowTenant,
|
|
161
172
|
assertScoped,
|
|
162
|
-
describePlan,
|
|
163
|
-
emptyPlan,
|
|
164
|
-
hasOrgPredicate,
|
|
165
|
-
isOrgScoped,
|
|
166
173
|
ORG_COLUMN,
|
|
167
174
|
orgScoped,
|
|
168
175
|
scopedPlan,
|
|
169
|
-
tenantColumnOf,
|
|
170
176
|
} from './tenancy';
|
|
171
177
|
export type { Move } from './transition';
|
|
172
178
|
export type {
|
package/src/jit-preload.ts
CHANGED
|
@@ -235,6 +235,24 @@ const preload = <Row>(read: PointRead<Row>, bucket: Bucket, ids: readonly unknow
|
|
|
235
235
|
void fill(read, wanted, settlers);
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The ids a bucket can KEEP, from the one asked for onward. The bucket holds `MAX_SIBLING_KEYS`
|
|
240
|
+
* rows, so preloading a wider page read every id and then evicted the oldest — measured, 2,500
|
|
241
|
+
* ids read to keep 2,000. A sequential loop walks forward, so the window starts at this lookup.
|
|
242
|
+
*/
|
|
243
|
+
const keptWindow = <Row>(
|
|
244
|
+
read: PointRead<Row>,
|
|
245
|
+
ids: readonly unknown[],
|
|
246
|
+
filedAt: string,
|
|
247
|
+
): readonly unknown[] => {
|
|
248
|
+
if (ids.length <= MAX_SIBLING_KEYS) return ids;
|
|
249
|
+
const start = Math.max(
|
|
250
|
+
0,
|
|
251
|
+
ids.findIndex((id) => keyOf(read.key.kind, id) === filedAt),
|
|
252
|
+
);
|
|
253
|
+
return ids.slice(start, start + MAX_SIBLING_KEYS);
|
|
254
|
+
};
|
|
255
|
+
|
|
238
256
|
const answered = <Row>(answer: Promise<Answer>): Promise<Row | null> =>
|
|
239
257
|
answer.then((settled) =>
|
|
240
258
|
'error' in settled ? Promise.reject(settled.error) : (settled.row as Row | null),
|
|
@@ -266,7 +284,7 @@ export const preloadedFindById = <Row>(
|
|
|
266
284
|
rows: new Map<string, Promise<Answer>>(),
|
|
267
285
|
};
|
|
268
286
|
store.preloaded.set(scope, target);
|
|
269
|
-
preload(read, target, ids);
|
|
287
|
+
preload(read, target, keptWindow(read, ids, filedAt));
|
|
270
288
|
const answer = target.rows.get(filedAt);
|
|
271
289
|
return answer === undefined ? undefined : answered<Row>(answer);
|
|
272
290
|
};
|
package/src/memory-repo.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
18
18
|
import { notFound } from './errors';
|
|
19
19
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
20
20
|
import { compareByKind, matchesPredicate } from './memory-match';
|
|
21
|
+
import { uniqueClash, uniqueViolation } from './memory-unique';
|
|
21
22
|
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
22
23
|
import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
|
|
23
24
|
import type { QueryPlan } from './tenancy';
|
|
@@ -69,6 +70,14 @@ const afterCursor = <Row>(
|
|
|
69
70
|
* migration and tests use it everywhere. Postgres is the production driver and implements
|
|
70
71
|
* this same interface.
|
|
71
72
|
*/
|
|
73
|
+
/**
|
|
74
|
+
* The patch with every `undefined` property dropped — `bindValues` skips them in Postgres, so a
|
|
75
|
+
* patch built from optional input (`{ body: input.body }`) leaves the column alone in both drivers
|
|
76
|
+
* rather than erasing it. `null` is the value that clears a column.
|
|
77
|
+
*/
|
|
78
|
+
const defined = (patch: object): object =>
|
|
79
|
+
Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
|
|
80
|
+
|
|
72
81
|
export const memoryRepo = <Row>(
|
|
73
82
|
entity: EntityCore<Row>,
|
|
74
83
|
seed: readonly Row[] = [],
|
|
@@ -119,10 +128,16 @@ export const memoryRepo = <Row>(
|
|
|
119
128
|
const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
|
|
120
129
|
batch.map((row) => narrowRow<Row>(entity.$columns, row));
|
|
121
130
|
|
|
131
|
+
/**
|
|
132
|
+
* `from` is the key the row is stored under NOW, or `undefined` for a new row. A new row may not
|
|
133
|
+
* land on a stored key, and a moved one may not land on another row's — Postgres answers both
|
|
134
|
+
* `X_DB_UNIQUE_VIOLATION` — and a moved row leaves its old key, where this map used to keep it.
|
|
135
|
+
*/
|
|
122
136
|
const write = (
|
|
123
137
|
given: RowWrite<Row>,
|
|
124
138
|
options: RepoOptions | undefined,
|
|
125
139
|
operation: string,
|
|
140
|
+
from?: string,
|
|
126
141
|
): Row => {
|
|
127
142
|
// `MoneyInput` lets a writer hand a `bigint`; a stored row holds the value type. The Postgres
|
|
128
143
|
// driver narrows at the same position — its write methods' entry — so without this an
|
|
@@ -135,11 +150,21 @@ export const memoryRepo = <Row>(
|
|
|
135
150
|
assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
|
|
136
151
|
entity.$assert(row);
|
|
137
152
|
const key = storeKey(row);
|
|
153
|
+
if (key !== from && rows.has(key)) throw uniqueViolation(entity, `${entity.$table}_pkey`);
|
|
154
|
+
const clash = uniqueClash(
|
|
155
|
+
entity,
|
|
156
|
+
row,
|
|
157
|
+
[...rows.entries()].filter(([stored]) => stored !== from).map(([, other]) => other),
|
|
158
|
+
);
|
|
159
|
+
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
160
|
+
const moved = from !== undefined && from !== key ? rows.get(from) : undefined;
|
|
138
161
|
const previous = rows.get(key);
|
|
139
162
|
options?.tx?.onRollback(() => {
|
|
140
163
|
if (previous === undefined) rows.delete(key);
|
|
141
164
|
else rows.set(key, previous);
|
|
165
|
+
if (from !== undefined && moved !== undefined) rows.set(from, moved);
|
|
142
166
|
});
|
|
167
|
+
if (moved !== undefined && from !== undefined) rows.delete(from);
|
|
143
168
|
rows.set(key, row);
|
|
144
169
|
return row;
|
|
145
170
|
};
|
|
@@ -203,9 +228,18 @@ export const memoryRepo = <Row>(
|
|
|
203
228
|
// Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
|
|
204
229
|
// the caller's `bigint` minor unit here and the narrowed `number` one call later.
|
|
205
230
|
const batch = narrowed(given);
|
|
206
|
-
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
for (const [position, row] of batch.entries()) {
|
|
207
233
|
assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
|
|
208
234
|
entity.$assert(row);
|
|
235
|
+
// Keys too, before any row lands: one duplicate refuses the whole statement in Postgres.
|
|
236
|
+
const key = storeKey(row);
|
|
237
|
+
if (seen.has(key) || rows.has(key)) {
|
|
238
|
+
throw uniqueViolation(entity, `${entity.$table}_pkey`);
|
|
239
|
+
}
|
|
240
|
+
seen.add(key);
|
|
241
|
+
const clash = uniqueClash(entity, row, [...rows.values(), ...batch.slice(0, position)]);
|
|
242
|
+
if (clash !== undefined) throw uniqueViolation(entity, clash);
|
|
209
243
|
}
|
|
210
244
|
return batch.map((row) => write(row, options, 'insertAll'));
|
|
211
245
|
},
|
|
@@ -248,7 +282,12 @@ export const memoryRepo = <Row>(
|
|
|
248
282
|
);
|
|
249
283
|
// `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
|
|
250
284
|
// passed to an upsert registers its undo exactly as it does for every other write here.
|
|
251
|
-
const result = write(
|
|
285
|
+
const result = write(
|
|
286
|
+
merged,
|
|
287
|
+
args,
|
|
288
|
+
'upsertAll',
|
|
289
|
+
existing === undefined ? undefined : storeKey(existing),
|
|
290
|
+
);
|
|
252
291
|
// Filed as it lands, so a later row of the same batch collides with an earlier one exactly
|
|
253
292
|
// as it would with a row the request stored a moment before it.
|
|
254
293
|
if (key !== undefined) stored.set(key, result);
|
|
@@ -258,14 +297,25 @@ export const memoryRepo = <Row>(
|
|
|
258
297
|
},
|
|
259
298
|
|
|
260
299
|
async update(id, patch, options) {
|
|
261
|
-
|
|
300
|
+
const current = addressed(id, options, 'update');
|
|
301
|
+
return write(
|
|
302
|
+
Object.assign({}, current, defined(patch)),
|
|
303
|
+
options,
|
|
304
|
+
'update',
|
|
305
|
+
storeKey(current),
|
|
306
|
+
);
|
|
262
307
|
},
|
|
263
308
|
|
|
264
309
|
async delete(id, options) {
|
|
265
310
|
const current = addressed(id, options, 'delete');
|
|
266
311
|
// Soft delete hides the row without losing it; the column's presence is the switch.
|
|
267
312
|
if (entity.$softDelete) {
|
|
268
|
-
write(
|
|
313
|
+
write(
|
|
314
|
+
Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
315
|
+
options,
|
|
316
|
+
'delete',
|
|
317
|
+
storeKey(current),
|
|
318
|
+
);
|
|
269
319
|
return;
|
|
270
320
|
}
|
|
271
321
|
const key = storeKey(current);
|
|
@@ -285,6 +335,7 @@ export const memoryRepo = <Row>(
|
|
|
285
335
|
Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
286
336
|
options,
|
|
287
337
|
'deleteWhere',
|
|
338
|
+
storeKey(row),
|
|
288
339
|
);
|
|
289
340
|
continue;
|
|
290
341
|
}
|
|
@@ -316,7 +367,8 @@ export const memoryRepo = <Row>(
|
|
|
316
367
|
if (hasJsOnlyInvariant(entity.$invariants) && found.length > MAX_ASSERTED_ROWS) {
|
|
317
368
|
throw assertedRowsTooMany(entity.$name, 'updateWhere', found.length);
|
|
318
369
|
}
|
|
319
|
-
for (const row of found)
|
|
370
|
+
for (const row of found)
|
|
371
|
+
write(Object.assign({}, row, defined(patch)), options, 'updateWhere', storeKey(row));
|
|
320
372
|
return found.length;
|
|
321
373
|
},
|
|
322
374
|
|
|
@@ -370,13 +422,28 @@ let txCounter = 0;
|
|
|
370
422
|
export const memoryTransactor = (): Transactor => ({
|
|
371
423
|
async run(work) {
|
|
372
424
|
const undos: (() => void)[] = [];
|
|
425
|
+
const commits: (() => void)[] = [];
|
|
373
426
|
txCounter += 1;
|
|
374
|
-
const tx: Tx = {
|
|
427
|
+
const tx: Tx = {
|
|
428
|
+
id: `tx-${txCounter}`,
|
|
429
|
+
onRollback: (undo) => undos.push(undo),
|
|
430
|
+
onCommit: (effect) => commits.push(effect),
|
|
431
|
+
};
|
|
432
|
+
let result: Awaited<ReturnType<typeof work>>;
|
|
375
433
|
try {
|
|
376
|
-
|
|
434
|
+
result = await work(tx);
|
|
377
435
|
} catch (error) {
|
|
378
436
|
for (const undo of undos.reverse()) undo();
|
|
379
437
|
throw error;
|
|
380
438
|
}
|
|
439
|
+
// After the work succeeded — the memory "commit" — and best-effort, as `@ultimat3/db` runs them.
|
|
440
|
+
for (const effect of commits) {
|
|
441
|
+
try {
|
|
442
|
+
effect();
|
|
443
|
+
} catch {
|
|
444
|
+
// an effect is a report about a durable write; it may not fail the write
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return result;
|
|
381
448
|
},
|
|
382
449
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Single responsibility: the uniqueness Postgres enforces, enforced by the in-memory driver too —
|
|
2
|
+
// the primary key and every non-partial `unique` index. Memory silently REPLACED a row on a
|
|
3
|
+
// duplicate key and ignored `unique()` entirely, so a signup race that is a 409 in production was
|
|
4
|
+
// a quiet overwrite under `x dev` and in every app test.
|
|
5
|
+
|
|
6
|
+
import { driverError } from '@ultimat3/db';
|
|
7
|
+
import type { EntityCore } from './entity';
|
|
8
|
+
import { bindValues } from './pg-row';
|
|
9
|
+
import type { RowPatch } from './types';
|
|
10
|
+
|
|
11
|
+
/** The refusal Postgres answers `23505` with, in the shape `driverError` gives it there. */
|
|
12
|
+
export const uniqueViolation = (entity: EntityCore, constraint: string): Error =>
|
|
13
|
+
driverError(`memory write into ${entity.$table}`, {
|
|
14
|
+
code: '23505',
|
|
15
|
+
constraint,
|
|
16
|
+
message: `duplicate key value violates unique constraint "${constraint}"`,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** A value as a comparable token; `null` answers `undefined` — NULLS DISTINCT, as Postgres. */
|
|
20
|
+
const cellOf = (value: unknown): string | undefined => {
|
|
21
|
+
if (value === null || value === undefined) return undefined;
|
|
22
|
+
if (value instanceof Date) return `date:${value.getTime()}`;
|
|
23
|
+
if (typeof value === 'string') return `s:${value}`;
|
|
24
|
+
return `${typeof value}:${JSON.stringify(value)}`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The first unique index `candidate` collides with among `others`, or `undefined`. Partial indexes
|
|
29
|
+
* are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse rows
|
|
30
|
+
* Postgres accepts — the one direction that must never happen.
|
|
31
|
+
*/
|
|
32
|
+
export const uniqueClash = <Row>(
|
|
33
|
+
entity: EntityCore<Row>,
|
|
34
|
+
candidate: Row,
|
|
35
|
+
others: Iterable<Row>,
|
|
36
|
+
): string | undefined => {
|
|
37
|
+
const unique = entity.$indexes.filter((index) => index.unique && index.where === undefined);
|
|
38
|
+
if (unique.length === 0) return undefined;
|
|
39
|
+
const bound = (row: Row) => bindValues(entity, row as unknown as RowPatch<Row>);
|
|
40
|
+
const incoming = bound(candidate);
|
|
41
|
+
for (const index of unique) {
|
|
42
|
+
const key = index.columns.map((name) => cellOf(incoming.get(name)));
|
|
43
|
+
if (key.some((part) => part === undefined)) continue;
|
|
44
|
+
for (const other of others) {
|
|
45
|
+
const stored = bound(other);
|
|
46
|
+
if (index.columns.every((name, at) => cellOf(stored.get(name)) === key[at]))
|
|
47
|
+
return index.name;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The record types the client keeps on disk — every registered entity declared `persist: true`,
|
|
2
|
+
// by name, sorted. What the server renders as `<meta name="ultimate-persist">`, since the browser
|
|
3
|
+
// holds no entity declarations. Memoised against the registry's generation, like `record-table.ts`.
|
|
4
|
+
|
|
5
|
+
import { registeredEntities, registryGeneration } from './registry';
|
|
6
|
+
|
|
7
|
+
let cached: { readonly generation: number; readonly types: readonly string[] } | null = null;
|
|
8
|
+
|
|
9
|
+
/** Sorted, so the rendered meta is byte-identical for one set of declarations. */
|
|
10
|
+
export const persistedRecordTypes = (): readonly string[] => {
|
|
11
|
+
const generation = registryGeneration();
|
|
12
|
+
if (cached !== null && cached.generation === generation) return cached.types;
|
|
13
|
+
const types = Object.freeze(
|
|
14
|
+
registeredEntities()
|
|
15
|
+
.filter((entry) => entry.persist === true)
|
|
16
|
+
.map((entry) => entry.name)
|
|
17
|
+
.sort(),
|
|
18
|
+
);
|
|
19
|
+
cached = { generation, types };
|
|
20
|
+
return types;
|
|
21
|
+
};
|
package/src/pg-driver.ts
CHANGED
|
@@ -12,9 +12,7 @@ import {
|
|
|
12
12
|
type DbClient,
|
|
13
13
|
db,
|
|
14
14
|
type SqlFragment,
|
|
15
|
-
type TransactionOptions,
|
|
16
15
|
withStatementAttribution,
|
|
17
|
-
withTransaction,
|
|
18
16
|
} from '@ultimat3/db';
|
|
19
17
|
import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
|
|
20
18
|
import { decodeAggregate } from './aggregate-decode';
|
|
@@ -37,18 +35,16 @@ import { notFound, repoClientPinned } from './errors';
|
|
|
37
35
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
38
36
|
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
39
37
|
import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
|
|
38
|
+
import { countStatement, type ReadShape, selectStatement } from './pg-sql';
|
|
40
39
|
import {
|
|
41
40
|
type AggregateRow,
|
|
42
41
|
aggregateStatement,
|
|
43
42
|
countByStatement,
|
|
44
|
-
countStatement,
|
|
45
43
|
currenciesStatement,
|
|
46
44
|
estimateStatement,
|
|
47
45
|
type GroupRow,
|
|
48
46
|
type MoneyUnitRow,
|
|
49
|
-
|
|
50
|
-
selectStatement,
|
|
51
|
-
} from './pg-sql';
|
|
47
|
+
} from './pg-sql-aggregate';
|
|
52
48
|
import {
|
|
53
49
|
type ConflictTarget,
|
|
54
50
|
deleteStatement,
|
|
@@ -56,10 +52,11 @@ import {
|
|
|
56
52
|
updateStatement,
|
|
57
53
|
} from './pg-write-sql';
|
|
58
54
|
import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
|
|
59
|
-
import type { FindManyArgs, Repo,
|
|
55
|
+
import type { FindManyArgs, Repo, UpsertArgs } from './repo';
|
|
60
56
|
import type { QueryPlan } from './tenancy';
|
|
61
57
|
import { assertRowTenant } from './tenancy';
|
|
62
58
|
import type { RowWrite } from './types';
|
|
59
|
+
import { taggedWrite } from './write-tag';
|
|
63
60
|
|
|
64
61
|
export interface PostgresDriverOptions {
|
|
65
62
|
/**
|
|
@@ -156,14 +153,14 @@ export const postgresRepo = <Row>(
|
|
|
156
153
|
: deleteStatement(entity, plan);
|
|
157
154
|
|
|
158
155
|
/**
|
|
159
|
-
* Every write goes out through here
|
|
160
|
-
*
|
|
161
|
-
* before
|
|
162
|
-
*
|
|
156
|
+
* Every write goes out through here: the ONE place the request's preloaded rows are dropped (a
|
|
157
|
+
* row this statement changes must not be served afterwards from a page read before it — so
|
|
158
|
+
* before the statement, not after), and where a keyed request's write names itself in the WAL
|
|
159
|
+
* (`write-tag.ts`).
|
|
163
160
|
*/
|
|
164
161
|
const writing = <T>(send: () => Promise<T>): Promise<T> => {
|
|
165
162
|
forgetPreloaded(entity.$name);
|
|
166
|
-
return send
|
|
163
|
+
return taggedWrite(config.client !== undefined, send);
|
|
167
164
|
};
|
|
168
165
|
|
|
169
166
|
/**
|
|
@@ -483,16 +480,3 @@ export const postgresRepo = <Row>(
|
|
|
483
480
|
export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
|
|
484
481
|
repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
|
|
485
482
|
});
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* A real Postgres transaction behind the same `Transactor` the in-memory one implements. The
|
|
489
|
-
* `Tx` handed to the callback is a token: repositories find the transaction through `db()`, so
|
|
490
|
-
* nothing has to thread a connection through the call stack.
|
|
491
|
-
*/
|
|
492
|
-
export const postgresTransactor = (options: TransactionOptions = {}): Transactor => ({
|
|
493
|
-
run: (work) =>
|
|
494
|
-
withTransaction(
|
|
495
|
-
(tx) => work({ id: tx.id, onRollback: (undo: () => void) => tx.onRollback(undo) }),
|
|
496
|
-
options,
|
|
497
|
-
),
|
|
498
|
-
});
|
package/src/pg-row.ts
CHANGED
|
@@ -72,7 +72,10 @@ export const allColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
|
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* Row (or patch) -> the columns to write. Absent properties are skipped rather than nulled,
|
|
75
|
-
* which is what makes the same function serve `insert` and a partial `update
|
|
75
|
+
* which is what makes the same function serve `insert` and a partial `update` — and a property
|
|
76
|
+
* PRESENT with the value `undefined` is absent too, as `namedColumns` already reads it. The common
|
|
77
|
+
* patch is built from optional action input (`{ body: input.body }`), and binding that `undefined`
|
|
78
|
+
* as NULL wiped the column the caller never meant to touch. NULL is written only for `null`.
|
|
76
79
|
*/
|
|
77
80
|
export const bindValues = <Row>(
|
|
78
81
|
entity: EntityCore<Row>,
|
|
@@ -85,6 +88,7 @@ export const bindValues = <Row>(
|
|
|
85
88
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
86
89
|
if (!Object.hasOwn(record, property)) continue;
|
|
87
90
|
const value = record[property];
|
|
91
|
+
if (value === undefined) continue;
|
|
88
92
|
if (column.$meta.kind !== 'money') {
|
|
89
93
|
bound.set(columnName(property, column.$meta), bindable(column, value));
|
|
90
94
|
continue;
|