@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.
- package/README.md +180 -176
- 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.d.ts +1 -0
- package/index.js +3 -16994
- 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/src/registry.d.ts +130 -0
|
@@ -0,0 +1,662 @@
|
|
|
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/registry.ts
|
|
134
|
+
var DEFAULT_MAX_ENTRIES = 1e4;
|
|
135
|
+
var DEFAULT_MAX_PAYLOAD_BYTES = 128 * 1024;
|
|
136
|
+
var DEFAULT_EVENT_RETENTION_MS = 5 * 60 * 1000;
|
|
137
|
+
var DEFAULT_EVENT_MAXLEN = 50000;
|
|
138
|
+
var DEFAULT_TOMBSTONE_RETENTION_MS = 5 * 60 * 1000;
|
|
139
|
+
var DEFAULT_LIST_LIMIT = 1000;
|
|
140
|
+
var DEFAULT_TIMEOUT_MS = 30000;
|
|
141
|
+
var MAX_KEY_BYTES = 512;
|
|
142
|
+
var MAX_KEY_DEPTH = 8;
|
|
143
|
+
var textEncoder = new TextEncoder;
|
|
144
|
+
|
|
145
|
+
class RegistryCapacityError extends Error {
|
|
146
|
+
constructor(message = "registry capacity reached") {
|
|
147
|
+
super(message);
|
|
148
|
+
this.name = "RegistryCapacityError";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class RegistryPayloadTooLargeError extends Error {
|
|
153
|
+
constructor(message) {
|
|
154
|
+
super(message);
|
|
155
|
+
this.name = "RegistryPayloadTooLargeError";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
var assertLogicalKey = (value) => {
|
|
159
|
+
if (value.length === 0)
|
|
160
|
+
throw new Error("key must be non-empty");
|
|
161
|
+
if (value.startsWith("/"))
|
|
162
|
+
throw new Error("key must not start with '/'");
|
|
163
|
+
if (value.endsWith("/"))
|
|
164
|
+
throw new Error("key must not end with '/'");
|
|
165
|
+
if (value.includes("//"))
|
|
166
|
+
throw new Error("key must not contain '//'");
|
|
167
|
+
const bytes = textEncoder.encode(value).byteLength;
|
|
168
|
+
if (bytes > MAX_KEY_BYTES)
|
|
169
|
+
throw new Error(`key exceeds max length (${MAX_KEY_BYTES} bytes)`);
|
|
170
|
+
const segments = value.split("/").filter(Boolean);
|
|
171
|
+
if (segments.length > MAX_KEY_DEPTH)
|
|
172
|
+
throw new Error(`key depth exceeds max (${MAX_KEY_DEPTH})`);
|
|
173
|
+
};
|
|
174
|
+
var assertIdentifier = (value, label) => {
|
|
175
|
+
if (value.length === 0)
|
|
176
|
+
throw new Error(`${label} must be non-empty`);
|
|
177
|
+
if (value.length > 256)
|
|
178
|
+
throw new Error(`${label} too long (max 256 chars)`);
|
|
179
|
+
};
|
|
180
|
+
var ancestorPrefixes = (key) => {
|
|
181
|
+
const parts = key.split("/").filter(Boolean);
|
|
182
|
+
const prefixes = [];
|
|
183
|
+
let current = "";
|
|
184
|
+
for (const part of parts) {
|
|
185
|
+
current += part + "/";
|
|
186
|
+
prefixes.push(current);
|
|
187
|
+
}
|
|
188
|
+
if (prefixes.length > 0) {
|
|
189
|
+
prefixes.pop();
|
|
190
|
+
}
|
|
191
|
+
return prefixes;
|
|
192
|
+
};
|
|
193
|
+
var registry = (config) => {
|
|
194
|
+
assertIdentifier(config.id, "config.id");
|
|
195
|
+
const defaultTenant = config.tenantId ?? "default";
|
|
196
|
+
assertIdentifier(defaultTenant, "tenantId");
|
|
197
|
+
const maxEntries = config.limits?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
198
|
+
const maxPayloadBytes = config.limits?.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
|
|
199
|
+
const eventRetentionMs = config.limits?.eventRetentionMs ?? DEFAULT_EVENT_RETENTION_MS;
|
|
200
|
+
const eventMaxLen = config.limits?.eventMaxLen ?? DEFAULT_EVENT_MAXLEN;
|
|
201
|
+
const tombstoneRetentionMs = config.limits?.tombstoneRetentionMs ?? DEFAULT_TOMBSTONE_RETENTION_MS;
|
|
202
|
+
const resolveTenant = (tenantId) => {
|
|
203
|
+
const resolved = tenantId ?? defaultTenant;
|
|
204
|
+
assertIdentifier(resolved, "tenantId");
|
|
205
|
+
return resolved;
|
|
206
|
+
};
|
|
207
|
+
const tenantStates = new Map;
|
|
208
|
+
const getTenantState = (tenantId) => {
|
|
209
|
+
let state = tenantStates.get(tenantId);
|
|
210
|
+
if (!state) {
|
|
211
|
+
state = {
|
|
212
|
+
seq: 0,
|
|
213
|
+
entries: new Map,
|
|
214
|
+
ttlTimers: new Map,
|
|
215
|
+
tombstones: new Map,
|
|
216
|
+
tombstoneTimers: new Map,
|
|
217
|
+
prefixRefs: new Map,
|
|
218
|
+
rootEventLog: new EventLog({ maxLen: eventMaxLen, retentionMs: eventRetentionMs }),
|
|
219
|
+
keyEventLogs: new Map,
|
|
220
|
+
prefixEventLogs: new Map
|
|
221
|
+
};
|
|
222
|
+
tenantStates.set(tenantId, state);
|
|
223
|
+
}
|
|
224
|
+
return state;
|
|
225
|
+
};
|
|
226
|
+
const getKeyEventLog = (state, key) => {
|
|
227
|
+
let log = state.keyEventLogs.get(key);
|
|
228
|
+
if (!log) {
|
|
229
|
+
log = new EventLog({ maxLen: eventMaxLen, retentionMs: eventRetentionMs });
|
|
230
|
+
state.keyEventLogs.set(key, log);
|
|
231
|
+
}
|
|
232
|
+
return log;
|
|
233
|
+
};
|
|
234
|
+
const getPrefixEventLog = (state, pfx) => {
|
|
235
|
+
let log = state.prefixEventLogs.get(pfx);
|
|
236
|
+
if (!log) {
|
|
237
|
+
log = new EventLog({ maxLen: eventMaxLen, retentionMs: eventRetentionMs });
|
|
238
|
+
state.prefixEventLogs.set(pfx, log);
|
|
239
|
+
}
|
|
240
|
+
return log;
|
|
241
|
+
};
|
|
242
|
+
const prefixRefInc = (state, key) => {
|
|
243
|
+
for (const pfx of ancestorPrefixes(key)) {
|
|
244
|
+
state.prefixRefs.set(pfx, (state.prefixRefs.get(pfx) ?? 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const prefixRefDec = (state, key) => {
|
|
248
|
+
for (const pfx of ancestorPrefixes(key)) {
|
|
249
|
+
const next = (state.prefixRefs.get(pfx) ?? 1) - 1;
|
|
250
|
+
if (next <= 0) {
|
|
251
|
+
state.prefixRefs.delete(pfx);
|
|
252
|
+
} else {
|
|
253
|
+
state.prefixRefs.set(pfx, next);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const emitEvent = (state, key, fields) => {
|
|
258
|
+
state.rootEventLog.append(fields);
|
|
259
|
+
getKeyEventLog(state, key).append(fields);
|
|
260
|
+
for (const pfx of ancestorPrefixes(key)) {
|
|
261
|
+
getPrefixEventLog(state, pfx).append(fields);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const scheduleExpiry = (state, logicalKey, ttlMs) => {
|
|
265
|
+
const existing = state.ttlTimers.get(logicalKey);
|
|
266
|
+
if (existing)
|
|
267
|
+
clearTimeout(existing);
|
|
268
|
+
state.ttlTimers.set(logicalKey, setTimeout(() => {
|
|
269
|
+
const entry = state.entries.get(logicalKey);
|
|
270
|
+
if (!entry)
|
|
271
|
+
return;
|
|
272
|
+
state.entries.delete(logicalKey);
|
|
273
|
+
state.ttlTimers.delete(logicalKey);
|
|
274
|
+
prefixRefDec(state, logicalKey);
|
|
275
|
+
const version = String(++state.seq);
|
|
276
|
+
const tombstone = {
|
|
277
|
+
key: logicalKey,
|
|
278
|
+
version,
|
|
279
|
+
removedAt: Date.now()
|
|
280
|
+
};
|
|
281
|
+
state.tombstones.set(logicalKey, tombstone);
|
|
282
|
+
state.tombstoneTimers.set(logicalKey, setTimeout(() => {
|
|
283
|
+
state.tombstones.delete(logicalKey);
|
|
284
|
+
state.tombstoneTimers.delete(logicalKey);
|
|
285
|
+
}, tombstoneRetentionMs));
|
|
286
|
+
emitEvent(state, logicalKey, {
|
|
287
|
+
type: "expire",
|
|
288
|
+
key: logicalKey,
|
|
289
|
+
version,
|
|
290
|
+
removedAt: Date.now()
|
|
291
|
+
});
|
|
292
|
+
}, ttlMs));
|
|
293
|
+
};
|
|
294
|
+
const upsert = async (cfg) => {
|
|
295
|
+
assertLogicalKey(cfg.key);
|
|
296
|
+
const tenantId = resolveTenant(cfg.tenantId);
|
|
297
|
+
const state = getTenantState(tenantId);
|
|
298
|
+
const isNew = !state.entries.has(cfg.key);
|
|
299
|
+
if (isNew && state.entries.size >= maxEntries) {
|
|
300
|
+
throw new RegistryCapacityError(`maxEntries (${maxEntries}) reached`);
|
|
301
|
+
}
|
|
302
|
+
const parsed = config.schema.safeParse(cfg.value);
|
|
303
|
+
if (!parsed.success)
|
|
304
|
+
throw parsed.error;
|
|
305
|
+
const payloadRaw = JSON.stringify(parsed.data);
|
|
306
|
+
const payloadBytes = textEncoder.encode(payloadRaw).byteLength;
|
|
307
|
+
if (payloadBytes > maxPayloadBytes) {
|
|
308
|
+
throw new RegistryPayloadTooLargeError(`payload exceeds limit (${maxPayloadBytes} bytes)`);
|
|
309
|
+
}
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const version = String(++state.seq);
|
|
312
|
+
const existingEntry = state.entries.get(cfg.key);
|
|
313
|
+
const stored = {
|
|
314
|
+
key: cfg.key,
|
|
315
|
+
data: parsed.data,
|
|
316
|
+
version,
|
|
317
|
+
createdAt: existingEntry?.createdAt ?? now,
|
|
318
|
+
updatedAt: now,
|
|
319
|
+
ttlMs: cfg.ttlMs ?? null,
|
|
320
|
+
expiresAt: cfg.ttlMs != null ? now + cfg.ttlMs : null
|
|
321
|
+
};
|
|
322
|
+
if (isNew) {
|
|
323
|
+
prefixRefInc(state, cfg.key);
|
|
324
|
+
}
|
|
325
|
+
state.entries.set(cfg.key, stored);
|
|
326
|
+
const tombstoneTimer = state.tombstoneTimers.get(cfg.key);
|
|
327
|
+
if (tombstoneTimer) {
|
|
328
|
+
clearTimeout(tombstoneTimer);
|
|
329
|
+
state.tombstoneTimers.delete(cfg.key);
|
|
330
|
+
}
|
|
331
|
+
state.tombstones.delete(cfg.key);
|
|
332
|
+
if (stored.ttlMs != null) {
|
|
333
|
+
scheduleExpiry(state, cfg.key, stored.ttlMs);
|
|
334
|
+
} else {
|
|
335
|
+
const ttlTimer = state.ttlTimers.get(cfg.key);
|
|
336
|
+
if (ttlTimer) {
|
|
337
|
+
clearTimeout(ttlTimer);
|
|
338
|
+
state.ttlTimers.delete(cfg.key);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const result = {
|
|
342
|
+
key: cfg.key,
|
|
343
|
+
value: parsed.data,
|
|
344
|
+
version,
|
|
345
|
+
status: "active",
|
|
346
|
+
createdAt: stored.createdAt,
|
|
347
|
+
updatedAt: now,
|
|
348
|
+
ttlMs: stored.ttlMs,
|
|
349
|
+
expiresAt: stored.expiresAt
|
|
350
|
+
};
|
|
351
|
+
emitEvent(state, cfg.key, {
|
|
352
|
+
type: "upsert",
|
|
353
|
+
key: cfg.key,
|
|
354
|
+
version,
|
|
355
|
+
status: "active",
|
|
356
|
+
createdAt: stored.createdAt,
|
|
357
|
+
updatedAt: now,
|
|
358
|
+
ttlMs: stored.ttlMs,
|
|
359
|
+
expiresAt: stored.expiresAt,
|
|
360
|
+
payload: payloadRaw
|
|
361
|
+
});
|
|
362
|
+
return result;
|
|
363
|
+
};
|
|
364
|
+
const touch = async (cfg) => {
|
|
365
|
+
assertLogicalKey(cfg.key);
|
|
366
|
+
const tenantId = resolveTenant(cfg.tenantId);
|
|
367
|
+
const state = getTenantState(tenantId);
|
|
368
|
+
const existing = state.entries.get(cfg.key);
|
|
369
|
+
if (!existing)
|
|
370
|
+
return { ok: false };
|
|
371
|
+
let ttlMs = cfg.ttlMs;
|
|
372
|
+
if (ttlMs == null) {
|
|
373
|
+
if (existing.expiresAt == null)
|
|
374
|
+
return { ok: false };
|
|
375
|
+
ttlMs = existing.expiresAt - existing.updatedAt;
|
|
376
|
+
if (ttlMs <= 0)
|
|
377
|
+
ttlMs = 1;
|
|
378
|
+
}
|
|
379
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0)
|
|
380
|
+
throw new Error("ttlMs must be > 0");
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
const expiresAt = now + ttlMs;
|
|
383
|
+
existing.updatedAt = now;
|
|
384
|
+
existing.expiresAt = expiresAt;
|
|
385
|
+
scheduleExpiry(state, cfg.key, ttlMs);
|
|
386
|
+
emitEvent(state, cfg.key, {
|
|
387
|
+
type: "touch",
|
|
388
|
+
key: cfg.key,
|
|
389
|
+
version: existing.version,
|
|
390
|
+
updatedAt: now,
|
|
391
|
+
expiresAt
|
|
392
|
+
});
|
|
393
|
+
return { ok: true, version: existing.version, expiresAt };
|
|
394
|
+
};
|
|
395
|
+
const remove = async (cfg) => {
|
|
396
|
+
assertLogicalKey(cfg.key);
|
|
397
|
+
const tenantId = resolveTenant(cfg.tenantId);
|
|
398
|
+
const state = getTenantState(tenantId);
|
|
399
|
+
const existing = state.entries.get(cfg.key);
|
|
400
|
+
if (!existing)
|
|
401
|
+
return false;
|
|
402
|
+
state.entries.delete(cfg.key);
|
|
403
|
+
prefixRefDec(state, cfg.key);
|
|
404
|
+
const ttlTimer = state.ttlTimers.get(cfg.key);
|
|
405
|
+
if (ttlTimer) {
|
|
406
|
+
clearTimeout(ttlTimer);
|
|
407
|
+
state.ttlTimers.delete(cfg.key);
|
|
408
|
+
}
|
|
409
|
+
const version = String(++state.seq);
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const tombstone = {
|
|
412
|
+
key: cfg.key,
|
|
413
|
+
version,
|
|
414
|
+
removedAt: now,
|
|
415
|
+
reason: cfg.reason
|
|
416
|
+
};
|
|
417
|
+
state.tombstones.set(cfg.key, tombstone);
|
|
418
|
+
state.tombstoneTimers.set(cfg.key, setTimeout(() => {
|
|
419
|
+
state.tombstones.delete(cfg.key);
|
|
420
|
+
state.tombstoneTimers.delete(cfg.key);
|
|
421
|
+
}, tombstoneRetentionMs));
|
|
422
|
+
const fields = {
|
|
423
|
+
type: "delete",
|
|
424
|
+
key: cfg.key,
|
|
425
|
+
version,
|
|
426
|
+
removedAt: now
|
|
427
|
+
};
|
|
428
|
+
if (cfg.reason)
|
|
429
|
+
fields.reason = cfg.reason;
|
|
430
|
+
emitEvent(state, cfg.key, fields);
|
|
431
|
+
return true;
|
|
432
|
+
};
|
|
433
|
+
const get = async (cfg) => {
|
|
434
|
+
assertLogicalKey(cfg.key);
|
|
435
|
+
const tenantId = resolveTenant(cfg.tenantId);
|
|
436
|
+
const state = getTenantState(tenantId);
|
|
437
|
+
const stored = state.entries.get(cfg.key);
|
|
438
|
+
if (stored) {
|
|
439
|
+
const parsed = config.schema.safeParse(stored.data);
|
|
440
|
+
if (!parsed.success)
|
|
441
|
+
return null;
|
|
442
|
+
return {
|
|
443
|
+
key: stored.key,
|
|
444
|
+
value: parsed.data,
|
|
445
|
+
version: stored.version,
|
|
446
|
+
status: "active",
|
|
447
|
+
createdAt: stored.createdAt,
|
|
448
|
+
updatedAt: stored.updatedAt,
|
|
449
|
+
ttlMs: stored.ttlMs,
|
|
450
|
+
expiresAt: stored.expiresAt
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
if (cfg.includeExpired) {
|
|
454
|
+
const tombstone = state.tombstones.get(cfg.key);
|
|
455
|
+
if (tombstone) {
|
|
456
|
+
return {
|
|
457
|
+
key: tombstone.key,
|
|
458
|
+
value: null,
|
|
459
|
+
version: tombstone.version,
|
|
460
|
+
status: "expired",
|
|
461
|
+
createdAt: tombstone.removedAt,
|
|
462
|
+
updatedAt: tombstone.removedAt,
|
|
463
|
+
ttlMs: null,
|
|
464
|
+
expiresAt: null
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
};
|
|
470
|
+
const list = async (cfg = {}) => {
|
|
471
|
+
const tenantId = resolveTenant(cfg.tenantId);
|
|
472
|
+
const state = getTenantState(tenantId);
|
|
473
|
+
const limit = cfg.limit ?? DEFAULT_LIST_LIMIT;
|
|
474
|
+
const status = cfg.status ?? "active";
|
|
475
|
+
const entries = [];
|
|
476
|
+
if (status === "active") {
|
|
477
|
+
for (const stored of state.entries.values()) {
|
|
478
|
+
if (cfg.prefix && !stored.key.startsWith(cfg.prefix))
|
|
479
|
+
continue;
|
|
480
|
+
const parsed = config.schema.safeParse(stored.data);
|
|
481
|
+
if (!parsed.success)
|
|
482
|
+
continue;
|
|
483
|
+
entries.push({
|
|
484
|
+
key: stored.key,
|
|
485
|
+
value: parsed.data,
|
|
486
|
+
version: stored.version,
|
|
487
|
+
status: "active",
|
|
488
|
+
createdAt: stored.createdAt,
|
|
489
|
+
updatedAt: stored.updatedAt,
|
|
490
|
+
ttlMs: stored.ttlMs,
|
|
491
|
+
expiresAt: stored.expiresAt
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
for (const tombstone of state.tombstones.values()) {
|
|
496
|
+
if (cfg.prefix && !tombstone.key.startsWith(cfg.prefix))
|
|
497
|
+
continue;
|
|
498
|
+
entries.push({
|
|
499
|
+
key: tombstone.key,
|
|
500
|
+
value: null,
|
|
501
|
+
version: tombstone.version,
|
|
502
|
+
status: "expired",
|
|
503
|
+
createdAt: tombstone.removedAt,
|
|
504
|
+
updatedAt: tombstone.removedAt,
|
|
505
|
+
ttlMs: null,
|
|
506
|
+
expiresAt: null
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
entries.sort((a, b) => a.key.localeCompare(b.key));
|
|
511
|
+
let start = 0;
|
|
512
|
+
if (cfg.afterKey) {
|
|
513
|
+
const idx = entries.findIndex((e) => e.key > cfg.afterKey);
|
|
514
|
+
start = idx >= 0 ? idx : entries.length;
|
|
515
|
+
}
|
|
516
|
+
const paginated = entries.slice(start, start + limit);
|
|
517
|
+
const hasMore = start + limit < entries.length;
|
|
518
|
+
return {
|
|
519
|
+
entries: paginated,
|
|
520
|
+
cursor: state.rootEventLog.latest(),
|
|
521
|
+
nextKey: hasMore ? entries[start + limit].key : undefined
|
|
522
|
+
};
|
|
523
|
+
};
|
|
524
|
+
const cas = async (cfg) => {
|
|
525
|
+
assertLogicalKey(cfg.key);
|
|
526
|
+
const tenantId = resolveTenant(cfg.tenantId);
|
|
527
|
+
const state = getTenantState(tenantId);
|
|
528
|
+
const existing = state.entries.get(cfg.key);
|
|
529
|
+
if (!existing)
|
|
530
|
+
return { ok: false };
|
|
531
|
+
if (existing.version !== cfg.version)
|
|
532
|
+
return { ok: false };
|
|
533
|
+
let preservedTtlMs;
|
|
534
|
+
if (existing.expiresAt != null) {
|
|
535
|
+
preservedTtlMs = Math.max(1, existing.expiresAt - Date.now());
|
|
536
|
+
}
|
|
537
|
+
const entry = await upsert({
|
|
538
|
+
key: cfg.key,
|
|
539
|
+
value: cfg.value,
|
|
540
|
+
ttlMs: preservedTtlMs,
|
|
541
|
+
tenantId: cfg.tenantId
|
|
542
|
+
});
|
|
543
|
+
return { ok: true, entry };
|
|
544
|
+
};
|
|
545
|
+
const reader = (readerCfg = {}) => {
|
|
546
|
+
const tenantId = resolveTenant(readerCfg.tenantId);
|
|
547
|
+
const state = getTenantState(tenantId);
|
|
548
|
+
let log;
|
|
549
|
+
if (readerCfg.key) {
|
|
550
|
+
log = getKeyEventLog(state, readerCfg.key);
|
|
551
|
+
} else if (readerCfg.prefix) {
|
|
552
|
+
log = getPrefixEventLog(state, readerCfg.prefix);
|
|
553
|
+
} else {
|
|
554
|
+
log = state.rootEventLog;
|
|
555
|
+
}
|
|
556
|
+
let cursor = readerCfg.after ?? log.latest();
|
|
557
|
+
const parseEvent = (entry) => {
|
|
558
|
+
const type = entry.fields.type;
|
|
559
|
+
if (type === "upsert") {
|
|
560
|
+
const rawPayload = entry.fields.payload;
|
|
561
|
+
if (!rawPayload)
|
|
562
|
+
return null;
|
|
563
|
+
try {
|
|
564
|
+
const payload = JSON.parse(rawPayload);
|
|
565
|
+
const parsed = config.schema.safeParse(payload);
|
|
566
|
+
if (!parsed.success)
|
|
567
|
+
return null;
|
|
568
|
+
return {
|
|
569
|
+
type: "upsert",
|
|
570
|
+
cursor: entry.id,
|
|
571
|
+
entry: {
|
|
572
|
+
key: entry.fields.key ?? "",
|
|
573
|
+
value: parsed.data,
|
|
574
|
+
version: String(entry.fields.version ?? ""),
|
|
575
|
+
status: "active",
|
|
576
|
+
createdAt: Number(entry.fields.createdAt ?? entry.fields.updatedAt),
|
|
577
|
+
updatedAt: Number(entry.fields.updatedAt),
|
|
578
|
+
ttlMs: entry.fields.ttlMs != null ? Number(entry.fields.ttlMs) : null,
|
|
579
|
+
expiresAt: entry.fields.expiresAt != null ? Number(entry.fields.expiresAt) : null
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
} catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (type === "touch") {
|
|
587
|
+
return {
|
|
588
|
+
type: "touch",
|
|
589
|
+
cursor: entry.id,
|
|
590
|
+
key: entry.fields.key ?? "",
|
|
591
|
+
version: String(entry.fields.version ?? ""),
|
|
592
|
+
updatedAt: Number(entry.fields.updatedAt ?? entry.fields.expiresAt),
|
|
593
|
+
expiresAt: Number(entry.fields.expiresAt)
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (type === "delete") {
|
|
597
|
+
return {
|
|
598
|
+
type: "delete",
|
|
599
|
+
cursor: entry.id,
|
|
600
|
+
key: entry.fields.key ?? "",
|
|
601
|
+
version: String(entry.fields.version ?? ""),
|
|
602
|
+
removedAt: Number(entry.fields.removedAt ?? entry.fields.deletedAt),
|
|
603
|
+
reason: entry.fields.reason
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
if (type === "expire") {
|
|
607
|
+
return {
|
|
608
|
+
type: "expire",
|
|
609
|
+
cursor: entry.id,
|
|
610
|
+
key: entry.fields.key ?? "",
|
|
611
|
+
version: String(entry.fields.version ?? ""),
|
|
612
|
+
removedAt: Number(entry.fields.removedAt ?? entry.fields.expiredAt)
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
};
|
|
617
|
+
const recv = async (cfg = {}) => {
|
|
618
|
+
const wait = cfg.wait ?? true;
|
|
619
|
+
const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
620
|
+
const entries = log.range(cursor, 1);
|
|
621
|
+
if (entries.length > 0) {
|
|
622
|
+
cursor = entries[0].id;
|
|
623
|
+
return parseEvent(entries[0]);
|
|
624
|
+
}
|
|
625
|
+
if (!wait)
|
|
626
|
+
return null;
|
|
627
|
+
const ac = new AbortController;
|
|
628
|
+
const timeout = setTimeout(() => ac.abort(), timeoutMs);
|
|
629
|
+
try {
|
|
630
|
+
for await (const entry of log.subscribe(cursor, cfg.signal ?? ac.signal)) {
|
|
631
|
+
clearTimeout(timeout);
|
|
632
|
+
cursor = entry.id;
|
|
633
|
+
const parsed = parseEvent(entry);
|
|
634
|
+
if (parsed)
|
|
635
|
+
return parsed;
|
|
636
|
+
}
|
|
637
|
+
} catch {} finally {
|
|
638
|
+
clearTimeout(timeout);
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
};
|
|
642
|
+
const stream = async function* (cfg = {}) {
|
|
643
|
+
const wait = cfg.wait ?? true;
|
|
644
|
+
while (!cfg.signal?.aborted) {
|
|
645
|
+
const event = await recv(cfg);
|
|
646
|
+
if (event) {
|
|
647
|
+
yield event;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (!wait)
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
return { recv, stream };
|
|
655
|
+
};
|
|
656
|
+
return { upsert, touch, remove, get, list, cas, reader };
|
|
657
|
+
};
|
|
658
|
+
export {
|
|
659
|
+
registry,
|
|
660
|
+
RegistryPayloadTooLargeError,
|
|
661
|
+
RegistryCapacityError
|
|
662
|
+
};
|