@ultimat3/realtime 5.0.0 → 6.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 +19 -1
- package/package.json +3 -3
- package/src/live-definition.ts +27 -3
- package/src/matcher-bridge.ts +48 -5
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": "6.0.0",
|
|
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": "6.0.0",
|
|
36
|
+
"@ultimat3/query": "6.0.0",
|
|
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
|
});
|