@ultimat3/realtime 20.2.1 → 22.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.
Files changed (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
package/src/channel.ts CHANGED
@@ -1,52 +1,39 @@
1
- // Tier 1: channels. Typed topics over Bun's native WS pub/sub, fanned across nodes by `Transport`.
2
- //
3
- // A channel message rides the `patch` frame with `sid = topic` and `op: 'insert'` — a channel is an
4
- // append-only stream, so tier 1 needs no frame of its own. That is why climbing the ladder is a
5
- // config change: the client's frame handler is the same code at every rung.
1
+ // Tier 1: channels. A declared `channel()` is subscribed by name + params — there is no other way
2
+ // to spell a topic. Its `records` are derived from the change feed on the node that delivers them
3
+ // (seq, epoch, ring, `replay-gap` — plan 101, slices 09-10), and its ephemeral `events` (presence
4
+ // included) fan out across nodes over the `Transport` bridge.
6
5
 
7
- import { type Actor, finiteOption, logger, renderThrowable, uuid } from '@ultimat3/core';
8
- import { formatLsn } from './changefeed';
6
+ import {
7
+ type Actor,
8
+ type Ctx,
9
+ createContext,
10
+ finiteOption,
11
+ invariant,
12
+ logger,
13
+ renderThrowable,
14
+ } from '@ultimat3/core';
15
+ import type { ChangeEvent } from './changefeed';
16
+ import { authorizeChannel } from './channel-authz';
17
+ import { type Bridge, unsubscribeWhenOpen } from './channel-bridge';
18
+ import type { Channel, Topic } from './channel-decl';
19
+ import { ChannelLogs } from './channel-logs';
20
+ import { getChannel, registeredChannels } from './channel-registry';
21
+ import type { ChannelEventsFrame, ChannelSubscribeTarget } from './channel-wire';
9
22
  import {
10
23
  isPolicyDenial,
11
24
  SubscriptionLimitError,
12
25
  TopicForbiddenError,
13
26
  TransportUnavailableError,
14
27
  } from './errors';
15
- import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
28
+ import type { Transport } from './fanout';
16
29
  import type { JsonObject } from './json';
17
30
  import type { SocketRegistry, SyncSocket } from './socket';
18
- import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
31
+ import { decode, PROTOCOL_VERSION } from './sync-protocol';
19
32
 
20
- /** Branded so a raw string can never be published to; `topic()` is the only constructor. */
21
- export type Topic = string & { readonly __ultimateTopic: unique symbol };
33
+ export { type Topic, topic } from './channel-decl';
22
34
 
23
- const SEGMENT = /^[A-Za-z0-9_-]+$/;
24
35
  const CHANNEL_SUBJECT_PREFIX = 'x.channel';
25
36
 
26
- /** `topic('org', orgId, 'cursors')` -> `org.<orgId>.cursors`. Segments are validated, never escaped. */
27
- export function topic(...parts: readonly (string | number)[]): Topic {
28
- const segments = parts.map((part) => String(part));
29
- for (const segment of segments) {
30
- if (!SEGMENT.test(segment)) {
31
- throw new TopicForbiddenError({
32
- topic: segments.join('.'),
33
- actorId: null,
34
- reason: `segment "${segment}" must match ${SEGMENT.source} (dots and wildcards are reserved)`,
35
- });
36
- }
37
- }
38
- return segments.join('.') as Topic;
39
- }
40
-
41
- export interface TopicGuardArgs {
42
- readonly actor: Actor | null;
43
- readonly topic: Topic;
44
- readonly segments: readonly string[];
45
- }
46
-
47
- export type TopicGuardResult = boolean | { readonly allowed: boolean; readonly reason?: string };
48
- export type TopicGuard = (args: TopicGuardArgs) => TopicGuardResult | Promise<TopicGuardResult>;
49
-
50
37
  export interface ChannelHubOptions {
51
38
  readonly transport: Transport;
52
39
  readonly sockets: SocketRegistry;
@@ -57,45 +44,24 @@ export interface ChannelHubOptions {
57
44
  * admits unbounded distinct names inside one tenant, and a per-socket cap bounds nothing.
58
45
  */
59
46
  readonly maxTopicsPerNode?: number;
60
- /**
61
- * This node's mark on the patch ids it mints. Defaults to a per-hub random id, which is enough
62
- * to keep two nodes apart; declare it (the pod name, the `sync` instance id) when an operator
63
- * reading one frame should be able to say which node published it.
64
- *
65
- * A blank string is read as OMITTED, never as a mark: `??` only answers for `undefined`, so
66
- * `nodeId: ''` — which is what an unset `POD_NAME` interpolates to — stored the empty mark and
67
- * two hubs then minted the SAME first patch id, `:0000000000000001`. That is precisely the
68
- * collision this field exists to prevent, arriving through the field itself.
69
- */
70
- readonly nodeId?: string;
47
+ /** Scope the hub to these declarations. Omitted = every `channel()` registered in the process. */
48
+ readonly channels?: readonly Channel[];
49
+ /** The node context a channel policy is evaluated under, as a live query's is. */
50
+ readonly ctx?: Ctx;
51
+ /** `records` frames kept per topic for a `since` resume. See `DEFAULT_CHANNEL_RING`. */
52
+ readonly ringSize?: number;
71
53
  }
72
54
 
73
55
  /** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
74
56
  export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
75
57
 
76
58
  /**
77
- * One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
78
- * into the table before it is awaited: looked up before the await and written after it, two sockets
79
- * reaching one topic at once opened two transport subscriptions — the second replacing the first in
80
- * the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
81
- * anything else, delivering every message on that topic a second time for the life of the process.
82
- *
83
- * `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
84
- * runs, so the reservation has to exist before there is anything to reserve it with.
85
- */
86
- interface Bridge {
87
- sub: Promise<TransportSubscription> | null;
88
- refs: number;
89
- }
90
-
91
- /**
92
- * Deny by default: a topic with no matching guard is forbidden. An authz hole must be a typed
93
- * error at subscribe time, not a config option someone forgot to set.
59
+ * Deny by default: a channel name no `channel()` declared is refused, so an authz hole is a typed
60
+ * error at subscribe time, never a topic somebody forgot to guard.
94
61
  */
95
62
  export class ChannelHub {
96
63
  readonly #transport: Transport;
97
64
  readonly #sockets: SocketRegistry;
98
- readonly #guards: Array<{ pattern: string; guard: TopicGuard }> = [];
99
65
  readonly #bridges = new Map<string, Bridge>();
100
66
  /**
101
67
  * Topics this socket has asked for and not yet joined. Weakly keyed, so it needs no teardown
@@ -105,15 +71,21 @@ export class ChannelHub {
105
71
  readonly #maxTopicsPerSocket: number;
106
72
  readonly #maxTopicsPerNode: number;
107
73
  #guardFailures = 0;
108
- #sequence = 0n;
109
- /**
110
- * `#sequence` counts within one PROCESS, so it cannot identify a message across nodes: two
111
- * `sync` replicas publishing to one topic minted the same id for the same subscriber, and a
112
- * channel has no cursor and no re-snapshot, so nothing downstream could repair the collision.
113
- */
114
- readonly #nodeId: string;
115
74
  /** Set by `close()`. Read by `#open`, which is the only thing that can reach a late subscription. */
116
75
  #closed = false;
76
+ /**
77
+ * `null` serves every registered channel — the default, so a host passes nothing. A list scopes
78
+ * this hub to exactly those declarations (a test, a node that serves a subset).
79
+ */
80
+ readonly #only: ReadonlyMap<string, Channel> | null;
81
+ readonly #logs: ChannelLogs;
82
+ readonly #ctx: Ctx;
83
+ /**
84
+ * Topics a socket's policy refused, latched until its actor changes: a denial is a decision, so
85
+ * a client re-asking in a loop is answered without re-running the policy each time — and only
86
+ * THAT channel is refused, every other one on the socket keeps flowing.
87
+ */
88
+ readonly #latched = new WeakMap<SyncSocket, Set<string>>();
117
89
 
118
90
  constructor(options: ChannelHubOptions) {
119
91
  this.#transport = options.transport;
@@ -128,9 +100,99 @@ export class ChannelHub {
128
100
  'maxTopicsPerNode',
129
101
  options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE,
130
102
  );
131
- // Trimmed before the emptiness test: `nodeId: ' '` marks a frame with a space, which reads in
132
- // a log as no mark at all and collides with the next hub that does the same.
133
- this.#nodeId = options.nodeId?.trim() || uuid();
103
+ this.#ctx = options.ctx ?? createContext();
104
+ this.#logs = new ChannelLogs(options.sockets, options.ringSize);
105
+ this.#only =
106
+ options.channels === undefined ? null : new Map(options.channels.map((c) => [c.name, c]));
107
+ }
108
+
109
+ /**
110
+ * Join a declared channel by name + params. The policy runs with the params as input; a denial
111
+ * is latched per (socket, topic). With `since`, the ring replays what was missed or a
112
+ * `replay-gap` says to re-read. Answers the topic, which presence keys its set by.
113
+ */
114
+ async subscribeChannel(socket: SyncSocket, target: ChannelSubscribeTarget): Promise<Topic> {
115
+ const declared =
116
+ this.#only === null ? getChannel(target.channel) : this.#only.get(target.channel);
117
+ if (declared === undefined) {
118
+ throw new TopicForbiddenError({
119
+ topic: target.channel,
120
+ actorId: socket.actorId,
121
+ reason: 'no channel() is declared with this name on this node',
122
+ });
123
+ }
124
+ const params: Record<string, string> = {};
125
+ for (const param of declared.params) {
126
+ params[param] = Object.hasOwn(target.params, param) ? (target.params[param] ?? '') : '';
127
+ }
128
+ const name = declared.topic(params);
129
+ if (this.#latched.get(socket)?.has(name) === true) {
130
+ throw new TopicForbiddenError({
131
+ topic: name,
132
+ actorId: socket.actorId,
133
+ reason: 'denied earlier on this connection; it is re-decided when the session changes',
134
+ });
135
+ }
136
+ // Asked before the join seats it: a repeated `add` (the presence beat) is not a fresh seat.
137
+ const fresh = !socket.topics.has(name);
138
+ await this.#join(socket, name, async () => {
139
+ try {
140
+ await authorizeChannel(declared, this.#ctx, socket.actor, name, params);
141
+ } catch (error) {
142
+ if (error instanceof TopicForbiddenError) this.#latch(socket, name);
143
+ throw error;
144
+ }
145
+ });
146
+ this.#logs.open(name, { channel: declared, params });
147
+ this.#logs.resume(socket, name, target.since, fresh);
148
+ return name;
149
+ }
150
+
151
+ /**
152
+ * One committed change from the feed, turned into `records` frames on every declared channel it
153
+ * touches and delivered on THIS node. Called for every change the node receives — the same
154
+ * stream `LiveQueryRegistry.deliver` is fed — so a write names no channel (axiom 2).
155
+ */
156
+ deliverChange(change: ChangeEvent): number {
157
+ return this.#logs.deliverChange(this.#only?.values() ?? registeredChannels(), change);
158
+ }
159
+
160
+ /** An ephemeral event (typing, a cursor) to every node's members of that topic. Never stored. */
161
+ async publishEvent<K extends string>(
162
+ declared: Channel<K>,
163
+ params: Readonly<Record<K, string>>,
164
+ event: JsonObject,
165
+ ): Promise<void> {
166
+ invariant(
167
+ declared.events,
168
+ 'X_CHANNEL_DECLARATION_INVALID',
169
+ `channel("${declared.name}") declares no events, so nothing may publish one on it`,
170
+ `declare it with events: true: channel('${declared.name}', { …, events: true })`,
171
+ );
172
+ await this.emit(declared.topic(params), event);
173
+ }
174
+
175
+ /**
176
+ * An `events` frame on a topic this node already resolved — `publishEvent`'s second half, and
177
+ * presence's one way out: a roster change is an event on the channel the member joined. Never
178
+ * a topic a caller spelled; every `Topic` comes from a declaration.
179
+ */
180
+ async emit(name: Topic, event: JsonObject): Promise<void> {
181
+ const frame: ChannelEventsFrame = { type: 'events', v: PROTOCOL_VERSION, channel: name, event };
182
+ await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, JSON.stringify(frame));
183
+ }
184
+
185
+ /** The declaration and params a topic joined on this node resolves to — `undefined` if none. */
186
+ channelOf(
187
+ name: Topic,
188
+ ): { readonly channel: Channel; readonly params: Readonly<Record<string, string>> } | undefined {
189
+ return this.#logs.target(name);
190
+ }
191
+
192
+ #latch(socket: SyncSocket, name: string): void {
193
+ const latched = this.#latched.get(socket) ?? new Set<string>();
194
+ latched.add(name);
195
+ this.#latched.set(socket, latched);
134
196
  }
135
197
 
136
198
  /** Sockets this node will deliver `name` to. The metric the fanout reads. */
@@ -152,18 +214,12 @@ export class ChannelHub {
152
214
  return this.#guardFailures;
153
215
  }
154
216
 
155
- /** `pattern` uses NATS wildcards: `org.*.cursors`, `org.>`. First registered match wins. */
156
- guard(pattern: string, guard: TopicGuard): this {
157
- this.#guards.push({ pattern, guard });
158
- return this;
159
- }
160
-
161
217
  /**
162
218
  * Both caps and the node's bridge slot are taken SYNCHRONOUSLY, before the guard is awaited: read
163
219
  * at the top and acted on after two awaits, one WebSocket write carrying N subscribe frames
164
220
  * passed each of them N times, and `maxTopicsPerSocket`/`maxTopicsPerNode` bounded nothing.
165
221
  */
166
- async subscribe(socket: SyncSocket, name: Topic): Promise<void> {
222
+ async #join(socket: SyncSocket, name: Topic, authorize: () => Promise<void>): Promise<void> {
167
223
  if (socket.topics.has(name)) return;
168
224
  const claimed = this.#claimed.get(socket) ?? 0;
169
225
  if (socket.topics.size + claimed >= this.#maxTopicsPerSocket) {
@@ -182,7 +238,7 @@ export class ChannelHub {
182
238
  const bridge = this.#reserve(name);
183
239
  this.#claimed.set(socket, claimed + 1);
184
240
  try {
185
- await this.#authorize(socket.actor, name);
241
+ await authorize();
186
242
  await this.#open(name, bridge);
187
243
  } catch (error) {
188
244
  // The slot this subscribe took, given back on the one path that will never fill it — and
@@ -226,10 +282,15 @@ export class ChannelHub {
226
282
  */
227
283
  async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
228
284
  socket.actor = actor;
285
+ // A new session re-decides everything, the latched denials included.
286
+ this.#latched.delete(socket);
229
287
  const dropped: Topic[] = [];
230
288
  for (const name of [...socket.topics] as Topic[]) {
231
289
  try {
232
- await this.#authorize(actor, name);
290
+ const target = this.#logs.target(name);
291
+ if (target !== undefined) {
292
+ await authorizeChannel(target.channel, this.#ctx, actor, name, target.params);
293
+ }
233
294
  } catch (error) {
234
295
  if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
235
296
  this.unsubscribe(socket, name);
@@ -247,21 +308,6 @@ export class ChannelHub {
247
308
  return dropped;
248
309
  }
249
310
 
250
- /** Publishes to every node. Local delivery happens via the transport bridge, never directly. */
251
- async publish(name: Topic, message: JsonObject): Promise<void> {
252
- this.#sequence += 1n;
253
- const lsn = formatLsn(this.#sequence);
254
- // The lsn stays this node's own counter — nothing reads a channel frame's lsn as an order
255
- // across nodes — but the patch ID is what a client keys by, so it carries the node too.
256
- const frame = channelFrame(name, lsn, message, `${this.#nodeId}:${lsn}`);
257
- await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, encode(frame));
258
- }
259
-
260
- /** Frames already encoded elsewhere (presence, for one) reuse the same bridge. */
261
- async publishFrame(name: Topic, frame: Frame): Promise<void> {
262
- await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, encode(frame));
263
- }
264
-
265
311
  async close(): Promise<void> {
266
312
  // Set BEFORE the table is walked, because the table is not the whole story: a reservation an
267
313
  // in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
@@ -274,29 +320,6 @@ export class ChannelHub {
274
320
  this.#bridges.clear();
275
321
  }
276
322
 
277
- async #authorize(actor: Actor | null, name: Topic): Promise<void> {
278
- const segments = name.split('.');
279
- const entry = this.#guards.find(({ pattern }) => subjectMatches(pattern, name));
280
- if (!entry) {
281
- throw new TopicForbiddenError({
282
- topic: name,
283
- actorId: actor === null ? null : actor.id,
284
- reason: 'no guard declared for this topic',
285
- });
286
- }
287
- const result = await entry.guard({ actor, topic: name, segments });
288
- const allowed = typeof result === 'boolean' ? result : result.allowed;
289
- if (!allowed) {
290
- const reason =
291
- typeof result === 'boolean' ? 'guard denied' : (result.reason ?? 'guard denied');
292
- throw new TopicForbiddenError({
293
- topic: name,
294
- actorId: actor === null ? null : actor.id,
295
- reason,
296
- });
297
- }
298
- }
299
-
300
323
  /**
301
324
  * The node's slot for this topic, taken synchronously. One bridge per topic per node, refcounted
302
325
  * across sockets — and the refcount includes the subscribes still deciding, so the count the node
@@ -365,40 +388,7 @@ export class ChannelHub {
365
388
  if (bridge.refs > 0) return;
366
389
  unsubscribeWhenOpen(bridge);
367
390
  this.#bridges.delete(name);
391
+ // The ring goes with the last local member; a later subscriber starts a new epoch.
392
+ this.#logs.close(name);
368
393
  }
369
394
  }
370
-
371
- /**
372
- * A bridge released while its subscription is still opening still has to be closed — the transport
373
- * hands the handle back after the caller has gone, and dropping the promise would leave a live
374
- * subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
375
- * rejection was already answered to the subscriber that caused it.
376
- */
377
- function unsubscribeWhenOpen(bridge: Bridge): void {
378
- void bridge.sub?.then(
379
- (sub) => {
380
- sub.unsubscribe();
381
- },
382
- () => undefined,
383
- );
384
- }
385
-
386
- /**
387
- * `id` identifies the MESSAGE and defaults to the lsn, which is what every caller outside this
388
- * file already passes as one. `ChannelHub.publish` gives it the publishing node's mark instead:
389
- * an lsn is a per-process counter, and two nodes on one topic mint the same one.
390
- */
391
- export function channelFrame(
392
- name: Topic,
393
- lsn: string,
394
- message: JsonObject,
395
- id: string = lsn,
396
- ): Frame {
397
- return {
398
- type: 'patch',
399
- v: PROTOCOL_VERSION,
400
- sid: name,
401
- lsn,
402
- patches: [{ op: 'insert', id, row: message, lsn }],
403
- };
404
- }