@valentinkolb/sync 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -193
- package/browser/ephemeral.js +472 -0
- package/browser/index.js +21 -0
- package/browser/job.js +14687 -0
- package/browser/mutex.js +165 -0
- package/browser/queue.js +342 -0
- package/browser/ratelimit.js +124 -0
- package/browser/registry.js +662 -0
- package/browser/retry.js +94 -0
- package/browser/scheduler.js +988 -0
- package/browser/store.js +61 -0
- package/browser/topic.js +359 -0
- package/index.js +1 -18531
- package/package.json +19 -4
- package/src/browser/ephemeral.d.ts +101 -0
- package/src/browser/index.d.ts +10 -0
- package/src/browser/internal/emitter.d.ts +11 -0
- package/src/browser/internal/event-log.d.ts +33 -0
- package/src/browser/internal/id.d.ts +9 -0
- package/src/browser/internal/sleep.d.ts +2 -0
- package/src/browser/job.d.ts +107 -0
- package/src/browser/mutex.d.ts +28 -0
- package/src/browser/queue.d.ts +67 -0
- package/src/browser/ratelimit.d.ts +24 -0
- package/src/browser/registry.d.ts +131 -0
- package/src/browser/retry.d.ts +19 -0
- package/src/browser/scheduler.d.ts +164 -0
- package/src/browser/store.d.ts +17 -0
- package/src/browser/topic.d.ts +65 -0
package/browser/store.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/browser/store.ts
|
|
2
|
+
class MemoryStore {
|
|
3
|
+
data = new Map;
|
|
4
|
+
timers = new Map;
|
|
5
|
+
get(key) {
|
|
6
|
+
const entry = this.data.get(key);
|
|
7
|
+
if (!entry)
|
|
8
|
+
return;
|
|
9
|
+
if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
|
|
10
|
+
this.del(key);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
return entry.value;
|
|
14
|
+
}
|
|
15
|
+
set(key, value, ttlMs) {
|
|
16
|
+
const existingTimer = this.timers.get(key);
|
|
17
|
+
if (existingTimer) {
|
|
18
|
+
clearTimeout(existingTimer);
|
|
19
|
+
this.timers.delete(key);
|
|
20
|
+
}
|
|
21
|
+
const expiresAt = ttlMs != null && ttlMs > 0 ? Date.now() + ttlMs : null;
|
|
22
|
+
this.data.set(key, { value, expiresAt });
|
|
23
|
+
if (ttlMs != null && ttlMs > 0) {
|
|
24
|
+
this.timers.set(key, setTimeout(() => this.del(key), ttlMs));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
del(key) {
|
|
28
|
+
this.data.delete(key);
|
|
29
|
+
const timer = this.timers.get(key);
|
|
30
|
+
if (timer) {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
this.timers.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
keys(prefix) {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const result = [];
|
|
38
|
+
for (const [key, entry] of this.data) {
|
|
39
|
+
if (entry.expiresAt !== null && now >= entry.expiresAt) {
|
|
40
|
+
this.del(key);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (prefix === undefined || key.startsWith(prefix)) {
|
|
44
|
+
result.push(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
clear() {
|
|
50
|
+
for (const timer of this.timers.values()) {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
this.timers.clear();
|
|
54
|
+
this.data.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
var createMemoryStore = () => new MemoryStore;
|
|
58
|
+
export {
|
|
59
|
+
createMemoryStore,
|
|
60
|
+
MemoryStore
|
|
61
|
+
};
|
package/browser/topic.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// src/browser/store.ts
|
|
2
|
+
class MemoryStore {
|
|
3
|
+
data = new Map;
|
|
4
|
+
timers = new Map;
|
|
5
|
+
get(key) {
|
|
6
|
+
const entry = this.data.get(key);
|
|
7
|
+
if (!entry)
|
|
8
|
+
return;
|
|
9
|
+
if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
|
|
10
|
+
this.del(key);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
return entry.value;
|
|
14
|
+
}
|
|
15
|
+
set(key, value, ttlMs) {
|
|
16
|
+
const existingTimer = this.timers.get(key);
|
|
17
|
+
if (existingTimer) {
|
|
18
|
+
clearTimeout(existingTimer);
|
|
19
|
+
this.timers.delete(key);
|
|
20
|
+
}
|
|
21
|
+
const expiresAt = ttlMs != null && ttlMs > 0 ? Date.now() + ttlMs : null;
|
|
22
|
+
this.data.set(key, { value, expiresAt });
|
|
23
|
+
if (ttlMs != null && ttlMs > 0) {
|
|
24
|
+
this.timers.set(key, setTimeout(() => this.del(key), ttlMs));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
del(key) {
|
|
28
|
+
this.data.delete(key);
|
|
29
|
+
const timer = this.timers.get(key);
|
|
30
|
+
if (timer) {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
this.timers.delete(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
keys(prefix) {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const result = [];
|
|
38
|
+
for (const [key, entry] of this.data) {
|
|
39
|
+
if (entry.expiresAt !== null && now >= entry.expiresAt) {
|
|
40
|
+
this.del(key);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (prefix === undefined || key.startsWith(prefix)) {
|
|
44
|
+
result.push(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
clear() {
|
|
50
|
+
for (const timer of this.timers.values()) {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
this.timers.clear();
|
|
54
|
+
this.data.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
var createMemoryStore = () => new MemoryStore;
|
|
58
|
+
|
|
59
|
+
// src/browser/internal/emitter.ts
|
|
60
|
+
class Emitter {
|
|
61
|
+
listeners = new Set;
|
|
62
|
+
on(fn) {
|
|
63
|
+
this.listeners.add(fn);
|
|
64
|
+
return () => {
|
|
65
|
+
this.listeners.delete(fn);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
emit(value) {
|
|
69
|
+
for (const fn of this.listeners) {
|
|
70
|
+
fn(value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
once() {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const unsub = this.on((value) => {
|
|
76
|
+
unsub();
|
|
77
|
+
resolve(value);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
onceWithSignal(signal) {
|
|
82
|
+
if (!signal)
|
|
83
|
+
return this.once();
|
|
84
|
+
if (signal.aborted)
|
|
85
|
+
return Promise.reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const unsub = this.on((value) => {
|
|
88
|
+
unsub();
|
|
89
|
+
signal.removeEventListener("abort", onAbort);
|
|
90
|
+
resolve(value);
|
|
91
|
+
});
|
|
92
|
+
const onAbort = () => {
|
|
93
|
+
unsub();
|
|
94
|
+
signal.removeEventListener("abort", onAbort);
|
|
95
|
+
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
|
|
96
|
+
};
|
|
97
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/browser/internal/event-log.ts
|
|
103
|
+
class EventLog {
|
|
104
|
+
entries = [];
|
|
105
|
+
seq = 0;
|
|
106
|
+
emitter = new Emitter;
|
|
107
|
+
maxLen;
|
|
108
|
+
retentionMs;
|
|
109
|
+
constructor(config = {}) {
|
|
110
|
+
this.maxLen = config.maxLen ?? 50000;
|
|
111
|
+
this.retentionMs = config.retentionMs ?? 5 * 60 * 1000;
|
|
112
|
+
}
|
|
113
|
+
append(fields) {
|
|
114
|
+
const id = String(++this.seq);
|
|
115
|
+
const entry = { id, ts: Date.now(), fields };
|
|
116
|
+
this.entries.push(entry);
|
|
117
|
+
this.trim();
|
|
118
|
+
this.emitter.emit(entry);
|
|
119
|
+
return id;
|
|
120
|
+
}
|
|
121
|
+
range(after, count) {
|
|
122
|
+
const afterNum = Number(after) || 0;
|
|
123
|
+
const result = [];
|
|
124
|
+
for (const entry of this.entries) {
|
|
125
|
+
if (Number(entry.id) <= afterNum)
|
|
126
|
+
continue;
|
|
127
|
+
result.push(entry);
|
|
128
|
+
if (count !== undefined && result.length >= count)
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
latest() {
|
|
134
|
+
if (this.entries.length === 0)
|
|
135
|
+
return "0";
|
|
136
|
+
return this.entries[this.entries.length - 1].id;
|
|
137
|
+
}
|
|
138
|
+
earliest() {
|
|
139
|
+
if (this.entries.length === 0)
|
|
140
|
+
return null;
|
|
141
|
+
return this.entries[0].id;
|
|
142
|
+
}
|
|
143
|
+
has(cursor) {
|
|
144
|
+
const num = Number(cursor);
|
|
145
|
+
return this.entries.some((e) => Number(e.id) === num);
|
|
146
|
+
}
|
|
147
|
+
async* subscribe(after, signal) {
|
|
148
|
+
let cursor = after;
|
|
149
|
+
while (!signal?.aborted) {
|
|
150
|
+
const buffered = this.range(cursor);
|
|
151
|
+
if (buffered.length > 0) {
|
|
152
|
+
for (const entry of buffered) {
|
|
153
|
+
cursor = entry.id;
|
|
154
|
+
yield entry;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const entry = await this.emitter.onceWithSignal(signal);
|
|
160
|
+
if (Number(entry.id) > Number(cursor)) {
|
|
161
|
+
cursor = entry.id;
|
|
162
|
+
yield entry;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
trim() {
|
|
170
|
+
if (this.entries.length > this.maxLen) {
|
|
171
|
+
this.entries.splice(0, this.entries.length - this.maxLen);
|
|
172
|
+
}
|
|
173
|
+
if (this.retentionMs > 0) {
|
|
174
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
175
|
+
let trimCount = 0;
|
|
176
|
+
for (const entry of this.entries) {
|
|
177
|
+
if (entry.ts >= cutoff)
|
|
178
|
+
break;
|
|
179
|
+
trimCount++;
|
|
180
|
+
}
|
|
181
|
+
if (trimCount > 0) {
|
|
182
|
+
this.entries.splice(0, trimCount);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
get size() {
|
|
187
|
+
return this.entries.length;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/browser/internal/id.ts
|
|
192
|
+
var randomId = () => crypto.randomUUID();
|
|
193
|
+
|
|
194
|
+
// src/browser/topic.ts
|
|
195
|
+
var DEFAULT_PREFIX = "sync:topic";
|
|
196
|
+
var DEFAULT_TENANT = "default";
|
|
197
|
+
var DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
198
|
+
var DEFAULT_IDEMPOTENCY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
199
|
+
var DEFAULT_PAYLOAD_BYTES = 128 * 1024;
|
|
200
|
+
var DEFAULT_TIMEOUT_MS = 30000;
|
|
201
|
+
var textEncoder = new TextEncoder;
|
|
202
|
+
var topic = (config) => {
|
|
203
|
+
const prefix = config.prefix ?? DEFAULT_PREFIX;
|
|
204
|
+
const defaultTenant = config.tenantId ?? DEFAULT_TENANT;
|
|
205
|
+
const retentionMs = config.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
206
|
+
const maxPayloadBytes = config.limits?.payloadBytes ?? DEFAULT_PAYLOAD_BYTES;
|
|
207
|
+
const store = config.store ?? createMemoryStore();
|
|
208
|
+
const resolveTenant = (tenantId) => tenantId ?? defaultTenant;
|
|
209
|
+
const eventLogs = new Map;
|
|
210
|
+
const getEventLog = (tenantId) => {
|
|
211
|
+
const key = `${prefix}:${tenantId}:${config.id}`;
|
|
212
|
+
let log = eventLogs.get(key);
|
|
213
|
+
if (!log) {
|
|
214
|
+
log = new EventLog({ retentionMs });
|
|
215
|
+
eventLogs.set(key, log);
|
|
216
|
+
}
|
|
217
|
+
return log;
|
|
218
|
+
};
|
|
219
|
+
const idempotencyKey = (tenantId, key) => `${prefix}:${tenantId}:${config.id}:idempotency:${key}`;
|
|
220
|
+
const parsePayload = (entry) => {
|
|
221
|
+
const rawPayload = entry.fields.payload;
|
|
222
|
+
if (typeof rawPayload !== "string")
|
|
223
|
+
return null;
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(rawPayload);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const pub = async (pubCfg) => {
|
|
231
|
+
const tenantId = resolveTenant(pubCfg.tenantId);
|
|
232
|
+
const log = getEventLog(tenantId);
|
|
233
|
+
const parsed = config.schema.safeParse(pubCfg.data);
|
|
234
|
+
if (!parsed.success)
|
|
235
|
+
throw parsed.error;
|
|
236
|
+
const payload = {
|
|
237
|
+
data: parsed.data,
|
|
238
|
+
orderingKey: pubCfg.orderingKey,
|
|
239
|
+
meta: pubCfg.meta,
|
|
240
|
+
publishedAt: Date.now()
|
|
241
|
+
};
|
|
242
|
+
const payloadRaw = JSON.stringify(payload);
|
|
243
|
+
const payloadBytes = textEncoder.encode(payloadRaw).byteLength;
|
|
244
|
+
if (payloadBytes > maxPayloadBytes) {
|
|
245
|
+
throw new Error(`payload exceeds limit (${maxPayloadBytes} bytes)`);
|
|
246
|
+
}
|
|
247
|
+
if (pubCfg.idempotencyKey) {
|
|
248
|
+
const idemKey = idempotencyKey(tenantId, pubCfg.idempotencyKey);
|
|
249
|
+
const existing = store.get(idemKey);
|
|
250
|
+
if (existing) {
|
|
251
|
+
return { eventId: existing, cursor: existing };
|
|
252
|
+
}
|
|
253
|
+
const eventId2 = log.append({ payload: payloadRaw });
|
|
254
|
+
store.set(idemKey, eventId2, pubCfg.idempotencyTtlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS);
|
|
255
|
+
return { eventId: eventId2, cursor: eventId2 };
|
|
256
|
+
}
|
|
257
|
+
const eventId = log.append({ payload: payloadRaw });
|
|
258
|
+
return { eventId, cursor: eventId };
|
|
259
|
+
};
|
|
260
|
+
const reader = (group = "default") => {
|
|
261
|
+
const consumerId = `consumer:${randomId()}`;
|
|
262
|
+
let cursor = "0";
|
|
263
|
+
const recv = async (recvCfg = {}) => {
|
|
264
|
+
const tenantId = resolveTenant(recvCfg.tenantId);
|
|
265
|
+
const log = getEventLog(tenantId);
|
|
266
|
+
const wait = recvCfg.wait ?? true;
|
|
267
|
+
const timeoutMs = recvCfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
268
|
+
const entries = log.range(cursor, 1);
|
|
269
|
+
if (entries.length > 0) {
|
|
270
|
+
return deliverEntry(entries[0], log, tenantId);
|
|
271
|
+
}
|
|
272
|
+
if (!wait)
|
|
273
|
+
return null;
|
|
274
|
+
const ac = new AbortController;
|
|
275
|
+
const timeout = setTimeout(() => ac.abort(), timeoutMs);
|
|
276
|
+
const onUserAbort = () => ac.abort();
|
|
277
|
+
if (recvCfg.signal)
|
|
278
|
+
recvCfg.signal.addEventListener("abort", onUserAbort, { once: true });
|
|
279
|
+
try {
|
|
280
|
+
for await (const entry of log.subscribe(cursor, ac.signal)) {
|
|
281
|
+
clearTimeout(timeout);
|
|
282
|
+
if (recvCfg.signal)
|
|
283
|
+
recvCfg.signal.removeEventListener("abort", onUserAbort);
|
|
284
|
+
return deliverEntry(entry, log, tenantId);
|
|
285
|
+
}
|
|
286
|
+
} catch {} finally {
|
|
287
|
+
clearTimeout(timeout);
|
|
288
|
+
if (recvCfg.signal)
|
|
289
|
+
recvCfg.signal.removeEventListener("abort", onUserAbort);
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
};
|
|
293
|
+
const deliverEntry = (entry, _log, _tenantId) => {
|
|
294
|
+
const stored = parsePayload(entry);
|
|
295
|
+
if (!stored) {
|
|
296
|
+
cursor = entry.id;
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
const parsed = config.schema.safeParse(stored.data);
|
|
300
|
+
if (!parsed.success) {
|
|
301
|
+
cursor = entry.id;
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
cursor = entry.id;
|
|
305
|
+
const commit = async () => {
|
|
306
|
+
return true;
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
data: parsed.data,
|
|
310
|
+
eventId: entry.id,
|
|
311
|
+
cursor: entry.id,
|
|
312
|
+
deliveryId: `${group}:${entry.id}`,
|
|
313
|
+
orderingKey: stored.orderingKey,
|
|
314
|
+
publishedAt: stored.publishedAt,
|
|
315
|
+
meta: stored.meta,
|
|
316
|
+
commit
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
const stream = async function* (streamCfg = {}) {
|
|
320
|
+
const wait = streamCfg.wait ?? true;
|
|
321
|
+
while (!streamCfg.signal?.aborted) {
|
|
322
|
+
const message = await recv(streamCfg);
|
|
323
|
+
if (message) {
|
|
324
|
+
yield message;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (!wait)
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
return { group, recv, stream };
|
|
332
|
+
};
|
|
333
|
+
const live = async function* (liveCfg = {}) {
|
|
334
|
+
const tenantId = resolveTenant(liveCfg.tenantId);
|
|
335
|
+
const log = getEventLog(tenantId);
|
|
336
|
+
let cursor = liveCfg.after ?? log.latest();
|
|
337
|
+
for await (const entry of log.subscribe(cursor, liveCfg.signal)) {
|
|
338
|
+
const stored = parsePayload(entry);
|
|
339
|
+
if (!stored)
|
|
340
|
+
continue;
|
|
341
|
+
const parsed = config.schema.safeParse(stored.data);
|
|
342
|
+
if (!parsed.success)
|
|
343
|
+
continue;
|
|
344
|
+
cursor = entry.id;
|
|
345
|
+
yield {
|
|
346
|
+
data: parsed.data,
|
|
347
|
+
eventId: entry.id,
|
|
348
|
+
cursor: entry.id,
|
|
349
|
+
orderingKey: stored.orderingKey,
|
|
350
|
+
publishedAt: stored.publishedAt,
|
|
351
|
+
meta: stored.meta
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
return { pub, reader, live };
|
|
356
|
+
};
|
|
357
|
+
export {
|
|
358
|
+
topic
|
|
359
|
+
};
|