@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/README.md
CHANGED
|
@@ -56,6 +56,51 @@ A view the columns cannot express — a joined `authorName`, a computed `excerpt
|
|
|
56
56
|
`t.object({...})`. `t` is re-exported here, the same object `@ultimat3/schema` exports, so that file
|
|
57
57
|
still imports one package: `import { entity, t } from '@ultimat3/entity'`.
|
|
58
58
|
|
|
59
|
+
## A whole row is a record
|
|
60
|
+
|
|
61
|
+
`posts.$schema` is the whole row as a `t` schema, and its node is branded
|
|
62
|
+
(`Symbol.for('ultimate.entity')`, non-enumerable) with the entity's **record projection**. An
|
|
63
|
+
action or query whose output names it — bare or wrapped — returns rows the client store adopts,
|
|
64
|
+
with no `records:` option anywhere: the envelope is derived from the output schema.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import {
|
|
68
|
+
entity,
|
|
69
|
+
hasEntityRows,
|
|
70
|
+
recordProjection,
|
|
71
|
+
recordTypeForTable,
|
|
72
|
+
rowsOf,
|
|
73
|
+
t,
|
|
74
|
+
text,
|
|
75
|
+
uuid,
|
|
76
|
+
} from '@ultimat3/entity';
|
|
77
|
+
|
|
78
|
+
const posts = entity('posts', { columns: { id: uuid().primaryKey(), title: text() } });
|
|
79
|
+
const Feed = t.object({ items: t.array(posts.$schema), featured: posts.$schema.nullable() });
|
|
80
|
+
declare const answer: unknown; // what a handler returned
|
|
81
|
+
|
|
82
|
+
hasEntityRows(Feed); // true — static, memoised per node
|
|
83
|
+
rowsOf(Feed, answer); // { posts: { [recordKey]: row } } — null-prototype, same objects
|
|
84
|
+
recordProjection(posts); // { type: 'posts', table: 'posts', key, schema, persist: false }
|
|
85
|
+
recordTypeForTable('posts'); // 'posts' — what a changefeed row belongs to
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| Rule | Detail |
|
|
89
|
+
|---|---|
|
|
90
|
+
| Survives every wrapper | `t.array`, `t.object`, `t.record`, `t.union` keep the child node by reference; `.nullable()`, `.optional()`, `.default()`, `.describe()`, `.refine()` re-brand the copy they make |
|
|
91
|
+
| A partial row is never a record | a `$view`, and any `t.object(...).pick/omit/extend`, carries no brand — it would overwrite a full record |
|
|
92
|
+
| Wire shape | type → record key → row: the KEY travels, because a browser cannot compute one without importing the app's `entity()` declarations |
|
|
93
|
+
| Union arms | a branded arm claims a value only when every column is an own key of it |
|
|
94
|
+
| Record key | the primary key, in DECLARED order; a single key is the value itself (so it equals `$tagFor(id)`'s id), a composite one percent-encodes each part and joins on `:` |
|
|
95
|
+
| Missing key | `X_RECORD_KEY_MISSING` — never keyed as `undefined`, because two keyless rows would be one record |
|
|
96
|
+
| `persist` | `entity(name, { persist: true })` — default `false`; a browser keeps the records on disk (IndexedDB, per principal) only when declared. Realtime's persister reads `recordProjection(e).persist`, never the declaration |
|
|
97
|
+
| `type` / `table` | the entity name (the store's key) and the physical relation (what a changefeed names); two entities over one table are `X_INVARIANT_VIOLATED` from `recordTypeForTable` |
|
|
98
|
+
|
|
99
|
+
**In browser code import these from `@ultimat3/entity/record`**, never the barrel: the package
|
|
100
|
+
declares no `sideEffects`, so the barrel retains ~1 MB of SQL rendering and `@ultimat3/db` a page
|
|
101
|
+
never runs, while the subpath retains the projection, the key and the registry and nothing else —
|
|
102
|
+
`record-bundle.test.ts` measures both.
|
|
103
|
+
|
|
59
104
|
## Blessed columns
|
|
60
105
|
|
|
61
106
|
| Builder | Emits | Why it is the only way |
|
|
@@ -842,7 +887,17 @@ database from its boot code has decided to, and a library that overruled that wo
|
|
|
842
887
|
`X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
|
|
843
888
|
`X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
|
|
844
889
|
`X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
|
|
845
|
-
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE`
|
|
890
|
+
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
|
|
891
|
+
`X_RECORD_KEY_MISSING`
|
|
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` |
|
|
846
901
|
|
|
847
902
|
## Boundaries
|
|
848
903
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"provenance": true
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
|
-
".": "./src/index.ts"
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./record": "./src/record.ts"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"src",
|
|
@@ -31,9 +32,9 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "22.0.0",
|
|
36
|
+
"@ultimat3/db": "22.0.0",
|
|
37
|
+
"@ultimat3/schema": "22.0.0",
|
|
38
|
+
"@ultimat3/time": "22.0.0"
|
|
38
39
|
}
|
|
39
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
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The entity layer's code registry, its error class, and the two refusals the declaration path
|
|
2
|
+
// raises — split from `errors.ts` so a module the BROWSER loads (the record key, the projection,
|
|
3
|
+
// the registry) can raise one without importing `@ultimat3/db`, which `errors.ts` needs for
|
|
4
|
+
// `dbDrift`'s shell-inert fix line.
|
|
5
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** Codes this package declares and owns. */
|
|
8
|
+
export const ENTITY_OWNED_ERROR_CODES = [
|
|
9
|
+
'X_ENTITY_DUPLICATE',
|
|
10
|
+
'X_INVARIANT_VIOLATED',
|
|
11
|
+
'X_TENANCY_UNSCOPED',
|
|
12
|
+
'X_TENANCY_ACTOR_MISMATCH',
|
|
13
|
+
'X_TENANCY_ACTOR_ORG_REQUIRED',
|
|
14
|
+
'X_TENANCY_CROSS_DENIED',
|
|
15
|
+
'X_NOT_FOUND',
|
|
16
|
+
'X_WRITE_UNFILTERED',
|
|
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
|
+
'X_RECORD_KEY_MISSING',
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `X_DB_DRIFT` is `@ultimat3/db`'s — drift is a fact about migrations, and this package imports db
|
|
35
|
+
* rather than the other way round. `dbDrift()` below throws it; nothing here titles it, because a
|
|
36
|
+
* second copy of the title is what lets the two packages disagree about what the code means.
|
|
37
|
+
*/
|
|
38
|
+
export const ENTITY_BORROWED_ERROR_CODES = ['X_DB_DRIFT'] as const;
|
|
39
|
+
|
|
40
|
+
/** Every code entity can throw: the ones it owns plus the one it borrows. */
|
|
41
|
+
export const ENTITY_ERROR_CODES = [
|
|
42
|
+
...ENTITY_OWNED_ERROR_CODES,
|
|
43
|
+
...ENTITY_BORROWED_ERROR_CODES,
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type EntityOwnedErrorCode = (typeof ENTITY_OWNED_ERROR_CODES)[number];
|
|
47
|
+
export type EntityErrorCode = (typeof ENTITY_ERROR_CODES)[number];
|
|
48
|
+
|
|
49
|
+
export const ENTITY_ERROR_TITLES: Readonly<Record<EntityOwnedErrorCode, string>> = {
|
|
50
|
+
X_ENTITY_DUPLICATE: 'two entities claim the same name',
|
|
51
|
+
X_INVARIANT_VIOLATED: 'a domain invariant rejected this row',
|
|
52
|
+
X_TENANCY_UNSCOPED: 'a tenant-scoped query has no org predicate',
|
|
53
|
+
// "call", not "query": the same code covers a predicate that names another tenant and a row or
|
|
54
|
+
// patch that writes one, because they are one mistake made in two places.
|
|
55
|
+
X_TENANCY_ACTOR_MISMATCH: "a call named a tenant other than the actor's",
|
|
56
|
+
X_TENANCY_ACTOR_ORG_REQUIRED: 'the acting actor carries no tenant',
|
|
57
|
+
X_TENANCY_CROSS_DENIED: 'a cross-tenant read was entered without the capability',
|
|
58
|
+
X_NOT_FOUND: 'no row for that id',
|
|
59
|
+
X_WRITE_UNFILTERED: 'a filtered write named no filter columns',
|
|
60
|
+
X_PATCH_EMPTY: 'a filtered update named no columns to write',
|
|
61
|
+
X_PRELOAD_UNKNOWN_RELATION: 'no relation of that name on this entity',
|
|
62
|
+
X_N_PLUS_ONE_QUERY: 'a read repeated once per row',
|
|
63
|
+
X_N_PLUS_ONE_WRITE: 'a write repeated once per row',
|
|
64
|
+
X_REPO_CLIENT_PINNED: 'a repository pinned to its own client cannot join the open transaction',
|
|
65
|
+
X_AGGREGATE_UNSUPPORTED: 'that column has no aggregate both drivers can answer alike',
|
|
66
|
+
X_AGGREGATE_MIXED_CURRENCY: 'an amount was aggregated across currencies',
|
|
67
|
+
X_APPROXIMATE_COUNT_FILTERED: 'an estimate was asked of a filtered chain',
|
|
68
|
+
X_SEARCH_UNDECLARED: 'this entity has no searchable column',
|
|
69
|
+
X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match',
|
|
70
|
+
X_STATE_UNDECLARED: 'that column declares no state machine',
|
|
71
|
+
X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
|
|
72
|
+
X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
|
|
73
|
+
X_RECORD_KEY_MISSING: 'a row reached its record key without a primary-key value',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Registered at module load, unconditionally, in one call. Without this the registry humanises the
|
|
77
|
+
// code and every surface renders a title this package never wrote; with a presence guard, a second
|
|
78
|
+
// package claiming one of these codes would silently win instead of throwing X_ERROR_CODE_DUPLICATE.
|
|
79
|
+
registerErrorCodes(
|
|
80
|
+
Object.fromEntries(Object.entries(ENTITY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Base for every error this package throws. No `docs:` — `UltimateError` fills it from
|
|
85
|
+
* `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
|
|
86
|
+
* code, never one per code, because `wiki/` is the framework's only public documentation surface
|
|
87
|
+
* and a code lives there in a TABLE ROW, which has no anchor. The
|
|
88
|
+
* `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
|
|
89
|
+
* included, on every refusal it has ever raised.
|
|
90
|
+
*/
|
|
91
|
+
export class EntityError extends UltimateError {
|
|
92
|
+
override readonly name = 'EntityError';
|
|
93
|
+
|
|
94
|
+
constructor(init: { code: EntityErrorCode; cause: string; fix: string }) {
|
|
95
|
+
super({
|
|
96
|
+
code: init.code,
|
|
97
|
+
cause: init.cause,
|
|
98
|
+
fix: init.fix,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
|
|
105
|
+
* given. A literal is an entity that does not exist, and this fix then hands the reader
|
|
106
|
+
* `x entities describe column --json`, which answers `X_DECLARATION_UNKNOWN` (issue #290). A
|
|
107
|
+
* refusal raised before any entity exists belongs in `refuse.ts`, where the caller supplies the
|
|
108
|
+
* edit; `refuse.test.ts` fails on a literal here.
|
|
109
|
+
*/
|
|
110
|
+
export const invariantViolated = (
|
|
111
|
+
entityName: string,
|
|
112
|
+
invariantName: string,
|
|
113
|
+
message: string,
|
|
114
|
+
): EntityError =>
|
|
115
|
+
new EntityError({
|
|
116
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
117
|
+
cause: `${entityName}.${invariantName}: ${message}`,
|
|
118
|
+
fix: `x entities describe ${entityName} --json # shows the invariant and its SQL CHECK`,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const entityDuplicate = (name: string, existingTable: string): EntityError =>
|
|
122
|
+
new EntityError({
|
|
123
|
+
code: 'X_ENTITY_DUPLICATE',
|
|
124
|
+
cause: `entity "${name}" is already registered for table "${existingTable}"`,
|
|
125
|
+
fix: `x entities list --json # then rename one of the two entity({ name }) declarations`,
|
|
126
|
+
});
|
package/src/entity.ts
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
// (the typed db handle, migrations, cache tags, the admin UI, the manifest) is projected from
|
|
4
4
|
// this one call.
|
|
5
5
|
|
|
6
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
7
6
|
import type { IndexMethod } from '@ultimat3/db';
|
|
8
|
-
import { describeValue, type
|
|
7
|
+
import { describeValue, type Schema } from '@ultimat3/schema';
|
|
9
8
|
import { entityNow } from './clock';
|
|
10
9
|
import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
|
|
11
10
|
import { newId } from './columns';
|
|
@@ -16,8 +15,10 @@ import { invariantColumns } from './expr';
|
|
|
16
15
|
import { indexName } from './index-name';
|
|
17
16
|
import type { Invariant, InvariantDef } from './invariants';
|
|
18
17
|
import { assertInvariants, bindInvariant } from './invariants';
|
|
18
|
+
import { recordProjection } from './record-projection';
|
|
19
19
|
import type { EntityDescription, ReferenceDescription } from './registry';
|
|
20
20
|
import { registerEntity } from './registry';
|
|
21
|
+
import { rowSchema } from './row-schema';
|
|
21
22
|
import type { SearchInit, SearchSource, SearchVector } from './search';
|
|
22
23
|
import { searchVectorOf } from './search';
|
|
23
24
|
import { resolveTenantColumn } from './tenancy';
|
|
@@ -84,6 +85,12 @@ export interface EntityInit<C extends ColumnMap> {
|
|
|
84
85
|
readonly search?: SearchInit;
|
|
85
86
|
/** Extra cache tags this entity participates in, beyond its own. */
|
|
86
87
|
readonly tags?: readonly string[];
|
|
88
|
+
/**
|
|
89
|
+
* Whether a browser keeps this entity's records on disk (IndexedDB, keyed by principal) so they
|
|
90
|
+
* survive a reload and an offline start. Default `false`: a record is private data by default,
|
|
91
|
+
* and disk is a decision. Read off `recordProjection(entity).persist` by realtime's persister.
|
|
92
|
+
*/
|
|
93
|
+
readonly persist?: boolean;
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/**
|
|
@@ -111,8 +118,13 @@ export interface EntityCore<Row = unknown, C extends ColumnMap = ColumnMap> {
|
|
|
111
118
|
readonly $search: SearchVector | null;
|
|
112
119
|
/** Phantom: `type Post = typeof posts.$row`. Reading it at runtime throws. */
|
|
113
120
|
readonly $row: Row;
|
|
114
|
-
/**
|
|
115
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The whole row as a `t` schema — forms and actions hand input to it, and an output naming it
|
|
123
|
+
* (bare or wrapped: `t.array(posts.$schema)`) is a row the client store adopts, because its node
|
|
124
|
+
* carries the `recordProjection` brand. A `$view` or a `.pick()` carries none: a partial row is
|
|
125
|
+
* never a record.
|
|
126
|
+
*/
|
|
127
|
+
readonly $schema: Schema<unknown, Row>;
|
|
116
128
|
/** `entity:<name>:<id>` — row-level invalidation for live queries. */
|
|
117
129
|
$tagFor(id: string): string;
|
|
118
130
|
/** Fills declared defaults, then validates every column. Throws on a bad value. */
|
|
@@ -388,25 +400,11 @@ export const entity = <const C extends ColumnMap>(
|
|
|
388
400
|
$softDelete: softDelete,
|
|
389
401
|
$tenantColumn: tenantColumn,
|
|
390
402
|
$search: search,
|
|
391
|
-
$schema:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
try {
|
|
397
|
-
return { value: parse(value) };
|
|
398
|
-
} catch (error) {
|
|
399
|
-
// `renderThrowable`, never `error instanceof Error ? error.message : String(error)`:
|
|
400
|
-
// both halves of that read the caught value directly. `instanceof` consults
|
|
401
|
-
// `getPrototypeOf` and `String()` runs the value's own coercion, so a `Proxy` or a
|
|
402
|
-
// null-prototype throwable raised a SECOND, uncatchable `TypeError` out of the
|
|
403
|
-
// validator — where a rejection belongs. A column parser is app-reachable and an
|
|
404
|
-
// app's `$parse` may throw anything at all.
|
|
405
|
-
return { issues: [{ message: renderThrowable(error) }] };
|
|
406
|
-
}
|
|
407
|
-
},
|
|
408
|
-
},
|
|
409
|
-
},
|
|
403
|
+
$schema: rowSchema<Row>(
|
|
404
|
+
{ name, table, primaryKey, persist: init.persist === true },
|
|
405
|
+
entries,
|
|
406
|
+
parse,
|
|
407
|
+
),
|
|
410
408
|
get $row(): Row {
|
|
411
409
|
// Type-only. Reading it means someone expected a value where a type was meant.
|
|
412
410
|
throw invariantViolated(name, '$row', '$row is a type, not a value — use typeof x.$row');
|
|
@@ -420,7 +418,15 @@ export const entity = <const C extends ColumnMap>(
|
|
|
420
418
|
$references: references,
|
|
421
419
|
};
|
|
422
420
|
|
|
423
|
-
registerEntity({
|
|
421
|
+
registerEntity({
|
|
422
|
+
name,
|
|
423
|
+
tableName: table,
|
|
424
|
+
persist: init.persist === true,
|
|
425
|
+
projection: recordProjection(core),
|
|
426
|
+
core: core as EntityCore<unknown>,
|
|
427
|
+
describe,
|
|
428
|
+
references,
|
|
429
|
+
});
|
|
424
430
|
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
425
431
|
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
426
432
|
return Object.assign(core, init.columns);
|