@ultimat3/realtime 4.1.0 → 5.0.1
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 +19 -1
- package/package.json +3 -3
- package/src/live-definition.ts +27 -3
- package/src/matcher-bridge.ts +48 -5
- package/src/pg-wire.ts +18 -3
package/CLAUDE.md
CHANGED
|
@@ -680,7 +680,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
680
680
|
| `live-contract.ts` | what a live query IS: `LiveQueryDefinition`, `SnapshotResult`, `LiveSubscription`. Four modules need the shape and none of them needs the registry that runs it. The **id** is not here and not anywhere in this package — it is `@ultimat3/query`'s `queryHash` |
|
|
681
681
|
| `json.ts` | the wire's value types and `fnv1a` (drift), the one hash still owned here. The canonical form and the sharing-key hash are `@ultimat3/core`'s (`canonicalJson`, `fingerprint`) — `json.test.ts` pins that `fnv1a` is never mistakable for one |
|
|
682
682
|
| `live-definition.ts` | the only bridge from a declared `query({ live: true })` to a registrable definition — and `policy-gate.ts`'s only caller |
|
|
683
|
-
| `matcher-bridge.ts` | the only `@ultimat3/query` matcher seam |
|
|
683
|
+
| `matcher-bridge.ts` | the only `@ultimat3/query` matcher seam — and where a patch row is narrowed to the columns the query returned |
|
|
684
684
|
|
|
685
685
|
## Commands
|
|
686
686
|
|
|
@@ -698,3 +698,21 @@ the same whitelist drops an old client's copy, and a new client's omission decod
|
|
|
698
698
|
field always held. Bumping for either refuses every in-flight client on a rolling deploy and buys
|
|
699
699
|
nothing — the version guards incompatibility, not novelty. Removing a field something *does* read
|
|
700
700
|
is the opposite case and bumps.
|
|
701
|
+
|
|
702
|
+
**A patch carries the result set's columns, never the table's** — `narrowRow` in
|
|
703
|
+
`matcher-bridge.ts`, `As of 2026-08-20`. A `ChangeEvent` carries the whole TABLE row (that is what
|
|
704
|
+
logical replication emits, and what `@ultimat3/entity`'s `setRowObserver` emits), while a live
|
|
705
|
+
query's result set is whatever its `sql` returned. Every patch used to forward the change row
|
|
706
|
+
unnarrowed, so a column a projection exists to withhold went out on the socket the moment it
|
|
707
|
+
CHANGED — `examples/dummy`'s feed projects ten columns and one publish delivered `updatedAt` to
|
|
708
|
+
every subscriber (#230). The per-subscriber gate cannot help: it decides whether a ROW is delivered,
|
|
709
|
+
never which of its columns.
|
|
710
|
+
|
|
711
|
+
`id` always survives the narrowing — it is the row's identity on the wire, and `applyToWindow` and
|
|
712
|
+
every client store key by it. An **unknown** projection narrows nothing, because "nothing has been
|
|
713
|
+
read yet" is not "the result set has no columns".
|
|
714
|
+
|
|
715
|
+
The projection is **learned from the query's own reads**, in `live-definition.ts`: a projection
|
|
716
|
+
lives inside the `sql` provider's closure and there is nothing static to read it from. Learned and
|
|
717
|
+
kept rather than re-derived per fanout, because the case the window's own rows cannot answer is an
|
|
718
|
+
EMPTY window — the first row to arrive would otherwise go out whole.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/query": "
|
|
35
|
+
"@ultimat3/core": "5.0.1",
|
|
36
|
+
"@ultimat3/query": "5.0.1",
|
|
37
37
|
"nats": "2.29.3"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/live-definition.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { type AnyQuery, queryHash, queryName } from '@ultimat3/query';
|
|
|
13
13
|
import { LiveRowUnidentifiedError } from './errors';
|
|
14
14
|
import { isRow, type JsonValue, type Row } from './json';
|
|
15
15
|
import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
|
|
16
|
-
import { type IncrementalMatcher, matcherFor } from './matcher-bridge';
|
|
16
|
+
import { type IncrementalMatcher, matcherFor, type Projection } from './matcher-bridge';
|
|
17
17
|
import { authorizeWithPolicy, visibleWithPolicy } from './policy-gate';
|
|
18
18
|
|
|
19
19
|
export interface LiveDefinitionOptions {
|
|
@@ -49,6 +49,25 @@ interface SharedWindow {
|
|
|
49
49
|
read(): Promise<readonly Row[]>;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The columns this query's result set carries, learned from the rows it returns rather than
|
|
54
|
+
* declared — a projection lives inside the `sql` provider's closure and there is nothing static to
|
|
55
|
+
* read it from. Learned once and kept, because the case the window's own rows cannot answer is an
|
|
56
|
+
* EMPTY window: the first row to arrive would otherwise be sent as the whole table row.
|
|
57
|
+
*/
|
|
58
|
+
const learnProjection = (): { read: () => Projection; teach: (rows: readonly Row[]) => void } => {
|
|
59
|
+
let projection: Projection;
|
|
60
|
+
return {
|
|
61
|
+
read: () => projection,
|
|
62
|
+
// Never unlearned: a window that empties still describes the same result set, and forgetting
|
|
63
|
+
// would put the leak back on the first row to return.
|
|
64
|
+
teach: (rows) => {
|
|
65
|
+
if (projection === undefined && rows[0] !== undefined)
|
|
66
|
+
projection = new Set(Object.keys(rows[0]));
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
52
71
|
/**
|
|
53
72
|
* A matcher for an input nothing has resolved yet. It refuses to decide rather than reporting "no
|
|
54
73
|
* change": a subscriber told nothing happened diverges silently, and `refill` is the one answer
|
|
@@ -88,12 +107,17 @@ export function liveQueryDefinition(
|
|
|
88
107
|
// that patches them describe the same `(query, input)` by construction. Asking `sourceFor` for
|
|
89
108
|
// a second subject-less copy — which is what this did — paid for the parse and the `sql()`
|
|
90
109
|
// twice per query id and left two descriptions of one read that agreed only by luck.
|
|
110
|
+
const projection = learnProjection();
|
|
91
111
|
const built: SharedWindow = {
|
|
92
|
-
matcher: matcherFor(live),
|
|
112
|
+
matcher: matcherFor(live, projection.read),
|
|
93
113
|
// `assertMatchable` already refused a shape without one, so this is the entity the matcher
|
|
94
114
|
// patches rows of — the same name `ChangeEvent.entity` and `tx.<table>` use.
|
|
95
115
|
rowEntity: live.shape.entity,
|
|
96
|
-
read: async () =>
|
|
116
|
+
read: async () => {
|
|
117
|
+
const rows = rowsOf(name, await live.execute());
|
|
118
|
+
projection.teach(rows);
|
|
119
|
+
return rows;
|
|
120
|
+
},
|
|
97
121
|
};
|
|
98
122
|
windows.set(qid, built);
|
|
99
123
|
evictOldest(windows, options.maxWindows ?? 256);
|
package/src/matcher-bridge.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function patchFromChange(change: ChangeEvent): RowPatch | null {
|
|
|
77
77
|
* swapping the matcher — or adopting an external protocol's, per the risk register — touches this
|
|
78
78
|
* function only.
|
|
79
79
|
*/
|
|
80
|
-
export function matcherFor(live: LiveQuery): IncrementalMatcher {
|
|
80
|
+
export function matcherFor(live: LiveQuery, projection?: () => Projection): IncrementalMatcher {
|
|
81
81
|
return {
|
|
82
82
|
entities: live.reads,
|
|
83
83
|
match: (change, rows) => {
|
|
@@ -89,13 +89,53 @@ export function matcherFor(live: LiveQuery): IncrementalMatcher {
|
|
|
89
89
|
row,
|
|
90
90
|
...(change.before === null ? {} : { before: change.before }),
|
|
91
91
|
});
|
|
92
|
-
|
|
92
|
+
// The window's own rows are the fallback, so a matcher built without a `projection` reader
|
|
93
|
+
// still narrows once the window holds anything — `live-definition.ts` supplies one because
|
|
94
|
+
// an EMPTY window is the case rows cannot answer.
|
|
95
|
+
return toBridgeResult(patches, change, projection?.() ?? projectionOf(rows));
|
|
93
96
|
},
|
|
94
97
|
};
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The columns a query's result set actually carries, learned from the rows it returned. `undefined`
|
|
102
|
+
* means nothing has been read yet, and then nothing is narrowed — the shape is unknown, and
|
|
103
|
+
* inventing one would drop columns a caller is owed.
|
|
104
|
+
*/
|
|
105
|
+
export type Projection = ReadonlySet<string> | undefined;
|
|
106
|
+
|
|
107
|
+
/** The projection a window's own rows describe. Empty window, no answer. */
|
|
108
|
+
export const projectionOf = (rows: readonly Row[]): Projection =>
|
|
109
|
+
rows[0] === undefined ? undefined : new Set(Object.keys(rows[0]));
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One row, restricted to the columns the result set carries.
|
|
113
|
+
*
|
|
114
|
+
* A `ChangeEvent` carries the whole TABLE row — that is what logical replication emits and what
|
|
115
|
+
* `setRowObserver` emits — while a live query's result set is whatever its `sql` returned. Sending
|
|
116
|
+
* the change row through unnarrowed put every column of the table on the socket, including the ones
|
|
117
|
+
* a projection exists to withhold: `examples/dummy`'s feed projects ten columns and a single publish
|
|
118
|
+
* delivered `body` to every subscriber. The per-subscriber gate cannot help — it decides whether a
|
|
119
|
+
* ROW is delivered, never which of its columns.
|
|
120
|
+
*
|
|
121
|
+
* `id` always survives: it is the row's identity on the wire, and `applyToWindow` and every client
|
|
122
|
+
* store key by it.
|
|
123
|
+
*/
|
|
124
|
+
export function narrowRow(row: JsonObject, projection: Projection): JsonObject {
|
|
125
|
+
if (projection === undefined) return row;
|
|
126
|
+
const out: JsonObject = {};
|
|
127
|
+
for (const key of Object.keys(row)) {
|
|
128
|
+
if (key === 'id' || projection.has(key)) out[key] = row[key] as JsonObject[string];
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
97
133
|
/** `Patch<Row>` (add/update/remove/refill, positional) -> the wire's `RowPatch`. */
|
|
98
|
-
export function toBridgeResult(
|
|
134
|
+
export function toBridgeResult(
|
|
135
|
+
patches: readonly Patch<Row>[],
|
|
136
|
+
change: ChangeEvent,
|
|
137
|
+
projection?: Projection,
|
|
138
|
+
): BridgeResult {
|
|
99
139
|
const out: RowPatch[] = [];
|
|
100
140
|
let refill = false;
|
|
101
141
|
for (const patch of patches) {
|
|
@@ -104,7 +144,7 @@ export function toBridgeResult(patches: readonly Patch<Row>[], change: ChangeEve
|
|
|
104
144
|
out.push({
|
|
105
145
|
op: 'insert',
|
|
106
146
|
id: patch.row.id,
|
|
107
|
-
row: patch.row,
|
|
147
|
+
row: narrowRow(patch.row, projection),
|
|
108
148
|
lsn: change.lsn,
|
|
109
149
|
index: patch.position,
|
|
110
150
|
});
|
|
@@ -113,7 +153,10 @@ export function toBridgeResult(patches: readonly Patch<Row>[], change: ChangeEve
|
|
|
113
153
|
out.push({
|
|
114
154
|
op: 'update',
|
|
115
155
|
id: patch.row.id,
|
|
116
|
-
row:
|
|
156
|
+
row: narrowRow(
|
|
157
|
+
{ id: patch.row.id, ...changedColumns(change.before, patch.row) },
|
|
158
|
+
projection,
|
|
159
|
+
),
|
|
117
160
|
lsn: change.lsn,
|
|
118
161
|
index: patch.position,
|
|
119
162
|
});
|
package/src/pg-wire.ts
CHANGED
|
@@ -170,13 +170,28 @@ export const describeFields = (fields: Readonly<Record<string, string>>): string
|
|
|
170
170
|
.filter((part): part is string => part !== undefined && part !== '')
|
|
171
171
|
.join(' — ');
|
|
172
172
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
173
|
+
/**
|
|
174
|
+
* SQLSTATEs worth their own fix line, because the operator's next command differs for each.
|
|
175
|
+
*
|
|
176
|
+
* Exported for `pg-wire.test.ts` alone, which pins the publication name in `42704` to
|
|
177
|
+
* `DEFAULT_REPLICATION_PUBLICATION` — a test may import `changefeed-env.ts`, and this module may
|
|
178
|
+
* not: `changefeed-env -> changefeed -> pg-replication -> pg-wire` is already a chain, so reading
|
|
179
|
+
* the constant here would close it into a cycle. Not re-exported from `index.ts`.
|
|
180
|
+
*/
|
|
181
|
+
export const FIXES: Readonly<Record<string, string>> = {
|
|
175
182
|
'28P01': 'correct the password in the replication URL — the server refused the credentials',
|
|
176
183
|
'28000': 'add a `host replication <user> <cidr> scram-sha-256` line to pg_hba.conf and reload',
|
|
177
184
|
'42501': 'grant the role REPLICATION: ALTER ROLE <user> WITH REPLICATION',
|
|
178
185
|
'55006': 'another replicator holds the slot — exactly one replicator per database, by design',
|
|
179
|
-
|
|
186
|
+
// NOT `x db replication init`, which this line said until 2026-08-20 and which is not a command:
|
|
187
|
+
// `x db` takes gen, migrate, reset, seed, studio, branch and backfill. It shipped because a fix
|
|
188
|
+
// read out of a TABLE was invisible to the gate — `fix: FIXES[code]` holds no literal — which is
|
|
189
|
+
// the half of #97 that outlived the three log-injection holes. The publication is the operator's
|
|
190
|
+
// to create; the slot the replicator creates for itself on its next start.
|
|
191
|
+
'42704':
|
|
192
|
+
'psql "$REPLICATION_URL" -c "CREATE PUBLICATION x_changes FOR ALL TABLES"' +
|
|
193
|
+
" # x_changes is the default name; use REPLICATION_PUBLICATION's value where it is set. " +
|
|
194
|
+
"The slot is the replicator's own and it creates one on its next start",
|
|
180
195
|
'0A000': 'set wal_level = logical in postgresql.conf and restart the server',
|
|
181
196
|
};
|
|
182
197
|
|