@ultimat3/entity 21.0.0 → 22.1.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 -1158
- package/README.md +9 -0
- package/package.json +5 -5
- 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.ts +1 -0
- package/src/index.ts +6 -9
- package/src/jit-preload.ts +19 -1
- package/src/memory-repo.ts +74 -7
- package/src/memory-unique.ts +51 -0
- package/src/pg-driver.ts +3 -20
- 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/registry.ts +13 -0
- package/src/repo.ts +5 -0
- package/src/row-observer.ts +37 -14
- package/src/seed.ts +7 -1
- package/src/transition.ts +6 -1
package/README.md
CHANGED
|
@@ -890,6 +890,15 @@ database from its boot code has decided to, and a library that overruled that wo
|
|
|
890
890
|
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
|
|
891
891
|
`X_RECORD_KEY_MISSING`
|
|
892
892
|
|
|
893
|
+
### Error classes
|
|
894
|
+
|
|
895
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
896
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
897
|
+
|
|
898
|
+
| Class | Code | Declared in |
|
|
899
|
+
|---|---|---|
|
|
900
|
+
| `EntityError` | any `EntityErrorCode` — `ENTITY_ERROR_CODES` | `src/entity-error.ts` |
|
|
901
|
+
|
|
893
902
|
## Boundaries
|
|
894
903
|
|
|
895
904
|
Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema` and `@ultimat3/db` only — `db` is tier 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.1.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "22.1.0",
|
|
36
|
+
"@ultimat3/db": "22.1.0",
|
|
37
|
+
"@ultimat3/schema": "22.1.0",
|
|
38
|
+
"@ultimat3/time": "22.1.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/aggregate-decode.ts
CHANGED
|
@@ -24,7 +24,8 @@ export const decodeAggregate = (fn: AggregateFn, kind: ColumnKind, text: string)
|
|
|
24
24
|
// whatever they fit in, while `sum('likeCount')` over a million of them does not.
|
|
25
25
|
if (fn === 'sum' || fn === 'avg') return text;
|
|
26
26
|
if (kind === 'timestamptz') {
|
|
27
|
-
|
|
27
|
+
// Epoch milliseconds (`pg-sql.ts`), so no zone and no calendar is parsed here at all.
|
|
28
|
+
const at = new Date(Number(text));
|
|
28
29
|
return Number.isNaN(at.getTime()) ? null : at;
|
|
29
30
|
}
|
|
30
31
|
if (kind === 'integer') {
|
package/src/columns-data.ts
CHANGED
|
@@ -47,6 +47,32 @@ export const json = <T>(schema: StandardSchemaV1<unknown, T>): Column<T> =>
|
|
|
47
47
|
|
|
48
48
|
const DIGITS = /^-?\d+$/;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The one spelling Postgres answers with: no leading zeros, and no `-0`. Memory stored `'007'`
|
|
52
|
+
* where Postgres returned `7`, so the same row was two values by driver. String work only — the
|
|
53
|
+
* digits never pass through a `Number`.
|
|
54
|
+
*/
|
|
55
|
+
const canonicalDigits = (digits: string): string => {
|
|
56
|
+
const negative = digits.startsWith('-');
|
|
57
|
+
const whole = digits.replace('-', '').replace(/^0+(?=\d)/, '');
|
|
58
|
+
return negative && whole !== '0' ? `-${whole}` : whole;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An ACCEPTED decimal in Postgres's spelling: leading zeros stripped, `-0` and `-0.00` unsigned,
|
|
63
|
+
* and a short fraction padded to the column's scale (`numeric(8, 2)` answers `7.50` for `7.5`).
|
|
64
|
+
* Never rounds — excess scale was refused above, and rounding here would widen what the column
|
|
65
|
+
* accepts. An unbounded `numeric` keeps the fraction it was given, as Postgres does.
|
|
66
|
+
*/
|
|
67
|
+
const canonicalDecimal = (text: string, scale: number | undefined): string => {
|
|
68
|
+
const negative = text.startsWith('-');
|
|
69
|
+
const [rawWhole = '', rawFraction = ''] = text.replace('-', '').split('.');
|
|
70
|
+
const whole = rawWhole.replace(/^0+(?=\d)/, '');
|
|
71
|
+
const fraction = scale === undefined ? rawFraction : rawFraction.padEnd(scale, '0');
|
|
72
|
+
const zero = /^0*$/.test(whole + fraction);
|
|
73
|
+
return `${negative && !zero ? '-' : ''}${whole}${fraction === '' ? '' : `.${fraction}`}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
50
76
|
/**
|
|
51
77
|
* `bigint`, whose row type is a decimal STRING. Neither alternative survives contact:
|
|
52
78
|
* a JS `bigint` is what `JSON.stringify` throws on — the reason `money.minor` is a `number` — and
|
|
@@ -71,7 +97,7 @@ export const bigint = (): Column<string> =>
|
|
|
71
97
|
);
|
|
72
98
|
}
|
|
73
99
|
return typeof value === 'string' && DIGITS.test(value)
|
|
74
|
-
? value
|
|
100
|
+
? canonicalDigits(value)
|
|
75
101
|
: refuseColumn(
|
|
76
102
|
'bigint',
|
|
77
103
|
`expected whole digits, ${got(value)}`,
|
|
@@ -149,7 +175,7 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
|
|
|
149
175
|
`widen the column — decimal({ precision: ${whole + (scale ?? 0)}, scale: ${scale ?? 0} }) — and run x db gen "widen the numeric": what overflows is the digits BEFORE the point`,
|
|
150
176
|
);
|
|
151
177
|
}
|
|
152
|
-
return text;
|
|
178
|
+
return canonicalDecimal(text, scale);
|
|
153
179
|
},
|
|
154
180
|
precision === undefined || scale === undefined ? {} : { precision, numericScale: scale },
|
|
155
181
|
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Single responsibility: the three plain scalar columns — `text`, `integer`, `boolean` — each
|
|
2
|
+
// refusing in `$parse` exactly what Postgres refuses at the column, so the memory driver cannot
|
|
3
|
+
// store a value production answers 23514 or 22003 for. Split from `columns.ts` at its ceiling.
|
|
4
|
+
|
|
5
|
+
import { charCount } from '@ultimat3/schema';
|
|
6
|
+
import { column } from './column';
|
|
7
|
+
import { got } from './column-values';
|
|
8
|
+
import { refuseColumn } from './refuse';
|
|
9
|
+
import type { Column } from './types';
|
|
10
|
+
|
|
11
|
+
export interface TextOptions {
|
|
12
|
+
/** Emits `char_length(<column>) <= max`, so Postgres refuses an over-long string too. */
|
|
13
|
+
readonly max?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const text = (options: TextOptions = {}): Column<string> => {
|
|
17
|
+
const { max } = options;
|
|
18
|
+
// Refused where it is declared: a `NaN` or fractional max emitted `char_length(x) <= NaN` into
|
|
19
|
+
// the DDL, which Postgres refuses one migration later, far from the line that wrote it.
|
|
20
|
+
if (max !== undefined && !(Number.isSafeInteger(max) && max >= 1)) {
|
|
21
|
+
refuseColumn(
|
|
22
|
+
'length',
|
|
23
|
+
`text({ max }) must be a whole number of characters, at least 1, ${got(max)}`,
|
|
24
|
+
'text({ max: 200 }) — a whole count of characters, or text() for no bound',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return column<string>(
|
|
28
|
+
'text',
|
|
29
|
+
(value) => {
|
|
30
|
+
if (typeof value !== 'string') {
|
|
31
|
+
return refuseColumn(
|
|
32
|
+
'type',
|
|
33
|
+
`expected a string, ${got(value)}`,
|
|
34
|
+
'String(value) at the call site when this really is text — a number column is integer(), an exact decimal is decimal(), a structured payload is json(schema)',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
// Code points, as `char_length` counts them: the CHECK refused an over-long value in
|
|
38
|
+
// Postgres (23514) while memory stored it, so a test passed a write production refuses.
|
|
39
|
+
if (max !== undefined && charCount(value) > max) {
|
|
40
|
+
return refuseColumn(
|
|
41
|
+
'length',
|
|
42
|
+
`expected at most ${max} characters, got ${charCount(value)}`,
|
|
43
|
+
`truncate at the call site, or widen the column — text({ max: ${charCount(value)} }) — and run x db gen "widen the text"`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
},
|
|
48
|
+
max === undefined ? {} : { length: max, check: (name) => `char_length(${name}) <= ${max}` },
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Postgres `integer` is int4: a safe JS integer past this range is 22003 there. */
|
|
53
|
+
const INT4_MIN = -2_147_483_648;
|
|
54
|
+
const INT4_MAX = 2_147_483_647;
|
|
55
|
+
|
|
56
|
+
export const integer = (): Column<number> =>
|
|
57
|
+
column<number>('integer', (value) =>
|
|
58
|
+
typeof value === 'number' &&
|
|
59
|
+
Number.isSafeInteger(value) &&
|
|
60
|
+
value >= INT4_MIN &&
|
|
61
|
+
value <= INT4_MAX
|
|
62
|
+
? value
|
|
63
|
+
: refuseColumn(
|
|
64
|
+
'type',
|
|
65
|
+
`expected a whole number in the int4 range (${INT4_MIN}..${INT4_MAX}), ${got(value)}`,
|
|
66
|
+
'Math.trunc(value) for a float and Number(value) for a numeric string — a count past int4 is bigint(), a fractional value is decimal()',
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
export const boolean = (): Column<boolean> =>
|
|
71
|
+
column<boolean>('boolean', (value) =>
|
|
72
|
+
typeof value === 'boolean'
|
|
73
|
+
? value
|
|
74
|
+
: refuseColumn(
|
|
75
|
+
'type',
|
|
76
|
+
`expected a boolean, ${got(value)}`,
|
|
77
|
+
"value === 'true' at the call site for a text flag, and boolean().nullable() when the column has a third state",
|
|
78
|
+
),
|
|
79
|
+
);
|
package/src/columns.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { uuid as uuidV7 } from '@ultimat3/core';
|
|
|
6
6
|
import {
|
|
7
7
|
CURRENCY_CODE_PATTERN,
|
|
8
8
|
isCurrencyCode,
|
|
9
|
+
isIsoDateTime,
|
|
9
10
|
isMoneyScale,
|
|
10
11
|
MAX_MONEY_SCALE,
|
|
11
12
|
} from '@ultimat3/schema';
|
|
@@ -85,59 +86,22 @@ const uuidWith = <T extends string>(meta: ColumnMeta): UuidColumn<T> => ({
|
|
|
85
86
|
column: (name) => uuidWith<T>({ ...meta, name: assertColumnName(name) }),
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
export
|
|
89
|
-
|
|
90
|
-
readonly max?: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export const text = (options: TextOptions = {}): Column<string> =>
|
|
94
|
-
column<string>(
|
|
95
|
-
'text',
|
|
96
|
-
(value) =>
|
|
97
|
-
typeof value === 'string'
|
|
98
|
-
? value
|
|
99
|
-
: refuseColumn(
|
|
100
|
-
'type',
|
|
101
|
-
`expected a string, ${got(value)}`,
|
|
102
|
-
'String(value) at the call site when this really is text — a number column is integer(), an exact decimal is decimal(), a structured payload is json(schema)',
|
|
103
|
-
),
|
|
104
|
-
options.max === undefined
|
|
105
|
-
? {}
|
|
106
|
-
: { length: options.max, check: (name) => `char_length(${name}) <= ${options.max}` },
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
export const integer = (): Column<number> =>
|
|
110
|
-
column<number>('integer', (value) =>
|
|
111
|
-
typeof value === 'number' && Number.isSafeInteger(value)
|
|
112
|
-
? value
|
|
113
|
-
: refuseColumn(
|
|
114
|
-
'type',
|
|
115
|
-
`expected a safe integer, ${got(value)}`,
|
|
116
|
-
'Math.trunc(value) for a float and Number(value) for a numeric string — a count past ±2^53 is bigint(), a fractional value is decimal()',
|
|
117
|
-
),
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
export const boolean = (): Column<boolean> =>
|
|
121
|
-
column<boolean>('boolean', (value) =>
|
|
122
|
-
typeof value === 'boolean'
|
|
123
|
-
? value
|
|
124
|
-
: refuseColumn(
|
|
125
|
-
'type',
|
|
126
|
-
`expected a boolean, ${got(value)}`,
|
|
127
|
-
"value === 'true' at the call site for a text flag, and boolean().nullable() when the column has a third state",
|
|
128
|
-
),
|
|
129
|
-
);
|
|
89
|
+
export type { TextOptions } from './columns-scalar';
|
|
90
|
+
export { boolean, integer, text } from './columns-scalar';
|
|
130
91
|
|
|
131
92
|
const parseInstant = (value: unknown): Date => {
|
|
132
93
|
if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
|
|
133
|
-
|
|
94
|
+
// A string must be ISO-8601 naming its own instant (`@ultimat3/schema`'s `isIsoDateTime`):
|
|
95
|
+
// `new Date('2026-03-14T09:00')` and `new Date('March 14, 2026')` resolved through the HOST's
|
|
96
|
+
// zone on insert and seed, so one row was a different instant per container `TZ`.
|
|
97
|
+
if ((typeof value === 'string' && isIsoDateTime(value)) || typeof value === 'number') {
|
|
134
98
|
const parsed = new Date(value);
|
|
135
99
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
|
136
100
|
}
|
|
137
101
|
return refuseColumn(
|
|
138
102
|
'format',
|
|
139
|
-
`expected a UTC instant, ${got(value)}`,
|
|
140
|
-
'
|
|
103
|
+
`expected a UTC instant — a Date, epoch milliseconds, or an ISO-8601 string with Z or an offset — ${got(value)}`,
|
|
104
|
+
"'2026-03-14T09:00:00Z' or a Date — timestamp() stores an instant, so a string must name its zone; a calendar date with no clock is date(), and an elapsed span is integer()",
|
|
141
105
|
);
|
|
142
106
|
};
|
|
143
107
|
|
|
@@ -156,7 +120,12 @@ export const url = (): Column<string> =>
|
|
|
156
120
|
if (typeof value === 'string') {
|
|
157
121
|
try {
|
|
158
122
|
const parsed = new URL(value);
|
|
159
|
-
|
|
123
|
+
// The scheme is stored in its canonical LOWER case: the CHECK is `~ '^https?://'`, so
|
|
124
|
+
// `HTTPS://a.b` was stored by memory and refused by Postgres. Only the scheme is
|
|
125
|
+
// rewritten — the rest of the URL is the caller's, byte for byte.
|
|
126
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
127
|
+
return value.replace(/^https?(?=:)/i, (scheme) => scheme.toLowerCase());
|
|
128
|
+
}
|
|
160
129
|
} catch {
|
|
161
130
|
// fall through to the shared rejection so the error names the rule
|
|
162
131
|
}
|
package/src/entity.ts
CHANGED
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
|
|
@@ -82,7 +81,11 @@ export type { StatementLoop } from './n-plus-one';
|
|
|
82
81
|
export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
|
|
83
82
|
export { persistedRecordTypes } from './persisted-types';
|
|
84
83
|
export type { PostgresDriverOptions } from './pg-driver';
|
|
85
|
-
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';
|
|
86
89
|
// The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
|
|
87
90
|
// its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
|
|
88
91
|
export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
|
|
@@ -106,6 +109,7 @@ export type {
|
|
|
106
109
|
export {
|
|
107
110
|
clearRegistry,
|
|
108
111
|
describeEntities,
|
|
112
|
+
entityForTable,
|
|
109
113
|
entityNames,
|
|
110
114
|
getEntity,
|
|
111
115
|
registerEntity,
|
|
@@ -138,9 +142,7 @@ export {
|
|
|
138
142
|
isSearchLanguage,
|
|
139
143
|
isSearchWeight,
|
|
140
144
|
SEARCH_LANGUAGES,
|
|
141
|
-
SEARCH_PROPERTY,
|
|
142
145
|
SEARCH_WEIGHTS,
|
|
143
|
-
searchExpression,
|
|
144
146
|
} from './search';
|
|
145
147
|
export type {
|
|
146
148
|
Seed,
|
|
@@ -168,14 +170,9 @@ export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './t
|
|
|
168
170
|
export {
|
|
169
171
|
assertRowTenant,
|
|
170
172
|
assertScoped,
|
|
171
|
-
describePlan,
|
|
172
|
-
emptyPlan,
|
|
173
|
-
hasOrgPredicate,
|
|
174
|
-
isOrgScoped,
|
|
175
173
|
ORG_COLUMN,
|
|
176
174
|
orgScoped,
|
|
177
175
|
scopedPlan,
|
|
178
|
-
tenantColumnOf,
|
|
179
176
|
} from './tenancy';
|
|
180
177
|
export type { Move } from './transition';
|
|
181
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
|
+
};
|
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,7 +52,7 @@ 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';
|
|
@@ -484,16 +480,3 @@ export const postgresRepo = <Row>(
|
|
|
484
480
|
export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
|
|
485
481
|
repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
|
|
486
482
|
});
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* A real Postgres transaction behind the same `Transactor` the in-memory one implements. The
|
|
490
|
-
* `Tx` handed to the callback is a token: repositories find the transaction through `db()`, so
|
|
491
|
-
* nothing has to thread a connection through the call stack.
|
|
492
|
-
*/
|
|
493
|
-
export const postgresTransactor = (options: TransactionOptions = {}): Transactor => ({
|
|
494
|
-
run: (work) =>
|
|
495
|
-
withTransaction(
|
|
496
|
-
(tx) => work({ id: tx.id, onRollback: (undo: () => void) => tx.onRollback(undo) }),
|
|
497
|
-
options,
|
|
498
|
-
),
|
|
499
|
-
});
|
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;
|