@eleven-am/golem-client 0.0.0-stage → 0.1.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/dist/live.js ADDED
@@ -0,0 +1,583 @@
1
+ import { assertLive, streamRequest } from './analysis.js';
2
+ import { Coalescer, COALESCE_MAX_WAIT_MS, COALESCE_WINDOW_MS } from './coalescer.js';
3
+ import { GolemNotFoundError, GolemProtocolError } from './errors.js';
4
+ import { applyRemoval, decideUpdate, EMPTY_MERGE_STATE, noteChange, parseVersion, resolveResponse } from './merge.js';
5
+ import { backoffDelay } from './protocol.js';
6
+ import { golemInfiniteQuery } from './pagination.js';
7
+ import { Patcher } from './patcher.js';
8
+ export const CLOSE_GRACE_MS = 1000;
9
+ export const STABLE_STREAM_MS = 10_000;
10
+ export const SubscriptionCode = {
11
+ Cancelled: 'GOLEM_SUBSCRIPTION_CANCELLED',
12
+ Resync: 'GOLEM_SUBSCRIPTION_RESYNC',
13
+ Overflow: 'GOLEM_SUBSCRIPTION_OVERFLOW',
14
+ Revalidation: 'GOLEM_SUBSCRIPTION_REVALIDATION',
15
+ Forbidden: 'GOLEM_SUBSCRIPTION_FORBIDDEN',
16
+ Limit: 'GOLEM_SUBSCRIPTION_LIMIT',
17
+ SourceClosed: 'GOLEM_SUBSCRIPTION_SOURCE_CLOSED',
18
+ };
19
+ export const REASON_DEPENDENCY_CHANGED = 'DEPENDENCY_CHANGED';
20
+ export function readMeta(query) {
21
+ const meta = query.meta?.golem;
22
+ if (typeof meta !== 'object' || meta === null) {
23
+ return undefined;
24
+ }
25
+ return meta;
26
+ }
27
+ function frameOf(plan, data) {
28
+ const frame = typeof data === 'object' && data !== null ? data[plan.eventsRoot] : undefined;
29
+ if (typeof frame !== 'object' || frame === null) {
30
+ throw new GolemProtocolError(`an event frame for ${plan.eventsRoot} carried no ${plan.eventsRoot} object`);
31
+ }
32
+ const { version, type, id, entity } = frame;
33
+ if (typeof version !== 'string' || typeof type !== 'string' || !(typeof id === 'string' || typeof id === 'number' || (typeof id === 'object' && id !== null))) {
34
+ throw new GolemProtocolError(`an event frame for ${plan.eventsRoot} lacks version, type or id`);
35
+ }
36
+ return { version, type, id: id, entity: typeof entity === 'object' && entity !== null ? entity : null };
37
+ }
38
+ const SAME_STATUS = (left, right) => left.state === right.state && left.error === right.error;
39
+ export class LiveSync {
40
+ client;
41
+ transport;
42
+ patcher;
43
+ manifest;
44
+ clock;
45
+ closeGraceMs;
46
+ windowMs;
47
+ maxWaitMs;
48
+ random;
49
+ entries = new Map();
50
+ statuses = new Map();
51
+ listeners = new Set();
52
+ unsubscribe;
53
+ constructor(options) {
54
+ this.client = options.queryClient;
55
+ this.transport = options.transport;
56
+ this.manifest = options.manifest;
57
+ this.patcher = new Patcher(options.manifest);
58
+ this.clock = options.clock ?? (() => performance.now());
59
+ this.closeGraceMs = options.closeGraceMs ?? CLOSE_GRACE_MS;
60
+ this.windowMs = options.windowMs ?? COALESCE_WINDOW_MS;
61
+ this.maxWaitMs = options.maxWaitMs ?? COALESCE_MAX_WAIT_MS;
62
+ this.random = options.random ?? Math.random;
63
+ }
64
+ get started() {
65
+ return this.unsubscribe !== undefined;
66
+ }
67
+ start() {
68
+ if (this.unsubscribe === undefined) {
69
+ const cache = this.client.getQueryCache();
70
+ this.unsubscribe = cache.subscribe((event) => this.onCacheEvent(event));
71
+ for (const query of cache.getAll()) {
72
+ this.reconcileHold(query);
73
+ }
74
+ }
75
+ return () => this.stop();
76
+ }
77
+ stop() {
78
+ this.unsubscribe?.();
79
+ this.unsubscribe = undefined;
80
+ for (const entry of [...this.entries.values()]) {
81
+ this.closeEntry(entry);
82
+ }
83
+ }
84
+ options(analysis, variables = {}) {
85
+ assertLive(analysis);
86
+ const meta = { analysis, variables };
87
+ return {
88
+ queryKey: ['golem', analysis.query, variables],
89
+ queryFn: (context) => this.fetch(context, meta),
90
+ meta: { golem: meta },
91
+ };
92
+ }
93
+ infiniteOptions(analysis, variables = {}) {
94
+ assertLive(analysis);
95
+ const plan = golemInfiniteQuery(analysis, variables, this.manifest);
96
+ const meta = { analysis, variables, pagination: plan.spec };
97
+ return {
98
+ queryKey: ['golem', analysis.query, variables, 'infinite'],
99
+ queryFn: (context) => this.fetch(context, meta, plan.requestFor(context.pageParam)),
100
+ initialPageParam: plan.initialPageParam,
101
+ getNextPageParam: (lastPage) => plan.getNextPageParam(lastPage),
102
+ meta: { golem: meta },
103
+ };
104
+ }
105
+ statusOf(query) {
106
+ return this.statuses.get(query.queryHash);
107
+ }
108
+ subscribeStatus(listener) {
109
+ this.listeners.add(listener);
110
+ return () => {
111
+ this.listeners.delete(listener);
112
+ };
113
+ }
114
+ onCacheEvent(event) {
115
+ switch (event.type) {
116
+ case 'observerAdded':
117
+ case 'observerRemoved':
118
+ case 'observerOptionsUpdated':
119
+ this.reconcileHold(event.query);
120
+ return;
121
+ case 'removed': {
122
+ const entry = this.entries.get(event.query.queryHash);
123
+ if (entry !== undefined) {
124
+ this.closeEntry(entry);
125
+ }
126
+ return;
127
+ }
128
+ default:
129
+ }
130
+ }
131
+ reconcileHold(query) {
132
+ const meta = readMeta(query);
133
+ if (meta === undefined || !meta.analysis.live) {
134
+ return;
135
+ }
136
+ const entry = this.entries.get(query.queryHash);
137
+ if (query.isActive()) {
138
+ if (entry === undefined) {
139
+ this.openEntry(query, meta);
140
+ }
141
+ else if (entry.closeTimer !== undefined) {
142
+ clearTimeout(entry.closeTimer);
143
+ entry.closeTimer = undefined;
144
+ }
145
+ return;
146
+ }
147
+ if (entry !== undefined && entry.closeTimer === undefined) {
148
+ entry.closeTimer = setTimeout(() => this.closeEntry(entry), this.closeGraceMs);
149
+ }
150
+ }
151
+ openEntry(query, meta) {
152
+ const entry = {
153
+ query,
154
+ meta,
155
+ streams: meta.analysis.streams.map((plan) => ({ plan, phase: 'opening', acked: false, ackedAt: 0, attempt: 0, generation: 0, stop: undefined, timer: undefined })),
156
+ coalescer: new Coalescer(() => this.refetch(entry), this.windowMs, this.maxWaitMs),
157
+ waiters: new Set(),
158
+ merge: EMPTY_MERGE_STATE,
159
+ inFlight: 0,
160
+ outage: query.state.dataUpdatedAt !== 0,
161
+ bypass: false,
162
+ everLive: false,
163
+ failure: undefined,
164
+ notFound: undefined,
165
+ closeTimer: undefined,
166
+ closed: false,
167
+ };
168
+ this.entries.set(query.queryHash, entry);
169
+ this.publish(entry);
170
+ for (const stream of entry.streams) {
171
+ this.openStream(entry, stream);
172
+ }
173
+ }
174
+ closeEntry(entry) {
175
+ if (entry.closed) {
176
+ return;
177
+ }
178
+ entry.closed = true;
179
+ if (entry.closeTimer !== undefined) {
180
+ clearTimeout(entry.closeTimer);
181
+ }
182
+ entry.coalescer.cancel();
183
+ this.entries.delete(entry.query.queryHash);
184
+ this.statuses.delete(entry.query.queryHash);
185
+ for (const stream of entry.streams) {
186
+ this.stopStream(stream);
187
+ }
188
+ this.releaseWaiters(entry);
189
+ this.notify();
190
+ this.retryLimited();
191
+ }
192
+ stopStream(stream) {
193
+ if (stream.timer !== undefined) {
194
+ clearTimeout(stream.timer);
195
+ stream.timer = undefined;
196
+ }
197
+ stream.generation++;
198
+ stream.stop?.();
199
+ stream.stop = undefined;
200
+ stream.phase = 'closed';
201
+ stream.acked = false;
202
+ }
203
+ openStream(entry, stream) {
204
+ if (entry.closed || stream.phase === 'closed') {
205
+ return;
206
+ }
207
+ stream.timer = undefined;
208
+ stream.phase = 'opening';
209
+ stream.acked = false;
210
+ const generation = ++stream.generation;
211
+ const current = () => !entry.closed && stream.generation === generation;
212
+ stream.stop = this.transport.subscribe(streamRequest(stream.plan, entry.meta.variables), {
213
+ onAck: () => {
214
+ if (current()) {
215
+ this.onAck(entry, stream);
216
+ }
217
+ },
218
+ onEvent: (body) => {
219
+ if (current()) {
220
+ this.guarded(entry, () => this.onEvent(entry, stream, frameOf(stream.plan, body.data)));
221
+ }
222
+ },
223
+ onInterrupted: () => {
224
+ if (current()) {
225
+ stream.acked = false;
226
+ entry.outage = true;
227
+ this.publish(entry);
228
+ }
229
+ },
230
+ onEnd: (end) => {
231
+ if (current()) {
232
+ stream.stop = undefined;
233
+ this.onEnd(entry, stream, end);
234
+ }
235
+ },
236
+ });
237
+ }
238
+ guarded(entry, run) {
239
+ try {
240
+ run();
241
+ }
242
+ catch (error) {
243
+ this.fail(entry, error instanceof Error ? error : new GolemProtocolError(String(error)));
244
+ }
245
+ }
246
+ fail(entry, error) {
247
+ entry.failure = error;
248
+ for (const stream of entry.streams) {
249
+ this.stopStream(stream);
250
+ stream.phase = 'failed';
251
+ }
252
+ this.publish(entry);
253
+ this.releaseWaiters(entry);
254
+ this.retryLimited();
255
+ }
256
+ settled(entry) {
257
+ return entry.streams.every((stream) => stream.acked || stream.phase === 'limit' || stream.phase === 'forbidden' || stream.phase === 'failed');
258
+ }
259
+ onAck(entry, stream) {
260
+ stream.acked = true;
261
+ stream.ackedAt = this.clock();
262
+ stream.phase = 'live';
263
+ this.afterSettle(entry);
264
+ }
265
+ afterSettle(entry) {
266
+ if (!this.settled(entry)) {
267
+ this.publish(entry);
268
+ return;
269
+ }
270
+ if (entry.streams.every((stream) => stream.acked)) {
271
+ entry.everLive = true;
272
+ }
273
+ this.publish(entry);
274
+ const covered = entry.waiters.size > 0;
275
+ this.releaseWaiters(entry);
276
+ if (entry.outage && entry.streams.every((stream) => stream.acked)) {
277
+ entry.outage = false;
278
+ const bypass = entry.bypass;
279
+ entry.bypass = false;
280
+ if (covered) {
281
+ return;
282
+ }
283
+ if (bypass) {
284
+ this.refetchNow(entry);
285
+ }
286
+ else {
287
+ entry.coalescer.request();
288
+ }
289
+ }
290
+ }
291
+ releaseWaiters(entry) {
292
+ for (const release of [...entry.waiters]) {
293
+ release();
294
+ }
295
+ }
296
+ onEnd(entry, stream, end) {
297
+ switch (end.kind) {
298
+ case 'reauthenticate':
299
+ this.reopen(entry, stream, this.backoff(stream), false);
300
+ return;
301
+ case 'fatal':
302
+ this.endFatal(entry, stream, end.error);
303
+ return;
304
+ case 'complete':
305
+ this.reopen(entry, stream, this.backoff(stream), false);
306
+ return;
307
+ case 'error':
308
+ this.onStreamError(entry, stream, end.errors);
309
+ }
310
+ }
311
+ onStreamError(entry, stream, errors) {
312
+ const first = errors[0];
313
+ switch (first?.code) {
314
+ case SubscriptionCode.Resync:
315
+ this.reopen(entry, stream, 0, first.extensions.reason === REASON_DEPENDENCY_CHANGED);
316
+ return;
317
+ case SubscriptionCode.Overflow:
318
+ case SubscriptionCode.SourceClosed:
319
+ case SubscriptionCode.Cancelled:
320
+ this.reopen(entry, stream, this.backoff(stream), false);
321
+ return;
322
+ case SubscriptionCode.Revalidation:
323
+ this.transport.renew();
324
+ this.reopen(entry, stream, this.backoff(stream), false);
325
+ return;
326
+ case SubscriptionCode.Forbidden:
327
+ this.endStream(entry, stream, 'forbidden');
328
+ entry.coalescer.request();
329
+ return;
330
+ case SubscriptionCode.Limit:
331
+ this.endStream(entry, stream, 'limit');
332
+ return;
333
+ default:
334
+ this.endFatal(entry, stream, first ?? new GolemProtocolError('a stream ended with an empty error list'));
335
+ }
336
+ }
337
+ endStream(entry, stream, phase) {
338
+ this.stopStream(stream);
339
+ stream.phase = phase;
340
+ this.afterSettle(entry);
341
+ if (phase === 'forbidden') {
342
+ this.retryLimited();
343
+ }
344
+ }
345
+ endFatal(entry, stream, error) {
346
+ this.stopStream(stream);
347
+ stream.phase = 'failed';
348
+ entry.failure = error;
349
+ this.publish(entry);
350
+ this.releaseWaiters(entry);
351
+ this.retryLimited();
352
+ }
353
+ backoff(stream) {
354
+ if (this.clock() - stream.ackedAt > STABLE_STREAM_MS) {
355
+ stream.attempt = 0;
356
+ }
357
+ return backoffDelay(stream.attempt++, this.random);
358
+ }
359
+ reopen(entry, stream, delay, bypass) {
360
+ this.stopStream(stream);
361
+ stream.phase = 'opening';
362
+ entry.outage = true;
363
+ entry.bypass ||= bypass;
364
+ this.publish(entry);
365
+ stream.timer = setTimeout(() => this.openStream(entry, stream), delay);
366
+ }
367
+ retryLimited() {
368
+ for (const entry of this.entries.values()) {
369
+ for (const stream of entry.streams) {
370
+ if (stream.phase === 'limit') {
371
+ entry.outage = true;
372
+ this.reopen(entry, stream, 0, false);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ statusFor(entry) {
378
+ if (entry.failure !== undefined || entry.streams.some((stream) => stream.phase === 'failed')) {
379
+ return { state: 'FAILED', error: entry.failure };
380
+ }
381
+ if (entry.streams.some((stream) => stream.phase === 'forbidden')) {
382
+ return { state: 'FORBIDDEN', error: undefined };
383
+ }
384
+ if (entry.streams.some((stream) => stream.phase === 'limit')) {
385
+ return { state: 'LIMIT', error: undefined };
386
+ }
387
+ if (entry.notFound !== undefined) {
388
+ return { state: 'NOT_FOUND', error: entry.notFound };
389
+ }
390
+ if (entry.streams.every((stream) => stream.acked)) {
391
+ return { state: 'LIVE', error: undefined };
392
+ }
393
+ return { state: entry.everLive || entry.outage ? 'RECOVERING' : 'CONNECTING', error: undefined };
394
+ }
395
+ publish(entry) {
396
+ if (entry.closed) {
397
+ return;
398
+ }
399
+ const next = this.statusFor(entry);
400
+ const known = this.statuses.get(entry.query.queryHash);
401
+ if (known !== undefined && SAME_STATUS(known, next)) {
402
+ return;
403
+ }
404
+ this.statuses.set(entry.query.queryHash, next);
405
+ this.notify();
406
+ }
407
+ notify() {
408
+ for (const listener of [...this.listeners]) {
409
+ listener();
410
+ }
411
+ }
412
+ onEvent(entry, stream, frame) {
413
+ const key = this.patcher.eventKey(stream.plan.model, frame.id, frame.entity);
414
+ const version = parseVersion(frame.version);
415
+ const receivedAt = this.clock();
416
+ const data = entry.query.state.data;
417
+ const within = entry.meta.pagination === undefined ? stream.plan.paths : stream.plan.paths.map((path) => `pages.${path}`);
418
+ const held = this.patcher.versions(data, frame.type === 'DELETED' ? undefined : within).get(key);
419
+ const recording = entry.inFlight > 0;
420
+ switch (frame.type) {
421
+ case 'CREATED':
422
+ if (recording) {
423
+ entry.merge = noteChange(entry.merge, key, receivedAt);
424
+ }
425
+ entry.coalescer.request();
426
+ return;
427
+ case 'UPDATED': {
428
+ if (recording) {
429
+ entry.merge = noteChange(entry.merge, key, receivedAt);
430
+ }
431
+ const decision = decideUpdate(held, version);
432
+ if (decision === 'invalidate' || (decision === 'patch' && !stream.plan.patchable)) {
433
+ entry.coalescer.request();
434
+ }
435
+ else if (decision === 'patch') {
436
+ if (frame.entity === null) {
437
+ throw new GolemProtocolError(`an UPDATED event for the held row ${key} carried no entity`);
438
+ }
439
+ this.write(entry, this.patcher.patch(data, key, frame.entity, within));
440
+ }
441
+ return;
442
+ }
443
+ case 'DELETED':
444
+ case 'EXITED': {
445
+ const outcome = applyRemoval(entry.merge, { key, version, receivedAt }, held);
446
+ if (recording) {
447
+ entry.merge = outcome.state;
448
+ }
449
+ if (outcome.remove) {
450
+ const removal = this.patcher.remove(data, new Set([key]), entry.meta.analysis.nullableRoots, frame.type === 'EXITED' ? within : undefined);
451
+ this.write(entry, removal.data);
452
+ if (removal.held.size > 0) {
453
+ this.markNotFound(entry, removal.held);
454
+ this.refetchNow(entry);
455
+ }
456
+ else if (removal.evicted) {
457
+ entry.coalescer.request();
458
+ }
459
+ }
460
+ return;
461
+ }
462
+ default:
463
+ throw new GolemProtocolError(`an event frame carried the unknown type ${JSON.stringify(frame.type)}`);
464
+ }
465
+ }
466
+ markNotFound(entry, keys) {
467
+ entry.notFound = new GolemNotFoundError([...keys]);
468
+ this.publish(entry);
469
+ }
470
+ clearNotFound(entry) {
471
+ if (entry.notFound !== undefined) {
472
+ entry.notFound = undefined;
473
+ this.publish(entry);
474
+ }
475
+ }
476
+ write(entry, next) {
477
+ if (next !== entry.query.state.data) {
478
+ this.client.setQueryData(entry.query.queryKey, next);
479
+ }
480
+ }
481
+ refetch(entry) {
482
+ if (entry.closed || entry.waiters.size > 0) {
483
+ return;
484
+ }
485
+ void this.client.refetchQueries({ queryKey: entry.query.queryKey, exact: true }, { cancelRefetch: true, throwOnError: false });
486
+ }
487
+ refetchNow(entry) {
488
+ entry.coalescer.cancel();
489
+ this.refetch(entry);
490
+ }
491
+ async gate(entry, signal) {
492
+ if (this.settled(entry) || entry.closed) {
493
+ return;
494
+ }
495
+ await new Promise((resolve, reject) => {
496
+ const release = () => {
497
+ entry.waiters.delete(release);
498
+ signal.removeEventListener('abort', abort);
499
+ resolve();
500
+ };
501
+ const abort = () => {
502
+ entry.waiters.delete(release);
503
+ reject(signal.reason);
504
+ };
505
+ entry.waiters.add(release);
506
+ signal.addEventListener('abort', abort, { once: true });
507
+ });
508
+ }
509
+ async fetch(context, meta, request = { query: meta.analysis.query, variables: meta.variables }) {
510
+ const query = this.client.getQueryCache().find({ queryKey: context.queryKey, exact: true });
511
+ const entry = query === undefined ? undefined : this.entries.get(query.queryHash);
512
+ if (entry !== undefined) {
513
+ await this.gate(entry, context.signal);
514
+ }
515
+ const live = entry !== undefined && !entry.closed ? entry : undefined;
516
+ const issuedAt = this.clock();
517
+ if (live !== undefined) {
518
+ live.inFlight++;
519
+ }
520
+ try {
521
+ const data = await this.transport.execute({ ...request, signal: context.signal });
522
+ if (live === undefined || live.closed || context.signal.aborted) {
523
+ return data;
524
+ }
525
+ return this.reconcile(live, issuedAt, data);
526
+ }
527
+ catch (error) {
528
+ if (live !== undefined && !context.signal.aborted) {
529
+ this.clearNotFound(live);
530
+ }
531
+ throw error;
532
+ }
533
+ finally {
534
+ if (live !== undefined) {
535
+ live.inFlight--;
536
+ if (live.inFlight === 0) {
537
+ live.merge = EMPTY_MERGE_STATE;
538
+ }
539
+ }
540
+ }
541
+ }
542
+ reconcile(entry, issuedAt, fresh) {
543
+ this.clearNotFound(entry);
544
+ const cachedData = entry.query.state.data;
545
+ const resolution = resolveResponse(entry.merge, {
546
+ issuedAt,
547
+ rows: this.patcher.versions(fresh),
548
+ cached: this.patcher.versions(cachedData),
549
+ });
550
+ entry.merge = resolution.state;
551
+ const dropped = new Set();
552
+ let stale = false;
553
+ for (const [key, decision] of resolution.rows) {
554
+ if (decision === 'drop') {
555
+ dropped.add(key);
556
+ }
557
+ else if (decision === 'keep-cached') {
558
+ stale = true;
559
+ }
560
+ }
561
+ if (stale) {
562
+ entry.coalescer.request();
563
+ if (entry.meta.pagination === undefined) {
564
+ return cachedData;
565
+ }
566
+ }
567
+ let next = fresh;
568
+ if (dropped.size > 0) {
569
+ const removal = this.patcher.remove(next, dropped, entry.meta.analysis.nullableRoots);
570
+ next = removal.data;
571
+ if (removal.held.size > 0) {
572
+ this.markNotFound(entry, removal.held);
573
+ }
574
+ if (removal.evicted) {
575
+ entry.coalescer.request();
576
+ }
577
+ }
578
+ if (resolution.refetch) {
579
+ entry.coalescer.request();
580
+ }
581
+ return next;
582
+ }
583
+ }
@@ -0,0 +1,26 @@
1
+ export interface ManifestRelation {
2
+ readonly target: string;
3
+ readonly list: boolean;
4
+ readonly nullable: boolean;
5
+ readonly inverse: string | null;
6
+ }
7
+ export interface ManifestModel {
8
+ readonly primaryKey: ReadonlyArray<string>;
9
+ readonly cursorSelector: string;
10
+ readonly uniqueInput: string | null;
11
+ readonly events: string | null;
12
+ readonly relations: Readonly<Record<string, ManifestRelation>>;
13
+ readonly computed: Readonly<Record<string, ReadonlyArray<string>>>;
14
+ }
15
+ export type RootShape = 'rows' | 'row' | 'deletedRow' | 'aggregate' | 'groupBy' | 'updateMany' | 'deleteMany' | 'value';
16
+ export interface ManifestRoot {
17
+ readonly kind: 'generated' | 'opaque';
18
+ readonly shape: RootShape;
19
+ readonly nullable: boolean;
20
+ readonly models: ReadonlyArray<string>;
21
+ }
22
+ export interface ClientManifest {
23
+ readonly contract: string;
24
+ readonly models: Readonly<Record<string, ManifestModel>>;
25
+ readonly roots: Readonly<Record<string, ManifestRoot>>;
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ export type Version = bigint;
2
+ export interface Marker {
3
+ readonly key: string;
4
+ readonly version: Version;
5
+ readonly receivedAt: number;
6
+ }
7
+ export interface Change {
8
+ readonly key: string;
9
+ readonly receivedAt: number;
10
+ }
11
+ export interface MergeState {
12
+ readonly markers: readonly Marker[];
13
+ readonly changes: readonly Change[];
14
+ }
15
+ export declare const EMPTY_MERGE_STATE: MergeState;
16
+ export type UpdateDecision = 'patch' | 'ignore' | 'invalidate';
17
+ export type RowDecision = 'apply' | 'keep-cached' | 'drop';
18
+ export interface Removal {
19
+ readonly key: string;
20
+ readonly version: Version;
21
+ readonly receivedAt: number;
22
+ }
23
+ export interface RemovalOutcome {
24
+ readonly remove: boolean;
25
+ readonly state: MergeState;
26
+ }
27
+ export interface Response {
28
+ readonly issuedAt: number;
29
+ readonly rows: ReadonlyMap<string, Version>;
30
+ readonly cached: ReadonlyMap<string, Version>;
31
+ }
32
+ export interface Resolution {
33
+ readonly rows: ReadonlyMap<string, RowDecision>;
34
+ readonly refetch: boolean;
35
+ readonly state: MergeState;
36
+ }
37
+ export declare function parseVersion(raw: string): Version;
38
+ export declare function decideUpdate(cached: Version | undefined, event: Version): UpdateDecision;
39
+ export declare function noteChange(state: MergeState, key: string, receivedAt: number): MergeState;
40
+ export declare function applyRemoval(state: MergeState, removal: Removal, cached: Version | undefined): RemovalOutcome;
41
+ export declare function resolveResponse(state: MergeState, response: Response): Resolution;