@valentinkolb/sync 2.1.2 → 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.
@@ -0,0 +1,472 @@
1
+ // src/browser/internal/emitter.ts
2
+ class Emitter {
3
+ listeners = new Set;
4
+ on(fn) {
5
+ this.listeners.add(fn);
6
+ return () => {
7
+ this.listeners.delete(fn);
8
+ };
9
+ }
10
+ emit(value) {
11
+ for (const fn of this.listeners) {
12
+ fn(value);
13
+ }
14
+ }
15
+ once() {
16
+ return new Promise((resolve) => {
17
+ const unsub = this.on((value) => {
18
+ unsub();
19
+ resolve(value);
20
+ });
21
+ });
22
+ }
23
+ onceWithSignal(signal) {
24
+ if (!signal)
25
+ return this.once();
26
+ if (signal.aborted)
27
+ return Promise.reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
28
+ return new Promise((resolve, reject) => {
29
+ const unsub = this.on((value) => {
30
+ unsub();
31
+ signal.removeEventListener("abort", onAbort);
32
+ resolve(value);
33
+ });
34
+ const onAbort = () => {
35
+ unsub();
36
+ signal.removeEventListener("abort", onAbort);
37
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
38
+ };
39
+ signal.addEventListener("abort", onAbort, { once: true });
40
+ });
41
+ }
42
+ }
43
+
44
+ // src/browser/internal/event-log.ts
45
+ class EventLog {
46
+ entries = [];
47
+ seq = 0;
48
+ emitter = new Emitter;
49
+ maxLen;
50
+ retentionMs;
51
+ constructor(config = {}) {
52
+ this.maxLen = config.maxLen ?? 50000;
53
+ this.retentionMs = config.retentionMs ?? 5 * 60 * 1000;
54
+ }
55
+ append(fields) {
56
+ const id = String(++this.seq);
57
+ const entry = { id, ts: Date.now(), fields };
58
+ this.entries.push(entry);
59
+ this.trim();
60
+ this.emitter.emit(entry);
61
+ return id;
62
+ }
63
+ range(after, count) {
64
+ const afterNum = Number(after) || 0;
65
+ const result = [];
66
+ for (const entry of this.entries) {
67
+ if (Number(entry.id) <= afterNum)
68
+ continue;
69
+ result.push(entry);
70
+ if (count !== undefined && result.length >= count)
71
+ break;
72
+ }
73
+ return result;
74
+ }
75
+ latest() {
76
+ if (this.entries.length === 0)
77
+ return "0";
78
+ return this.entries[this.entries.length - 1].id;
79
+ }
80
+ earliest() {
81
+ if (this.entries.length === 0)
82
+ return null;
83
+ return this.entries[0].id;
84
+ }
85
+ has(cursor) {
86
+ const num = Number(cursor);
87
+ return this.entries.some((e) => Number(e.id) === num);
88
+ }
89
+ async* subscribe(after, signal) {
90
+ let cursor = after;
91
+ while (!signal?.aborted) {
92
+ const buffered = this.range(cursor);
93
+ if (buffered.length > 0) {
94
+ for (const entry of buffered) {
95
+ cursor = entry.id;
96
+ yield entry;
97
+ }
98
+ continue;
99
+ }
100
+ try {
101
+ const entry = await this.emitter.onceWithSignal(signal);
102
+ if (Number(entry.id) > Number(cursor)) {
103
+ cursor = entry.id;
104
+ yield entry;
105
+ }
106
+ } catch {
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ trim() {
112
+ if (this.entries.length > this.maxLen) {
113
+ this.entries.splice(0, this.entries.length - this.maxLen);
114
+ }
115
+ if (this.retentionMs > 0) {
116
+ const cutoff = Date.now() - this.retentionMs;
117
+ let trimCount = 0;
118
+ for (const entry of this.entries) {
119
+ if (entry.ts >= cutoff)
120
+ break;
121
+ trimCount++;
122
+ }
123
+ if (trimCount > 0) {
124
+ this.entries.splice(0, trimCount);
125
+ }
126
+ }
127
+ }
128
+ get size() {
129
+ return this.entries.length;
130
+ }
131
+ }
132
+
133
+ // src/browser/ephemeral.ts
134
+ var DEFAULT_MAX_ENTRIES = 1e4;
135
+ var DEFAULT_MAX_PAYLOAD_BYTES = 4 * 1024;
136
+ var DEFAULT_EVENT_RETENTION_MS = 5 * 60 * 1000;
137
+ var DEFAULT_EVENT_MAXLEN = 50000;
138
+ var DEFAULT_TIMEOUT_MS = 30000;
139
+ var MAX_KEY_BYTES = 512;
140
+ var textEncoder = new TextEncoder;
141
+
142
+ class EphemeralCapacityError extends Error {
143
+ constructor(message = "ephemeral store capacity reached") {
144
+ super(message);
145
+ this.name = "EphemeralCapacityError";
146
+ }
147
+ }
148
+
149
+ class EphemeralPayloadTooLargeError extends Error {
150
+ constructor(message) {
151
+ super(message);
152
+ this.name = "EphemeralPayloadTooLargeError";
153
+ }
154
+ }
155
+ var assertLogicalKey = (value) => {
156
+ if (value.length === 0)
157
+ throw new Error("key must be non-empty");
158
+ const bytes = textEncoder.encode(value).byteLength;
159
+ if (bytes > MAX_KEY_BYTES)
160
+ throw new Error(`key exceeds max length (${MAX_KEY_BYTES} bytes)`);
161
+ };
162
+ var assertIdentifier = (value, label) => {
163
+ if (value.length === 0)
164
+ throw new Error(`${label} must be non-empty`);
165
+ if (value.length > 256)
166
+ throw new Error(`${label} too long (max 256 chars)`);
167
+ };
168
+ var ephemeral = (config) => {
169
+ if (!Number.isFinite(config.ttlMs) || config.ttlMs <= 0) {
170
+ throw new Error("ttlMs must be > 0");
171
+ }
172
+ assertIdentifier(config.id, "config.id");
173
+ const defaultTenant = config.tenantId ?? "default";
174
+ assertIdentifier(defaultTenant, "tenantId");
175
+ const maxEntries = config.limits?.maxEntries ?? DEFAULT_MAX_ENTRIES;
176
+ const maxPayloadBytes = config.limits?.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
177
+ const eventRetentionMs = config.limits?.eventRetentionMs ?? DEFAULT_EVENT_RETENTION_MS;
178
+ const eventMaxLen = config.limits?.eventMaxLen ?? DEFAULT_EVENT_MAXLEN;
179
+ const resolveTenant = (tenantId) => {
180
+ const resolved = tenantId ?? defaultTenant;
181
+ assertIdentifier(resolved, "tenantId");
182
+ return resolved;
183
+ };
184
+ const tenantStates = new Map;
185
+ const getTenantState = (tenantId) => {
186
+ let state = tenantStates.get(tenantId);
187
+ if (!state) {
188
+ state = {
189
+ seq: 0,
190
+ entries: new Map,
191
+ timers: new Map,
192
+ eventLog: new EventLog({ maxLen: eventMaxLen, retentionMs: eventRetentionMs })
193
+ };
194
+ tenantStates.set(tenantId, state);
195
+ }
196
+ return state;
197
+ };
198
+ const scheduleExpiry = (state, logicalKey, ttlMs) => {
199
+ const existing = state.timers.get(logicalKey);
200
+ if (existing)
201
+ clearTimeout(existing);
202
+ state.timers.set(logicalKey, setTimeout(() => {
203
+ const entry = state.entries.get(logicalKey);
204
+ if (!entry)
205
+ return;
206
+ state.entries.delete(logicalKey);
207
+ state.timers.delete(logicalKey);
208
+ const version = String(++state.seq);
209
+ state.eventLog.append({
210
+ type: "expire",
211
+ key: logicalKey,
212
+ version,
213
+ expiredAt: Date.now()
214
+ });
215
+ }, ttlMs));
216
+ };
217
+ const upsert = async (cfg) => {
218
+ assertLogicalKey(cfg.key);
219
+ const tenantId = resolveTenant(cfg.tenantId);
220
+ const state = getTenantState(tenantId);
221
+ const ttlMs = cfg.ttlMs ?? config.ttlMs;
222
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
223
+ throw new Error("ttlMs must be > 0");
224
+ }
225
+ if (!state.entries.has(cfg.key) && state.entries.size >= maxEntries) {
226
+ throw new EphemeralCapacityError(`maxEntries (${maxEntries}) reached`);
227
+ }
228
+ const parsed = config.schema.safeParse(cfg.value);
229
+ if (!parsed.success)
230
+ throw parsed.error;
231
+ const payloadRaw = JSON.stringify(parsed.data);
232
+ const payloadBytes = textEncoder.encode(payloadRaw).byteLength;
233
+ if (payloadBytes > maxPayloadBytes) {
234
+ throw new EphemeralPayloadTooLargeError(`payload exceeds limit (${maxPayloadBytes} bytes)`);
235
+ }
236
+ const now = Date.now();
237
+ const version = String(++state.seq);
238
+ const expiresAt = now + ttlMs;
239
+ const stored = {
240
+ key: cfg.key,
241
+ data: parsed.data,
242
+ version,
243
+ updatedAt: now,
244
+ expiresAt
245
+ };
246
+ state.entries.set(cfg.key, stored);
247
+ scheduleExpiry(state, cfg.key, ttlMs);
248
+ state.eventLog.append({
249
+ type: "upsert",
250
+ key: cfg.key,
251
+ version,
252
+ updatedAt: now,
253
+ expiresAt,
254
+ payload: payloadRaw
255
+ });
256
+ return {
257
+ key: cfg.key,
258
+ value: parsed.data,
259
+ version,
260
+ updatedAt: now,
261
+ expiresAt
262
+ };
263
+ };
264
+ const touch = async (cfg) => {
265
+ assertLogicalKey(cfg.key);
266
+ const tenantId = resolveTenant(cfg.tenantId);
267
+ const state = getTenantState(tenantId);
268
+ const existing = state.entries.get(cfg.key);
269
+ if (!existing)
270
+ return { ok: false };
271
+ const ttlMs = cfg.ttlMs ?? config.ttlMs;
272
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
273
+ throw new Error("ttlMs must be > 0");
274
+ }
275
+ const now = Date.now();
276
+ const version = String(++state.seq);
277
+ const expiresAt = now + ttlMs;
278
+ existing.version = version;
279
+ existing.updatedAt = now;
280
+ existing.expiresAt = expiresAt;
281
+ scheduleExpiry(state, cfg.key, ttlMs);
282
+ state.eventLog.append({
283
+ type: "touch",
284
+ key: cfg.key,
285
+ version,
286
+ expiresAt
287
+ });
288
+ return { ok: true, version, expiresAt };
289
+ };
290
+ const remove = async (cfg) => {
291
+ assertLogicalKey(cfg.key);
292
+ const tenantId = resolveTenant(cfg.tenantId);
293
+ const state = getTenantState(tenantId);
294
+ const existing = state.entries.get(cfg.key);
295
+ if (!existing)
296
+ return false;
297
+ state.entries.delete(cfg.key);
298
+ const timer = state.timers.get(cfg.key);
299
+ if (timer) {
300
+ clearTimeout(timer);
301
+ state.timers.delete(cfg.key);
302
+ }
303
+ const version = String(++state.seq);
304
+ const fields = {
305
+ type: "delete",
306
+ key: cfg.key,
307
+ version,
308
+ deletedAt: Date.now()
309
+ };
310
+ if (cfg.reason)
311
+ fields.reason = cfg.reason;
312
+ state.eventLog.append(fields);
313
+ return true;
314
+ };
315
+ const snapshot = async (cfg = {}) => {
316
+ const tenantId = resolveTenant(cfg.tenantId);
317
+ const state = getTenantState(tenantId);
318
+ const entries = [];
319
+ for (const stored of state.entries.values()) {
320
+ const parsed = config.schema.safeParse(stored.data);
321
+ if (!parsed.success)
322
+ continue;
323
+ entries.push({
324
+ key: stored.key,
325
+ value: parsed.data,
326
+ version: stored.version,
327
+ updatedAt: stored.updatedAt,
328
+ expiresAt: stored.expiresAt
329
+ });
330
+ }
331
+ entries.sort((a, b) => a.key.localeCompare(b.key));
332
+ return {
333
+ entries,
334
+ cursor: state.eventLog.latest()
335
+ };
336
+ };
337
+ const reader = (readerCfg = {}) => {
338
+ const tenantId = resolveTenant(readerCfg.tenantId);
339
+ const state = getTenantState(tenantId);
340
+ let cursor = readerCfg.after ?? state.eventLog.latest();
341
+ let overflowPending = null;
342
+ let replayChecked = false;
343
+ const checkReplayGap = () => {
344
+ if (replayChecked)
345
+ return;
346
+ replayChecked = true;
347
+ const after = readerCfg.after;
348
+ if (!after || after === "0")
349
+ return;
350
+ const earliest = state.eventLog.earliest();
351
+ if (!earliest)
352
+ return;
353
+ if (!state.eventLog.has(after) && Number(after) < Number(earliest)) {
354
+ const liveCursor = state.eventLog.latest();
355
+ overflowPending = {
356
+ type: "overflow",
357
+ cursor: liveCursor,
358
+ after,
359
+ firstAvailable: earliest
360
+ };
361
+ cursor = liveCursor;
362
+ }
363
+ };
364
+ const parseEvent = (entry) => {
365
+ const type = entry.fields.type;
366
+ if (type === "upsert") {
367
+ const rawPayload = entry.fields.payload;
368
+ if (!rawPayload)
369
+ return null;
370
+ try {
371
+ const payload = JSON.parse(rawPayload);
372
+ const parsed = config.schema.safeParse(payload);
373
+ if (!parsed.success)
374
+ return null;
375
+ return {
376
+ type: "upsert",
377
+ cursor: entry.id,
378
+ entry: {
379
+ key: entry.fields.key ?? "",
380
+ value: parsed.data,
381
+ version: String(entry.fields.version ?? ""),
382
+ updatedAt: Number(entry.fields.updatedAt),
383
+ expiresAt: Number(entry.fields.expiresAt)
384
+ }
385
+ };
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+ if (type === "touch") {
391
+ return {
392
+ type: "touch",
393
+ cursor: entry.id,
394
+ key: entry.fields.key ?? "",
395
+ version: String(entry.fields.version ?? ""),
396
+ expiresAt: Number(entry.fields.expiresAt)
397
+ };
398
+ }
399
+ if (type === "delete") {
400
+ return {
401
+ type: "delete",
402
+ cursor: entry.id,
403
+ key: entry.fields.key ?? "",
404
+ version: String(entry.fields.version ?? ""),
405
+ deletedAt: Number(entry.fields.deletedAt),
406
+ reason: entry.fields.reason
407
+ };
408
+ }
409
+ if (type === "expire") {
410
+ return {
411
+ type: "expire",
412
+ cursor: entry.id,
413
+ key: entry.fields.key ?? "",
414
+ version: String(entry.fields.version ?? ""),
415
+ expiredAt: Number(entry.fields.expiredAt)
416
+ };
417
+ }
418
+ return null;
419
+ };
420
+ const recv = async (cfg = {}) => {
421
+ checkReplayGap();
422
+ if (overflowPending) {
423
+ const event = overflowPending;
424
+ overflowPending = null;
425
+ return event;
426
+ }
427
+ const wait = cfg.wait ?? true;
428
+ const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
429
+ const entries = state.eventLog.range(cursor, 1);
430
+ if (entries.length > 0) {
431
+ cursor = entries[0].id;
432
+ return parseEvent(entries[0]);
433
+ }
434
+ if (!wait)
435
+ return null;
436
+ const ac = new AbortController;
437
+ const timeout = setTimeout(() => ac.abort(), timeoutMs);
438
+ const signal = cfg.signal;
439
+ try {
440
+ for await (const entry of state.eventLog.subscribe(cursor, signal ?? ac.signal)) {
441
+ clearTimeout(timeout);
442
+ cursor = entry.id;
443
+ const parsed = parseEvent(entry);
444
+ if (parsed)
445
+ return parsed;
446
+ }
447
+ } catch {} finally {
448
+ clearTimeout(timeout);
449
+ }
450
+ return null;
451
+ };
452
+ const stream = async function* (cfg = {}) {
453
+ const wait = cfg.wait ?? true;
454
+ while (!cfg.signal?.aborted) {
455
+ const event = await recv(cfg);
456
+ if (event) {
457
+ yield event;
458
+ continue;
459
+ }
460
+ if (!wait)
461
+ break;
462
+ }
463
+ };
464
+ return { recv, stream };
465
+ };
466
+ return { upsert, touch, remove, snapshot, reader };
467
+ };
468
+ export {
469
+ ephemeral,
470
+ EphemeralPayloadTooLargeError,
471
+ EphemeralCapacityError
472
+ };
@@ -0,0 +1,21 @@
1
+ export {
2
+ topic,
3
+ scheduler,
4
+ retry,
5
+ registry,
6
+ ratelimit,
7
+ queue,
8
+ mutex,
9
+ job,
10
+ isRetryableTransportError,
11
+ ephemeral,
12
+ createMemoryStore,
13
+ RegistryPayloadTooLargeError,
14
+ RegistryCapacityError,
15
+ RateLimitError,
16
+ MemoryStore,
17
+ LockError,
18
+ EphemeralPayloadTooLargeError,
19
+ EphemeralCapacityError,
20
+ DEFAULT_RETRY_OPTIONS
21
+ };