@loro-dev/streams-crdt 0.7.0 → 0.8.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/README.md +37 -0
- package/dist/flock.d.ts +2 -2
- package/dist/flock.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/loro.d.ts +27 -2
- package/dist/loro.js +24 -2
- package/dist/loro.js.map +1 -1
- package/dist/{stream-id-CS7SzuLW.d.ts → stream-id-BXJvwX_O.d.ts} +117 -2
- package/dist/{stream-id-CbVwCS3g.js → stream-id-CF1egQDz.js} +1052 -404
- package/dist/stream-id-CF1egQDz.js.map +1 -0
- package/package.json +5 -3
- package/dist/stream-id-CbVwCS3g.js.map +0 -1
|
@@ -1,211 +1,4 @@
|
|
|
1
|
-
import { StreamsClient } from "@loro-dev/streams-client";
|
|
2
|
-
//#region src/checkpoint.ts
|
|
3
|
-
function composeBeforeRemoteCursorSaveHooks(...hooks) {
|
|
4
|
-
const active = hooks.filter((hook) => hook != null);
|
|
5
|
-
if (active.length === 0) return;
|
|
6
|
-
return async (context) => {
|
|
7
|
-
for (const hook of active) await hook(context);
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
async function persistRemoteCursor(input) {
|
|
11
|
-
const next = {
|
|
12
|
-
...input.cursor,
|
|
13
|
-
updatedAtMs: (input.now ?? Date.now)()
|
|
14
|
-
};
|
|
15
|
-
if (input.beforeRemoteCursorSave != null) await input.beforeRemoteCursorSave({
|
|
16
|
-
cursor: next,
|
|
17
|
-
source: input.source
|
|
18
|
-
});
|
|
19
|
-
await input.remoteCursorStore.save(next);
|
|
20
|
-
return next;
|
|
21
|
-
}
|
|
22
|
-
function key(streamUrl) {
|
|
23
|
-
return streamUrl;
|
|
24
|
-
}
|
|
25
|
-
function isJsonObject(value) {
|
|
26
|
-
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
27
|
-
}
|
|
28
|
-
function cloneJsonValue(value) {
|
|
29
|
-
return structuredClone(value);
|
|
30
|
-
}
|
|
31
|
-
function cloneRemoteCursor(cursor) {
|
|
32
|
-
return {
|
|
33
|
-
...cursor,
|
|
34
|
-
serverLowerBoundVersion: cloneJsonValue(cursor.serverLowerBoundVersion)
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
function fromStoredRemoteCursor(record) {
|
|
38
|
-
if (!isJsonObject(record.serverLowerBoundVersion)) throw new Error("invalid remote cursor record: serverLowerBoundVersion is not a JSON object");
|
|
39
|
-
return {
|
|
40
|
-
streamUrl: record.streamUrl,
|
|
41
|
-
nextOffset: record.nextOffset,
|
|
42
|
-
serverLowerBoundVersion: cloneJsonValue(record.serverLowerBoundVersion),
|
|
43
|
-
updatedAtMs: record.updatedAtMs
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
function toStoredRemoteCursor(cursor) {
|
|
47
|
-
return {
|
|
48
|
-
streamUrl: cursor.streamUrl,
|
|
49
|
-
nextOffset: cursor.nextOffset,
|
|
50
|
-
serverLowerBoundVersion: cloneJsonValue(cursor.serverLowerBoundVersion),
|
|
51
|
-
updatedAtMs: cursor.updatedAtMs
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* In-memory cursor store useful for tests or ephemeral runtimes.
|
|
56
|
-
*/
|
|
57
|
-
var InMemoryRemoteCursorStore = class {
|
|
58
|
-
data = /* @__PURE__ */ new Map();
|
|
59
|
-
async load(streamUrl) {
|
|
60
|
-
const value = this.data.get(key(streamUrl));
|
|
61
|
-
return value == null ? null : cloneRemoteCursor(value);
|
|
62
|
-
}
|
|
63
|
-
async save(cursor) {
|
|
64
|
-
this.data.set(key(cursor.streamUrl), cloneRemoteCursor(cursor));
|
|
65
|
-
}
|
|
66
|
-
async delete(streamUrl) {
|
|
67
|
-
this.data.delete(key(streamUrl));
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
const DEFAULT_DB_NAME = "streams-crdt";
|
|
71
|
-
const DEFAULT_STORE_NAME = "remote-cursors";
|
|
72
|
-
const DEFAULT_DB_VERSION = 1;
|
|
73
|
-
/**
|
|
74
|
-
* IndexedDB-backed cursor store for browser clients that need replay progress persistence.
|
|
75
|
-
*
|
|
76
|
-
* This store only persists `RemoteCursor` metadata. It does not persist CRDT
|
|
77
|
-
* payloads. Use it only when the application also persists the corresponding
|
|
78
|
-
* local document state and restores that state before calling `sync()` or
|
|
79
|
-
* `join()` after a reload.
|
|
80
|
-
*/
|
|
81
|
-
var IndexedDbRemoteCursorStore = class {
|
|
82
|
-
dbName;
|
|
83
|
-
storeName;
|
|
84
|
-
dbVersion;
|
|
85
|
-
indexedDb;
|
|
86
|
-
db = null;
|
|
87
|
-
initPromise = null;
|
|
88
|
-
constructor(options) {
|
|
89
|
-
this.dbName = options?.dbName ?? DEFAULT_DB_NAME;
|
|
90
|
-
this.storeName = options?.storeName ?? DEFAULT_STORE_NAME;
|
|
91
|
-
this.dbVersion = options?.dbVersion ?? DEFAULT_DB_VERSION;
|
|
92
|
-
this.indexedDb = options?.indexedDb ?? globalThis.indexedDB;
|
|
93
|
-
}
|
|
94
|
-
async load(streamUrl) {
|
|
95
|
-
const db = await this.ensureDb();
|
|
96
|
-
const record = await new Promise((resolve, reject) => {
|
|
97
|
-
const request = db.transaction([this.storeName], "readonly").objectStore(this.storeName).get(key(streamUrl));
|
|
98
|
-
request.onsuccess = () => {
|
|
99
|
-
resolve(request.result ?? null);
|
|
100
|
-
};
|
|
101
|
-
request.onerror = () => {
|
|
102
|
-
reject(request.error ?? /* @__PURE__ */ new Error("indexeddb load failed"));
|
|
103
|
-
};
|
|
104
|
-
});
|
|
105
|
-
return record == null ? null : fromStoredRemoteCursor(record);
|
|
106
|
-
}
|
|
107
|
-
async save(cursor) {
|
|
108
|
-
const db = await this.ensureDb();
|
|
109
|
-
const record = toStoredRemoteCursor(cursor);
|
|
110
|
-
await new Promise((resolve, reject) => {
|
|
111
|
-
const tx = db.transaction([this.storeName], "readwrite");
|
|
112
|
-
const request = tx.objectStore(this.storeName).put(record, key(cursor.streamUrl));
|
|
113
|
-
tx.oncomplete = () => resolve();
|
|
114
|
-
tx.onerror = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb save failed"));
|
|
115
|
-
tx.onabort = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb save transaction aborted"));
|
|
116
|
-
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb save failed"));
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
async delete(streamUrl) {
|
|
120
|
-
const db = await this.ensureDb();
|
|
121
|
-
await new Promise((resolve, reject) => {
|
|
122
|
-
const tx = db.transaction([this.storeName], "readwrite");
|
|
123
|
-
const request = tx.objectStore(this.storeName).delete(key(streamUrl));
|
|
124
|
-
tx.oncomplete = () => resolve();
|
|
125
|
-
tx.onerror = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb delete failed"));
|
|
126
|
-
tx.onabort = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb delete transaction aborted"));
|
|
127
|
-
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb delete failed"));
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
async ensureDb() {
|
|
131
|
-
if (this.db != null) return this.db;
|
|
132
|
-
if (this.initPromise != null) {
|
|
133
|
-
await this.initPromise;
|
|
134
|
-
if (this.db == null) throw new Error("indexeddb is not available");
|
|
135
|
-
return this.db;
|
|
136
|
-
}
|
|
137
|
-
if (this.indexedDb == null) throw new Error("indexeddb is not available in this environment");
|
|
138
|
-
this.initPromise = new Promise((resolve, reject) => {
|
|
139
|
-
const request = this.indexedDb.open(this.dbName, this.dbVersion);
|
|
140
|
-
request.onupgradeneeded = () => {
|
|
141
|
-
const db = request.result;
|
|
142
|
-
if (!db.objectStoreNames.contains(this.storeName)) db.createObjectStore(this.storeName);
|
|
143
|
-
};
|
|
144
|
-
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb open failed"));
|
|
145
|
-
request.onsuccess = () => {
|
|
146
|
-
this.db = request.result;
|
|
147
|
-
this.db.onversionchange = () => {
|
|
148
|
-
this.db?.close();
|
|
149
|
-
this.db = null;
|
|
150
|
-
};
|
|
151
|
-
resolve();
|
|
152
|
-
};
|
|
153
|
-
}).finally(() => {
|
|
154
|
-
this.initPromise = null;
|
|
155
|
-
});
|
|
156
|
-
await this.initPromise;
|
|
157
|
-
if (this.db == null) throw new Error("indexeddb open succeeded without a database handle");
|
|
158
|
-
return this.db;
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
/**
|
|
162
|
-
* Creates a cursor value suitable for seeding a `RemoteCursorStore`.
|
|
163
|
-
*/
|
|
164
|
-
function createInitialRemoteCursor(input) {
|
|
165
|
-
return {
|
|
166
|
-
streamUrl: input.streamUrl,
|
|
167
|
-
nextOffset: input.nextOffset,
|
|
168
|
-
serverLowerBoundVersion: cloneJsonValue(input.serverLowerBoundVersion),
|
|
169
|
-
updatedAtMs: Date.now()
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
//#endregion
|
|
173
|
-
//#region src/reconnect.ts
|
|
174
|
-
const DEFAULT_RECONNECT_CONFIG = {
|
|
175
|
-
delays: [
|
|
176
|
-
0,
|
|
177
|
-
500,
|
|
178
|
-
1e3,
|
|
179
|
-
2e3,
|
|
180
|
-
4e3,
|
|
181
|
-
8e3,
|
|
182
|
-
15e3
|
|
183
|
-
],
|
|
184
|
-
jitterFraction: .2,
|
|
185
|
-
maxAttempts: Infinity,
|
|
186
|
-
connectTimeoutMs: 1e4,
|
|
187
|
-
pollTimeoutMs: 3e4,
|
|
188
|
-
liveInactivityTimeoutMs: 45e3
|
|
189
|
-
};
|
|
190
|
-
/**
|
|
191
|
-
* Apply jitter of +/- fraction to a base delay.
|
|
192
|
-
* E.g. applyJitter(1000, 0.2) returns a value in [800, 1200].
|
|
193
|
-
*/
|
|
194
|
-
function applyJitter(baseMs, fraction) {
|
|
195
|
-
if (baseMs === 0) return 0;
|
|
196
|
-
const jitter = baseMs * fraction * (2 * Math.random() - 1);
|
|
197
|
-
return Math.max(0, Math.round(baseMs + jitter));
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Compute the retry delay for a given attempt number.
|
|
201
|
-
* Uses the delays table from config, clamping at the last entry.
|
|
202
|
-
*/
|
|
203
|
-
function computeRetryDelay(attempt, config = DEFAULT_RECONNECT_CONFIG) {
|
|
204
|
-
const { delays, jitterFraction } = config;
|
|
205
|
-
const base = delays[Math.min(attempt, delays.length - 1)];
|
|
206
|
-
return applyJitter(base, jitterFraction);
|
|
207
|
-
}
|
|
208
|
-
//#endregion
|
|
1
|
+
import { StreamsClient, decodeSsePayload, readSseEvents } from "@loro-dev/streams-client";
|
|
209
2
|
//#region src/deque.ts
|
|
210
3
|
const DEFAULT_COMPACT_THRESHOLD = 64;
|
|
211
4
|
const DEFAULT_MIN_CAPACITY = 16;
|
|
@@ -327,6 +120,42 @@ var Deque = class {
|
|
|
327
120
|
}
|
|
328
121
|
};
|
|
329
122
|
//#endregion
|
|
123
|
+
//#region src/reconnect.ts
|
|
124
|
+
const DEFAULT_RECONNECT_CONFIG = {
|
|
125
|
+
delays: [
|
|
126
|
+
0,
|
|
127
|
+
500,
|
|
128
|
+
1e3,
|
|
129
|
+
2e3,
|
|
130
|
+
4e3,
|
|
131
|
+
8e3,
|
|
132
|
+
15e3
|
|
133
|
+
],
|
|
134
|
+
jitterFraction: .2,
|
|
135
|
+
maxAttempts: Infinity,
|
|
136
|
+
connectTimeoutMs: 1e4,
|
|
137
|
+
pollTimeoutMs: 3e4,
|
|
138
|
+
liveInactivityTimeoutMs: 45e3
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Apply jitter of +/- fraction to a base delay.
|
|
142
|
+
* E.g. applyJitter(1000, 0.2) returns a value in [800, 1200].
|
|
143
|
+
*/
|
|
144
|
+
function applyJitter(baseMs, fraction) {
|
|
145
|
+
if (baseMs === 0) return 0;
|
|
146
|
+
const jitter = baseMs * fraction * (2 * Math.random() - 1);
|
|
147
|
+
return Math.max(0, Math.round(baseMs + jitter));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Compute the retry delay for a given attempt number.
|
|
151
|
+
* Uses the delays table from config, clamping at the last entry.
|
|
152
|
+
*/
|
|
153
|
+
function computeRetryDelay(attempt, config = DEFAULT_RECONNECT_CONFIG) {
|
|
154
|
+
const { delays, jitterFraction } = config;
|
|
155
|
+
const base = delays[Math.min(attempt, delays.length - 1)];
|
|
156
|
+
return applyJitter(base, jitterFraction);
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
330
159
|
//#region src/items.ts
|
|
331
160
|
const BINARY_CONTENT_TYPE = "application/octet-stream";
|
|
332
161
|
function encodeItems(items) {
|
|
@@ -748,6 +577,23 @@ function sleep(ms, signal) {
|
|
|
748
577
|
}, { once: true });
|
|
749
578
|
});
|
|
750
579
|
}
|
|
580
|
+
function mergeAbortSignals(...signals) {
|
|
581
|
+
const active = signals.filter((signal) => signal != null);
|
|
582
|
+
if (active.length === 0) return;
|
|
583
|
+
if (active.length === 1) return active[0];
|
|
584
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any(active);
|
|
585
|
+
const controller = new AbortController();
|
|
586
|
+
for (const signal of active) {
|
|
587
|
+
if (signal.aborted) {
|
|
588
|
+
controller.abort(signal.reason);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
signal.addEventListener("abort", () => {
|
|
592
|
+
controller.abort(signal.reason);
|
|
593
|
+
}, { once: true });
|
|
594
|
+
}
|
|
595
|
+
return controller.signal;
|
|
596
|
+
}
|
|
751
597
|
function classifyFetchError(err) {
|
|
752
598
|
const message = err instanceof Error ? err.message : String(err);
|
|
753
599
|
const status = err instanceof HttpError ? err.status : void 0;
|
|
@@ -959,7 +805,10 @@ var StreamsTransportClient = class {
|
|
|
959
805
|
streamUrl;
|
|
960
806
|
adapter;
|
|
961
807
|
client;
|
|
808
|
+
fetchImpl;
|
|
809
|
+
authProvider;
|
|
962
810
|
reconnectConfig;
|
|
811
|
+
authRefreshInFlight;
|
|
963
812
|
constructor(options) {
|
|
964
813
|
this.streamUrl = options.streamUrl;
|
|
965
814
|
this.adapter = options.adapter;
|
|
@@ -973,6 +822,8 @@ var StreamsTransportClient = class {
|
|
|
973
822
|
pollTimeoutMs: options.reconnectConfig.pollTimeoutMs
|
|
974
823
|
}
|
|
975
824
|
});
|
|
825
|
+
this.fetchImpl = options.fetchImpl;
|
|
826
|
+
this.authProvider = options.authProvider;
|
|
976
827
|
this.reconnectConfig = options.reconnectConfig;
|
|
977
828
|
}
|
|
978
829
|
async createStreamRequest() {
|
|
@@ -986,8 +837,8 @@ var StreamsTransportClient = class {
|
|
|
986
837
|
if (result.ok) return result.result.deleted;
|
|
987
838
|
throw this.toTransportFailure(result.result);
|
|
988
839
|
}
|
|
989
|
-
async readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal) {
|
|
990
|
-
return this.readItemsSse(offset, cursor, onControl, onOpen, signal);
|
|
840
|
+
async readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal, onActivity) {
|
|
841
|
+
return this.readItemsSse(offset, cursor, onControl, onOpen, signal, onActivity);
|
|
991
842
|
}
|
|
992
843
|
async readFromOffset(offset, cursor, live, signal) {
|
|
993
844
|
const result = await this.client.readOnce({
|
|
@@ -1031,170 +882,859 @@ var StreamsTransportClient = class {
|
|
|
1031
882
|
const snapshotOffset = result.result.snapshotOffset;
|
|
1032
883
|
return snapshotOffset == null || snapshotOffset.length === 0 ? null : snapshotOffset;
|
|
1033
884
|
}
|
|
1034
|
-
async putSnapshotAt(snapshotOffset, snapshot) {
|
|
1035
|
-
const result = await this.client.putSnapshot({
|
|
1036
|
-
offset: snapshotOffset,
|
|
1037
|
-
part: {
|
|
1038
|
-
contentType: "application/octet-stream",
|
|
1039
|
-
body: snapshot
|
|
1040
|
-
}
|
|
885
|
+
async putSnapshotAt(snapshotOffset, snapshot) {
|
|
886
|
+
const result = await this.client.putSnapshot({
|
|
887
|
+
offset: snapshotOffset,
|
|
888
|
+
part: {
|
|
889
|
+
contentType: "application/octet-stream",
|
|
890
|
+
body: snapshot
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
if (!result.ok) throw this.toTransportFailure(result.result);
|
|
894
|
+
}
|
|
895
|
+
async appendUpdates(updates, producerSession) {
|
|
896
|
+
const result = await this.client.append({
|
|
897
|
+
part: {
|
|
898
|
+
contentType: BINARY_CONTENT_TYPE,
|
|
899
|
+
body: encodeItems(updates)
|
|
900
|
+
},
|
|
901
|
+
producer: {
|
|
902
|
+
producerId: producerSession.producerId,
|
|
903
|
+
epoch: producerSession.producerEpoch,
|
|
904
|
+
seq: producerSession.nextProducerSeq
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
if (result.ok) return {
|
|
908
|
+
kind: "ok",
|
|
909
|
+
ackEpoch: result.result.producer?.epoch ?? producerSession.producerEpoch,
|
|
910
|
+
ackSeq: result.result.producer?.seq ?? producerSession.nextProducerSeq,
|
|
911
|
+
nextOffset: result.result.nextOffset
|
|
912
|
+
};
|
|
913
|
+
const error = result.result;
|
|
914
|
+
if (error.code === "conflict" && error.detail?.kind === "producer_seq_gap") return {
|
|
915
|
+
kind: "seq-gap",
|
|
916
|
+
expectedSeq: error.detail.expectedSeq
|
|
917
|
+
};
|
|
918
|
+
if (error.code === "forbidden" && error.detail?.kind === "stale_producer_epoch") return {
|
|
919
|
+
kind: "stale-epoch",
|
|
920
|
+
serverEpoch: error.detail.serverEpoch
|
|
921
|
+
};
|
|
922
|
+
throw this.toTransportFailure(error);
|
|
923
|
+
}
|
|
924
|
+
async appendItems(updates) {
|
|
925
|
+
const result = await this.client.append({ part: {
|
|
926
|
+
contentType: BINARY_CONTENT_TYPE,
|
|
927
|
+
body: encodeItems(updates)
|
|
928
|
+
} });
|
|
929
|
+
if (result.ok) return result.result.nextOffset;
|
|
930
|
+
throw this.toTransportFailure(result.result);
|
|
931
|
+
}
|
|
932
|
+
async appendEphemeralUpdate(update, signal) {
|
|
933
|
+
if (update.byteLength === 0) return;
|
|
934
|
+
const response = await this.fetchAuthorized(this.streamUrl, {
|
|
935
|
+
method: "POST",
|
|
936
|
+
headers: { "Content-Type": BINARY_CONTENT_TYPE },
|
|
937
|
+
body: update,
|
|
938
|
+
signal
|
|
939
|
+
}, this.reconnectConfig.connectTimeoutMs, "connect");
|
|
940
|
+
if (response.status === 200 || response.status === 204) return;
|
|
941
|
+
throw await this.responseToEphemeralFailure(response, "ephemeral append");
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Opens one bootstrap-first ephemeral SSE session.
|
|
945
|
+
*
|
|
946
|
+
* Ephemeral channels intentionally do not expose offsets or control events:
|
|
947
|
+
* the first event must be `bootstrap`, and all following events must be live
|
|
948
|
+
* `data` updates.
|
|
949
|
+
*/
|
|
950
|
+
async readEphemeralSse(onBootstrap, onData, signal) {
|
|
951
|
+
const requestUrl = this.ephemeralReadEndpoint();
|
|
952
|
+
const response = await this.fetchAuthorized(requestUrl, {
|
|
953
|
+
method: "GET",
|
|
954
|
+
headers: { Accept: "text/event-stream" },
|
|
955
|
+
signal
|
|
956
|
+
}, this.reconnectConfig.connectTimeoutMs, "connect");
|
|
957
|
+
if (response.status === 404) return { kind: "not-found" };
|
|
958
|
+
if (!response.ok) {
|
|
959
|
+
if (response.status === 400) {
|
|
960
|
+
const message = await response.text();
|
|
961
|
+
if (isSseUnsupportedErrorText(message)) return { kind: "unsupported" };
|
|
962
|
+
throw new HttpError(message.length > 0 ? `ephemeral live read failed: ${message}` : "ephemeral live read failed", response.status);
|
|
963
|
+
}
|
|
964
|
+
throw await this.responseToEphemeralFailure(response, "ephemeral live read");
|
|
965
|
+
}
|
|
966
|
+
if (response.body == null) throw new ProtocolError("ephemeral sse response missing body");
|
|
967
|
+
if (!(response.headers.get("Content-Type") ?? "").toLowerCase().includes("text/event-stream")) throw new ProtocolError("ephemeral sse response must use text/event-stream content type");
|
|
968
|
+
const dataEncoding = response.headers.get("Stream-SSE-Data-Encoding") ?? void 0;
|
|
969
|
+
const consume = async () => {
|
|
970
|
+
let sawBootstrap = false;
|
|
971
|
+
for await (const event of readSseEvents(response.body)) {
|
|
972
|
+
if (!sawBootstrap) {
|
|
973
|
+
if (event.event !== "bootstrap") throw new ProtocolError(`ephemeral sse must start with bootstrap, got '${event.event}'`);
|
|
974
|
+
await onBootstrap(decodeSsePayload(event.data, dataEncoding));
|
|
975
|
+
sawBootstrap = true;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (event.event === "data") {
|
|
979
|
+
await onData(decodeSsePayload(event.data, dataEncoding));
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
throw new ProtocolError(`unexpected ephemeral sse event '${event.event}'`);
|
|
983
|
+
}
|
|
984
|
+
if (!sawBootstrap) throw new ProtocolError("ephemeral sse ended before bootstrap");
|
|
985
|
+
return { kind: "ok" };
|
|
986
|
+
};
|
|
987
|
+
let abortHandler;
|
|
988
|
+
const abortRace = signal != null && !signal.aborted ? new Promise((_, reject) => {
|
|
989
|
+
abortHandler = () => {
|
|
990
|
+
response.body?.cancel().catch(() => {});
|
|
991
|
+
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
992
|
+
};
|
|
993
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
994
|
+
}) : null;
|
|
995
|
+
try {
|
|
996
|
+
if (signal?.aborted) {
|
|
997
|
+
await response.body.cancel().catch(() => {});
|
|
998
|
+
throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
999
|
+
}
|
|
1000
|
+
return abortRace == null ? await consume() : await Promise.race([consume(), abortRace]);
|
|
1001
|
+
} finally {
|
|
1002
|
+
if (abortHandler != null && signal != null) signal.removeEventListener("abort", abortHandler);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
async fetchAuthorized(input, init, timeoutMs, phase) {
|
|
1006
|
+
const firstToken = await this.resolveToken({ reason: "request" });
|
|
1007
|
+
const firstResponse = await this.fetchWithTimeout(input, {
|
|
1008
|
+
...init,
|
|
1009
|
+
headers: this.attachAuthorization(init.headers, firstToken)
|
|
1010
|
+
}, timeoutMs, phase);
|
|
1011
|
+
if (this.authProvider == null || firstResponse.status !== 401 && firstResponse.status !== 403) return firstResponse;
|
|
1012
|
+
await firstResponse.body?.cancel().catch(() => {});
|
|
1013
|
+
const refreshedToken = await this.refreshTokenAfterFailure(firstResponse.status, firstToken);
|
|
1014
|
+
return this.fetchWithTimeout(input, {
|
|
1015
|
+
...init,
|
|
1016
|
+
headers: this.attachAuthorization(init.headers, refreshedToken)
|
|
1017
|
+
}, timeoutMs, phase);
|
|
1018
|
+
}
|
|
1019
|
+
async fetchWithTimeout(input, init, timeoutMs, phase) {
|
|
1020
|
+
const controller = new AbortController();
|
|
1021
|
+
const signal = mergeAbortSignals(init.signal, controller.signal);
|
|
1022
|
+
const timer = setTimeout(() => {
|
|
1023
|
+
controller.abort(new TimeoutError(phase, timeoutMs));
|
|
1024
|
+
}, timeoutMs);
|
|
1025
|
+
try {
|
|
1026
|
+
return await this.fetchImpl(input, {
|
|
1027
|
+
...init,
|
|
1028
|
+
signal
|
|
1029
|
+
});
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (controller.signal.aborted && controller.signal.reason instanceof TimeoutError) throw controller.signal.reason;
|
|
1032
|
+
throw error;
|
|
1033
|
+
} finally {
|
|
1034
|
+
clearTimeout(timer);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
async resolveToken(context) {
|
|
1038
|
+
return await this.authProvider?.(context);
|
|
1039
|
+
}
|
|
1040
|
+
async refreshTokenAfterFailure(status, previousToken) {
|
|
1041
|
+
if (this.authProvider == null) return;
|
|
1042
|
+
if (this.authRefreshInFlight == null) this.authRefreshInFlight = this.resolveToken({
|
|
1043
|
+
reason: "unauthorized",
|
|
1044
|
+
status,
|
|
1045
|
+
previousToken
|
|
1046
|
+
}).finally(() => {
|
|
1047
|
+
this.authRefreshInFlight = void 0;
|
|
1048
|
+
});
|
|
1049
|
+
return await this.authRefreshInFlight;
|
|
1050
|
+
}
|
|
1051
|
+
attachAuthorization(base, token) {
|
|
1052
|
+
const headers = new Headers(base);
|
|
1053
|
+
if (token == null || token.length === 0) {
|
|
1054
|
+
headers.delete("Authorization");
|
|
1055
|
+
return headers;
|
|
1056
|
+
}
|
|
1057
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
1058
|
+
return headers;
|
|
1059
|
+
}
|
|
1060
|
+
async responseToEphemeralFailure(response, context) {
|
|
1061
|
+
const message = await response.text().catch(() => "");
|
|
1062
|
+
return new HttpError(message.length > 0 ? `${context} failed: ${message}` : `${context} failed with status ${response.status}`, response.status);
|
|
1063
|
+
}
|
|
1064
|
+
ephemeralReadEndpoint() {
|
|
1065
|
+
const target = new URL(this.streamUrl);
|
|
1066
|
+
target.searchParams.set("live", "sse");
|
|
1067
|
+
return target.toString();
|
|
1068
|
+
}
|
|
1069
|
+
toTransportFailure(error) {
|
|
1070
|
+
switch (error.code) {
|
|
1071
|
+
case "bad_request":
|
|
1072
|
+
case "unauthorized":
|
|
1073
|
+
case "forbidden":
|
|
1074
|
+
case "not_found":
|
|
1075
|
+
case "conflict":
|
|
1076
|
+
case "gone":
|
|
1077
|
+
case "rate_limited": return new HttpError(error.message, error.status);
|
|
1078
|
+
case "timeout": return new TimeoutError(error.phase, error.phase === "poll" ? this.reconnectConfig.pollTimeoutMs : this.reconnectConfig.connectTimeoutMs);
|
|
1079
|
+
case "network_error": return error.cause instanceof TypeError ? error.cause : new TypeError(error.message);
|
|
1080
|
+
case "protocol_error": return new ProtocolError(error.message, error.status);
|
|
1081
|
+
case "unknown":
|
|
1082
|
+
if (typeof error.status === "number") return new HttpError(error.message, error.status);
|
|
1083
|
+
if (error.cause instanceof TypeError) return error.cause;
|
|
1084
|
+
if (error.cause instanceof Error) return error.cause;
|
|
1085
|
+
return new Error(error.message);
|
|
1086
|
+
default: {
|
|
1087
|
+
const exhaustive = error;
|
|
1088
|
+
return new Error(String(exhaustive));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
async readItemsSse(initialOffset, cursor, onControl, onOpen, signal, onActivity) {
|
|
1093
|
+
const opened = await this.openItemsSseSession(initialOffset, cursor, onOpen, signal, onActivity);
|
|
1094
|
+
if (opened.kind !== "ok") return opened;
|
|
1095
|
+
let abortHandler;
|
|
1096
|
+
const abortRace = signal != null && !signal.aborted ? new Promise((_, reject) => {
|
|
1097
|
+
abortHandler = () => {
|
|
1098
|
+
opened.session.cancel();
|
|
1099
|
+
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
1100
|
+
};
|
|
1101
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
1102
|
+
}) : null;
|
|
1103
|
+
try {
|
|
1104
|
+
if (signal?.aborted) {
|
|
1105
|
+
await opened.session.cancel();
|
|
1106
|
+
throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
1107
|
+
}
|
|
1108
|
+
const result = abortRace != null ? await Promise.race([opened.session.continue(onControl), abortRace]) : await opened.session.continue(onControl);
|
|
1109
|
+
if (result.paused) await opened.session.cancel();
|
|
1110
|
+
return {
|
|
1111
|
+
kind: "ok",
|
|
1112
|
+
latestOffset: result.latestOffset,
|
|
1113
|
+
latestCursor: result.latestCursor
|
|
1114
|
+
};
|
|
1115
|
+
} finally {
|
|
1116
|
+
if (abortHandler != null && signal != null) signal.removeEventListener("abort", abortHandler);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
async openItemsSseSession(initialOffset, cursor, onOpen, signal, onActivity) {
|
|
1120
|
+
const result = await this.client.openSseSession({
|
|
1121
|
+
offset: initialOffset,
|
|
1122
|
+
cursor,
|
|
1123
|
+
signal,
|
|
1124
|
+
onActivity
|
|
1125
|
+
});
|
|
1126
|
+
if (!result.ok) {
|
|
1127
|
+
if (result.result.code === "gone") return { kind: "gone" };
|
|
1128
|
+
if (result.result.code === "not_found") return { kind: "not-found" };
|
|
1129
|
+
if (result.result.code === "bad_request" && isSseUnsupportedErrorText(result.result.message)) return { kind: "unsupported" };
|
|
1130
|
+
throw this.toTransportFailure(result.result);
|
|
1131
|
+
}
|
|
1132
|
+
const session = result.result;
|
|
1133
|
+
onOpen?.();
|
|
1134
|
+
let pendingDataEvents = [];
|
|
1135
|
+
let latestOffset = session.nextOffset ?? initialOffset;
|
|
1136
|
+
let latestCursor = session.cursor;
|
|
1137
|
+
let exhausted = false;
|
|
1138
|
+
return {
|
|
1139
|
+
kind: "ok",
|
|
1140
|
+
session: {
|
|
1141
|
+
continue: async (nextOnControl) => {
|
|
1142
|
+
if (exhausted) return {
|
|
1143
|
+
kind: "ok",
|
|
1144
|
+
latestOffset,
|
|
1145
|
+
latestCursor,
|
|
1146
|
+
paused: false
|
|
1147
|
+
};
|
|
1148
|
+
for await (const event of session.events) {
|
|
1149
|
+
if (event.event === "data") {
|
|
1150
|
+
pendingDataEvents.push(event.data);
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
if (event.event !== "control") continue;
|
|
1154
|
+
const control = parseSseControlData(event.data);
|
|
1155
|
+
const updates = [];
|
|
1156
|
+
for (const eventData of pendingDataEvents) updates.push(...decodeSseItemPayloads(eventData, session.dataEncoding));
|
|
1157
|
+
pendingDataEvents = [];
|
|
1158
|
+
const handlerResult = await nextOnControl(updates, control.nextOffset, control.upToDate, control.streamCursor);
|
|
1159
|
+
latestOffset = control.nextOffset;
|
|
1160
|
+
if (control.streamCursor != null) latestCursor = control.streamCursor;
|
|
1161
|
+
if (handlerResult === "stop") {
|
|
1162
|
+
exhausted = true;
|
|
1163
|
+
await session.cancel();
|
|
1164
|
+
return {
|
|
1165
|
+
kind: "ok",
|
|
1166
|
+
latestOffset,
|
|
1167
|
+
latestCursor,
|
|
1168
|
+
paused: false
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
if (handlerResult === "pause") return {
|
|
1172
|
+
kind: "ok",
|
|
1173
|
+
latestOffset,
|
|
1174
|
+
latestCursor,
|
|
1175
|
+
paused: true
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
exhausted = true;
|
|
1179
|
+
return {
|
|
1180
|
+
kind: "ok",
|
|
1181
|
+
latestOffset,
|
|
1182
|
+
latestCursor,
|
|
1183
|
+
paused: false
|
|
1184
|
+
};
|
|
1185
|
+
},
|
|
1186
|
+
cancel: async () => {
|
|
1187
|
+
if (exhausted) return;
|
|
1188
|
+
exhausted = true;
|
|
1189
|
+
await session.cancel();
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
//#endregion
|
|
1196
|
+
//#region src/ephemeral-runtime.ts
|
|
1197
|
+
function createNoopAdapter() {
|
|
1198
|
+
return {
|
|
1199
|
+
emptyVersion: () => ({}),
|
|
1200
|
+
mergeVersions: (base) => base,
|
|
1201
|
+
exportSnapshot: () => new Uint8Array(),
|
|
1202
|
+
applySnapshot: () => ({}),
|
|
1203
|
+
exportUpdates: () => void 0,
|
|
1204
|
+
applyRemoteUpdates: () => ({}),
|
|
1205
|
+
subscribeLocalUpdates: () => () => void 0
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function normalizeEphemeralUpdate(update) {
|
|
1209
|
+
if (update == null || update.byteLength === 0) return null;
|
|
1210
|
+
return update;
|
|
1211
|
+
}
|
|
1212
|
+
function toError(error) {
|
|
1213
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1214
|
+
}
|
|
1215
|
+
function closedSubscriptionError() {
|
|
1216
|
+
return /* @__PURE__ */ new Error("subscription closed");
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Realtime transport for one ephemeral CRDT stream.
|
|
1220
|
+
*
|
|
1221
|
+
* This is intentionally separate from `StreamsCrdt`: it bootstraps through
|
|
1222
|
+
* the first SSE event, then tails live deltas, and never stores a replay
|
|
1223
|
+
* offset, snapshot checkpoint, or remote cursor.
|
|
1224
|
+
*/
|
|
1225
|
+
var EphemeralStreamCrdt = class {
|
|
1226
|
+
/** Full ephemeral stream URL, including `?ephemeral=` or `?awareness=`. */
|
|
1227
|
+
streamUrl;
|
|
1228
|
+
/** Store-specific adapter used to encode local state and apply remote bytes. */
|
|
1229
|
+
adaptor;
|
|
1230
|
+
/** Low-level HTTP/SSE transport. It is reused only for auth, fetch, and parsing helpers. */
|
|
1231
|
+
client;
|
|
1232
|
+
/** Retry policy shared by the read loop and local write queue. */
|
|
1233
|
+
reconnectConfig;
|
|
1234
|
+
/** Period for republishing full local ephemeral state, or `false` to disable it. */
|
|
1235
|
+
heartbeatMs;
|
|
1236
|
+
/** Enables diagnostic console errors for failed read/write loops. */
|
|
1237
|
+
debug;
|
|
1238
|
+
/** Current shared subscription state, if the stream is joined. */
|
|
1239
|
+
joinState;
|
|
1240
|
+
constructor(options) {
|
|
1241
|
+
this.streamUrl = options.streamUrl;
|
|
1242
|
+
this.adaptor = options.adaptor;
|
|
1243
|
+
this.reconnectConfig = {
|
|
1244
|
+
...DEFAULT_RECONNECT_CONFIG,
|
|
1245
|
+
...options.reconnectConfig
|
|
1246
|
+
};
|
|
1247
|
+
this.heartbeatMs = options.heartbeatMs ?? 15e3;
|
|
1248
|
+
this.debug = options.debug ?? false;
|
|
1249
|
+
this.client = new StreamsTransportClient({
|
|
1250
|
+
streamUrl: options.streamUrl,
|
|
1251
|
+
adapter: createNoopAdapter(),
|
|
1252
|
+
authProvider: normalizeAuthProvider(options.auth),
|
|
1253
|
+
fetchImpl: options.fetch ?? globalThis.fetch.bind(globalThis),
|
|
1254
|
+
reconnectConfig: this.reconnectConfig
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
async join(params) {
|
|
1258
|
+
const existing = this.joinState;
|
|
1259
|
+
if (existing != null) try {
|
|
1260
|
+
await existing.started;
|
|
1261
|
+
return {
|
|
1262
|
+
ok: true,
|
|
1263
|
+
value: this.createSubscription(existing, params)
|
|
1264
|
+
};
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
return {
|
|
1267
|
+
ok: false,
|
|
1268
|
+
error: toTransportError(error)
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
const state = this.createJoinState();
|
|
1272
|
+
state.started = this.startJoin(state);
|
|
1273
|
+
this.joinState = state;
|
|
1274
|
+
try {
|
|
1275
|
+
await state.started;
|
|
1276
|
+
return {
|
|
1277
|
+
ok: true,
|
|
1278
|
+
value: this.createSubscription(state, params)
|
|
1279
|
+
};
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
await this.disposeJoinState(state);
|
|
1282
|
+
return {
|
|
1283
|
+
ok: false,
|
|
1284
|
+
error: toTransportError(error)
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
rejoin() {
|
|
1289
|
+
const state = this.joinState;
|
|
1290
|
+
if (state == null || state.closed) return;
|
|
1291
|
+
state.retryAttempt = 0;
|
|
1292
|
+
state.retrySleepController.abort();
|
|
1293
|
+
state.retrySleepController = new AbortController();
|
|
1294
|
+
const previousController = state.requestController;
|
|
1295
|
+
state.requestController = new AbortController();
|
|
1296
|
+
previousController?.abort();
|
|
1297
|
+
if (state.readStatus === "disconnected" || state.readStatus === "error") {
|
|
1298
|
+
this.setReadStatus(state, "reconnecting");
|
|
1299
|
+
this.runReadLoop(state);
|
|
1300
|
+
}
|
|
1301
|
+
if (state.writeStatus === "error") {
|
|
1302
|
+
this.setWriteStatus(state, "reconnecting");
|
|
1303
|
+
this.flushPendingLocal(state);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
async close() {
|
|
1307
|
+
const state = this.joinState;
|
|
1308
|
+
if (state != null) await this.disposeJoinState(state);
|
|
1309
|
+
}
|
|
1310
|
+
createJoinState() {
|
|
1311
|
+
return {
|
|
1312
|
+
refCount: 0,
|
|
1313
|
+
closed: false,
|
|
1314
|
+
status: "connecting",
|
|
1315
|
+
readStatus: "connecting",
|
|
1316
|
+
writeStatus: "connecting",
|
|
1317
|
+
statusListeners: /* @__PURE__ */ new Set(),
|
|
1318
|
+
pendingLocal: new Deque(),
|
|
1319
|
+
syncWaiters: [],
|
|
1320
|
+
flushingLocal: false,
|
|
1321
|
+
retryAttempt: 0,
|
|
1322
|
+
retrySleepController: new AbortController(),
|
|
1323
|
+
firstOpenSettled: false,
|
|
1324
|
+
started: Promise.resolve()
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
async startJoin(state) {
|
|
1328
|
+
state.requestController = new AbortController();
|
|
1329
|
+
const firstOpen = this.waitForFirstOpen(state);
|
|
1330
|
+
this.runReadLoop(state);
|
|
1331
|
+
await firstOpen;
|
|
1332
|
+
if (state.closed) return;
|
|
1333
|
+
state.unsubscribeLocal = this.adaptor.subscribeLocalUpdates((update) => {
|
|
1334
|
+
this.enqueueLocal(state, normalizeEphemeralUpdate(update));
|
|
1335
|
+
});
|
|
1336
|
+
this.setWriteStatus(state, "ok");
|
|
1337
|
+
const fullState = normalizeEphemeralUpdate(await this.adaptor.encodeAll());
|
|
1338
|
+
if (fullState != null) state.pendingLocal.pushBack(fullState);
|
|
1339
|
+
this.startHeartbeat(state);
|
|
1340
|
+
await this.flushPendingLocal(state);
|
|
1341
|
+
if (state.status === "error" || state.status === "disconnected") throw new Error(`ephemeral stream failed to join: ${state.status}`);
|
|
1342
|
+
}
|
|
1343
|
+
waitForFirstOpen(state) {
|
|
1344
|
+
return new Promise((resolve, reject) => {
|
|
1345
|
+
state.firstOpenResolve = resolve;
|
|
1346
|
+
state.firstOpenReject = reject;
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
createSubscription(state, params) {
|
|
1350
|
+
state.refCount += 1;
|
|
1351
|
+
const statusListener = params?.onStatusChange;
|
|
1352
|
+
if (statusListener != null) {
|
|
1353
|
+
state.statusListeners.add(statusListener);
|
|
1354
|
+
statusListener(state.status);
|
|
1355
|
+
}
|
|
1356
|
+
let unsubscribed = false;
|
|
1357
|
+
return {
|
|
1358
|
+
unsubscribe: () => {
|
|
1359
|
+
if (unsubscribed) return;
|
|
1360
|
+
unsubscribed = true;
|
|
1361
|
+
if (statusListener != null) state.statusListeners.delete(statusListener);
|
|
1362
|
+
state.refCount = Math.max(0, state.refCount - 1);
|
|
1363
|
+
if (state.refCount === 0 && !state.closed) this.disposeJoinState(state);
|
|
1364
|
+
},
|
|
1365
|
+
get connected() {
|
|
1366
|
+
return state.status === "joined";
|
|
1367
|
+
},
|
|
1368
|
+
get status() {
|
|
1369
|
+
return state.status;
|
|
1370
|
+
},
|
|
1371
|
+
onStatusChange: (listener) => this.onStatusChange(state, listener),
|
|
1372
|
+
waitUntilSynced: async () => {
|
|
1373
|
+
if (state.closed) throw closedSubscriptionError();
|
|
1374
|
+
const remaining = state.pendingLocal.length;
|
|
1375
|
+
if (remaining === 0) return;
|
|
1376
|
+
return new Promise((resolve, reject) => {
|
|
1377
|
+
state.syncWaiters.push({
|
|
1378
|
+
remaining,
|
|
1379
|
+
resolve,
|
|
1380
|
+
reject
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
onStatusChange(state, listener) {
|
|
1387
|
+
state.statusListeners.add(listener);
|
|
1388
|
+
listener(state.status);
|
|
1389
|
+
return () => {
|
|
1390
|
+
state.statusListeners.delete(listener);
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
async disposeJoinState(state) {
|
|
1394
|
+
if (state.closed) return;
|
|
1395
|
+
state.closed = true;
|
|
1396
|
+
state.unsubscribeLocal?.();
|
|
1397
|
+
if (state.heartbeatTimer != null) clearInterval(state.heartbeatTimer);
|
|
1398
|
+
state.requestController?.abort();
|
|
1399
|
+
state.retrySleepController.abort();
|
|
1400
|
+
this.rejectFirstOpen(state, closedSubscriptionError());
|
|
1401
|
+
this.rejectSyncWaiters(state, closedSubscriptionError());
|
|
1402
|
+
if (this.joinState === state) this.joinState = void 0;
|
|
1403
|
+
}
|
|
1404
|
+
resolveFirstOpen(state) {
|
|
1405
|
+
if (state.firstOpenSettled) return;
|
|
1406
|
+
state.firstOpenSettled = true;
|
|
1407
|
+
state.firstOpenResolve?.();
|
|
1408
|
+
}
|
|
1409
|
+
rejectFirstOpen(state, error) {
|
|
1410
|
+
if (state.firstOpenSettled) return;
|
|
1411
|
+
state.firstOpenSettled = true;
|
|
1412
|
+
state.firstOpenReject?.(error);
|
|
1413
|
+
}
|
|
1414
|
+
async runReadLoop(state) {
|
|
1415
|
+
while (!state.closed) {
|
|
1416
|
+
state.requestController ??= new AbortController();
|
|
1417
|
+
const capturedController = state.requestController;
|
|
1418
|
+
try {
|
|
1419
|
+
const result = await this.client.readEphemeralSse(async (bootstrap) => {
|
|
1420
|
+
if (bootstrap.byteLength > 0) await this.adaptor.applyRemoteUpdates([bootstrap]);
|
|
1421
|
+
this.setReadStatus(state, "ok");
|
|
1422
|
+
this.resolveFirstOpen(state);
|
|
1423
|
+
if (state.unsubscribeLocal != null) this.enqueueFullState(state);
|
|
1424
|
+
}, async (update) => {
|
|
1425
|
+
if (update.byteLength > 0) await this.adaptor.applyRemoteUpdates([update]);
|
|
1426
|
+
}, capturedController.signal);
|
|
1427
|
+
if (state.closed) return;
|
|
1428
|
+
if (result.kind === "ok") {
|
|
1429
|
+
state.retryAttempt = 0;
|
|
1430
|
+
this.setReadStatus(state, "reconnecting");
|
|
1431
|
+
await sleep(0);
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
if (result.kind === "unsupported") throw new Error("ephemeral stream requires SSE support");
|
|
1435
|
+
if (result.kind === "not-found") throw new HttpError("ephemeral stream not found", 404);
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
if (state.closed) return;
|
|
1438
|
+
if (capturedController.signal.aborted) {
|
|
1439
|
+
state.requestController = new AbortController();
|
|
1440
|
+
this.setReadStatus(state, "reconnecting");
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const classified = classifyFetchError(error);
|
|
1444
|
+
if (!classified.retriable || isAuthOrProtocolError(error)) {
|
|
1445
|
+
this.logError("ephemeral live read failed", error, {
|
|
1446
|
+
streamUrl: this.streamUrl,
|
|
1447
|
+
code: classified.code
|
|
1448
|
+
});
|
|
1449
|
+
this.setReadStatus(state, "error");
|
|
1450
|
+
this.rejectFirstOpen(state, toError(error));
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
this.setReadStatus(state, "reconnecting");
|
|
1454
|
+
if (state.retryAttempt >= this.reconnectConfig.maxAttempts) {
|
|
1455
|
+
this.logError("ephemeral live reconnect exhausted", error, {
|
|
1456
|
+
streamUrl: this.streamUrl,
|
|
1457
|
+
attempts: state.retryAttempt
|
|
1458
|
+
});
|
|
1459
|
+
this.setReadStatus(state, "disconnected");
|
|
1460
|
+
this.rejectFirstOpen(state, toError(error));
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
|
|
1464
|
+
state.retryAttempt += 1;
|
|
1465
|
+
await sleep(delay, state.retrySleepController.signal);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
startHeartbeat(state) {
|
|
1470
|
+
if (this.heartbeatMs === false || this.heartbeatMs <= 0) return;
|
|
1471
|
+
state.heartbeatTimer = setInterval(() => {
|
|
1472
|
+
this.enqueueFullState(state);
|
|
1473
|
+
}, this.heartbeatMs);
|
|
1474
|
+
}
|
|
1475
|
+
async enqueueFullState(state) {
|
|
1476
|
+
try {
|
|
1477
|
+
const update = await this.adaptor.encodeAll();
|
|
1478
|
+
this.enqueueLocal(state, normalizeEphemeralUpdate(update));
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
this.logError("ephemeral encodeAll failed", error, { streamUrl: this.streamUrl });
|
|
1481
|
+
this.setWriteStatus(state, "error");
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
enqueueLocal(state, update) {
|
|
1485
|
+
if (state.closed || update == null || update.byteLength === 0) return;
|
|
1486
|
+
state.pendingLocal.pushBack(update);
|
|
1487
|
+
this.flushPendingLocal(state);
|
|
1488
|
+
}
|
|
1489
|
+
async flushPendingLocal(state) {
|
|
1490
|
+
if (state.flushingLocal || state.closed) return;
|
|
1491
|
+
state.flushingLocal = true;
|
|
1492
|
+
try {
|
|
1493
|
+
while (!state.closed) {
|
|
1494
|
+
const update = state.pendingLocal.peekFront();
|
|
1495
|
+
if (update == null) return;
|
|
1496
|
+
let attempt = 0;
|
|
1497
|
+
while (!state.closed) try {
|
|
1498
|
+
await this.client.appendEphemeralUpdate(update);
|
|
1499
|
+
state.pendingLocal.popFront();
|
|
1500
|
+
this.notifySyncWaiters(state, 1);
|
|
1501
|
+
this.setWriteStatus(state, "ok");
|
|
1502
|
+
break;
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
const classified = classifyFetchError(error);
|
|
1505
|
+
if (!classified.retriable || isAuthOrProtocolError(error)) {
|
|
1506
|
+
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`ephemeral append failed: ${classified.code}`));
|
|
1507
|
+
this.setWriteStatus(state, "error");
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (attempt >= this.reconnectConfig.maxAttempts) {
|
|
1511
|
+
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("ephemeral append retry budget exhausted"));
|
|
1512
|
+
this.setWriteStatus(state, "error");
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
this.setWriteStatus(state, "reconnecting");
|
|
1516
|
+
const delay = computeRetryDelay(attempt, this.reconnectConfig);
|
|
1517
|
+
attempt += 1;
|
|
1518
|
+
await sleep(delay, state.retrySleepController.signal);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
} finally {
|
|
1522
|
+
state.flushingLocal = false;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
notifySyncWaiters(state, flushedCount) {
|
|
1526
|
+
let i = 0;
|
|
1527
|
+
while (i < state.syncWaiters.length) {
|
|
1528
|
+
const waiter = state.syncWaiters[i];
|
|
1529
|
+
waiter.remaining -= flushedCount;
|
|
1530
|
+
if (waiter.remaining <= 0) {
|
|
1531
|
+
state.syncWaiters.splice(i, 1);
|
|
1532
|
+
waiter.resolve();
|
|
1533
|
+
} else i += 1;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
rejectSyncWaiters(state, error) {
|
|
1537
|
+
for (const waiter of state.syncWaiters.splice(0)) waiter.reject(error);
|
|
1538
|
+
}
|
|
1539
|
+
setReadStatus(state, readStatus) {
|
|
1540
|
+
if (state.readStatus === readStatus) return;
|
|
1541
|
+
state.readStatus = readStatus;
|
|
1542
|
+
this.recomputeStatus(state);
|
|
1543
|
+
}
|
|
1544
|
+
setWriteStatus(state, writeStatus) {
|
|
1545
|
+
if (state.writeStatus === writeStatus) return;
|
|
1546
|
+
state.writeStatus = writeStatus;
|
|
1547
|
+
this.recomputeStatus(state);
|
|
1548
|
+
}
|
|
1549
|
+
recomputeStatus(state) {
|
|
1550
|
+
const status = this.computeStatus(state);
|
|
1551
|
+
if (state.status === status) return;
|
|
1552
|
+
state.status = status;
|
|
1553
|
+
for (const listener of state.statusListeners) listener(status);
|
|
1554
|
+
}
|
|
1555
|
+
computeStatus(state) {
|
|
1556
|
+
if (state.readStatus === "error" || state.writeStatus === "error") return "error";
|
|
1557
|
+
if (state.readStatus === "disconnected") return "disconnected";
|
|
1558
|
+
if (state.readStatus === "connecting" || state.writeStatus === "connecting") return "connecting";
|
|
1559
|
+
if (state.readStatus === "reconnecting" || state.writeStatus === "reconnecting") return "reconnecting";
|
|
1560
|
+
return "joined";
|
|
1561
|
+
}
|
|
1562
|
+
logError(message, error, detail) {
|
|
1563
|
+
if (!this.debug) return;
|
|
1564
|
+
console.error(`[streams-crdt] ${message}`, error, detail);
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
//#endregion
|
|
1568
|
+
//#region src/checkpoint.ts
|
|
1569
|
+
function composeBeforeRemoteCursorSaveHooks(...hooks) {
|
|
1570
|
+
const active = hooks.filter((hook) => hook != null);
|
|
1571
|
+
if (active.length === 0) return;
|
|
1572
|
+
return async (context) => {
|
|
1573
|
+
for (const hook of active) await hook(context);
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
async function persistRemoteCursor(input) {
|
|
1577
|
+
const next = {
|
|
1578
|
+
...input.cursor,
|
|
1579
|
+
updatedAtMs: (input.now ?? Date.now)()
|
|
1580
|
+
};
|
|
1581
|
+
if (input.beforeRemoteCursorSave != null) await input.beforeRemoteCursorSave({
|
|
1582
|
+
cursor: next,
|
|
1583
|
+
source: input.source
|
|
1584
|
+
});
|
|
1585
|
+
await input.remoteCursorStore.save(next);
|
|
1586
|
+
return next;
|
|
1587
|
+
}
|
|
1588
|
+
function key(streamUrl) {
|
|
1589
|
+
return streamUrl;
|
|
1590
|
+
}
|
|
1591
|
+
function isJsonObject(value) {
|
|
1592
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
1593
|
+
}
|
|
1594
|
+
function cloneJsonValue(value) {
|
|
1595
|
+
return structuredClone(value);
|
|
1596
|
+
}
|
|
1597
|
+
function cloneRemoteCursor(cursor) {
|
|
1598
|
+
return {
|
|
1599
|
+
...cursor,
|
|
1600
|
+
serverLowerBoundVersion: cloneJsonValue(cursor.serverLowerBoundVersion)
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
function fromStoredRemoteCursor(record) {
|
|
1604
|
+
if (!isJsonObject(record.serverLowerBoundVersion)) throw new Error("invalid remote cursor record: serverLowerBoundVersion is not a JSON object");
|
|
1605
|
+
return {
|
|
1606
|
+
streamUrl: record.streamUrl,
|
|
1607
|
+
nextOffset: record.nextOffset,
|
|
1608
|
+
serverLowerBoundVersion: cloneJsonValue(record.serverLowerBoundVersion),
|
|
1609
|
+
updatedAtMs: record.updatedAtMs
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
function toStoredRemoteCursor(cursor) {
|
|
1613
|
+
return {
|
|
1614
|
+
streamUrl: cursor.streamUrl,
|
|
1615
|
+
nextOffset: cursor.nextOffset,
|
|
1616
|
+
serverLowerBoundVersion: cloneJsonValue(cursor.serverLowerBoundVersion),
|
|
1617
|
+
updatedAtMs: cursor.updatedAtMs
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* In-memory cursor store useful for tests or ephemeral runtimes.
|
|
1622
|
+
*/
|
|
1623
|
+
var InMemoryRemoteCursorStore = class {
|
|
1624
|
+
data = /* @__PURE__ */ new Map();
|
|
1625
|
+
async load(streamUrl) {
|
|
1626
|
+
const value = this.data.get(key(streamUrl));
|
|
1627
|
+
return value == null ? null : cloneRemoteCursor(value);
|
|
1628
|
+
}
|
|
1629
|
+
async save(cursor) {
|
|
1630
|
+
this.data.set(key(cursor.streamUrl), cloneRemoteCursor(cursor));
|
|
1631
|
+
}
|
|
1632
|
+
async delete(streamUrl) {
|
|
1633
|
+
this.data.delete(key(streamUrl));
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
const DEFAULT_DB_NAME = "streams-crdt";
|
|
1637
|
+
const DEFAULT_STORE_NAME = "remote-cursors";
|
|
1638
|
+
const DEFAULT_DB_VERSION = 1;
|
|
1639
|
+
/**
|
|
1640
|
+
* IndexedDB-backed cursor store for browser clients that need replay progress persistence.
|
|
1641
|
+
*
|
|
1642
|
+
* This store only persists `RemoteCursor` metadata. It does not persist CRDT
|
|
1643
|
+
* payloads. Use it only when the application also persists the corresponding
|
|
1644
|
+
* local document state and restores that state before calling `sync()` or
|
|
1645
|
+
* `join()` after a reload.
|
|
1646
|
+
*/
|
|
1647
|
+
var IndexedDbRemoteCursorStore = class {
|
|
1648
|
+
dbName;
|
|
1649
|
+
storeName;
|
|
1650
|
+
dbVersion;
|
|
1651
|
+
indexedDb;
|
|
1652
|
+
db = null;
|
|
1653
|
+
initPromise = null;
|
|
1654
|
+
constructor(options) {
|
|
1655
|
+
this.dbName = options?.dbName ?? DEFAULT_DB_NAME;
|
|
1656
|
+
this.storeName = options?.storeName ?? DEFAULT_STORE_NAME;
|
|
1657
|
+
this.dbVersion = options?.dbVersion ?? DEFAULT_DB_VERSION;
|
|
1658
|
+
this.indexedDb = options?.indexedDb ?? globalThis.indexedDB;
|
|
1659
|
+
}
|
|
1660
|
+
async load(streamUrl) {
|
|
1661
|
+
const db = await this.ensureDb();
|
|
1662
|
+
const record = await new Promise((resolve, reject) => {
|
|
1663
|
+
const request = db.transaction([this.storeName], "readonly").objectStore(this.storeName).get(key(streamUrl));
|
|
1664
|
+
request.onsuccess = () => {
|
|
1665
|
+
resolve(request.result ?? null);
|
|
1666
|
+
};
|
|
1667
|
+
request.onerror = () => {
|
|
1668
|
+
reject(request.error ?? /* @__PURE__ */ new Error("indexeddb load failed"));
|
|
1669
|
+
};
|
|
1041
1670
|
});
|
|
1042
|
-
|
|
1671
|
+
return record == null ? null : fromStoredRemoteCursor(record);
|
|
1043
1672
|
}
|
|
1044
|
-
async
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
}
|
|
1673
|
+
async save(cursor) {
|
|
1674
|
+
const db = await this.ensureDb();
|
|
1675
|
+
const record = toStoredRemoteCursor(cursor);
|
|
1676
|
+
await new Promise((resolve, reject) => {
|
|
1677
|
+
const tx = db.transaction([this.storeName], "readwrite");
|
|
1678
|
+
const request = tx.objectStore(this.storeName).put(record, key(cursor.streamUrl));
|
|
1679
|
+
tx.oncomplete = () => resolve();
|
|
1680
|
+
tx.onerror = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb save failed"));
|
|
1681
|
+
tx.onabort = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb save transaction aborted"));
|
|
1682
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb save failed"));
|
|
1055
1683
|
});
|
|
1056
|
-
if (result.ok) return {
|
|
1057
|
-
kind: "ok",
|
|
1058
|
-
ackEpoch: result.result.producer?.epoch ?? producerSession.producerEpoch,
|
|
1059
|
-
ackSeq: result.result.producer?.seq ?? producerSession.nextProducerSeq,
|
|
1060
|
-
nextOffset: result.result.nextOffset
|
|
1061
|
-
};
|
|
1062
|
-
const error = result.result;
|
|
1063
|
-
if (error.code === "conflict" && error.detail?.kind === "producer_seq_gap") return {
|
|
1064
|
-
kind: "seq-gap",
|
|
1065
|
-
expectedSeq: error.detail.expectedSeq
|
|
1066
|
-
};
|
|
1067
|
-
if (error.code === "forbidden" && error.detail?.kind === "stale_producer_epoch") return {
|
|
1068
|
-
kind: "stale-epoch",
|
|
1069
|
-
serverEpoch: error.detail.serverEpoch
|
|
1070
|
-
};
|
|
1071
|
-
throw this.toTransportFailure(error);
|
|
1072
1684
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
case "network_error": return error.cause instanceof TypeError ? error.cause : new TypeError(error.message);
|
|
1084
|
-
case "protocol_error": return new ProtocolError(error.message, error.status);
|
|
1085
|
-
case "unknown":
|
|
1086
|
-
if (typeof error.status === "number") return new HttpError(error.message, error.status);
|
|
1087
|
-
if (error.cause instanceof TypeError) return error.cause;
|
|
1088
|
-
if (error.cause instanceof Error) return error.cause;
|
|
1089
|
-
return new Error(error.message);
|
|
1090
|
-
default: {
|
|
1091
|
-
const exhaustive = error;
|
|
1092
|
-
return new Error(String(exhaustive));
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1685
|
+
async delete(streamUrl) {
|
|
1686
|
+
const db = await this.ensureDb();
|
|
1687
|
+
await new Promise((resolve, reject) => {
|
|
1688
|
+
const tx = db.transaction([this.storeName], "readwrite");
|
|
1689
|
+
const request = tx.objectStore(this.storeName).delete(key(streamUrl));
|
|
1690
|
+
tx.oncomplete = () => resolve();
|
|
1691
|
+
tx.onerror = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb delete failed"));
|
|
1692
|
+
tx.onabort = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb delete transaction aborted"));
|
|
1693
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb delete failed"));
|
|
1694
|
+
});
|
|
1095
1695
|
}
|
|
1096
|
-
async
|
|
1097
|
-
|
|
1098
|
-
if (
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1696
|
+
async ensureDb() {
|
|
1697
|
+
if (this.db != null) return this.db;
|
|
1698
|
+
if (this.initPromise != null) {
|
|
1699
|
+
await this.initPromise;
|
|
1700
|
+
if (this.db == null) throw new Error("indexeddb is not available");
|
|
1701
|
+
return this.db;
|
|
1702
|
+
}
|
|
1703
|
+
if (this.indexedDb == null) throw new Error("indexeddb is not available in this environment");
|
|
1704
|
+
this.initPromise = new Promise((resolve, reject) => {
|
|
1705
|
+
const request = this.indexedDb.open(this.dbName, this.dbVersion);
|
|
1706
|
+
request.onupgradeneeded = () => {
|
|
1707
|
+
const db = request.result;
|
|
1708
|
+
if (!db.objectStoreNames.contains(this.storeName)) db.createObjectStore(this.storeName);
|
|
1104
1709
|
};
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
if (result.paused) await opened.session.cancel();
|
|
1114
|
-
return {
|
|
1115
|
-
kind: "ok",
|
|
1116
|
-
latestOffset: result.latestOffset,
|
|
1117
|
-
latestCursor: result.latestCursor
|
|
1710
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb open failed"));
|
|
1711
|
+
request.onsuccess = () => {
|
|
1712
|
+
this.db = request.result;
|
|
1713
|
+
this.db.onversionchange = () => {
|
|
1714
|
+
this.db?.close();
|
|
1715
|
+
this.db = null;
|
|
1716
|
+
};
|
|
1717
|
+
resolve();
|
|
1118
1718
|
};
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
async openItemsSseSession(initialOffset, cursor, onOpen, signal) {
|
|
1124
|
-
const result = await this.client.openSseSession({
|
|
1125
|
-
offset: initialOffset,
|
|
1126
|
-
cursor,
|
|
1127
|
-
signal
|
|
1719
|
+
}).finally(() => {
|
|
1720
|
+
this.initPromise = null;
|
|
1128
1721
|
});
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
if (result.result.code === "bad_request" && isSseUnsupportedErrorText(result.result.message)) return { kind: "unsupported" };
|
|
1133
|
-
throw this.toTransportFailure(result.result);
|
|
1134
|
-
}
|
|
1135
|
-
const session = result.result;
|
|
1136
|
-
onOpen?.();
|
|
1137
|
-
let pendingDataEvents = [];
|
|
1138
|
-
let latestOffset = session.nextOffset ?? initialOffset;
|
|
1139
|
-
let latestCursor = session.cursor;
|
|
1140
|
-
let exhausted = false;
|
|
1141
|
-
return {
|
|
1142
|
-
kind: "ok",
|
|
1143
|
-
session: {
|
|
1144
|
-
continue: async (nextOnControl) => {
|
|
1145
|
-
if (exhausted) return {
|
|
1146
|
-
kind: "ok",
|
|
1147
|
-
latestOffset,
|
|
1148
|
-
latestCursor,
|
|
1149
|
-
paused: false
|
|
1150
|
-
};
|
|
1151
|
-
for await (const event of session.events) {
|
|
1152
|
-
if (event.event === "data") {
|
|
1153
|
-
pendingDataEvents.push(event.data);
|
|
1154
|
-
continue;
|
|
1155
|
-
}
|
|
1156
|
-
if (event.event !== "control") continue;
|
|
1157
|
-
const control = parseSseControlData(event.data);
|
|
1158
|
-
const updates = [];
|
|
1159
|
-
for (const eventData of pendingDataEvents) updates.push(...decodeSseItemPayloads(eventData, session.dataEncoding));
|
|
1160
|
-
pendingDataEvents = [];
|
|
1161
|
-
const handlerResult = await nextOnControl(updates, control.nextOffset, control.upToDate, control.streamCursor);
|
|
1162
|
-
latestOffset = control.nextOffset;
|
|
1163
|
-
if (control.streamCursor != null) latestCursor = control.streamCursor;
|
|
1164
|
-
if (handlerResult === "stop") {
|
|
1165
|
-
exhausted = true;
|
|
1166
|
-
await session.cancel();
|
|
1167
|
-
return {
|
|
1168
|
-
kind: "ok",
|
|
1169
|
-
latestOffset,
|
|
1170
|
-
latestCursor,
|
|
1171
|
-
paused: false
|
|
1172
|
-
};
|
|
1173
|
-
}
|
|
1174
|
-
if (handlerResult === "pause") return {
|
|
1175
|
-
kind: "ok",
|
|
1176
|
-
latestOffset,
|
|
1177
|
-
latestCursor,
|
|
1178
|
-
paused: true
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
|
-
exhausted = true;
|
|
1182
|
-
return {
|
|
1183
|
-
kind: "ok",
|
|
1184
|
-
latestOffset,
|
|
1185
|
-
latestCursor,
|
|
1186
|
-
paused: false
|
|
1187
|
-
};
|
|
1188
|
-
},
|
|
1189
|
-
cancel: async () => {
|
|
1190
|
-
if (exhausted) return;
|
|
1191
|
-
exhausted = true;
|
|
1192
|
-
await session.cancel();
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
};
|
|
1722
|
+
await this.initPromise;
|
|
1723
|
+
if (this.db == null) throw new Error("indexeddb open succeeded without a database handle");
|
|
1724
|
+
return this.db;
|
|
1196
1725
|
}
|
|
1197
1726
|
};
|
|
1727
|
+
/**
|
|
1728
|
+
* Creates a cursor value suitable for seeding a `RemoteCursorStore`.
|
|
1729
|
+
*/
|
|
1730
|
+
function createInitialRemoteCursor(input) {
|
|
1731
|
+
return {
|
|
1732
|
+
streamUrl: input.streamUrl,
|
|
1733
|
+
nextOffset: input.nextOffset,
|
|
1734
|
+
serverLowerBoundVersion: cloneJsonValue(input.serverLowerBoundVersion),
|
|
1735
|
+
updatedAtMs: Date.now()
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1198
1738
|
//#endregion
|
|
1199
1739
|
//#region src/transport-cursor.ts
|
|
1200
1740
|
var TransportCursorManager = class {
|
|
@@ -1428,6 +1968,16 @@ async function disposeJoinState(state, options) {
|
|
|
1428
1968
|
}
|
|
1429
1969
|
//#endregion
|
|
1430
1970
|
//#region src/transport-runtime.ts
|
|
1971
|
+
const MIN_RECONNECT_DELAY_MS = 1e3;
|
|
1972
|
+
const MIN_SUCCESSFUL_SSE_DURATION_MS = 5e3;
|
|
1973
|
+
function reconnectDelayWithFloor(attempt, config) {
|
|
1974
|
+
return Math.max(computeRetryDelay(attempt, config), MIN_RECONNECT_DELAY_MS);
|
|
1975
|
+
}
|
|
1976
|
+
function fastSseCloseDelay(startedAtMs) {
|
|
1977
|
+
const elapsedMs = Math.max(0, Date.now() - startedAtMs);
|
|
1978
|
+
if (elapsedMs >= MIN_SUCCESSFUL_SSE_DURATION_MS) return 0;
|
|
1979
|
+
return Math.max(MIN_RECONNECT_DELAY_MS, MIN_SUCCESSFUL_SSE_DURATION_MS - elapsedMs);
|
|
1980
|
+
}
|
|
1431
1981
|
/**
|
|
1432
1982
|
* Durable Streams transport runtime bound to one stream URL and one local CRDT adapter.
|
|
1433
1983
|
*/
|
|
@@ -1451,6 +2001,8 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1451
2001
|
producerId = createProducerId();
|
|
1452
2002
|
producerEpoch = 0;
|
|
1453
2003
|
nextProducerSeq = 0;
|
|
2004
|
+
frozenLocalAppend;
|
|
2005
|
+
committedPendingCount;
|
|
1454
2006
|
pendingWriteOnly = new Deque();
|
|
1455
2007
|
writeOnlyCursor;
|
|
1456
2008
|
unsubscribeWriteOnlyLocal;
|
|
@@ -1660,6 +2212,8 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1660
2212
|
await this.remoteCursorStore.delete?.(this.streamUrl);
|
|
1661
2213
|
this.producerEpoch = 0;
|
|
1662
2214
|
this.nextProducerSeq = 0;
|
|
2215
|
+
this.frozenLocalAppend = void 0;
|
|
2216
|
+
this.committedPendingCount = void 0;
|
|
1663
2217
|
return deleted;
|
|
1664
2218
|
}) }
|
|
1665
2219
|
};
|
|
@@ -1707,15 +2261,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1707
2261
|
localVersion: this.writeOnlyCursor.serverLowerBoundVersion
|
|
1708
2262
|
}
|
|
1709
2263
|
};
|
|
1710
|
-
const
|
|
1711
|
-
|
|
1712
|
-
|
|
2264
|
+
const appended = await this.appendLocalBatchRemote(this.writeOnlyCursor, drained.batch, drained.count, "write-only");
|
|
2265
|
+
const committedCount = appended.pendingCount ?? drained.count;
|
|
2266
|
+
for (let i = 0; i < committedCount; i += 1) this.pendingWriteOnly.popFront();
|
|
2267
|
+
this.writeOnlyCursor = appended.cursor;
|
|
2268
|
+
this.commitProducerAck(appended.producerAck);
|
|
1713
2269
|
return {
|
|
1714
2270
|
ok: true,
|
|
1715
2271
|
value: {
|
|
1716
2272
|
appended: true,
|
|
1717
|
-
nextOffset:
|
|
1718
|
-
localVersion:
|
|
2273
|
+
nextOffset: appended.cursor.nextOffset,
|
|
2274
|
+
localVersion: appended.cursor.serverLowerBoundVersion
|
|
1719
2275
|
}
|
|
1720
2276
|
};
|
|
1721
2277
|
} catch (error) {
|
|
@@ -1891,7 +2447,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1891
2447
|
}
|
|
1892
2448
|
}
|
|
1893
2449
|
async syncOnce() {
|
|
1894
|
-
let cursor = (await this.resolveInitialRemoteState()).cursor;
|
|
2450
|
+
let cursor = (await this.finalizeDirectLocalAppendIfNeeded(await this.resolveInitialRemoteState())).cursor;
|
|
1895
2451
|
const localBatch = this.exportUpdates(cursor.serverLowerBoundVersion);
|
|
1896
2452
|
if (localBatch.batch != null) {
|
|
1897
2453
|
cursor = await this.appendLocalBatch(cursor, localBatch.batch);
|
|
@@ -1900,7 +2456,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1900
2456
|
return cursor;
|
|
1901
2457
|
}
|
|
1902
2458
|
async performInitialJoinSync(preferredLiveMode) {
|
|
1903
|
-
const initial = await this.resolveInitialRemoteState();
|
|
2459
|
+
const initial = await this.finalizeDirectLocalAppendIfNeeded(await this.resolveInitialRemoteState());
|
|
1904
2460
|
let cursor = initial.cursor;
|
|
1905
2461
|
let localExportRefVersion = cursor.serverLowerBoundVersion;
|
|
1906
2462
|
let streamCursor = initial.streamCursor;
|
|
@@ -1996,17 +2552,52 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1996
2552
|
};
|
|
1997
2553
|
}
|
|
1998
2554
|
}
|
|
1999
|
-
async appendLocalBatch(cursor, batch,
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2555
|
+
async appendLocalBatch(cursor, batch, pendingCount) {
|
|
2556
|
+
const source = pendingCount == null ? "direct" : "pending";
|
|
2557
|
+
const committed = await this.appendLocalBatchDurably(cursor, batch, pendingCount, source);
|
|
2558
|
+
this.committedPendingCount = committed.pendingCount;
|
|
2559
|
+
return committed.cursor;
|
|
2560
|
+
}
|
|
2561
|
+
async appendLocalBatchDurably(cursor, batch, pendingCount, source = pendingCount == null ? "direct" : "pending") {
|
|
2562
|
+
const frozen = await this.getOrCreateFrozenLocalAppend(cursor, batch, pendingCount, source);
|
|
2563
|
+
const appended = await this.appendFrozenLocalBatchRemote(frozen);
|
|
2564
|
+
const saved = await this.saveRemoteCursor(appended.cursor, "local");
|
|
2565
|
+
this.commitProducerAck(appended.producerAck);
|
|
2566
|
+
return {
|
|
2567
|
+
cursor: saved,
|
|
2568
|
+
pendingCount: frozen.pendingCount
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
async appendLocalBatchRemote(cursor, batch, pendingCount, source = pendingCount == null ? "direct" : "pending") {
|
|
2572
|
+
const frozen = await this.getOrCreateFrozenLocalAppend(cursor, batch, pendingCount, source);
|
|
2573
|
+
return {
|
|
2574
|
+
...await this.appendFrozenLocalBatchRemote(frozen),
|
|
2575
|
+
pendingCount: frozen.pendingCount
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
async getOrCreateFrozenLocalAppend(cursor, batch, pendingCount, source = pendingCount == null ? "direct" : "pending") {
|
|
2579
|
+
const existing = this.frozenLocalAppend;
|
|
2580
|
+
if (existing != null) {
|
|
2581
|
+
if (existing.source !== source) throw new Error(`${existing.source} local append must be finalized before ${source} append`);
|
|
2582
|
+
return existing;
|
|
2583
|
+
}
|
|
2584
|
+
const frozen = {
|
|
2585
|
+
source,
|
|
2586
|
+
baseCursor: cursor,
|
|
2587
|
+
batch,
|
|
2588
|
+
pendingCount,
|
|
2589
|
+
wireUpdates: this.encodeUpdateItems == null ? batch.updates : await this.encodeUpdateItems(batch.updates),
|
|
2590
|
+
producerSession: this.createCurrentProducerSession()
|
|
2004
2591
|
};
|
|
2592
|
+
this.frozenLocalAppend = frozen;
|
|
2593
|
+
return frozen;
|
|
2594
|
+
}
|
|
2595
|
+
async appendFrozenLocalBatchRemote(frozen) {
|
|
2596
|
+
if (frozen.acknowledged != null) return frozen.acknowledged;
|
|
2005
2597
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2006
2598
|
let result;
|
|
2007
2599
|
try {
|
|
2008
|
-
|
|
2009
|
-
result = await this.client.appendUpdates(wireUpdates, producerSession);
|
|
2600
|
+
result = await this.client.appendUpdates(frozen.wireUpdates, frozen.producerSession);
|
|
2010
2601
|
} catch (error) {
|
|
2011
2602
|
if (isStreamNotFoundError(error) && this.createStreamIfMissing) {
|
|
2012
2603
|
await this.ensureStreamExists();
|
|
@@ -2015,30 +2606,66 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2015
2606
|
throw error;
|
|
2016
2607
|
}
|
|
2017
2608
|
if (result.kind === "ok") {
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2609
|
+
const appended = {
|
|
2610
|
+
cursor: {
|
|
2611
|
+
...frozen.baseCursor,
|
|
2612
|
+
nextOffset: result.nextOffset,
|
|
2613
|
+
serverLowerBoundVersion: this.adapter.mergeVersions(frozen.baseCursor.serverLowerBoundVersion, frozen.batch.version)
|
|
2614
|
+
},
|
|
2615
|
+
producerAck: {
|
|
2616
|
+
epoch: result.ackEpoch,
|
|
2617
|
+
seq: result.ackSeq
|
|
2618
|
+
}
|
|
2024
2619
|
};
|
|
2025
|
-
|
|
2620
|
+
frozen.acknowledged = appended;
|
|
2621
|
+
return appended;
|
|
2026
2622
|
}
|
|
2027
2623
|
if (result.kind === "seq-gap") {
|
|
2028
|
-
producerSession = {
|
|
2029
|
-
...producerSession,
|
|
2624
|
+
frozen.producerSession = {
|
|
2625
|
+
...frozen.producerSession,
|
|
2030
2626
|
nextProducerSeq: result.expectedSeq
|
|
2031
2627
|
};
|
|
2032
2628
|
continue;
|
|
2033
2629
|
}
|
|
2034
|
-
producerSession = {
|
|
2035
|
-
...producerSession,
|
|
2630
|
+
frozen.producerSession = {
|
|
2631
|
+
...frozen.producerSession,
|
|
2036
2632
|
producerEpoch: result.serverEpoch + 1,
|
|
2037
2633
|
nextProducerSeq: 0
|
|
2038
2634
|
};
|
|
2039
2635
|
}
|
|
2040
2636
|
throw new Error("append retry budget exhausted");
|
|
2041
2637
|
}
|
|
2638
|
+
async finalizeDirectLocalAppendIfNeeded(state) {
|
|
2639
|
+
const frozen = this.frozenLocalAppend;
|
|
2640
|
+
if (frozen == null || frozen.pendingCount != null) return state;
|
|
2641
|
+
const appended = await this.appendFrozenLocalBatchRemote(frozen);
|
|
2642
|
+
const saved = await this.saveRemoteCursor(appended.cursor, "local");
|
|
2643
|
+
this.commitProducerAck(appended.producerAck);
|
|
2644
|
+
return await this.catchup(saved, void 0, state.streamCursor);
|
|
2645
|
+
}
|
|
2646
|
+
createCurrentProducerSession() {
|
|
2647
|
+
return {
|
|
2648
|
+
producerId: this.producerId,
|
|
2649
|
+
producerEpoch: this.producerEpoch,
|
|
2650
|
+
nextProducerSeq: this.nextProducerSeq
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
commitProducerAck(ack) {
|
|
2654
|
+
this.producerEpoch = ack.epoch;
|
|
2655
|
+
this.nextProducerSeq = ack.seq + 1;
|
|
2656
|
+
this.frozenLocalAppend = void 0;
|
|
2657
|
+
}
|
|
2658
|
+
abandonFrozenLocalAppend(frozen) {
|
|
2659
|
+
if (this.frozenLocalAppend !== frozen) return;
|
|
2660
|
+
this.frozenLocalAppend = void 0;
|
|
2661
|
+
this.producerEpoch = Math.max(this.producerEpoch, frozen.producerSession.producerEpoch + 1);
|
|
2662
|
+
this.nextProducerSeq = 0;
|
|
2663
|
+
}
|
|
2664
|
+
consumeCommittedPendingCount() {
|
|
2665
|
+
const count = this.committedPendingCount;
|
|
2666
|
+
this.committedPendingCount = void 0;
|
|
2667
|
+
return count;
|
|
2668
|
+
}
|
|
2042
2669
|
/**
|
|
2043
2670
|
* Ensures the target stream exists, creating it if necessary.
|
|
2044
2671
|
* Tolerates 409 Conflict (stream already created concurrently).
|
|
@@ -2086,9 +2713,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2086
2713
|
*/
|
|
2087
2714
|
async inferRemoteAndAppendLocal(bootstrapCursor, cursorManager) {
|
|
2088
2715
|
const inferredRemote = await this.bootstrapState(bootstrapCursor, { cursorManager });
|
|
2089
|
-
|
|
2090
|
-
if (
|
|
2091
|
-
|
|
2716
|
+
let pendingAppend;
|
|
2717
|
+
if (this.frozenLocalAppend != null) {
|
|
2718
|
+
if (this.frozenLocalAppend.pendingCount != null) throw new Error("pending local append must be finalized by pending flush");
|
|
2719
|
+
pendingAppend = await this.appendFrozenLocalBatchRemote(this.frozenLocalAppend);
|
|
2720
|
+
} else {
|
|
2721
|
+
const localBatch = this.exportUpdates(inferredRemote.cursor.serverLowerBoundVersion);
|
|
2722
|
+
if (localBatch.batch != null) pendingAppend = await this.appendLocalBatchRemote(inferredRemote.cursor, localBatch.batch);
|
|
2723
|
+
}
|
|
2724
|
+
const result = await this.bootstrapState(bootstrapCursor);
|
|
2725
|
+
if (pendingAppend != null) this.commitProducerAck(pendingAppend.producerAck);
|
|
2726
|
+
return result;
|
|
2092
2727
|
}
|
|
2093
2728
|
async recoverFromGoneWithLocalBootstrap(cursor) {
|
|
2094
2729
|
const isolatedCursorManager = await this.createIsolatedCursorManager();
|
|
@@ -2104,9 +2739,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2104
2739
|
const inferredRemote = await this.bootstrapState(bootstrapCursor, { cursorManager: this.volatileCursorManager });
|
|
2105
2740
|
await this.adapter.applySnapshot(preservedLocalSnapshot);
|
|
2106
2741
|
restoredLocalSnapshot = true;
|
|
2107
|
-
|
|
2108
|
-
if (
|
|
2109
|
-
|
|
2742
|
+
let pendingAppend;
|
|
2743
|
+
if (this.frozenLocalAppend != null) {
|
|
2744
|
+
if (this.frozenLocalAppend.pendingCount != null) throw new Error("pending local append must be finalized by pending flush");
|
|
2745
|
+
pendingAppend = await this.appendFrozenLocalBatchRemote(this.frozenLocalAppend);
|
|
2746
|
+
} else {
|
|
2747
|
+
const localBatch = this.exportUpdates(inferredRemote.cursor.serverLowerBoundVersion);
|
|
2748
|
+
if (localBatch.batch != null) pendingAppend = await this.appendLocalBatchRemote(inferredRemote.cursor, localBatch.batch);
|
|
2749
|
+
}
|
|
2750
|
+
const result = await this.bootstrapState(bootstrapCursor);
|
|
2751
|
+
if (pendingAppend != null) this.commitProducerAck(pendingAppend.producerAck);
|
|
2752
|
+
return result;
|
|
2110
2753
|
} catch (error) {
|
|
2111
2754
|
if (!restoredLocalSnapshot) await this.adapter.applySnapshot(preservedLocalSnapshot);
|
|
2112
2755
|
throw error;
|
|
@@ -2166,7 +2809,9 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2166
2809
|
const pendingRemote = [];
|
|
2167
2810
|
let flushChain = Promise.resolve();
|
|
2168
2811
|
resetInactivity();
|
|
2812
|
+
const sseStartedAtMs = Date.now();
|
|
2169
2813
|
const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
|
|
2814
|
+
if (capturedController?.signal.aborted) return "stop";
|
|
2170
2815
|
resetInactivity();
|
|
2171
2816
|
pendingRemote.push({
|
|
2172
2817
|
updates,
|
|
@@ -2175,7 +2820,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2175
2820
|
});
|
|
2176
2821
|
flushChain = flushChain.then(async () => {
|
|
2177
2822
|
const events = pendingRemote.splice(0);
|
|
2178
|
-
if (events.length === 0 || state.closed) return;
|
|
2823
|
+
if (events.length === 0 || state.closed || capturedController?.signal.aborted) return;
|
|
2179
2824
|
const allUpdates = events.flatMap((e) => e.updates);
|
|
2180
2825
|
const lastEvent = events[events.length - 1];
|
|
2181
2826
|
await this.enqueueExclusive(async () => {
|
|
@@ -2199,14 +2844,14 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2199
2844
|
}, () => {
|
|
2200
2845
|
resetInactivity();
|
|
2201
2846
|
this.setReadSubStatus(state, "ok");
|
|
2202
|
-
}, capturedController?.signal);
|
|
2847
|
+
}, capturedController?.signal, resetInactivity);
|
|
2203
2848
|
clearInactivity();
|
|
2204
2849
|
await flushChain;
|
|
2205
2850
|
if (state.closed) return;
|
|
2206
2851
|
if (sseResult.kind === "ok") {
|
|
2207
2852
|
state.retryAttempt = 0;
|
|
2208
2853
|
state.streamCursor = sseResult.latestCursor;
|
|
2209
|
-
await sleep(
|
|
2854
|
+
await sleep(fastSseCloseDelay(sseStartedAtMs), state.retrySleepController.signal);
|
|
2210
2855
|
continue;
|
|
2211
2856
|
}
|
|
2212
2857
|
if (sseResult.kind === "unsupported") {
|
|
@@ -2256,6 +2901,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2256
2901
|
return;
|
|
2257
2902
|
}
|
|
2258
2903
|
state.retryAttempt += 1;
|
|
2904
|
+
await sleep(reconnectDelayWithFloor(state.retryAttempt - 1, this.reconnectConfig), state.retrySleepController.signal);
|
|
2259
2905
|
continue;
|
|
2260
2906
|
}
|
|
2261
2907
|
const abortReason = capturedController.signal.reason;
|
|
@@ -2318,7 +2964,6 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2318
2964
|
let mergedVersion = first.version;
|
|
2319
2965
|
const allUpdates = Array.from(first.updates);
|
|
2320
2966
|
let count = 1;
|
|
2321
|
-
await this.measureAppendBodyByteLength(allUpdates);
|
|
2322
2967
|
while (count < pendingLocal.length) {
|
|
2323
2968
|
const next = pendingLocal.at(count);
|
|
2324
2969
|
const candidateUpdates = [...allUpdates, ...next.updates];
|
|
@@ -2354,9 +2999,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2354
2999
|
if (drained == null) return;
|
|
2355
3000
|
let attempts = 0;
|
|
2356
3001
|
while (!state.closed) try {
|
|
2357
|
-
state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch));
|
|
2358
|
-
|
|
2359
|
-
|
|
3002
|
+
state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count));
|
|
3003
|
+
const committedCount = this.consumeCommittedPendingCount() ?? drained.count;
|
|
3004
|
+
for (let i = 0; i < committedCount; i += 1) state.pendingLocal.popFront();
|
|
3005
|
+
this.notifySyncWaiters(state, committedCount);
|
|
2360
3006
|
this.setWriteSubStatus(state, "ok");
|
|
2361
3007
|
this.snapshotManager.schedule(state);
|
|
2362
3008
|
break;
|
|
@@ -2451,6 +3097,8 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2451
3097
|
return updates.reduce((sum, update) => sum + 4 + update.byteLength, 0);
|
|
2452
3098
|
}
|
|
2453
3099
|
disableWriteOnlyMode(reason) {
|
|
3100
|
+
const frozen = this.frozenLocalAppend;
|
|
3101
|
+
if (frozen?.source === "write-only") this.abandonFrozenLocalAppend(frozen);
|
|
2454
3102
|
if (this.writeOnlyDisabledReason != null) return;
|
|
2455
3103
|
this.writeOnlyDisabledReason = reason;
|
|
2456
3104
|
this.pendingWriteOnly.clear();
|
|
@@ -2508,6 +3156,6 @@ function createStreamUrl(input) {
|
|
|
2508
3156
|
return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
|
|
2509
3157
|
}
|
|
2510
3158
|
//#endregion
|
|
2511
|
-
export {
|
|
3159
|
+
export { InMemoryRemoteCursorStore as a, EphemeralStreamCrdt as c, StreamsCrdt as i, PayloadProtectionError as l, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
|
|
2512
3160
|
|
|
2513
|
-
//# sourceMappingURL=stream-id-
|
|
3161
|
+
//# sourceMappingURL=stream-id-CF1egQDz.js.map
|