@ultimat3/realtime 5.0.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 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": "5.0.0",
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": "5.0.0",
36
- "@ultimat3/query": "5.0.0",
35
+ "@ultimat3/core": "5.0.1",
36
+ "@ultimat3/query": "5.0.1",
37
37
  "nats": "2.29.3"
38
38
  }
39
39
  }
@@ -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 () => rowsOf(name, await live.execute()),
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);
@@ -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
- return toBridgeResult(patches, change);
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(patches: readonly Patch<Row>[], change: ChangeEvent): BridgeResult {
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: { id: patch.row.id, ...changedColumns(change.before, patch.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
  });