@ultimat3/realtime 1.2.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.
Files changed (61) hide show
  1. package/CLAUDE.md +641 -0
  2. package/README.md +336 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +202 -20
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +99 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +324 -248
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
@@ -1,261 +0,0 @@
1
- // Single responsibility: the one scripted walsender this package's replication tests run against —
2
- // pgoutput message builders, a fake `PgStream` that answers the exact preflight sequence, and the
3
- // `start()` that boots a feed over it. Kept out of the test file so neither outgrows its ceiling.
4
- // Not part of the public API — `index.ts` deliberately does not re-export it.
5
-
6
- import { frozenClock } from '@ultimat3/core';
7
- import type { ChangeEvent } from './changefeed';
8
- import { PgLogicalReplicationFeed } from './changefeed';
9
- import { ByteReader, ByteWriter, pgTimestampToEpochMs } from './pg-bytes';
10
- import type { PgStream } from './pg-wire';
11
- import { frame } from './pg-wire';
12
-
13
- // ---- pgoutput fixtures ------------------------------------------------------------------------
14
-
15
- export const POSTS_OID = 16_384;
16
- export const OTHER_OID = 16_385;
17
- export const TEXT = 25;
18
- export const INT8 = 20;
19
-
20
- export interface FixtureColumn {
21
- readonly name: string;
22
- readonly key?: boolean;
23
- /** Type oid, `TEXT` unless a case is specifically about how another type decodes. */
24
- readonly type?: number;
25
- }
26
-
27
- export const relation = (
28
- oid: number,
29
- name: string,
30
- columns: readonly FixtureColumn[],
31
- ): Uint8Array => {
32
- const writer = new ByteWriter()
33
- .uint8(0x52)
34
- .int32(oid)
35
- .cstring('public')
36
- .cstring(name)
37
- .uint8(0x66)
38
- .int16(columns.length);
39
- for (const column of columns) {
40
- writer
41
- .uint8(column.key === true ? 1 : 0)
42
- .cstring(column.name)
43
- .int32(column.type ?? TEXT)
44
- .int32(-1);
45
- }
46
- return writer.finish();
47
- };
48
-
49
- export const tuple = (writer: ByteWriter, values: readonly (string | null)[]): ByteWriter => {
50
- writer.int16(values.length);
51
- for (const value of values) {
52
- if (value === null) writer.uint8(0x6e);
53
- else writer.uint8(0x74).int32(value.length).utf8(value);
54
- }
55
- return writer;
56
- };
57
-
58
- export const begin = (commitLsn: bigint, at: bigint, xid: number): Uint8Array =>
59
- new ByteWriter().uint8(0x42).int64(commitLsn).int64(at).int32(xid).finish();
60
-
61
- export const commit = (commitLsn: bigint, endLsn: bigint, at: bigint): Uint8Array =>
62
- new ByteWriter().uint8(0x43).uint8(0).int64(commitLsn).int64(endLsn).int64(at).finish();
63
-
64
- export const insert = (oid: number, values: readonly (string | null)[]): Uint8Array =>
65
- tuple(new ByteWriter().uint8(0x49).int32(oid).uint8(0x4e), values).finish();
66
-
67
- export const update = (
68
- oid: number,
69
- before: readonly (string | null)[] | null,
70
- after: readonly (string | null)[],
71
- ): Uint8Array => {
72
- const writer = new ByteWriter().uint8(0x55).int32(oid);
73
- if (before !== null) tuple(writer.uint8(0x4f), before);
74
- return tuple(writer.uint8(0x4e), after).finish();
75
- };
76
-
77
- export const remove = (oid: number, before: readonly (string | null)[]): Uint8Array =>
78
- tuple(new ByteWriter().uint8(0x44).int32(oid).uint8(0x4f), before).finish();
79
-
80
- /** `w` XLogData, wrapped as the `d` CopyData message the walsender sends it in. */
81
- export const xlog = (payload: Uint8Array, walEnd = 0n): Uint8Array =>
82
- frame(
83
- 'd',
84
- new ByteWriter().uint8(0x77).int64(walEnd).int64(walEnd).int64(0n).raw(payload).finish(),
85
- );
86
-
87
- export const keepalive = (walEnd: bigint, replyRequested: number): Uint8Array =>
88
- frame('d', new ByteWriter().uint8(0x6b).int64(walEnd).int64(0n).uint8(replyRequested).finish());
89
-
90
- // ---- a scripted walsender ---------------------------------------------------------------------
91
-
92
- export const dataRow = (values: readonly (string | null)[]): Uint8Array => {
93
- const writer = new ByteWriter().int16(values.length);
94
- for (const value of values) {
95
- if (value === null) writer.int32(-1);
96
- else writer.int32(value.length).utf8(value);
97
- }
98
- return frame('D', writer.finish());
99
- };
100
-
101
- export const ready = (): Uint8Array => frame('Z', new Uint8Array([0x49]));
102
- export const complete = (): Uint8Array => frame('C', new ByteWriter().cstring('SELECT 1').finish());
103
-
104
- export const joined = (...parts: readonly Uint8Array[]): Uint8Array => {
105
- const total = parts.reduce((sum, part) => sum + part.length, 0);
106
- const out = new Uint8Array(total);
107
- let at = 0;
108
- for (const part of parts) {
109
- out.set(part, at);
110
- at += part.length;
111
- }
112
- return out;
113
- };
114
-
115
- export interface ServerScript {
116
- readonly walLevel?: string;
117
- readonly publicationExists?: boolean;
118
- /** `null` = no slot yet, so the feed has to create one. */
119
- readonly slotPlugin?: string | null;
120
- }
121
-
122
- /**
123
- * Answers the exact command sequence the feed issues, and records what it sent back — enough to
124
- * drive the whole preflight and then inject WAL by hand.
125
- */
126
- export class FakeWalsender implements PgStream {
127
- readonly queries: string[] = [];
128
- readonly standby: { position: bigint; at: number }[] = [];
129
- closed = false;
130
- // `undefined` is EOF: `PgStream.read()` already returns `Uint8Array | undefined`, so the queue
131
- // holds it honestly rather than letting `close()` cast one type into the other.
132
- readonly #chunks: (Uint8Array | undefined)[] = [];
133
- readonly #script: ServerScript;
134
- #waiting: ((chunk: Uint8Array | undefined) => void) | null = null;
135
-
136
- constructor(script: ServerScript = {}) {
137
- this.#script = script;
138
- }
139
-
140
- push(chunk: Uint8Array | undefined): void {
141
- const waiter = this.#waiting;
142
- this.#waiting = null;
143
- if (waiter === null) this.#chunks.push(chunk);
144
- else waiter(chunk);
145
- }
146
-
147
- read(): Promise<Uint8Array | undefined> {
148
- if (this.#chunks.length > 0) return Promise.resolve(this.#chunks.shift());
149
- return new Promise((resolve) => {
150
- this.#waiting = resolve;
151
- });
152
- }
153
-
154
- write(bytes: Uint8Array): Promise<void> {
155
- // The startup packet is untagged, so a leading protocol version is how it is recognised.
156
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
157
- if (bytes.length >= 8 && view.getInt32(4, false) === 196_608) {
158
- this.push(joined(frame('R', new ByteWriter().int32(0).finish()), ready()));
159
- return Promise.resolve();
160
- }
161
- const reader = new ByteReader(bytes, 'client');
162
- const tag = reader.tag();
163
- reader.int32();
164
- if (tag === 'Q') this.#answer(reader.cstring());
165
- if (tag === 'd') this.#recordStandby(reader.rest());
166
- return Promise.resolve();
167
- }
168
-
169
- close(): void {
170
- this.closed = true;
171
- this.push(undefined);
172
- }
173
-
174
- #recordStandby(payload: Uint8Array): void {
175
- const reader = new ByteReader(payload, 'standby');
176
- if (reader.tag() !== 'r') return;
177
- const position = reader.int64();
178
- reader.int64();
179
- reader.int64();
180
- this.standby.push({ position, at: pgTimestampToEpochMs(reader.int64()) });
181
- }
182
-
183
- #answer(sql: string): void {
184
- this.queries.push(sql);
185
- if (sql.startsWith('START_REPLICATION')) {
186
- this.push(frame('W', new ByteWriter().uint8(0).int16(0).finish()));
187
- return;
188
- }
189
- if (sql === 'SHOW wal_level') {
190
- this.push(joined(dataRow([this.#script.walLevel ?? 'logical']), complete(), ready()));
191
- return;
192
- }
193
- if (sql.includes('pg_publication')) {
194
- const rows = this.#script.publicationExists === false ? [] : [dataRow(['1'])];
195
- this.push(joined(...rows, complete(), ready()));
196
- return;
197
- }
198
- if (sql.includes('pg_replication_slots')) {
199
- const plugin = this.#script.slotPlugin === undefined ? 'pgoutput' : this.#script.slotPlugin;
200
- const rows = plugin === null ? [] : [dataRow([plugin])];
201
- this.push(joined(...rows, complete(), ready()));
202
- return;
203
- }
204
- this.push(joined(dataRow(['ok']), complete(), ready()));
205
- }
206
- }
207
-
208
- export const POST_COLUMNS: readonly FixtureColumn[] = [
209
- { name: 'id', key: true },
210
- { name: 'title' },
211
- { name: 'org_id' },
212
- { name: 'price_minor' },
213
- { name: 'price_currency' },
214
- ];
215
-
216
- export interface Started {
217
- readonly feed: PgLogicalReplicationFeed;
218
- readonly server: FakeWalsender;
219
- readonly events: ChangeEvent[];
220
- /** Resolves once `count` events have been delivered. */
221
- settled(count: number): Promise<void>;
222
- }
223
-
224
- export const start = async (
225
- options: {
226
- script?: ServerScript;
227
- from?: string;
228
- entities?: readonly string[];
229
- statusIntervalMs?: number;
230
- } = {},
231
- ): Promise<Started> => {
232
- const server = new FakeWalsender(options.script);
233
- const events: ChangeEvent[] = [];
234
- const feed = new PgLogicalReplicationFeed({
235
- url: 'postgres://replicator:secret@db.test:5432/app',
236
- slot: 'ultimate_slot',
237
- publication: 'ultimate_pub',
238
- entities: options.entities ?? ['posts'],
239
- clock: frozenClock('2026-08-09T12:00:00.000Z'),
240
- stream: () => Promise.resolve(server),
241
- ...(options.statusIntervalMs === undefined
242
- ? {}
243
- : { statusIntervalMs: options.statusIntervalMs }),
244
- });
245
- await feed.start(
246
- options.from === undefined
247
- ? { onChange: (event) => void events.push(event) }
248
- : { from: options.from, onChange: (event) => void events.push(event) },
249
- );
250
- return {
251
- feed,
252
- server,
253
- events,
254
- // Nothing here is real I/O, so draining the microtask queue is a deterministic "let the pump
255
- // finish" — including the commit that follows the last row.
256
- settled: async (count) => {
257
- for (let tick = 0; tick < 200 && events.length < count; tick += 1) await Promise.resolve();
258
- for (let tick = 0; tick < 50; tick += 1) await Promise.resolve();
259
- },
260
- };
261
- };