@opinionated-machine/sse-fallback 0.1.1
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/LICENSE +21 -0
- package/README.md +365 -0
- package/dist/binding.d.ts +82 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +181 -0
- package/dist/binding.js.map +1 -0
- package/dist/bindingTypes.d.ts +327 -0
- package/dist/bindingTypes.d.ts.map +1 -0
- package/dist/bindingTypes.js +43 -0
- package/dist/bindingTypes.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/pollGate.d.ts +58 -0
- package/dist/pollGate.d.ts.map +1 -0
- package/dist/pollGate.js +64 -0
- package/dist/pollGate.js.map +1 -0
- package/dist/reconciler.d.ts +245 -0
- package/dist/reconciler.d.ts.map +1 -0
- package/dist/reconciler.js +568 -0
- package/dist/reconciler.js.map +1 -0
- package/dist/scheduler.d.ts +19 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +51 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/subscription.d.ts +164 -0
- package/dist/subscription.d.ts.map +1 -0
- package/dist/subscription.js +878 -0
- package/dist/subscription.js.map +1 -0
- package/dist/transport.d.ts +177 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +168 -0
- package/dist/transport.js.map +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { EventPayloadMap, FallbackBindingConfig, FallbackEvent, Version } from './bindingTypes.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The reconciler is the pure correctness core of the fallback pattern: a
|
|
4
|
+
* version-gated event pipeline with a high-watermark, hydration buffering,
|
|
5
|
+
* and gap detection. It owns NO timers and NO transport — the subscription
|
|
6
|
+
* wires those around it — which keeps every race testable synchronously.
|
|
7
|
+
*
|
|
8
|
+
* The single delivery rule: an item (event or snapshot) with version `v` is
|
|
9
|
+
* delivered iff `v` is greater than the high-watermark; delivery advances
|
|
10
|
+
* the watermark. That one rule simultaneously handles duplicate delivery
|
|
11
|
+
* (SSE event + poll snapshot of the same update), the stale-poll race (a
|
|
12
|
+
* slow poll response arriving AFTER a newer pushed event is dropped at
|
|
13
|
+
* arrival time), and replay overlap after reconnection.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A break in the version sequence that only a snapshot can repair.
|
|
17
|
+
*
|
|
18
|
+
* `'sequence'` means the dense counter skipped ahead inside one epoch, so a
|
|
19
|
+
* known number of events was lost. `'epoch-change'` means the id epoch itself
|
|
20
|
+
* changed (a writer restarted, or the ordering scope was reset): counters on
|
|
21
|
+
* either side are not comparable, so the number of missed events is unknowable
|
|
22
|
+
* and delta state has to be rebuilt from a snapshot rather than carried across.
|
|
23
|
+
*/
|
|
24
|
+
export type VersionGap = {
|
|
25
|
+
from: Version;
|
|
26
|
+
to: Version;
|
|
27
|
+
reason: 'sequence' | 'epoch-change';
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A version extractor returned a value that cannot be ordered — `undefined`
|
|
31
|
+
* from `version.ofSnapshot` (a snapshot body missing its version field is the
|
|
32
|
+
* usual cause), `NaN`, `Infinity`, an empty string, or a non-scalar.
|
|
33
|
+
*
|
|
34
|
+
* The item is still delivered, just without advancing the watermark, so the
|
|
35
|
+
* subscription degrades to at-least-once rather than wedging. This exists so
|
|
36
|
+
* that degradation is visible instead of silent.
|
|
37
|
+
*/
|
|
38
|
+
export type InvalidVersionInfo = {
|
|
39
|
+
source: 'snapshot' | 'event';
|
|
40
|
+
value: unknown;
|
|
41
|
+
};
|
|
42
|
+
export type IncomingEvent = {
|
|
43
|
+
event: string;
|
|
44
|
+
data: unknown;
|
|
45
|
+
id?: string;
|
|
46
|
+
};
|
|
47
|
+
export type EventOutcome<Events extends EventPayloadMap> = {
|
|
48
|
+
deliveries: Array<FallbackEvent<Events>>;
|
|
49
|
+
/** Event was at/below the watermark and dropped. */
|
|
50
|
+
duplicate: boolean;
|
|
51
|
+
/** Event was buffered (hydration in progress). */
|
|
52
|
+
buffered: boolean;
|
|
53
|
+
/** Hydration buffer overflowed — caller must refetch the snapshot. */
|
|
54
|
+
bufferOverflow: boolean;
|
|
55
|
+
/** A gap or an epoch change was detected — caller should poll now. */
|
|
56
|
+
gap?: VersionGap;
|
|
57
|
+
/** A terminal event was delivered — the subscription is complete. */
|
|
58
|
+
terminated: boolean;
|
|
59
|
+
/** New state value when the state layer applied the event. */
|
|
60
|
+
state?: {
|
|
61
|
+
value: unknown;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Whether the state layer is gap-suspended after this event. While
|
|
65
|
+
* suspended `apply` is skipped, so `getState()` keeps returning the
|
|
66
|
+
* pre-gap value even though events are still delivered — the caller must
|
|
67
|
+
* poll for a repair snapshot and can surface the staleness meanwhile.
|
|
68
|
+
*/
|
|
69
|
+
stateSuspended: boolean;
|
|
70
|
+
};
|
|
71
|
+
export type SnapshotOutcome<Events extends EventPayloadMap> = {
|
|
72
|
+
deliveries: Array<FallbackEvent<Events>>;
|
|
73
|
+
/** Snapshot was at/below the watermark and dropped entirely. */
|
|
74
|
+
stale: boolean;
|
|
75
|
+
/** The watermark (or versionless equivalent) advanced — the poll carried news. */
|
|
76
|
+
advanced: boolean;
|
|
77
|
+
/** Hydration completed with this snapshot (buffered events were flushed). */
|
|
78
|
+
hydrationCompleted: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* A gap was detected while flushing the hydration buffer — caller should
|
|
81
|
+
* poll now. Buffered events pass through the same version gate as live
|
|
82
|
+
* ones, so the hole they expose needs the same repair poll; without this
|
|
83
|
+
* the gap would be found and then silently dropped with the flush outcome.
|
|
84
|
+
*/
|
|
85
|
+
gap?: VersionGap;
|
|
86
|
+
terminated: boolean;
|
|
87
|
+
state?: {
|
|
88
|
+
value: unknown;
|
|
89
|
+
};
|
|
90
|
+
/** Whether the state layer is still gap-suspended after this snapshot. */
|
|
91
|
+
stateSuspended: boolean;
|
|
92
|
+
/** This snapshot lifted a gap suspension and re-initialized state. */
|
|
93
|
+
stateRepaired: boolean;
|
|
94
|
+
};
|
|
95
|
+
export declare class Reconciler<Snapshot, Events extends EventPayloadMap, State> {
|
|
96
|
+
private readonly config;
|
|
97
|
+
private readonly snapshotToEvents;
|
|
98
|
+
private readonly terminalSet;
|
|
99
|
+
private highWatermark;
|
|
100
|
+
private hydrationBuffer;
|
|
101
|
+
private readonly hydrationBufferLimit;
|
|
102
|
+
private stateValue;
|
|
103
|
+
private stateInitialized;
|
|
104
|
+
private stateSuspended;
|
|
105
|
+
/**
|
|
106
|
+
* Events delivered while the state layer is gap-suspended, in arrival
|
|
107
|
+
* order, so the repair snapshot can re-apply the ones it does not cover.
|
|
108
|
+
*/
|
|
109
|
+
private stateReplayBuffer;
|
|
110
|
+
/**
|
|
111
|
+
* The replay buffer overflowed and was dropped. A below-watermark snapshot
|
|
112
|
+
* can no longer repair state without losing the events it does not cover,
|
|
113
|
+
* so the suspension holds until a snapshot reaches the watermark.
|
|
114
|
+
*/
|
|
115
|
+
private stateReplayTruncated;
|
|
116
|
+
private terminated;
|
|
117
|
+
private readonly onInvalidVersion;
|
|
118
|
+
constructor(config: FallbackBindingConfig<Snapshot, Events, State>, options: {
|
|
119
|
+
hydrationBufferLimit: number;
|
|
120
|
+
/**
|
|
121
|
+
* Reported when a version extractor hands back something that cannot be
|
|
122
|
+
* ordered. Purely observational — the reconciler degrades on its own.
|
|
123
|
+
*/
|
|
124
|
+
onInvalidVersion?: (info: InvalidVersionInfo) => void;
|
|
125
|
+
});
|
|
126
|
+
get isTerminated(): boolean;
|
|
127
|
+
get isHydrating(): boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Whether the state layer is suspended after a detected gap. `apply` stays
|
|
130
|
+
* disabled until a snapshot repairs state, so `getState()` is known-stale
|
|
131
|
+
* while this is true.
|
|
132
|
+
*/
|
|
133
|
+
get isStateSuspended(): boolean;
|
|
134
|
+
getState(): State | undefined;
|
|
135
|
+
/** Start buffering live events until the next snapshot arrives. */
|
|
136
|
+
beginHydration(): void;
|
|
137
|
+
/**
|
|
138
|
+
* Give up on subscribe-first hydration and resume direct delivery.
|
|
139
|
+
*
|
|
140
|
+
* The buffered events are FLUSHED, not discarded: hydration never completed,
|
|
141
|
+
* so no snapshot subsumes them and dropping them would lose exactly the
|
|
142
|
+
* events the buffer existed to protect. They pass through the version gate
|
|
143
|
+
* in arrival order, so a later snapshot still wins on version.
|
|
144
|
+
*/
|
|
145
|
+
abandonHydration(): SnapshotOutcome<Events>;
|
|
146
|
+
handleEvent(incoming: IncomingEvent): EventOutcome<Events>;
|
|
147
|
+
handleSnapshot(snapshot: Snapshot): SnapshotOutcome<Events>;
|
|
148
|
+
/**
|
|
149
|
+
* The stale-poll race: everything this snapshot describes has already been
|
|
150
|
+
* delivered (or superseded) through the stream.
|
|
151
|
+
*
|
|
152
|
+
* One exception: a gap-suspended state layer is repaired by ANY snapshot,
|
|
153
|
+
* not only one whose version matches the watermark exactly. Live events keep
|
|
154
|
+
* advancing the watermark after a gap, so the repair snapshot this branch
|
|
155
|
+
* was polled for usually arrives strictly below it; requiring equality left
|
|
156
|
+
* `apply` disabled forever and froze `getState()` at its pre-gap value while
|
|
157
|
+
* events kept flowing. The events the snapshot does not cover are replayed
|
|
158
|
+
* onto it, so nothing delivered during the suspension is dropped from state.
|
|
159
|
+
* The watermark is NOT rewound, so nothing is re-delivered.
|
|
160
|
+
*
|
|
161
|
+
* With a dropped replay buffer that is impossible, so the suspension holds
|
|
162
|
+
* until a snapshot reaches the watermark, which covers everything delivered
|
|
163
|
+
* during it and needs no replay.
|
|
164
|
+
*/
|
|
165
|
+
private handleStaleSnapshot;
|
|
166
|
+
/**
|
|
167
|
+
* Re-initialize a gap-suspended state layer from a snapshot below the
|
|
168
|
+
* watermark, then re-apply the buffered events the snapshot does not cover.
|
|
169
|
+
*
|
|
170
|
+
* The repair snapshot is usually older than the watermark, because live
|
|
171
|
+
* events keep arriving while the repair poll is in flight. Without the
|
|
172
|
+
* replay those events would be missing from state permanently: delivered to
|
|
173
|
+
* listeners, skipped by `apply` while suspended, then overwritten by an
|
|
174
|
+
* `init` that predates them. The next event would apply to a state that
|
|
175
|
+
* never saw them.
|
|
176
|
+
*
|
|
177
|
+
* The cut is by arrival order, anchored on the last buffered event the
|
|
178
|
+
* snapshot demonstrably covers. Events after it are replayed even when they
|
|
179
|
+
* carry no orderable version, because arrival order is the only ordering
|
|
180
|
+
* they have.
|
|
181
|
+
*/
|
|
182
|
+
private repairState;
|
|
183
|
+
/**
|
|
184
|
+
* Keep a delivered event for the repair snapshot to replay. Overflow drops
|
|
185
|
+
* the whole buffer rather than half of it: replaying a partial buffer would
|
|
186
|
+
* apply deltas across a hole, which is exactly what the suspension exists
|
|
187
|
+
* to prevent.
|
|
188
|
+
*/
|
|
189
|
+
private recordSuspendedEvent;
|
|
190
|
+
private finishHydration;
|
|
191
|
+
/**
|
|
192
|
+
* Record a detected gap on the outcome and suspend the state layer, so the
|
|
193
|
+
* caller polls for a repair snapshot instead of applying deltas across a
|
|
194
|
+
* hole.
|
|
195
|
+
*/
|
|
196
|
+
private registerGap;
|
|
197
|
+
private gateAndDeliver;
|
|
198
|
+
private extractEventVersion;
|
|
199
|
+
/**
|
|
200
|
+
* A version the gate can actually order, or `undefined`.
|
|
201
|
+
*
|
|
202
|
+
* Nothing in the type system stops an extractor from handing back
|
|
203
|
+
* `undefined` (a snapshot body whose version field is absent), `NaN`, an
|
|
204
|
+
* empty string or an object, and storing one as the watermark is the worst
|
|
205
|
+
* outcome the gate has: `defaultCompareVersions` falls through to a
|
|
206
|
+
* lexicographic comparison against `'undefined'`/`'NaN'`, every later item
|
|
207
|
+
* ranks at or below it, and the subscription drops everything as a
|
|
208
|
+
* duplicate — silently, forever. Dropping the version instead costs
|
|
209
|
+
* deduplication (at-least-once delivery) and keeps the stream flowing.
|
|
210
|
+
*/
|
|
211
|
+
private normalizeVersion;
|
|
212
|
+
/**
|
|
213
|
+
* Whether the epoch bypass below applies. A binding that declares
|
|
214
|
+
* `version.compare` owns ordering end to end, including across epochs, so
|
|
215
|
+
* its verdict is never overridden here — the bypass exists to correct the
|
|
216
|
+
* DEFAULT comparator, which ranks by epoch and therefore reads a lowered
|
|
217
|
+
* epoch as an older version.
|
|
218
|
+
*/
|
|
219
|
+
private get ordersEpochs();
|
|
220
|
+
/**
|
|
221
|
+
* Whether two versions belong to different ordering scopes under the
|
|
222
|
+
* default comparator — see {@link ordersEpochs}.
|
|
223
|
+
*/
|
|
224
|
+
private isEpochChange;
|
|
225
|
+
private compare;
|
|
226
|
+
private detectGap;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The version a bare SSE `id:` carries under the default extractor: a bare
|
|
230
|
+
* integer becomes a number, a `createEventIdSequence()` id stays a string
|
|
231
|
+
* (ordered by {@link defaultCompareVersions}), anything else carries no
|
|
232
|
+
* version at all.
|
|
233
|
+
*/
|
|
234
|
+
export declare function parseDefaultVersion(id: string): Version | undefined;
|
|
235
|
+
/**
|
|
236
|
+
* Numeric when both sides are numeric, epoch-then-counter when both are
|
|
237
|
+
* `createEventIdSequence()` ids, lexicographic otherwise.
|
|
238
|
+
*
|
|
239
|
+
* Comparing sequence ids by their parsed parts rather than lexicographically
|
|
240
|
+
* keeps ordering correct when the counter outgrows its zero padding, and
|
|
241
|
+
* makes a new epoch (a process restart) sort above the old one, so the
|
|
242
|
+
* restarted counter does not read as a flood of duplicates.
|
|
243
|
+
*/
|
|
244
|
+
export declare function defaultCompareVersions(a: Version, b: Version): number;
|
|
245
|
+
//# sourceMappingURL=reconciler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconciler.d.ts","sourceRoot":"","sources":["../src/reconciler.ts"],"names":[],"mappings":"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"}
|