@ultimat3/query 2.0.0 → 3.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 CHANGED
@@ -229,6 +229,17 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
229
229
  `"null"`. Refused: a structural member (`object`, `record`, `money`, or an array/union of one),
230
230
  a REQUIRED nullable member, and a top-level input that is not an object. A schema
231
231
  `tryIntrospect` cannot read is left alone, or `configureSchemaProvider` would be unusable.
232
+ - **A refill is owed by a FULL window and by nothing else** (`matcher.ts`, `As of 2026-08`).
233
+ `removeAt` pushed one whenever `shape.limit !== null`, with no reference to how many rows the
234
+ window holds: three rows under `limit: 50`, delete one, and the patch list was
235
+ `[{remove, position:1}, {refill, from:49}]` — a position no two-row result set has. It is not a
236
+ harmless extra: `@ultimat3/realtime`'s `matcher-bridge` folds any refill into
237
+ `BridgeResult.refill`, and `live-fanout` then sends **no patch frame at all** that round, marking
238
+ every subscriber desynced instead — so on a quiet feed the deleted row stays rendered until some
239
+ other change to the same query id arrives, and on a busy one it is a full DB re-read plus one
240
+ snapshot per subscriber per delete. A window under `limit` has no unknown tail: the source served
241
+ fewer rows than it was allowed to, so what the client holds IS the result set. `held >=
242
+ shape.limit` is the gate, and it is `wasFull` one branch away, already written.
232
243
  - **A move OUT of a full window is a `refill`, never an `add`** (`matcher.ts`). `insert()` places a
233
244
  moved row among the `limit - 1` rows the client still holds, so its position can never reach
234
245
  `shape.limit` and the `position >= shape.limit` bail is unreachable on that path — the row was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/cache": "2.0.0",
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/http": "2.0.0",
37
- "@ultimat3/policy": "2.0.0",
38
- "@ultimat3/schema": "2.0.0"
34
+ "@ultimat3/cache": "3.0.0",
35
+ "@ultimat3/core": "3.0.0",
36
+ "@ultimat3/http": "3.0.0",
37
+ "@ultimat3/policy": "3.0.0",
38
+ "@ultimat3/schema": "3.0.0"
39
39
  }
40
40
  }
package/src/matcher.ts CHANGED
@@ -61,7 +61,7 @@ export function match<TRow extends object>(
61
61
  const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters);
62
62
 
63
63
  if (event.op === 'delete' || (inSet && !belongs)) {
64
- return inSet ? removeAt(shape, index, id, true) : [];
64
+ return inSet ? removeAt(shape, index, id, true, rows.length) : [];
65
65
  }
66
66
  if (!belongs) return [];
67
67
  if (!inSet) return insert(shape, rows, event.row);
@@ -84,9 +84,12 @@ export function match<TRow extends object>(
84
84
  // too, so the refill covers both.
85
85
  const wasFull = shape.limit !== null && rows.length >= shape.limit;
86
86
  if (wasFull && positionFor(shape, without, event.row) >= without.length) {
87
- return removeAt<TRow>(shape, index, id, true);
87
+ return removeAt<TRow>(shape, index, id, true, rows.length);
88
88
  }
89
- return [...removeAt<TRow>(shape, index, id, false), ...insert(shape, without, event.row)];
89
+ return [
90
+ ...removeAt<TRow>(shape, index, id, false, rows.length),
91
+ ...insert(shape, without, event.row),
92
+ ];
90
93
  }
91
94
 
92
95
  function insert<TRow extends object>(
@@ -107,15 +110,28 @@ function insert<TRow extends object>(
107
110
  return patches;
108
111
  }
109
112
 
113
+ /**
114
+ * `held` is how many rows the window actually holds, and it is the whole condition on the refill.
115
+ *
116
+ * A refill says the tail is unknown to the client, and it is answered by a full re-read: the bridge
117
+ * folds it into `BridgeResult.refill`, and the fanout then sends NO patch frame that round —
118
+ * suppressing the `remove` beside it and leaving a deleted row on screen until the next change to
119
+ * the same query. A window under `limit` has no unknown tail: the source served fewer rows than it
120
+ * was allowed to, so what the client holds IS the result set. Unconditional, this also named a
121
+ * position no result set has — `limit: 50` over three rows emitted `{ refill, from: 49 }`.
122
+ */
110
123
  function removeAt<TRow extends object>(
111
124
  shape: QueryShape,
112
125
  index: number,
113
126
  id: string,
114
127
  refill: boolean,
128
+ held: number,
115
129
  ): readonly Patch<TRow>[] {
116
130
  const patches: Patch<TRow>[] = [{ kind: 'remove', position: index, id }];
117
- // A limited window may now be one row short, and the tail lives on the server.
118
- if (refill && shape.limit !== null) patches.push({ kind: 'refill', from: shape.limit - 1 });
131
+ // A window that WAS full is now one row short, and that row lives on the server.
132
+ if (refill && shape.limit !== null && held >= shape.limit) {
133
+ patches.push({ kind: 'refill', from: shape.limit - 1 });
134
+ }
119
135
  return patches;
120
136
  }
121
137