@procedures/client 0.48.1 → 0.49.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/body.d.ts.map +1 -1
  4. package/dist/adapters/http/server/body.js +13 -2
  5. package/dist/adapters/http/server/body.js.map +1 -1
  6. package/dist/adapters/websocket/client/transport.d.ts +6 -0
  7. package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
  8. package/dist/adapters/websocket/client/transport.js +53 -6
  9. package/dist/adapters/websocket/client/transport.js.map +1 -1
  10. package/dist/adapters/websocket/client/types.d.ts +2 -0
  11. package/dist/adapters/websocket/client/types.d.ts.map +1 -1
  12. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  13. package/dist/adapters/websocket/server/transport.js +2 -1
  14. package/dist/adapters/websocket/server/transport.js.map +1 -1
  15. package/dist/adapters/websocket/server/types.d.ts +2 -0
  16. package/dist/adapters/websocket/server/types.d.ts.map +1 -1
  17. package/dist/procedures/collection/admission.d.ts.map +1 -1
  18. package/dist/procedures/collection/admission.js +3 -2
  19. package/dist/procedures/collection/admission.js.map +1 -1
  20. package/dist/procedures/collection/changes.d.ts +16 -0
  21. package/dist/procedures/collection/changes.d.ts.map +1 -1
  22. package/dist/procedures/collection/changes.js +81 -9
  23. package/dist/procedures/collection/changes.js.map +1 -1
  24. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  25. package/dist/procedures/collection/procedures.js +8 -3
  26. package/dist/procedures/collection/procedures.js.map +1 -1
  27. package/dist/procedures/collection/storage.d.ts +10 -0
  28. package/dist/procedures/collection/storage.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/adapters/http/server/body.ts +11 -2
  31. package/src/adapters/websocket/client/transport.ts +52 -7
  32. package/src/adapters/websocket/client/types.ts +3 -0
  33. package/src/adapters/websocket/server/transport.ts +2 -1
  34. package/src/adapters/websocket/server/types.ts +3 -0
  35. package/src/procedures/collection/admission.ts +3 -2
  36. package/src/procedures/collection/changes.ts +87 -9
  37. package/src/procedures/collection/procedures.ts +7 -2
  38. package/src/procedures/collection/storage.ts +10 -0
@@ -133,6 +133,12 @@ function detach(ws: WebSocket): void {
133
133
  ws.onerror = ignoreError;
134
134
  }
135
135
 
136
+ /** A call longer than this length waits for the `hello` of the server. The `hello` tells the limit of the server (roadmap CLI-108). */
137
+ const HELLO_WAIT_FROM_CHARS = 64 * 1024;
138
+
139
+ /** The longest wait for the `hello` of the server. An older server answers the `hello` with an error, which also ends the wait. */
140
+ const HELLO_WAIT_MS = 2_000;
141
+
136
142
  /** The close code of a message above the limit of the other side (RFC 6455, 1009). */
137
143
  const MESSAGE_TOO_BIG = 1009;
138
144
 
@@ -146,6 +152,13 @@ function tooLarge(id: string): ClientError {
146
152
  return new ClientError({ type: "error", code: "PAYLOAD_TOO_LARGE", message, retryable: false }, id);
147
153
  }
148
154
 
155
+ /** This function tells if the UTF-8 form of a text has more bytes than a limit. It encodes the text only when its length does not decide. */
156
+ function byteLengthAbove(text: string, limit: number): boolean {
157
+ if (text.length > limit) return true;
158
+ if (text.length * 3 <= limit) return false;
159
+ return new TextEncoder().encode(text).length > limit;
160
+ }
161
+
149
162
  function errorItem<TRes>(id: string, code: string, message: string, retryable: boolean): ResponseItem<TRes> {
150
163
  return { id, status: { type: "error", code, message, retryable }, payload: null as TRes, metadata: {} };
151
164
  }
@@ -263,6 +276,12 @@ export class WebSocketTransport implements Transport {
263
276
  private readonly transitionHandler: ((transition: MachineTransition) => void) | undefined;
264
277
  /** The protocol version of the server, from its hello, or undefined (an older server) (R25) */
265
278
  private serverProtocol: number | undefined;
279
+ /** The largest message in bytes that the server takes, from its `hello` (roadmap CLI-108). */
280
+ private serverMaxPayload: number | undefined;
281
+ /** The wait for the `hello` of the server on this connection. It ends at the answer, or after `HELLO_WAIT_MS`. */
282
+ private helloWait: Promise<void> = Promise.resolve();
283
+ /** The end of `helloWait`. */
284
+ private helloSettle: () => void = () => undefined;
266
285
  /** The calls that the server sends on the current socket, served by `serve` (roadmap R24) */
267
286
  private incoming: IncomingCalls | undefined;
268
287
  /** The time limit of the handshake of the socket that opens now */
@@ -359,6 +378,16 @@ export class WebSocketTransport implements Transport {
359
378
  // The protocol version of this client (roadmap R25). An older server answers with an
360
379
  // error frame, which the client ignores.
361
380
  this.serverProtocol = undefined;
381
+ this.serverMaxPayload = undefined;
382
+ this.helloSettle();
383
+ let cancelWait: () => void = () => undefined;
384
+ this.helloWait = new Promise<void>((resolve) => {
385
+ this.helloSettle = () => {
386
+ cancelWait();
387
+ resolve();
388
+ };
389
+ });
390
+ cancelWait = this.clock.after(HELLO_WAIT_MS, () => this.helloSettle());
362
391
  ws.send(JSON.stringify({ id: "hello", type: "hello", protocol: PROTOCOL_VERSION }));
363
392
  this.setState(WebSocketState.CONNECTED, "open");
364
393
  console.error(`[${this.name}] Connected to ${this.options.url}`);
@@ -566,8 +595,11 @@ export class WebSocketTransport implements Transport {
566
595
  private handleMessage(message: WebSocketMessage): void {
567
596
  // The hello of the server, or the error of an older server that does not know it (R25)
568
597
  if (message.id === "hello" && (message.type === "hello" || message.type === "error")) {
569
- if (message.type === "hello") this.serverProtocol = protocolVersionOf(message.protocol);
570
- else if (message.error?.code === "PROTOCOL_VERSION") this.options.onError?.(new Error(message.error.message));
598
+ if (message.type === "hello") {
599
+ this.serverProtocol = protocolVersionOf(message.protocol);
600
+ this.serverMaxPayload = typeof message.maxPayload === "number" && message.maxPayload > 0 ? message.maxPayload : undefined;
601
+ } else if (message.error?.code === "PROTOCOL_VERSION") this.options.onError?.(new Error(message.error.message));
602
+ this.helloSettle();
571
603
  return;
572
604
  }
573
605
  // A call of the server, its cancel or its credit (roadmap R24)
@@ -757,11 +789,24 @@ export class WebSocketTransport implements Transport {
757
789
  message.signal?.addEventListener("abort", onAbort, { once: true });
758
790
 
759
791
  try {
760
- try {
761
- this.ws!.send(JSON.stringify(wsMessage));
762
- } catch (error) {
763
- console.error(`[${this.name}] Send error:`, error);
764
- throw error;
792
+ const text = JSON.stringify(wsMessage);
793
+ // A large call waits for the hello of the server, which tells its limit (roadmap CLI-108)
794
+ if (text.length > HELLO_WAIT_FROM_CHARS) await this.helloWait;
795
+ if (this.pendingRequests.get(wireId) !== pending) {
796
+ // The connection closed, the call timed out or the reader aborted in the wait: the queue has the end of the call
797
+ } else if (this.serverMaxPayload !== undefined && byteLengthAbove(text, this.serverMaxPayload)) {
798
+ // The server closes the connection on a larger message, and each pending call fails with it.
799
+ // So this call fails alone, before the send (roadmap CLI-108)
800
+ this.pendingRequests.delete(wireId);
801
+ queue.push(errorItem(message.id, "PAYLOAD_TOO_LARGE", `The call is larger than the limit of the server (${this.serverMaxPayload} bytes)`, false));
802
+ queue.end();
803
+ } else {
804
+ try {
805
+ this.ws!.send(text);
806
+ } catch (error) {
807
+ console.error(`[${this.name}] Send error:`, error);
808
+ throw error;
809
+ }
765
810
  }
766
811
  let taken = 0;
767
812
  for await (const item of queue) {
@@ -224,6 +224,9 @@ export interface WebSocketMessage<T = unknown> {
224
224
  /** The protocol version of a `hello` frame (roadmap R25). */
225
225
  protocol?: number;
226
226
 
227
+ /** The largest message in bytes that the server takes, in the `hello` frame of the server (roadmap CLI-108). */
228
+ maxPayload?: number;
229
+
227
230
  /** RPC method (for requests) */
228
231
  method?: {
229
232
  service: string;
@@ -558,7 +558,8 @@ export class WebSocketServerTransport implements ServerTransport {
558
558
  ws.close(1002, "protocol version");
559
559
  return;
560
560
  }
561
- this.send(ws, { id: message.id || "hello", type: "hello", protocol: PROTOCOL_VERSION });
561
+ // The limit of the server: a client refuses a larger call alone, before the server closes the connection (roadmap CLI-108)
562
+ this.send(ws, { id: message.id || "hello", type: "hello", protocol: PROTOCOL_VERSION, maxPayload: this.options.maxPayload });
562
563
  return;
563
564
  }
564
565
 
@@ -120,6 +120,9 @@ export interface WebSocketMessage<TReq = unknown> {
120
120
  /** The protocol version of a `hello` frame (roadmap R25). */
121
121
  protocol?: number;
122
122
 
123
+ /** The largest message in bytes that the server takes, in the `hello` frame of the server (roadmap CLI-108). */
124
+ maxPayload?: number;
125
+
123
126
  /** RPC method (for requests) */
124
127
  method?: {
125
128
  service: string;
@@ -265,9 +265,10 @@ export function admittedStorage<T>(storage: CollectionStorage<T>, admission: Col
265
265
  /** The full answer of `changes`: each admitted row, at a version that the rows include. */
266
266
  const fullAnswer = async (): Promise<StorageChanges<T>> => {
267
267
  const all = await storage.getChangesSince!(undefined);
268
- if (all.full) return { version: all.version, full: true, changed: await admitted(all.changed), deleted: [] };
268
+ const held = all.held ? { held: true } : {};
269
+ if (all.full) return { version: all.version, full: true, changed: await admitted(all.changed), deleted: [], ...held };
269
270
  // A log that does not give all rows: the rows, read after the version, include that version
270
- return { version: all.version, full: true, changed: await entries(), deleted: [] };
271
+ return { version: all.version, full: true, changed: await entries(), deleted: [], ...held };
271
272
  };
272
273
 
273
274
  const view: CollectionStorage<T> = {
@@ -22,6 +22,16 @@
22
22
  * listens to the topic, the procedures publish nothing, and the chain stops.
23
23
  * - The publishes of one storage and one topic run one at a time, in the order of the writes. A
24
24
  * write resolves after its event is on the bus. A failed publish never fails the write.
25
+ * - The writes that end while a publish runs share the next publish, so one read of the change
26
+ * log replies to all of them. At most one publish runs, and at most one publish waits.
27
+ * - A version can stop below a write in flight (`StorageChanges.held`, roadmap CLI-102). The chain
28
+ * then reads the log again, after a wait that starts at 100 ms and doubles up to 5 s. It stops when
29
+ * no write holds the version or no one listens. Thus a write that a write in flight holds back
30
+ * reaches the subscribers with no later write. The event of such a read has no origin. It has
31
+ * no rows before the changes, so a row filter makes a hint without ids.
32
+ * - The log can give a write two times (`StorageChanges`), so a subscriber applies a change by its id.
33
+ * - A read with no change sends no event, and the chain keeps its version. The version of a log can
34
+ * move with no change, when another process writes a row again at a later number.
25
35
  * - With the rule `admission.admit`, the publisher filters the ids of each event for each
26
36
  * subscriber (roadmap CLI-78, `hint-filter.ts`). The event carries the rows after the changes,
27
37
  * from the change log. While a subscriber has a filter, each write first reads the rows before
@@ -103,6 +113,11 @@ interface ChangeLog {
103
113
  getBatch?(ids: string[]): Promise<Map<string, unknown>>;
104
114
  }
105
115
 
116
+ /** The first wait before a read again of a log that a write in flight holds back. */
117
+ const HELD_FIRST_MS = 100;
118
+ /** The longest wait before a read again. */
119
+ const HELD_MOST_MS = 5000;
120
+
106
121
  /** A write that ended, and that no event told yet. */
107
122
  interface EndedWrite {
108
123
  /** The ids of the write, or "*" for a clear. */
@@ -120,8 +135,30 @@ interface ChangeChain {
120
135
  anchor: string | undefined;
121
136
  /** The publishes, one at a time. */
122
137
  tail: Promise<void>;
138
+ /** The publish that did not start. A write that ends joins it, because it reads the writes that ended before its start. */
139
+ next: Promise<void> | undefined;
123
140
  /** The writes that ended since the last publish. */
124
141
  readonly ended: EndedWrite[];
142
+ /** True when the next publish reads the log with no write that ended. */
143
+ owed: boolean;
144
+ /** The last wait before a read again, or 0 when no write in flight holds the log back. */
145
+ heldWaitMs: number;
146
+ /** The timer of the next read again. */
147
+ heldTimer: ReturnType<typeof setTimeout> | undefined;
148
+ }
149
+
150
+ /** This function gives the publish that did not start, and it makes one when none waits. A failed publish rejects nothing here. */
151
+ function nextPublish(chain: ChangeChain, start: () => Promise<void>): Promise<void> {
152
+ if (chain.next) return chain.next;
153
+ const next = chain.tail
154
+ .then(() => {
155
+ chain.next = undefined;
156
+ return start();
157
+ })
158
+ .catch(() => undefined);
159
+ chain.next = next;
160
+ chain.tail = next;
161
+ return next;
125
162
  }
126
163
 
127
164
  /** The chains of each storage, by topic. A storage that goes away takes its chains with it. */
@@ -135,7 +172,7 @@ function chainOf(storage: object, topic: string): ChangeChain {
135
172
  }
136
173
  let chain = topics.get(topic);
137
174
  if (!chain) {
138
- chain = { anchor: undefined, tail: Promise.resolve(), ended: [] };
175
+ chain = { anchor: undefined, tail: Promise.resolve(), next: undefined, ended: [], owed: false, heldWaitMs: 0, heldTimer: undefined };
139
176
  topics.set(topic, chain);
140
177
  }
141
178
  return chain;
@@ -187,20 +224,41 @@ function withValues(event: CollectionChangeEvent, entries: ReadonlyArray<{ reado
187
224
  );
188
225
  }
189
226
 
190
- /** One publish: the event of the writes that ended since the last publish. */
227
+ /** This function starts the timer of the next read again while a write holds the log back, and it stops the timer otherwise. */
228
+ function whileHeld(chain: ChangeChain, held: boolean, read: () => void): void {
229
+ if (!held) {
230
+ chain.heldWaitMs = 0;
231
+ if (chain.heldTimer !== undefined) clearTimeout(chain.heldTimer);
232
+ chain.heldTimer = undefined;
233
+ return;
234
+ }
235
+ if (chain.heldTimer !== undefined) return;
236
+ chain.heldWaitMs = Math.min(HELD_MOST_MS, chain.heldWaitMs * 2 || HELD_FIRST_MS);
237
+ chain.heldTimer = setTimeout(() => {
238
+ chain.heldTimer = undefined;
239
+ read();
240
+ }, chain.heldWaitMs);
241
+ chain.heldTimer.unref?.();
242
+ }
243
+
244
+ /** One publish: the event of the writes that ended since the last publish, and of a read again. */
191
245
  async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, topic: string, values: boolean, admission: CollectionAdmission | undefined): Promise<void> {
192
246
  const admit = admission?.admit;
193
247
  const writes = chain.ended.splice(0);
248
+ const owed = chain.owed;
249
+ chain.owed = false;
194
250
  // An earlier publish told these writes
195
- if (writes.length === 0) return;
251
+ if (writes.length === 0 && !owed) return;
252
+ const readAgain = (): void => rereadChain(chain, storage, bus, topic, values, admission);
196
253
  if (!bus.hasSubscribers(topic)) {
197
254
  // No one listens: the procedures do not read the log. The next call of `changes` starts the chain again.
198
255
  chain.anchor = undefined;
256
+ whileHeld(chain, false, readAgain);
199
257
  return;
200
258
  }
201
259
  const since = chain.anchor;
202
260
  if (!storage.getChangesSince || since === undefined) {
203
- bus.emit(topic, hint(undefined, originOf(writes, undefined)));
261
+ if (writes.length > 0) bus.emit(topic, hint(undefined, originOf(writes, undefined)));
204
262
  return;
205
263
  }
206
264
  let changes: StorageChanges<unknown>;
@@ -208,16 +266,21 @@ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, to
208
266
  changes = await storage.getChangesSince(since);
209
267
  } catch {
210
268
  // The subscribers read the changes themselves. The chain keeps its version.
211
- bus.emit(topic, hint(undefined, originOf(writes, undefined)));
269
+ if (writes.length > 0) bus.emit(topic, hint(undefined, originOf(writes, undefined)));
270
+ // A read again that failed told no subscriber, so it stays owed
271
+ else whileHeld(chain, true, readAgain);
212
272
  return;
213
273
  }
214
- chain.anchor = changes.version;
274
+ whileHeld(chain, changes.held === true, readAgain);
215
275
  // A full result: the log does not know the version of the chain any more
216
276
  if (changes.full || writes.some((write) => !write.anchored)) {
277
+ chain.anchor = changes.version;
217
278
  bus.emit(topic, hint(changes.version, originOf(writes, undefined)));
218
279
  return;
219
280
  }
281
+ // No event tells this version, so the chain stays at the version of the last event. The next read from it gets each change.
220
282
  if (changes.changed.length === 0 && changes.deleted.length === 0) return;
283
+ chain.anchor = changes.version;
221
284
  const origin = originOf(writes, changes);
222
285
  const event: CollectionChangeEvent = {
223
286
  since,
@@ -231,6 +294,12 @@ async function publish(chain: ChangeChain, storage: ChangeLog, bus: EventBus, to
231
294
  bus.emit(topic, admit ? withHintImages(out, { after: afterOf(changes), before: beforeOf(writes), admission: admission! }) : out);
232
295
  }
233
296
 
297
+ /** This function reads the log of a chain again. A publish that did not start does this read. */
298
+ function rereadChain(chain: ChangeChain, storage: ChangeLog, bus: EventBus, topic: string, values: boolean, admission: CollectionAdmission | undefined): void {
299
+ chain.owed = true;
300
+ void nextPublish(chain, () => publish(chain, storage, bus, topic, values, admission));
301
+ }
302
+
234
303
  /** The rows after the changes, from the change log: a changed id has its item, a deleted id has none. */
235
304
  function afterOf(changes: StorageChanges<unknown>): Map<string, HintRowState> {
236
305
  const after = new Map<string, HintRowState>();
@@ -295,9 +364,7 @@ export async function writeWithChangeEvent<R>(
295
364
  return await write();
296
365
  } finally {
297
366
  chain.ended.push(started);
298
- const run = chain.tail.then(() => publish(chain, storage, bus, topic, values, admission));
299
- chain.tail = run.catch(() => undefined);
300
- await chain.tail;
367
+ await nextPublish(chain, () => publish(chain, storage, bus, topic, values, admission));
301
368
  }
302
369
  }
303
370
 
@@ -311,3 +378,14 @@ export function startChangeChain(bus: EventBus | undefined, storage: object, top
311
378
  const chain = chainOf(storage, topic);
312
379
  if (chain.anchor === undefined) chain.anchor = version;
313
380
  }
381
+
382
+ /**
383
+ * This function makes the chain read the log again when a call of `changes` gets a version that a
384
+ * write in flight holds back. A write outside the procedures then reaches the subscribers too.
385
+ * With no bus, no topic or no subscriber, it does nothing.
386
+ */
387
+ export function rereadWhileHeld(bus: EventBus | undefined, storage: object & ChangeLog, topic: string | undefined, values = false, admission?: CollectionAdmission): void {
388
+ if (bus === undefined || topic === undefined || !bus.hasSubscribers(topic)) return;
389
+ const chain = chainOf(storage, topic);
390
+ whileHeld(chain, true, () => rereadChain(chain, storage, bus, topic, values, admission));
391
+ }
@@ -58,7 +58,7 @@ import {
58
58
  } from "./schemas.js";
59
59
  import { DEFAULT_FIND_LIMIT, queryEntries, type StorageQueryOptions, type StorageQueryPolicy } from "./query.js";
60
60
  import type { CollectionStorage, StorageBatchWriteOptions, StorageItemWriteOptions, StorageWriteOptions } from "./storage.js";
61
- import { collectionChangeTopic, startChangeChain, writeWithChangeEvent } from "./changes.js";
61
+ import { collectionChangeTopic, rereadWhileHeld, startChangeChain, writeWithChangeEvent } from "./changes.js";
62
62
  import { partitionOfCall, storageOfCall, withPartition, type PartitionInput } from "./partition.js";
63
63
  import { admitsRow, admittedStorage, checkAdmission, type CollectionAdmission } from "./admission.js";
64
64
  import { patchItem } from "./patch-in-parts.js";
@@ -339,7 +339,12 @@ export function createCollectionProcedures(collectionName: string, options: Coll
339
339
  const changes = await view.getChangesSince(input.since);
340
340
  // The chain of the change events starts at this version: the caller then applies the
341
341
  // next event without a read (roadmap CLI-15)
342
- if (topicRule) startChangeChain(ctx.bus, storage, topicOf(ctx, partition), changes.version);
342
+ if (topicRule) {
343
+ const topic = topicOf(ctx, partition);
344
+ startChangeChain(ctx.bus, storage, topic, changes.version);
345
+ // A write in flight holds this read back, so the chain reads again until the write ends (roadmap CLI-102)
346
+ if (changes.held) rereadWhileHeld(ctx.bus, storage, topic, withValues, admission);
347
+ }
343
348
  return {
344
349
  version: changes.version,
345
350
  full: changes.full,
@@ -185,6 +185,11 @@ export type StorageObserver<T> = (changes: StorageChangeBatch<T>) => void;
185
185
  * has the ids that were deleted after it.
186
186
  * - The order of a full result is the order of the entries. The order of a delta is the order of
187
187
  * the last change of each id.
188
+ * - A result gives each write at least one time. A write can come again in two cases, with the same
189
+ * value or a later value. A full result takes its version before it lists the items, so it can
190
+ * hold a write above that version, and the next delta gives the write again. A write that ends
191
+ * after its lease can come again in a later delta, with a new version. A consumer applies a change
192
+ * by its id, and does not count changes.
188
193
  */
189
194
  export interface StorageChanges<T> {
190
195
  /** The version of the storage that includes these changes. */
@@ -195,6 +200,11 @@ export interface StorageChanges<T> {
195
200
  changed: Array<[string, T]>;
196
201
  /** The ids that were deleted. Empty when `full` is true. */
197
202
  deleted: string[];
203
+ /**
204
+ * True when this version stops below a write in flight. A later read can give more changes, with no
205
+ * new write.
206
+ */
207
+ held?: boolean;
198
208
  }
199
209
 
200
210
  /** The metadata that a storage gives about itself. */