replyonce 0.0.0-stage → 0.1.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/dist/index.js ADDED
@@ -0,0 +1,801 @@
1
+ // src/replyonce.ts
2
+ import { randomUUID } from "crypto";
3
+
4
+ // src/key.ts
5
+ import { createHash } from "crypto";
6
+ function canonicalJson(value) {
7
+ return JSON.stringify(normalize(value, /* @__PURE__ */ new Set()));
8
+ }
9
+ function normalize(value, seen) {
10
+ if (value === null || value === void 0) return null;
11
+ if (typeof value === "bigint") return value.toString();
12
+ if (typeof value !== "object") {
13
+ if (typeof value === "number" && !Number.isFinite(value)) return String(value);
14
+ if (typeof value === "function" || typeof value === "symbol") return null;
15
+ return value;
16
+ }
17
+ if (value instanceof Date) return value.toISOString();
18
+ if (seen.has(value)) throw new TypeError("replyonce: cannot hash a circular payload");
19
+ seen.add(value);
20
+ let out;
21
+ if (Array.isArray(value)) {
22
+ out = value.map((v) => normalize(v, seen));
23
+ } else {
24
+ const obj = {};
25
+ for (const k of Object.keys(value).sort()) {
26
+ const v = value[k];
27
+ if (v === void 0) continue;
28
+ obj[k] = normalize(v, seen);
29
+ }
30
+ out = obj;
31
+ }
32
+ seen.delete(value);
33
+ return out;
34
+ }
35
+ function sha256Hex(input) {
36
+ return createHash("sha256").update(input).digest("hex");
37
+ }
38
+ function hashPayload(payload) {
39
+ return sha256Hex(canonicalJson(payload)).slice(0, 32);
40
+ }
41
+ var SAFE_INTENT = /^[A-Za-z0-9_.:-]{1,100}$/;
42
+ function effectKey(intent, payload) {
43
+ if (!SAFE_INTENT.test(intent)) {
44
+ throw new TypeError(
45
+ `replyonce: intent must match ${SAFE_INTENT} (got ${JSON.stringify(intent)})`
46
+ );
47
+ }
48
+ return payload === void 0 ? intent : `${intent}:${hashPayload(payload)}`;
49
+ }
50
+ function providerIdempotencyKey(scope, key) {
51
+ return `ro_${sha256Hex(`${scope}\0${key}`).slice(0, 40)}`;
52
+ }
53
+
54
+ // src/logic.ts
55
+ var SUMMARY_MAX = 2e3;
56
+ function emptyState() {
57
+ return { v: 1, seq: 0, tokenSeq: 0, runs: {}, effects: {} };
58
+ }
59
+ function load(state, now, policy) {
60
+ const s = state ? structuredClone(state) : emptyState();
61
+ if (s.v !== 1) throw new Error(`replyonce: unsupported state version ${String(s.v)}`);
62
+ const horizon = now - policy.retentionMs;
63
+ for (const [k, e] of Object.entries(s.effects)) {
64
+ if ((e.status === "committed" || e.status === "failed") && (e.settledAt ?? e.claimedAt) < horizon) {
65
+ delete s.effects[k];
66
+ }
67
+ }
68
+ for (const [id, r] of Object.entries(s.runs)) {
69
+ if ((r.endedAt ?? r.leaseExpiresAt) < horizon) delete s.runs[id];
70
+ }
71
+ return s;
72
+ }
73
+ function tick(s) {
74
+ s.seq += 1;
75
+ return s.seq;
76
+ }
77
+ function isAbandoned(e, now, policy) {
78
+ return e.status === "pending" && now - e.claimedAt >= policy.pendingTtlMs;
79
+ }
80
+ function effectiveStatus(e, now, policy) {
81
+ return isAbandoned(e, now, policy) ? "ambiguous" : e.status;
82
+ }
83
+ function isLive(r, now) {
84
+ return r.endedAt === null && r.leaseExpiresAt > now;
85
+ }
86
+ function isNewer(a, b) {
87
+ if (a.mode === "exclusive" && b.mode === "exclusive") return a.token > b.token;
88
+ if (a.orderAt !== b.orderAt) return a.orderAt > b.orderAt;
89
+ if (a.baseContextAt !== null || b.baseContextAt !== null) return false;
90
+ return a.baseSeq > b.baseSeq;
91
+ }
92
+ function supersedingRun(s, run, now) {
93
+ let best = null;
94
+ for (const r of Object.values(s.runs)) {
95
+ if (r.runId === run.runId || r.endReason === "cancelled" || !isNewer(r, run)) continue;
96
+ if (r.wrote || isLive(r, now)) {
97
+ if (!best || isNewer(r, best)) best = r;
98
+ }
99
+ }
100
+ return best;
101
+ }
102
+ function markSeen(run, seq, now) {
103
+ run.seenSeq = Math.max(run.seenSeq, seq);
104
+ run.seenAt = Math.max(run.seenAt, now);
105
+ run.contextAt = null;
106
+ }
107
+ function begin(state, input, policy) {
108
+ const { now } = input;
109
+ const s = load(state, now, policy);
110
+ const existing = s.runs[input.runId];
111
+ if (existing) {
112
+ const priorEffects = Object.values(s.effects).filter((e) => e.runId === input.runId);
113
+ if (existing.endedAt !== null) {
114
+ return { next: null, result: { state: "ended", run: existing, priorEffects } };
115
+ }
116
+ existing.leaseExpiresAt = Math.max(existing.leaseExpiresAt, now + input.leaseTtlMs);
117
+ tick(s);
118
+ s.tokenSeq += 1;
119
+ existing.token = s.tokenSeq;
120
+ existing.seenSeq = existing.baseSeq;
121
+ existing.seenAt = existing.baseContextAt ?? existing.startedAt;
122
+ existing.contextAt = existing.baseContextAt;
123
+ if (input.contextAt !== void 0 && input.contextAt > existing.seenAt) {
124
+ const fresh = Math.min(input.contextAt, now);
125
+ for (const e of Object.values(s.effects)) {
126
+ if (e.settleSeq !== null && e.settledAt !== null && e.settledAt < fresh) {
127
+ existing.seenSeq = Math.max(existing.seenSeq, e.settleSeq);
128
+ }
129
+ }
130
+ existing.seenAt = fresh;
131
+ existing.contextAt = fresh;
132
+ }
133
+ const newer2 = supersedingRun(s, existing, now);
134
+ return { next: s, result: { state: newer2 ? "superseded" : "resumed", run: existing, priorEffects } };
135
+ }
136
+ if (input.mode === "exclusive") {
137
+ let holder;
138
+ for (const r of Object.values(s.runs)) {
139
+ if (isLive(r, now) && (!holder || r.token > holder.token)) holder = r;
140
+ }
141
+ if (holder) {
142
+ return {
143
+ next: null,
144
+ result: {
145
+ state: "busy",
146
+ holder: { runId: holder.runId, token: holder.token, leaseExpiresAt: holder.leaseExpiresAt }
147
+ }
148
+ };
149
+ }
150
+ }
151
+ const seq = tick(s);
152
+ s.tokenSeq += 1;
153
+ const contextAt = input.contextAt === void 0 ? null : Math.min(input.contextAt, now);
154
+ const run = {
155
+ runId: input.runId,
156
+ token: s.tokenSeq,
157
+ mode: input.mode,
158
+ startedAt: now,
159
+ orderAt: input.mode === "exclusive" ? now : contextAt ?? now,
160
+ leaseExpiresAt: now + input.leaseTtlMs,
161
+ seenSeq: seq,
162
+ seenAt: contextAt ?? now,
163
+ contextAt,
164
+ baseSeq: seq,
165
+ baseContextAt: contextAt,
166
+ wrote: false,
167
+ endedAt: null,
168
+ endReason: null
169
+ };
170
+ s.runs[input.runId] = run;
171
+ const newer = supersedingRun(s, run, now);
172
+ return { next: s, result: { state: newer ? "superseded" : "new", run, priorEffects: [] } };
173
+ }
174
+ function renew(state, input, policy) {
175
+ const s = load(state, input.now, policy);
176
+ const r = s.runs[input.runId];
177
+ if (!r) return { next: null, result: { ok: false, reason: "unknown_run" } };
178
+ if (r.token !== input.token) return { next: null, result: { ok: false, reason: "superseded" } };
179
+ if (r.endReason === "cancelled") return { next: null, result: { ok: false, reason: "run_cancelled" } };
180
+ if (r.endedAt !== null) return { next: null, result: { ok: false, reason: "run_ended" } };
181
+ if (supersedingRun(s, r, input.now)) return { next: null, result: { ok: false, reason: "superseded" } };
182
+ r.leaseExpiresAt = Math.max(r.leaseExpiresAt, input.now + input.leaseTtlMs);
183
+ tick(s);
184
+ return { next: s, result: { ok: true, run: r } };
185
+ }
186
+ function endRun(state, input, policy) {
187
+ const s = load(state, input.now, policy);
188
+ const r = s.runs[input.runId];
189
+ if (!r) return { next: null, result: null };
190
+ if (input.token !== void 0 && r.token !== input.token) return { next: null, result: null };
191
+ if (r.endedAt !== null && !(input.reason === "cancelled" && r.endReason !== "cancelled")) {
192
+ return { next: null, result: r };
193
+ }
194
+ r.endedAt = r.endedAt ?? input.now;
195
+ r.endReason = input.reason;
196
+ tick(s);
197
+ return { next: s, result: r };
198
+ }
199
+ function conflictsFor(s, run, key, intent, mode, now, policy) {
200
+ if (mode === "none") return [];
201
+ const out = [];
202
+ for (const e of Object.values(s.effects)) {
203
+ if (e.key === key) continue;
204
+ if (e.runId === run.runId) {
205
+ if (e.token === run.token || e.intent !== intent) continue;
206
+ }
207
+ if (mode === "same-intent" && e.intent !== intent) continue;
208
+ const abandoned = isAbandoned(e, now, policy);
209
+ let conflict = false;
210
+ if (e.status === "pending" && !abandoned) {
211
+ conflict = true;
212
+ } else if (abandoned) {
213
+ conflict = e.claimedAt + policy.pendingTtlMs >= run.seenAt;
214
+ } else if (e.status === "committed" || e.status === "ambiguous") {
215
+ conflict = (e.settleSeq ?? 0) > run.seenSeq || run.contextAt !== null && (e.settledAt ?? 0) >= run.contextAt;
216
+ }
217
+ if (conflict) {
218
+ out.push({
219
+ key: e.key,
220
+ intent: e.intent,
221
+ status: abandoned ? "ambiguous" : e.status,
222
+ runId: e.runId,
223
+ summary: e.summary,
224
+ at: e.settledAt ?? e.claimedAt
225
+ });
226
+ }
227
+ }
228
+ return out.sort((a, b) => a.at - b.at).slice(0, 10);
229
+ }
230
+ function claim(state, scope, input, policy) {
231
+ const { now } = input;
232
+ const s = load(state, now, policy);
233
+ const prior = s.effects[input.key];
234
+ if (prior) {
235
+ if (prior.status === "committed") return { next: null, result: { kind: "replay", entry: prior } };
236
+ if (prior.status === "pending") {
237
+ if (!isAbandoned(prior, now, policy)) {
238
+ return { next: null, result: { kind: "in_flight", entry: prior } };
239
+ }
240
+ const seq2 = tick(s);
241
+ prior.status = "ambiguous";
242
+ prior.settledAt = prior.claimedAt + policy.pendingTtlMs;
243
+ prior.settleSeq = seq2;
244
+ prior.error = prior.error ?? "abandoned: not settled within pendingTtlMs";
245
+ return { next: s, result: { kind: "ambiguous", entry: prior } };
246
+ }
247
+ if (prior.status === "ambiguous") return { next: null, result: { kind: "ambiguous", entry: prior } };
248
+ if (prior.attempt >= policy.maxAttempts) {
249
+ return { next: null, result: { kind: "refused", reason: "exhausted", entry: prior } };
250
+ }
251
+ }
252
+ let run;
253
+ if (input.runId !== null) {
254
+ run = s.runs[input.runId];
255
+ if (!run) return { next: null, result: { kind: "refused", reason: "unknown_run" } };
256
+ if (run.endReason === "cancelled") return { next: null, result: { kind: "refused", reason: "run_cancelled" } };
257
+ if (run.endedAt !== null) return { next: null, result: { kind: "refused", reason: "run_ended" } };
258
+ if (input.token !== null && run.token !== input.token || supersedingRun(s, run, now)) {
259
+ return { next: null, result: { kind: "refused", reason: "superseded" } };
260
+ }
261
+ const conflicts = conflictsFor(s, run, input.key, input.intent, input.conflict, now, policy);
262
+ if (conflicts.length > 0) {
263
+ if (!input.ack) {
264
+ return { next: null, result: { kind: "refused", reason: "conversation_changed", conflicts } };
265
+ }
266
+ markSeen(run, tick(s), now);
267
+ return { next: s, result: { kind: "refused", reason: "conversation_changed", conflicts } };
268
+ }
269
+ }
270
+ const seq = tick(s);
271
+ const entry = {
272
+ key: input.key,
273
+ scope,
274
+ intent: input.intent,
275
+ status: "pending",
276
+ attempt: (prior?.attempt ?? 0) + 1,
277
+ attemptId: input.attemptId,
278
+ runId: input.runId,
279
+ token: run?.token ?? null,
280
+ summary: input.summary === null ? null : input.summary.slice(0, SUMMARY_MAX),
281
+ error: null,
282
+ createdAt: prior?.createdAt ?? now,
283
+ claimedAt: now,
284
+ settledAt: null,
285
+ claimSeq: seq,
286
+ settleSeq: null
287
+ };
288
+ s.effects[input.key] = entry;
289
+ if (run) {
290
+ run.wrote = true;
291
+ run.leaseExpiresAt = Math.max(run.leaseExpiresAt, now + input.leaseTtlMs);
292
+ }
293
+ return { next: s, result: { kind: "acquired", entry } };
294
+ }
295
+ function settle(state, input, policy) {
296
+ const s = load(state, input.now, policy);
297
+ const e = s.effects[input.key];
298
+ const lateTruth = e?.status === "failed" && input.outcome === "committed";
299
+ if (!e || e.attemptId !== input.attemptId || e.status !== "pending" && e.status !== "ambiguous" && !lateTruth) {
300
+ return { next: null, result: { kind: "stale", entry: e ?? null } };
301
+ }
302
+ const wasAbandoned = e.status !== "pending" || isAbandoned(e, input.now, policy);
303
+ e.status = input.outcome;
304
+ e.settledAt = input.now;
305
+ e.settleSeq = tick(s);
306
+ e.error = input.error ?? null;
307
+ if (input.outcome === "committed" && input.result !== void 0) e.result = input.result;
308
+ return { next: s, result: { kind: "settled", entry: e, wasAbandoned } };
309
+ }
310
+ function resolve(state, input, policy) {
311
+ const s = load(state, input.now, policy);
312
+ const e = s.effects[input.key];
313
+ if (!e) return { next: null, result: null };
314
+ if (input.attemptId !== void 0 && e.attemptId !== input.attemptId) return { next: null, result: null };
315
+ if (effectiveStatus(e, input.now, policy) !== "ambiguous") return { next: null, result: null };
316
+ e.settledAt = input.now;
317
+ e.settleSeq = tick(s);
318
+ if (input.outcome === "committed") {
319
+ e.status = "committed";
320
+ e.error = null;
321
+ if (input.result !== void 0) e.result = input.result;
322
+ } else {
323
+ e.status = "failed";
324
+ e.error = "reconciled: not applied";
325
+ }
326
+ return { next: s, result: e };
327
+ }
328
+
329
+ // src/replyonce.ts
330
+ var NotApplied = class extends Error {
331
+ replyonce = "not_applied";
332
+ constructor(message = "effect not applied", options) {
333
+ super(message);
334
+ this.name = "NotApplied";
335
+ if (options && "cause" in options) this.cause = options.cause;
336
+ }
337
+ };
338
+ function defaultClassifyError(err) {
339
+ if (err instanceof NotApplied) return "not_applied";
340
+ if (err && typeof err === "object" && err.replyonce === "not_applied") {
341
+ return "not_applied";
342
+ }
343
+ return "ambiguous";
344
+ }
345
+ var MAX_ROUNDS = 5;
346
+ var ReplyOnce = class {
347
+ store;
348
+ policy;
349
+ leaseTtlMs;
350
+ classify;
351
+ reconciler;
352
+ emit;
353
+ now;
354
+ constructor(options) {
355
+ if (!options || !options.store) throw new TypeError("replyonce: a store is required");
356
+ this.store = options.store;
357
+ this.policy = {
358
+ pendingTtlMs: options.pendingTtlMs ?? 15 * 6e4,
359
+ maxAttempts: options.maxAttempts ?? 3,
360
+ retentionMs: options.retentionMs ?? 7 * 24 * 36e5
361
+ };
362
+ if (this.policy.pendingTtlMs <= 0 || this.policy.maxAttempts < 1 || this.policy.retentionMs <= 0) {
363
+ throw new RangeError("replyonce: pendingTtlMs, maxAttempts and retentionMs must be positive");
364
+ }
365
+ this.leaseTtlMs = options.leaseTtlMs ?? 6e4;
366
+ this.classify = options.classifyError ?? defaultClassifyError;
367
+ this.reconciler = options.reconcile;
368
+ const onEvent = options.onEvent;
369
+ this.emit = onEvent ? (e) => {
370
+ try {
371
+ onEvent(e);
372
+ } catch {
373
+ }
374
+ } : () => {
375
+ };
376
+ this.now = options.clock ?? Date.now;
377
+ }
378
+ /** Resolve key parts to the ledger key used for them. */
379
+ key(parts) {
380
+ return effectKey(parts.intent, parts.payload);
381
+ }
382
+ /**
383
+ * Run `effect` at most once for this key. A string key is its own scope; key parts give
384
+ * a scope (conversation) plus an intent and business identity.
385
+ */
386
+ once(key, effect, options = {}) {
387
+ const { scope, ledgerKey, intent } = this.resolveKey(key);
388
+ return this.execute(scope, ledgerKey, intent, null, effect, { ...options, conflict: "none", ack: false });
389
+ }
390
+ /** "Was this already done?" — safe to expose to a model as a read-only tool. */
391
+ async check(key) {
392
+ const { scope, ledgerKey } = this.resolveKey(key);
393
+ const state = await this.store.read(scope);
394
+ const entry = state?.effects[ledgerKey] ?? null;
395
+ if (!entry) return { done: false, status: "none", entry: null };
396
+ const status = effectiveStatus(entry, this.now(), this.policy);
397
+ return { done: status === "committed", status, entry };
398
+ }
399
+ /** Every effect recorded on a scope, oldest first. */
400
+ async history(scope) {
401
+ const state = await this.store.read(scope);
402
+ if (!state) return [];
403
+ const now = this.now();
404
+ return Object.values(state.effects).map((e) => ({ ...e, status: effectiveStatus(e, now, this.policy) })).sort((a, b) => a.createdAt - b.createdAt);
405
+ }
406
+ /** Record what an operator or reconciler found out about an ambiguous effect. */
407
+ async resolve(key, verdict) {
408
+ const { scope, ledgerKey } = this.resolveKey(key);
409
+ const result = "result" in verdict ? verdict.result : void 0;
410
+ const now = this.now();
411
+ const entry = await this.store.transact(
412
+ scope,
413
+ (s) => resolve(s, { key: ledgerKey, outcome: verdict.outcome, result, now }, this.policy)
414
+ );
415
+ if (entry) this.emit({ type: "reconciled", scope, key: ledgerKey, outcome: verdict.outcome });
416
+ return entry;
417
+ }
418
+ /**
419
+ * Start (or, with the same `runId`, resume) a unit of work on a scope. The run gets a
420
+ * fencing token; its writes are refused once a newer run owns the scope.
421
+ */
422
+ async begin(scope, options = {}) {
423
+ assertScope(scope);
424
+ const runId = options.runId ?? randomUUID();
425
+ const leaseTtlMs = options.leaseTtlMs ?? this.leaseTtlMs;
426
+ const now = this.now();
427
+ const outcome = await this.store.transact(
428
+ scope,
429
+ (s) => begin(
430
+ s,
431
+ {
432
+ runId,
433
+ mode: options.mode ?? "supersede",
434
+ leaseTtlMs,
435
+ now,
436
+ ...options.contextAt !== void 0 ? { contextAt: options.contextAt } : {}
437
+ },
438
+ this.policy
439
+ )
440
+ );
441
+ return new Run(this, scope, runId, leaseTtlMs, outcome);
442
+ }
443
+ /** Cancel a run: its future writes are refused with `run_cancelled`. */
444
+ async cancel(scope, runId) {
445
+ const now = this.now();
446
+ const r = await this.store.transact(
447
+ scope,
448
+ (s) => endRun(s, { runId, reason: "cancelled", now }, this.policy)
449
+ );
450
+ return r !== null;
451
+ }
452
+ /** @internal */
453
+ async renewRun(scope, runId, token, leaseTtlMs) {
454
+ const now = this.now();
455
+ const r = await this.store.transact(
456
+ scope,
457
+ (s) => renew(s, { runId, token, leaseTtlMs, now }, this.policy)
458
+ );
459
+ return r.ok;
460
+ }
461
+ /** @internal */
462
+ async endRun(scope, runId, token, reason) {
463
+ const now = this.now();
464
+ await this.store.transact(scope, (s) => endRun(s, { runId, token, reason, now }, this.policy));
465
+ }
466
+ /** @internal */
467
+ resolveKey(key) {
468
+ if (typeof key === "string") {
469
+ assertScope(key);
470
+ return { scope: key, ledgerKey: key, intent: "once" };
471
+ }
472
+ assertScope(key.scope);
473
+ return { scope: key.scope, ledgerKey: effectKey(key.intent, key.payload), intent: key.intent };
474
+ }
475
+ /** @internal */
476
+ async execute(scope, key, intent, run, effect, options) {
477
+ const runId = run?.runId ?? null;
478
+ const token = run?.token ?? null;
479
+ const classify = options.classifyError ?? this.classify;
480
+ const reconcile = options.reconcile ?? this.reconciler;
481
+ const waitUntil = options.wait ? this.now() + options.wait.timeoutMs : 0;
482
+ let reconciledOnce = false;
483
+ for (let round = 0; round < MAX_ROUNDS; round++) {
484
+ const attemptId = randomUUID();
485
+ const now = this.now();
486
+ const claimed = await this.store.transact(
487
+ scope,
488
+ (s) => claim(
489
+ s,
490
+ scope,
491
+ {
492
+ key,
493
+ intent,
494
+ summary: options.summary ?? null,
495
+ runId,
496
+ token,
497
+ attemptId,
498
+ conflict: options.conflict ?? "any",
499
+ ack: options.ack ?? true,
500
+ leaseTtlMs: run?.leaseTtlMs ?? this.leaseTtlMs,
501
+ now
502
+ },
503
+ this.policy
504
+ )
505
+ );
506
+ switch (claimed.kind) {
507
+ case "replay":
508
+ this.emit({ type: "replayed", scope, key });
509
+ return { status: "replayed", ok: true, value: claimed.entry.result, key, entry: claimed.entry };
510
+ case "refused":
511
+ this.emit({ type: "refused", scope, key, reason: claimed.reason, runId });
512
+ return {
513
+ status: "refused",
514
+ ok: false,
515
+ key,
516
+ reason: claimed.reason,
517
+ ...claimed.conflicts ? { conflicts: claimed.conflicts } : {},
518
+ ...claimed.entry ? { entry: claimed.entry } : {}
519
+ };
520
+ case "in_flight": {
521
+ if (options.wait && this.now() < waitUntil) {
522
+ await this.waitForSettle(scope, key, waitUntil, options.wait.intervalMs ?? 100);
523
+ continue;
524
+ }
525
+ this.emit({ type: "in_flight", scope, key });
526
+ return { status: "in_flight", ok: false, key, entry: claimed.entry };
527
+ }
528
+ case "ambiguous": {
529
+ if (reconcile && !reconciledOnce) {
530
+ reconciledOnce = true;
531
+ let verdict;
532
+ try {
533
+ verdict = await reconcile(claimed.entry);
534
+ } catch {
535
+ verdict = { outcome: "unknown" };
536
+ }
537
+ if (verdict.outcome !== "unknown") {
538
+ const at = this.now();
539
+ const resolved = await this.store.transact(
540
+ scope,
541
+ (s) => resolve(
542
+ s,
543
+ {
544
+ key,
545
+ outcome: verdict.outcome,
546
+ result: verdict.outcome === "committed" ? verdict.result : void 0,
547
+ attemptId: claimed.entry.attemptId,
548
+ now: at
549
+ },
550
+ this.policy
551
+ )
552
+ );
553
+ if (resolved) this.emit({ type: "reconciled", scope, key, outcome: verdict.outcome });
554
+ continue;
555
+ }
556
+ }
557
+ this.emit({ type: "ambiguous", scope, key });
558
+ return { status: "ambiguous", ok: false, key, entry: claimed.entry };
559
+ }
560
+ case "acquired":
561
+ return this.runAcquired(scope, key, claimed.entry, effect, classify);
562
+ }
563
+ }
564
+ const entry = (await this.store.read(scope))?.effects[key];
565
+ if (entry && entry.status === "committed") {
566
+ return { status: "replayed", ok: true, value: entry.result, key, entry };
567
+ }
568
+ if (entry) {
569
+ this.emit({ type: "in_flight", scope, key });
570
+ return { status: "in_flight", ok: false, key, entry };
571
+ }
572
+ throw new Error("replyonce: could not claim key after several rounds");
573
+ }
574
+ async runAcquired(scope, key, entry, effect, classify) {
575
+ const { attempt, attemptId, runId } = entry;
576
+ this.emit({ type: "claimed", scope, key, attempt, runId });
577
+ const remaining = Math.max(1, entry.claimedAt + this.policy.pendingTtlMs - this.now());
578
+ const ctx = {
579
+ key,
580
+ scope,
581
+ intent: entry.intent,
582
+ attempt,
583
+ idempotencyKey: providerIdempotencyKey(scope, key),
584
+ fencingToken: entry.token,
585
+ signal: AbortSignal.timeout(remaining)
586
+ };
587
+ let value;
588
+ try {
589
+ value = await effect(ctx);
590
+ } catch (error) {
591
+ const cls = safeClassify(classify, error);
592
+ const outcome = cls === "not_applied" ? "failed" : "ambiguous";
593
+ await this.settleWithRetry(scope, {
594
+ key,
595
+ attemptId,
596
+ outcome,
597
+ error: describeError(error),
598
+ now: 0
599
+ }).catch(() => void 0);
600
+ if (outcome === "failed") {
601
+ this.emit({ type: "failed", scope, key, attempt, error });
602
+ return {
603
+ status: "failed",
604
+ ok: false,
605
+ key,
606
+ attempt,
607
+ error,
608
+ retryable: attempt < this.policy.maxAttempts
609
+ };
610
+ }
611
+ this.emit({ type: "ambiguous", scope, key, error });
612
+ const current = (await this.store.read(scope).catch(() => null))?.effects[key] ?? entry;
613
+ return { status: "ambiguous", ok: false, key, entry: current, error };
614
+ }
615
+ let stored;
616
+ try {
617
+ stored = value === void 0 ? null : JSON.parse(JSON.stringify(value));
618
+ } catch {
619
+ stored = null;
620
+ this.emit({ type: "unserializable_result", scope, key });
621
+ }
622
+ try {
623
+ const settled = await this.settleWithRetry(scope, {
624
+ key,
625
+ attemptId,
626
+ outcome: "committed",
627
+ result: stored,
628
+ now: 0
629
+ });
630
+ this.emit({ type: "committed", scope, key, attempt, runId });
631
+ if (settled.kind === "stale" || settled.wasAbandoned) {
632
+ this.emit({ type: "late_settle", scope, key, attempt });
633
+ return { status: "executed", ok: true, value, key, attempt, lateSettle: true };
634
+ }
635
+ } catch {
636
+ this.emit({ type: "late_settle", scope, key, attempt });
637
+ return { status: "executed", ok: true, value, key, attempt, lateSettle: true };
638
+ }
639
+ return { status: "executed", ok: true, value, key, attempt };
640
+ }
641
+ async settleWithRetry(scope, input) {
642
+ let lastError;
643
+ for (let i = 0; i < 4; i++) {
644
+ try {
645
+ const now = this.now();
646
+ return await this.store.transact(scope, (s) => settle(s, { ...input, now }, this.policy));
647
+ } catch (err) {
648
+ lastError = err;
649
+ await sleep(25 * 4 ** i);
650
+ }
651
+ }
652
+ throw lastError;
653
+ }
654
+ async waitForSettle(scope, key, until, intervalMs) {
655
+ while (this.now() < until) {
656
+ await sleep(Math.min(intervalMs, Math.max(1, until - this.now())));
657
+ const e = (await this.store.read(scope))?.effects[key];
658
+ if (!e || effectiveStatus(e, this.now(), this.policy) !== "pending") return;
659
+ }
660
+ }
661
+ };
662
+ var Run = class {
663
+ scope;
664
+ runId;
665
+ /** `new`, `resumed` (same runId seen before), `superseded`, `ended`, or `busy` (exclusive mode). */
666
+ state;
667
+ /** Monotonic per scope. `null` when the run could not start (`busy`). */
668
+ fencingToken;
669
+ /** Effects this runId already claimed before (non-empty on redelivery). */
670
+ priorEffects;
671
+ busyHolder;
672
+ ro;
673
+ leaseTtlMs;
674
+ constructor(ro, scope, runId, leaseTtlMs, outcome) {
675
+ this.ro = ro;
676
+ this.scope = scope;
677
+ this.runId = runId;
678
+ this.leaseTtlMs = leaseTtlMs;
679
+ this.state = outcome.state;
680
+ if (outcome.state === "busy") {
681
+ this.fencingToken = null;
682
+ this.priorEffects = [];
683
+ this.busyHolder = outcome.holder;
684
+ } else {
685
+ this.fencingToken = outcome.run.token;
686
+ this.priorEffects = outcome.priorEffects;
687
+ this.busyHolder = null;
688
+ }
689
+ }
690
+ /** `true` when this run may write: it is new or resumed, not superseded, ended or busy. */
691
+ get canWrite() {
692
+ return this.state === "new" || this.state === "resumed";
693
+ }
694
+ /**
695
+ * `true` when a previous delivery of this run already claimed a write that may have
696
+ * reached the outside world (pending, committed or ambiguous).
697
+ */
698
+ get alreadyWrote() {
699
+ return this.priorEffects.some((e) => e.status !== "failed");
700
+ }
701
+ /** Run `effect` at most once per (scope, intent, payload), fenced to this run. */
702
+ once(intent, payload, effect, options = {}) {
703
+ const key = effectKey(intent, payload);
704
+ if (this.fencingToken === null) {
705
+ return Promise.resolve({ status: "refused", ok: false, key, reason: "busy" });
706
+ }
707
+ const self = { runId: this.runId, token: this.fencingToken, leaseTtlMs: this.leaseTtlMs };
708
+ return this.ro.execute(this.scope, key, intent, self, effect, options);
709
+ }
710
+ check(intent, payload) {
711
+ return this.ro.check({ scope: this.scope, intent, payload });
712
+ }
713
+ history() {
714
+ return this.ro.history(this.scope);
715
+ }
716
+ /**
717
+ * Renew the lease. `false` means stop: the run was superseded (by a newer run or a later
718
+ * delivery of this one), cancelled or ended.
719
+ */
720
+ heartbeat() {
721
+ if (this.fencingToken === null) return Promise.resolve(false);
722
+ return this.ro.renewRun(this.scope, this.runId, this.fencingToken, this.leaseTtlMs);
723
+ }
724
+ /** Mark the run finished. A no-op if a later delivery of the same run has taken over. */
725
+ end(reason = "completed") {
726
+ if (this.fencingToken === null) return Promise.resolve();
727
+ return this.ro.endRun(this.scope, this.runId, this.fencingToken, reason);
728
+ }
729
+ };
730
+ function createReplyOnce(options) {
731
+ return new ReplyOnce(options);
732
+ }
733
+ function unwrap(result) {
734
+ if (result.ok) return result.value;
735
+ throw new ReplyOnceError(result);
736
+ }
737
+ var ReplyOnceError = class extends Error {
738
+ result;
739
+ constructor(result) {
740
+ const detail = result.status === "refused" ? `${result.status}: ${result.reason}` : result.status;
741
+ super(`replyonce: effect ${result.key} not executed (${detail})`);
742
+ this.name = "ReplyOnceError";
743
+ this.result = result;
744
+ }
745
+ };
746
+ function assertScope(scope) {
747
+ if (typeof scope !== "string" || scope.length === 0 || scope.length > 512) {
748
+ throw new TypeError("replyonce: scope must be a non-empty string of at most 512 characters");
749
+ }
750
+ }
751
+ function safeClassify(classify, err) {
752
+ try {
753
+ return classify(err) === "not_applied" ? "not_applied" : "ambiguous";
754
+ } catch {
755
+ return "ambiguous";
756
+ }
757
+ }
758
+ function describeError(err) {
759
+ const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
760
+ return text.slice(0, 500);
761
+ }
762
+ function sleep(ms) {
763
+ return new Promise((r) => setTimeout(r, ms));
764
+ }
765
+
766
+ // src/stores/memory.ts
767
+ var MemoryStore = class {
768
+ data = /* @__PURE__ */ new Map();
769
+ async transact(scope, fn) {
770
+ const raw = this.data.get(scope);
771
+ const { next, result } = fn(raw === void 0 ? null : JSON.parse(raw));
772
+ if (next) this.data.set(scope, JSON.stringify(next));
773
+ return result;
774
+ }
775
+ async read(scope) {
776
+ const raw = this.data.get(scope);
777
+ return raw === void 0 ? null : JSON.parse(raw);
778
+ }
779
+ /** Number of scopes held. */
780
+ get size() {
781
+ return this.data.size;
782
+ }
783
+ clear() {
784
+ this.data.clear();
785
+ }
786
+ };
787
+ export {
788
+ MemoryStore,
789
+ NotApplied,
790
+ ReplyOnce,
791
+ ReplyOnceError,
792
+ Run,
793
+ canonicalJson,
794
+ createReplyOnce,
795
+ defaultClassifyError,
796
+ effectKey,
797
+ hashPayload,
798
+ providerIdempotencyKey,
799
+ unwrap
800
+ };
801
+ //# sourceMappingURL=index.js.map