@ultimat3/entity 20.2.1 → 21.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 +24 -1
- package/README.md +47 -1
- package/package.json +7 -6
- package/src/entity-error.ts +126 -0
- package/src/entity.ts +29 -24
- package/src/errors.ts +13 -120
- package/src/index.ts +9 -0
- package/src/persisted-types.ts +21 -0
- package/src/pg-driver.ts +6 -5
- 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 +6 -1
- package/src/row-observer.ts +17 -1
- package/src/row-schema.ts +63 -0
- package/src/rows-of.ts +132 -0
- package/src/write-tag.ts +75 -0
package/CLAUDE.md
CHANGED
|
@@ -1130,6 +1130,22 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
1130
1130
|
conflict. **`whyNot` asks three questions in one order** — unknown state, then terminal, then the
|
|
1131
1131
|
legal list — because an unknown state has no outgoing moves either, and a check that skipped it
|
|
1132
1132
|
reported a typo as "the row is terminal in `pendign`".
|
|
1133
|
+
- **An entity row on the client is a RECORD, and the record is derived — plan 101, `As of
|
|
1134
|
+
2026-09-22`.** `$schema` is a full `t` schema (`row-schema.ts`) whose node carries the entity's
|
|
1135
|
+
`RecordProjection` under the non-enumerable `ENTITY_BRAND` (`Symbol.for('ultimate.entity')`, so a
|
|
1136
|
+
brand minted in one island bundle is read in another). Rules, none optional. **The brand lives on
|
|
1137
|
+
the NODE**, because `t.array`/`t.object`/`t.record`/`t.union` keep only the child's node, by
|
|
1138
|
+
reference; the five methods that COPY a node (`nullable`, `optional`, `default`, `describe`,
|
|
1139
|
+
`refine`) are re-branded in `row-schema.ts`, since a spread drops a non-enumerable symbol — do not
|
|
1140
|
+
"fix" that in `@ultimat3/schema` by making the brand enumerable, which would put it in every
|
|
1141
|
+
spread and `toEqual` of the IR. **A partial row is never a record**: `$view` and any
|
|
1142
|
+
`.pick/.omit/.extend` build unbranded nodes, and `rows-of.test.ts` pins it. **`rowsOf` answers type → record key → row**, both levels null-prototype: the key is what
|
|
1143
|
+
travels, since a browser cannot compute one without importing the app's `entity()` declarations. **`persist` is an `EntityInit` key, default `false`**, read ONLY off the projection
|
|
1144
|
+
(`recordProjection(e).persist`) — realtime's persister is its reader, so disk is a per-entity
|
|
1145
|
+
declaration and never a store-wide switch. **The browser path is `@ultimat3/entity/record`** (`record.ts`), and the modules behind
|
|
1146
|
+
it import `entity-error.ts`, never `errors.ts`: `errors.ts` imports `@ultimat3/db` for
|
|
1147
|
+
`dbDrift`, and one such import put 16 `db` modules (pglite included) in the chunk.
|
|
1148
|
+
`record-bundle.test.ts` lists the retained entity modules by name, so a new import there fails.
|
|
1133
1149
|
- Never throw a bare `Error` — use `errors.ts`.
|
|
1134
1150
|
- **Tests restore the process-global registry in `afterAll` (`clearRegistry()`), and the hook is at
|
|
1135
1151
|
FILE scope — a build error since 2026-08-25, because the prose form was violated by 19 of the 19
|
|
@@ -1163,6 +1179,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
1163
1179
|
| `column-values.ts` | `got()` and `oneOf()`, so `enum-column.ts` needs no import of the file that imports it |
|
|
1164
1180
|
| `feature-errors.ts` | the refusals search and the state machine raise at call time; the codes and titles stay in `errors.ts` |
|
|
1165
1181
|
| `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
|
|
1182
|
+
| `row-schema.ts` | `$schema` — the whole row as a `t` schema, branded, with the copying wrappers re-branded |
|
|
1183
|
+
| `record-projection.ts` / `record-key.ts` | `ENTITY_BRAND`, `RecordProjection`, `recordProjection()`; the record key and `X_RECORD_KEY_MISSING` |
|
|
1184
|
+
| `rows-of.ts` | `rowsOf`/`hasEntityRows` — the entity rows an output schema declares, read beside its value |
|
|
1185
|
+
| `record-table.ts` | `recordTypeForTable` — a changefeed's table back to the record type, memoised on the registry generation |
|
|
1186
|
+
| `record.ts` | the `@ultimat3/entity/record` subpath — the browser-safe entry for the four above |
|
|
1187
|
+
| `entity-error.ts` | the code registry, `EntityError`, `invariantViolated`, `entityDuplicate` — no `@ultimat3/db`, so the browser path can raise them; `errors.ts` re-exports all of it |
|
|
1166
1188
|
| `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
|
|
1167
1189
|
| `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
|
|
1168
1190
|
| `memory-match.ts` | what a `Predicate` means in the memory driver: compare/equal/LIKE, by the column's kind. The decimal comparison itself is `@ultimat3/core`'s `compareDecimalText` |
|
|
@@ -1178,7 +1200,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
1178
1200
|
| `jit-preload.ts` | a page's foreign key values → one `in` statement for the whole `for … of` loop |
|
|
1179
1201
|
| `preload.ts` | the relation `preload()` names → one related-rows statement → attached to the page |
|
|
1180
1202
|
| `pg-sql.ts` / `pg-row.ts` | plan → parameterised SQL; physical row ⇄ entity row (money is three columns) |
|
|
1181
|
-
| `row-observer.ts` | `setRowObserver` — committed row changes, above the driver, for a change feed that has no log to read |
|
|
1203
|
+
| `row-observer.ts` | `setRowObserver` — committed row changes, above the driver, for a change feed that has no log to read. A change made inside a keyed request carries `write` (`currentWriteOrigin()`) |
|
|
1204
|
+
| `write-tag.ts` | a keyed request's write names itself in the WAL: `pg_logical_emit_message(true, WRITE_ORIGIN_WAL_PREFIX, digest)` opens its transaction, once per transaction; a write outside one gets a transaction of its own; a role that may not execute it is probed once and its writes go out untagged; a pinned repository is never wrapped |
|
|
1182
1205
|
| `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
|
|
1183
1206
|
| `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
|
|
1184
1207
|
| `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
|
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,8 @@ 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`
|
|
846
892
|
|
|
847
893
|
## Boundaries
|
|
848
894
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.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": "21.0.0",
|
|
36
|
+
"@ultimat3/db": "21.0.0",
|
|
37
|
+
"@ultimat3/schema": "21.0.0",
|
|
38
|
+
"@ultimat3/time": "21.0.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
|
@@ -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,14 @@ 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
|
+
describe,
|
|
427
|
+
references,
|
|
428
|
+
});
|
|
424
429
|
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
425
430
|
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
426
431
|
return Object.assign(core, init.columns);
|
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
|
@@ -80,6 +80,7 @@ export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
|
80
80
|
export { memoryRepo, memoryTransactor } from './memory-repo';
|
|
81
81
|
export type { StatementLoop } from './n-plus-one';
|
|
82
82
|
export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
|
|
83
|
+
export { persistedRecordTypes } from './persisted-types';
|
|
83
84
|
export type { PostgresDriverOptions } from './pg-driver';
|
|
84
85
|
export { postgresDriver, postgresRepo, postgresTransactor } from './pg-driver';
|
|
85
86
|
// The two page bounds, beside `N_PLUS_ONE_THRESHOLD` and for the same reason: an app validating
|
|
@@ -88,6 +89,12 @@ export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
|
|
|
88
89
|
export type { RelatedTable, RelatedTables } from './preload';
|
|
89
90
|
export type { Preloaded, ReadBuilder, Table } from './query';
|
|
90
91
|
export { tableFor } from './query';
|
|
92
|
+
// The client projection of an entity (plan 101): record type, record key, and the brand on
|
|
93
|
+
// `$schema` that lets an action or query derive its record envelope from its output schema.
|
|
94
|
+
// Value-light — no Postgres driver behind any of them — so a browser store may import them here.
|
|
95
|
+
export type { ProjectedEntity, RecordProjection } from './record-projection';
|
|
96
|
+
export { ENTITY_BRAND, recordProjection } from './record-projection';
|
|
97
|
+
export { recordProjectionForTable, recordTypeForTable } from './record-table';
|
|
91
98
|
export type {
|
|
92
99
|
ColumnDescription,
|
|
93
100
|
EntityDescription,
|
|
@@ -119,6 +126,8 @@ export type {
|
|
|
119
126
|
} from './repo';
|
|
120
127
|
export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
|
|
121
128
|
export { observedRepo, rowObserver, setRowObserver } from './row-observer';
|
|
129
|
+
export type { RecordsByKey, RecordsByType } from './rows-of';
|
|
130
|
+
export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
|
|
122
131
|
// Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
|
|
123
132
|
// `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
|
|
124
133
|
export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
|
|
@@ -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
|
@@ -60,6 +60,7 @@ import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
|
|
|
60
60
|
import type { QueryPlan } from './tenancy';
|
|
61
61
|
import { assertRowTenant } from './tenancy';
|
|
62
62
|
import type { RowWrite } from './types';
|
|
63
|
+
import { taggedWrite } from './write-tag';
|
|
63
64
|
|
|
64
65
|
export interface PostgresDriverOptions {
|
|
65
66
|
/**
|
|
@@ -156,14 +157,14 @@ export const postgresRepo = <Row>(
|
|
|
156
157
|
: deleteStatement(entity, plan);
|
|
157
158
|
|
|
158
159
|
/**
|
|
159
|
-
* Every write goes out through here
|
|
160
|
-
*
|
|
161
|
-
* before
|
|
162
|
-
*
|
|
160
|
+
* Every write goes out through here: the ONE place the request's preloaded rows are dropped (a
|
|
161
|
+
* row this statement changes must not be served afterwards from a page read before it — so
|
|
162
|
+
* before the statement, not after), and where a keyed request's write names itself in the WAL
|
|
163
|
+
* (`write-tag.ts`).
|
|
163
164
|
*/
|
|
164
165
|
const writing = <T>(send: () => Promise<T>): Promise<T> => {
|
|
165
166
|
forgetPreloaded(entity.$name);
|
|
166
|
-
return send
|
|
167
|
+
return taggedWrite(config.client !== undefined, send);
|
|
167
168
|
};
|
|
168
169
|
|
|
169
170
|
/**
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A row's record key: its primary-key columns rendered to one string, in DECLARED order, so the
|
|
2
|
+
// client store keys one record once whatever order a row's own properties arrived in. Runs in the
|
|
3
|
+
// browser too (the store keys adopted rows with it), so it imports nothing a page cannot carry.
|
|
4
|
+
|
|
5
|
+
import { isFixShellSafe, type Row, renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import { describeValue } from '@ultimat3/schema';
|
|
7
|
+
import { EntityError } from './entity-error';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A row reached a record key without one of its primary-key columns — a handler built the row by
|
|
11
|
+
* hand, or a partial row was handed where a whole one was declared. Refused, never keyed as
|
|
12
|
+
* `undefined`: two keyless rows would be ONE record in the store and overwrite each other.
|
|
13
|
+
*
|
|
14
|
+
* The value is described by SHAPE, never echoed: a key is the caller's data, and this cause
|
|
15
|
+
* reaches the log line.
|
|
16
|
+
*/
|
|
17
|
+
export const recordKeyMissing = (type: string, column: string, value: unknown): EntityError =>
|
|
18
|
+
new EntityError({
|
|
19
|
+
code: 'X_RECORD_KEY_MISSING',
|
|
20
|
+
cause: `a ${type} row reached its record key with primary-key column "${column}" holding ${describeValue(value)}, not a string, number, bigint, boolean or Date`,
|
|
21
|
+
// Screened, never spliced: an entity NAME is only checked as an identifier when it is also the
|
|
22
|
+
// table, so a declared name carrying shell syntax degrades to prose rather than a command. The
|
|
23
|
+
// `renderFixShellArg` inside the safe branch is verbatim there; it is the call
|
|
24
|
+
// `bun run fix-shell-arg` recognises as the screen.
|
|
25
|
+
fix: isFixShellSafe(type)
|
|
26
|
+
? `x entities describe ${renderFixShellArg(type, 'ENTITY')} --json # lists the primary key; return the whole row (every primary-key column) from the handler that built this one`
|
|
27
|
+
: 'x entities list --json # find this entity, then return the whole row (every primary-key column) from the handler that built this one',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** One part of a key. `Object.hasOwn`, never `row[column]` alone: an inherited member is no key. */
|
|
31
|
+
const partOf = (type: string, row: Row, column: string): string => {
|
|
32
|
+
const value = Object.hasOwn(row, column) ? row[column] : undefined;
|
|
33
|
+
switch (typeof value) {
|
|
34
|
+
case 'string':
|
|
35
|
+
return value;
|
|
36
|
+
case 'number':
|
|
37
|
+
if (Number.isFinite(value)) return String(value);
|
|
38
|
+
break;
|
|
39
|
+
case 'bigint':
|
|
40
|
+
case 'boolean':
|
|
41
|
+
return String(value);
|
|
42
|
+
default:
|
|
43
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
44
|
+
}
|
|
45
|
+
throw recordKeyMissing(type, column, value);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A single key is the value itself, so it is the same string `$tagFor(id)` and every realtime
|
|
50
|
+
* topic already carry. A composite key percent-encodes each part before joining on `:`, so a part
|
|
51
|
+
* holding the separator cannot make two different rows one key.
|
|
52
|
+
*/
|
|
53
|
+
export const recordKeyOf =
|
|
54
|
+
(type: string, primaryKey: readonly string[]) =>
|
|
55
|
+
(row: Row): string => {
|
|
56
|
+
const [only, ...rest] = primaryKey;
|
|
57
|
+
if (only !== undefined && rest.length === 0) return partOf(type, row, only);
|
|
58
|
+
return primaryKey.map((column) => encodeURIComponent(partOf(type, row, column))).join(':');
|
|
59
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// An entity's client projection — record type, record key, row node, persist — and the brand that
|
|
2
|
+
// carries it on the entity's row schema. Value-light on purpose: the browser store imports this,
|
|
3
|
+
// and nothing here may reach the Postgres driver.
|
|
4
|
+
|
|
5
|
+
import type { Row } from '@ultimat3/core';
|
|
6
|
+
import { isSchemaNode, type SchemaNode } from '@ultimat3/schema';
|
|
7
|
+
import { invariantViolated } from './entity-error';
|
|
8
|
+
import { recordKeyOf } from './record-key';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `Symbol.for`, not `Symbol()`: islands are separate bundles, so a row schema declared in one and
|
|
12
|
+
* walked in another holds a brand minted by a different copy of this module. Only the registry key
|
|
13
|
+
* is the same symbol in both.
|
|
14
|
+
*/
|
|
15
|
+
export const ENTITY_BRAND: unique symbol = Symbol.for('ultimate.entity');
|
|
16
|
+
|
|
17
|
+
export interface RecordProjection {
|
|
18
|
+
/** The wire name — the entity's name, the framework's key for it everywhere else too. */
|
|
19
|
+
readonly type: string;
|
|
20
|
+
/**
|
|
21
|
+
* The physical relation. A changefeed and a snapshot speak TABLES; `recordTypeForTable` is how
|
|
22
|
+
* they reach `type`, and this is the same fact read from the other end.
|
|
23
|
+
*/
|
|
24
|
+
readonly table: string;
|
|
25
|
+
/** Primary key → string, in declared order. Throws `X_RECORD_KEY_MISSING`. */
|
|
26
|
+
readonly key: (row: Row) => string;
|
|
27
|
+
/** The row's schema IR — JSON, so a store can put it on disk beside the rows. */
|
|
28
|
+
readonly schema: SchemaNode;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the client keeps this entity's records on disk — `entity(name, { persist: true })`,
|
|
31
|
+
* default `false`. Realtime's persister reads it here, never off the declaration.
|
|
32
|
+
*/
|
|
33
|
+
readonly persist: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What `entity()` hands the projection: everything but the row node, which the schema builds. */
|
|
37
|
+
export interface ProjectionIdentity {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly table: string;
|
|
40
|
+
readonly primaryKey: readonly string[];
|
|
41
|
+
readonly persist: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Built once per `entity()`, frozen: the brand, `recordProjection` and `rowsOf` share one object. */
|
|
45
|
+
export const createProjection = (
|
|
46
|
+
{ name, table, primaryKey, persist }: ProjectionIdentity,
|
|
47
|
+
schema: SchemaNode,
|
|
48
|
+
): RecordProjection =>
|
|
49
|
+
Object.freeze({ type: name, table, key: recordKeyOf(name, primaryKey), schema, persist });
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The projection a schema node is branded with, or `undefined`. Own and non-enumerable: an
|
|
53
|
+
* enumerable symbol would ride every spread and `toEqual` of the IR, and an inherited one would
|
|
54
|
+
* brand every node built from a branded prototype.
|
|
55
|
+
*/
|
|
56
|
+
export const projectionOf = (node: unknown): RecordProjection | undefined => {
|
|
57
|
+
if (!isSchemaNode(node) || !Object.hasOwn(node, ENTITY_BRAND)) return undefined;
|
|
58
|
+
return (node as { readonly [ENTITY_BRAND]?: RecordProjection })[ENTITY_BRAND];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Brands `node` in place. Called by the row schema on its own node and on each wrapper's. */
|
|
62
|
+
export const brandNode = (node: SchemaNode, projection: RecordProjection): void => {
|
|
63
|
+
Object.defineProperty(node, ENTITY_BRAND, { value: projection, enumerable: false });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The structural slice of an entity this reads — so it never imports `entity.ts`. */
|
|
67
|
+
export interface ProjectedEntity {
|
|
68
|
+
readonly $name: string;
|
|
69
|
+
readonly $schema: { readonly node: SchemaNode };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `recordProjection(posts)` — the one answer to "what is this entity on the client". Read off the
|
|
74
|
+
* brand rather than rebuilt, so the store, the envelope and `rowsOf` hold the same object.
|
|
75
|
+
*/
|
|
76
|
+
export const recordProjection = (entity: ProjectedEntity): RecordProjection => {
|
|
77
|
+
const projection = projectionOf(entity.$schema.node);
|
|
78
|
+
// Every `entity()` brands its row schema, so absence means a hand-built lookalike: the type is
|
|
79
|
+
// `EntityCore`, and the only way here without a brand is a cast.
|
|
80
|
+
if (projection === undefined) {
|
|
81
|
+
throw invariantViolated(
|
|
82
|
+
entity.$name,
|
|
83
|
+
'$schema',
|
|
84
|
+
'carries no record brand; declare it with entity()',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return projection;
|
|
88
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Table → record type, over the registered entities. A changefeed and a snapshot name the
|
|
2
|
+
// physical relation; the client store keys records by entity name, and this is the one bridge —
|
|
3
|
+
// memoised against the registry's generation, so a per-change lookup is one `Map` read.
|
|
4
|
+
|
|
5
|
+
import { invariantViolated } from './entity-error';
|
|
6
|
+
import type { RecordProjection } from './record-projection';
|
|
7
|
+
import type { RegistryEntry } from './registry';
|
|
8
|
+
import { registeredEntities, registryGeneration } from './registry';
|
|
9
|
+
|
|
10
|
+
let cached: {
|
|
11
|
+
readonly generation: number;
|
|
12
|
+
readonly byTable: ReadonlyMap<string, RegistryEntry>;
|
|
13
|
+
} | null = null;
|
|
14
|
+
|
|
15
|
+
const tableIndex = (): ReadonlyMap<string, RegistryEntry> => {
|
|
16
|
+
const generation = registryGeneration();
|
|
17
|
+
if (cached !== null && cached.generation === generation) return cached.byTable;
|
|
18
|
+
const byTable = new Map<string, RegistryEntry>();
|
|
19
|
+
for (const entry of registeredEntities()) {
|
|
20
|
+
const other = byTable.get(entry.tableName);
|
|
21
|
+
// Two entities over one relation (`table:` lets an app adopt a table twice) leave a changed
|
|
22
|
+
// row with no single record type; refused rather than guessed, since the wrong guess writes
|
|
23
|
+
// one entity's row into another's records.
|
|
24
|
+
if (other !== undefined) {
|
|
25
|
+
throw invariantViolated(
|
|
26
|
+
entry.name,
|
|
27
|
+
'table',
|
|
28
|
+
`shares table "${entry.tableName}" with entity "${other.name}", so a change on it has no one record type — give one of them its own table, or stop streaming it`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
byTable.set(entry.tableName, entry);
|
|
32
|
+
}
|
|
33
|
+
cached = { generation, byTable };
|
|
34
|
+
return byTable;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The record type (entity name) a table's rows belong to, or `undefined` for an unknown table. */
|
|
38
|
+
export const recordTypeForTable = (table: string): string | undefined =>
|
|
39
|
+
tableIndex().get(table)?.name;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The whole client projection a table's rows belong to — type, key, row schema, `persist` — or
|
|
43
|
+
* `undefined` for an unknown table. Same index as `recordTypeForTable`, so the two never disagree.
|
|
44
|
+
*/
|
|
45
|
+
export const recordProjectionForTable = (table: string): RecordProjection | undefined =>
|
|
46
|
+
tableIndex().get(table)?.projection;
|
package/src/record.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `@ultimat3/entity/record` — the entity's client projection, and nothing that reaches a driver.
|
|
2
|
+
// The package barrel re-exports the same bindings for server code; a BROWSER module imports them
|
|
3
|
+
// from here, because the barrel retains ~1 MB of SQL rendering (`pg-sql.ts`, `@ultimat3/db`) a page
|
|
4
|
+
// never runs — measured in `record-bundle.test.ts`, which fails if this entry ever grows it back.
|
|
5
|
+
|
|
6
|
+
export type { ProjectedEntity, RecordProjection } from './record-projection';
|
|
7
|
+
export { ENTITY_BRAND, recordProjection } from './record-projection';
|
|
8
|
+
export { recordProjectionForTable, recordTypeForTable } from './record-table';
|
|
9
|
+
export type { RecordsByKey, RecordsByType } from './rows-of';
|
|
10
|
+
export { hasEntityRows, rowsOf } from './rows-of';
|
package/src/registry.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// than a silent last-one-wins.
|
|
5
5
|
|
|
6
6
|
import type { IndexMethod } from '@ultimat3/db';
|
|
7
|
-
import { entityDuplicate } from './
|
|
7
|
+
import { entityDuplicate } from './entity-error';
|
|
8
8
|
import type { InvariantKind } from './invariants';
|
|
9
|
+
import type { RecordProjection } from './record-projection';
|
|
9
10
|
import type { ColumnDefault, OnDelete } from './types';
|
|
10
11
|
|
|
11
12
|
export interface ColumnDescription {
|
|
@@ -128,6 +129,10 @@ export interface EntityDescription {
|
|
|
128
129
|
export interface RegistryEntry {
|
|
129
130
|
readonly name: string;
|
|
130
131
|
readonly tableName: string;
|
|
132
|
+
/** `entity(name, { persist: true })` — the client keeps this type on disk. Absent = `false`. */
|
|
133
|
+
readonly persist?: boolean;
|
|
134
|
+
/** The entity's client projection — what a changefeed's table maps to on the client. */
|
|
135
|
+
readonly projection?: RecordProjection;
|
|
131
136
|
describe(): EntityDescription;
|
|
132
137
|
/**
|
|
133
138
|
* The foreign keys this entity declares, resolved. This is how a relation reaches query time:
|
package/src/row-observer.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// the replicator — `@ultimat3/realtime`'s `selectChangeFeed` still decides, and this is never in
|
|
14
14
|
// that decision.
|
|
15
15
|
|
|
16
|
+
import { currentWriteOrigin } from '@ultimat3/core';
|
|
16
17
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
17
18
|
import type { EntityCore } from './entity';
|
|
18
19
|
import { MAX_PAGE_SIZE } from './plan';
|
|
@@ -32,6 +33,13 @@ export interface RowChange {
|
|
|
32
33
|
readonly op: RowChangeOp;
|
|
33
34
|
readonly before: Readonly<Record<string, unknown>> | null;
|
|
34
35
|
readonly after: Readonly<Record<string, unknown>> | null;
|
|
36
|
+
/**
|
|
37
|
+
* The keyed write this change belongs to — `@ultimat3/core`'s `currentWriteOrigin()` at the
|
|
38
|
+
* moment the repository wrote, i.e. the digest of the idempotency key the request arrived with.
|
|
39
|
+
* The WAL decoder reads the same fact off the transaction's opening message; absent both ways
|
|
40
|
+
* for a write no keyed request made.
|
|
41
|
+
*/
|
|
42
|
+
readonly write?: string;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/**
|
|
@@ -177,7 +185,15 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
|
|
|
177
185
|
const name = entity.$name;
|
|
178
186
|
|
|
179
187
|
const emit = (op: RowChangeOp, before: unknown, after: unknown): void => {
|
|
180
|
-
|
|
188
|
+
if (installed === null) return;
|
|
189
|
+
const write = currentWriteOrigin();
|
|
190
|
+
installed.onChange({
|
|
191
|
+
entity: name,
|
|
192
|
+
op,
|
|
193
|
+
before: asRecord(before),
|
|
194
|
+
after: asRecord(after),
|
|
195
|
+
...(write === undefined ? {} : { write }),
|
|
196
|
+
});
|
|
181
197
|
};
|
|
182
198
|
|
|
183
199
|
const bulk = (op: 'delete' | 'update', rows: number): void => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `entity.$schema` — the whole row as a `t`-compatible schema, branded with the entity's record
|
|
2
|
+
// projection. The brand is what lets `rowsOf` find a row inside any output shape, so it has to
|
|
3
|
+
// survive every wrapper a row can be put in: containers keep the child node by reference, and the
|
|
4
|
+
// five methods that COPY a node are re-branded here rather than in `@ultimat3/schema`.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { fail, makeSchema, pass, type Schema, type SchemaNode } from '@ultimat3/schema';
|
|
8
|
+
import {
|
|
9
|
+
brandNode,
|
|
10
|
+
createProjection,
|
|
11
|
+
type ProjectionIdentity,
|
|
12
|
+
type RecordProjection,
|
|
13
|
+
} from './record-projection';
|
|
14
|
+
import type { AnyColumn } from './types';
|
|
15
|
+
import { columnNode } from './view';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Re-brands whatever a copying method returns. `nullable()`, `optional()`, `default()`,
|
|
19
|
+
* `describe()` and `refine()` each build a fresh node by spreading this one, and a spread drops a
|
|
20
|
+
* non-enumerable symbol — so without this, `t.nullable(posts.$schema)` would be a row nobody
|
|
21
|
+
* recognises. `refine()` keeps the brand because it keeps the shape; `.pick()` is not here
|
|
22
|
+
* because an entity row schema has none, and a `t.object` that does builds an unbranded node.
|
|
23
|
+
*/
|
|
24
|
+
const branded = <In, Out>(
|
|
25
|
+
schema: Schema<In, Out>,
|
|
26
|
+
projection: RecordProjection,
|
|
27
|
+
): Schema<In, Out> => {
|
|
28
|
+
brandNode(schema.node, projection);
|
|
29
|
+
return {
|
|
30
|
+
...schema,
|
|
31
|
+
optional: () => branded(schema.optional(), projection),
|
|
32
|
+
nullable: () => branded(schema.nullable(), projection),
|
|
33
|
+
default: (value) => branded(schema.default(value), projection),
|
|
34
|
+
describe: (description) => branded(schema.describe(description), projection),
|
|
35
|
+
refine: (refinement) => branded(schema.refine(refinement), projection),
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The row schema for one entity. Validation IS `$parse` — defaults filled, every column parsed —
|
|
41
|
+
* so `$schema` and `$parse` can never disagree about what a row is. A refusal is rendered with
|
|
42
|
+
* `renderThrowable`, never `instanceof`/`String()`: a column parser is app-reachable and may throw
|
|
43
|
+
* anything, and a second `TypeError` out of a validator is the one outcome a validator may not have.
|
|
44
|
+
*/
|
|
45
|
+
export const rowSchema = <Row>(
|
|
46
|
+
identity: ProjectionIdentity,
|
|
47
|
+
columns: readonly (readonly [string, AnyColumn])[],
|
|
48
|
+
parse: (value: unknown) => Row,
|
|
49
|
+
): Schema<unknown, Row> => {
|
|
50
|
+
const node: SchemaNode = {
|
|
51
|
+
kind: 'object',
|
|
52
|
+
properties: Object.fromEntries(columns.map(([key, column]) => [key, columnNode(column)])),
|
|
53
|
+
};
|
|
54
|
+
const projection = createProjection(identity, node);
|
|
55
|
+
const schema = makeSchema<unknown, Row>(node, (value, path) => {
|
|
56
|
+
try {
|
|
57
|
+
return pass(parse(value));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return fail(path, renderThrowable(error));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return branded(schema, projection);
|
|
63
|
+
};
|
package/src/rows-of.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Which values in an output are entity rows — read off the output SCHEMA, never declared beside it
|
|
2
|
+
// (axiom 2). `hasEntityRows` answers statically and is memoised per node; `rowsOf` walks only the
|
|
3
|
+
// branches that can hold a row, so an output with none costs one WeakMap read per call.
|
|
4
|
+
|
|
5
|
+
import type { Row } from '@ultimat3/core';
|
|
6
|
+
import { isSchemaNode, nodeOf, type SchemaNode } from '@ultimat3/schema';
|
|
7
|
+
import { projectionOf, type RecordProjection } from './record-projection';
|
|
8
|
+
|
|
9
|
+
/** Children in the IR that can hold a value: object fields, array items, record values, arms. */
|
|
10
|
+
const childrenOf = (node: SchemaNode): readonly SchemaNode[] => [
|
|
11
|
+
...Object.values(node.properties ?? {}),
|
|
12
|
+
...(node.items === undefined ? [] : [node.items]),
|
|
13
|
+
...(node.valueNode === undefined ? [] : [node.valueNode]),
|
|
14
|
+
...(node.anyOf ?? []),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const holdsRows = new WeakMap<SchemaNode, boolean>();
|
|
18
|
+
|
|
19
|
+
const containsBrand = (node: SchemaNode): boolean => {
|
|
20
|
+
const known = holdsRows.get(node);
|
|
21
|
+
if (known !== undefined) return known;
|
|
22
|
+
// Seeded `false` before recursing, so a node reachable from itself ends rather than overflows.
|
|
23
|
+
holdsRows.set(node, false);
|
|
24
|
+
const answer = projectionOf(node) !== undefined || childrenOf(node).some(containsBrand);
|
|
25
|
+
holdsRows.set(node, answer);
|
|
26
|
+
return answer;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** A schema object (anything carrying `node`) or a bare node; anything else has no rows. */
|
|
30
|
+
const rootOf = (schema: unknown): SchemaNode | undefined =>
|
|
31
|
+
nodeOf(schema) ?? (isSchemaNode(schema) ? schema : undefined);
|
|
32
|
+
|
|
33
|
+
/** Does this output schema reference any entity row, at any depth? Static — no value needed. */
|
|
34
|
+
export const hasEntityRows = (schema: unknown): boolean => {
|
|
35
|
+
const root = rootOf(schema);
|
|
36
|
+
return root !== undefined && containsBrand(root);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every entity projection an output schema can carry, one per record type, in first-seen order.
|
|
41
|
+
* Static, like `hasEntityRows`: a branded node is a row and its columns are never walked. What a
|
|
42
|
+
* caller needing the ENTITIES rather than the rows reads — `@ultimat3/action`'s mutator clock check.
|
|
43
|
+
*/
|
|
44
|
+
export const projectionsIn = (schema: unknown): readonly RecordProjection[] => {
|
|
45
|
+
const root = rootOf(schema);
|
|
46
|
+
if (root === undefined) return [];
|
|
47
|
+
const found = new Map<string, RecordProjection>();
|
|
48
|
+
const seen = new Set<SchemaNode>();
|
|
49
|
+
const visit = (node: SchemaNode): void => {
|
|
50
|
+
if (seen.has(node) || !containsBrand(node)) return;
|
|
51
|
+
seen.add(node);
|
|
52
|
+
const projection = projectionOf(node);
|
|
53
|
+
if (projection !== undefined) {
|
|
54
|
+
if (!found.has(projection.type)) found.set(projection.type, projection);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const child of childrenOf(node)) visit(child);
|
|
58
|
+
};
|
|
59
|
+
visit(root);
|
|
60
|
+
return [...found.values()];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
64
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Inside a union the IR cannot say which arm a value took, so a branded arm claims the value only
|
|
68
|
+
* when it carries every column of that row as an OWN key — `$parse` writes every column, `null`
|
|
69
|
+
* included, so a real row always does. Outside a union the output schema already validated the
|
|
70
|
+
* value, and it is taken as the row it was declared to be.
|
|
71
|
+
*/
|
|
72
|
+
const fits = (projection: RecordProjection, value: Readonly<Record<string, unknown>>): boolean =>
|
|
73
|
+
Object.keys(projection.schema.properties ?? {}).every((column) => Object.hasOwn(value, column));
|
|
74
|
+
|
|
75
|
+
type Found = Map<string, Map<string, Row>>;
|
|
76
|
+
|
|
77
|
+
const collect = (found: Found, projection: RecordProjection, row: Row): void => {
|
|
78
|
+
const byKey = found.get(projection.type) ?? new Map<string, Row>();
|
|
79
|
+
found.set(projection.type, byKey);
|
|
80
|
+
const key = projection.key(row);
|
|
81
|
+
// First sighting wins: one record shown twice in a response is one record, not two writes.
|
|
82
|
+
if (!byKey.has(key)) byKey.set(key, row);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const walk = (node: SchemaNode, value: unknown, found: Found, inUnion: boolean): void => {
|
|
86
|
+
if (value === null || value === undefined || !containsBrand(node)) return;
|
|
87
|
+
const projection = projectionOf(node);
|
|
88
|
+
if (projection !== undefined) {
|
|
89
|
+
// A row's own columns are never walked: a column node carries no brand, and a `json()` value
|
|
90
|
+
// shaped like a row is data, not a record.
|
|
91
|
+
if (isObject(value) && (!inUnion || fits(projection, value))) collect(found, projection, value);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (node.kind === 'array' && node.items !== undefined && Array.isArray(value)) {
|
|
95
|
+
for (const item of value) walk(node.items, item, found, inUnion);
|
|
96
|
+
} else if (node.kind === 'object' && node.properties !== undefined && isObject(value)) {
|
|
97
|
+
for (const [key, child] of Object.entries(node.properties)) {
|
|
98
|
+
if (Object.hasOwn(value, key)) walk(child, value[key], found, inUnion);
|
|
99
|
+
}
|
|
100
|
+
} else if (node.kind === 'record' && node.valueNode !== undefined && isObject(value)) {
|
|
101
|
+
for (const entry of Object.values(value)) walk(node.valueNode, entry, found, inUnion);
|
|
102
|
+
} else if (node.kind === 'union') {
|
|
103
|
+
for (const arm of node.anyOf ?? []) walk(arm, value, found, true);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Keyed by record key, null-prototype: a key is data, and `'__proto__'` must stay a key. */
|
|
108
|
+
export type RecordsByKey = Readonly<Record<string, Row>>;
|
|
109
|
+
|
|
110
|
+
/** Keyed by record type, then by record key — the shape the record envelope carries on the wire. */
|
|
111
|
+
export type RecordsByType = Readonly<Record<string, RecordsByKey>>;
|
|
112
|
+
|
|
113
|
+
const nullProto = <V>(entries: Iterable<readonly [string, V]>): Readonly<Record<string, V>> => {
|
|
114
|
+
const out = Object.create(null) as Record<string, V>;
|
|
115
|
+
for (const [key, value] of entries) out[key] = value;
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every entity row in `value`, grouped by record type and keyed by record key — `{}` when the
|
|
121
|
+
* schema references none. The KEY travels because the browser cannot compute it: it would have to
|
|
122
|
+
* import the app's `entity()` declarations. The rows are the SAME objects the handler returned,
|
|
123
|
+
* one per key; a row with no key is `X_RECORD_KEY_MISSING`, because a keyless record would
|
|
124
|
+
* overwrite every other keyless one.
|
|
125
|
+
*/
|
|
126
|
+
export const rowsOf = (schema: unknown, value: unknown): RecordsByType => {
|
|
127
|
+
const root = rootOf(schema);
|
|
128
|
+
if (root === undefined || !containsBrand(root)) return nullProto([]);
|
|
129
|
+
const found: Found = new Map();
|
|
130
|
+
walk(root, value, found, false);
|
|
131
|
+
return nullProto([...found].map(([type, byKey]) => [type, nullProto(byKey)] as const));
|
|
132
|
+
};
|
package/src/write-tag.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A keyed write names itself in the write-ahead log. The Postgres driver opens the transaction a
|
|
2
|
+
// keyed request's write lands in with `pg_logical_emit_message(true, WRITE_ORIGIN_WAL_PREFIX,
|
|
3
|
+
// <digest>)`, and `@ultimat3/realtime`'s replication stream names every change after it with that
|
|
4
|
+
// digest — which is how a `records` frame produced by the replicator, in another process, still
|
|
5
|
+
// tells the page that wrote it that this is its own echo. The in-process row observer reads the
|
|
6
|
+
// same fact off the request scope and needs none of this.
|
|
7
|
+
|
|
8
|
+
import { currentWriteOrigin, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
|
|
9
|
+
import { currentTx, type DbClient, type DbTx, db, sql, withTransaction } from '@ultimat3/db';
|
|
10
|
+
|
|
11
|
+
/** Transactions this process already opened with the message: one per transaction is enough. */
|
|
12
|
+
const tagged = new WeakSet<DbTx>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether this database lets the app's role emit the message. Asked once per process and
|
|
16
|
+
* remembered only once answered: a role without `EXECUTE` (a managed service may revoke it) must
|
|
17
|
+
* cost the page its echo match, never the write — so the answer is read BEFORE the first emit
|
|
18
|
+
* rather than learned from a failed one, which would already have aborted the caller's transaction.
|
|
19
|
+
*/
|
|
20
|
+
let emits: Promise<boolean> | undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* By name, across overloads: Postgres 17 added a fourth parameter (`flush`, defaulted), so a probe
|
|
24
|
+
* naming the three-argument signature answered "no such function" there while the call below works
|
|
25
|
+
* on every version from 14 up. Measured on 17-alpine.
|
|
26
|
+
*/
|
|
27
|
+
const CAN_EMIT = sql`select exists (select 1 from pg_catalog.pg_proc where proname = 'pg_logical_emit_message' and has_function_privilege(oid, 'execute')) as "ok"`;
|
|
28
|
+
|
|
29
|
+
function canEmit(client: DbClient): Promise<boolean> {
|
|
30
|
+
if (emits === undefined) {
|
|
31
|
+
const asked = client.one<{ ok: unknown }>(CAN_EMIT).then((row) => row?.ok === true);
|
|
32
|
+
emits = asked;
|
|
33
|
+
// A probe that FAILED answered nothing: the next write asks again.
|
|
34
|
+
asked.catch(() => {
|
|
35
|
+
if (emits === asked) emits = undefined;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return emits.catch(() => false);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const emit = (client: DbClient, digest: string): Promise<unknown> =>
|
|
42
|
+
client.query(
|
|
43
|
+
sql`select pg_logical_emit_message(true, ${WRITE_ORIGIN_WAL_PREFIX}::text, ${digest}::text)`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send `write` so the WAL names the keyed write it belongs to. Outside a keyed request, or on a
|
|
48
|
+
* repository pinned to its own client (which may not join a transaction — `X_REPO_CLIENT_PINNED`),
|
|
49
|
+
* it is `write()` unchanged. Inside an open transaction the message goes first, once. Outside one,
|
|
50
|
+
* the write is wrapped in a transaction of its own, because a message in another transaction names
|
|
51
|
+
* nothing: three more round trips per keyed write, and only for a write a page is waiting on.
|
|
52
|
+
*/
|
|
53
|
+
export async function taggedWrite<T>(pinned: boolean, write: () => Promise<T>): Promise<T> {
|
|
54
|
+
const digest = currentWriteOrigin();
|
|
55
|
+
if (digest === undefined || pinned) return write();
|
|
56
|
+
const open = currentTx();
|
|
57
|
+
if (!(await canEmit(open ?? db()))) return write();
|
|
58
|
+
if (open !== undefined) {
|
|
59
|
+
if (!tagged.has(open)) {
|
|
60
|
+
await emit(open, digest);
|
|
61
|
+
tagged.add(open);
|
|
62
|
+
}
|
|
63
|
+
return write();
|
|
64
|
+
}
|
|
65
|
+
return withTransaction(async (tx) => {
|
|
66
|
+
await emit(tx, digest);
|
|
67
|
+
tagged.add(tx);
|
|
68
|
+
return write();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Forget the capability answer. Tests only: a process asks one database once. */
|
|
73
|
+
export function resetWriteTag(): void {
|
|
74
|
+
emits = undefined;
|
|
75
|
+
}
|