replyonce 0.0.0-stage → 0.1.1

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,209 @@
1
+ type Json = null | boolean | number | string | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ /**
5
+ * Lifecycle of one effect in the ledger.
6
+ *
7
+ * - `pending`: an attempt claimed the key and may be executing right now.
8
+ * - `committed`: the effect is known to have happened. It is never run again.
9
+ * - `failed`: the effect is known NOT to have happened. A later call may retry.
10
+ * - `ambiguous`: it may or may not have happened (timeout, crash, unknown error).
11
+ * It is never retried blindly; it must be reconciled.
12
+ */
13
+ type EffectStatus = 'pending' | 'committed' | 'failed' | 'ambiguous';
14
+ interface EffectRecord {
15
+ key: string;
16
+ scope: string;
17
+ intent: string;
18
+ status: EffectStatus;
19
+ attempt: number;
20
+ attemptId: string;
21
+ runId: string | null;
22
+ token: number | null;
23
+ summary: string | null;
24
+ result?: Json;
25
+ error: string | null;
26
+ createdAt: number;
27
+ claimedAt: number;
28
+ settledAt: number | null;
29
+ /** Scope sequence number of the claim and of the last settlement (logical clock). */
30
+ claimSeq: number;
31
+ settleSeq: number | null;
32
+ }
33
+ /**
34
+ * `supersede`: the newest run on a scope owns it; older runs are refused at write time.
35
+ * `exclusive`: one live lease holder at a time; a second `begin` is told the scope is busy
36
+ * until the lease expires.
37
+ */
38
+ type RunMode = 'supersede' | 'exclusive';
39
+ type RunEndReason = 'completed' | 'failed' | 'cancelled';
40
+ interface RunRecord {
41
+ runId: string;
42
+ token: number;
43
+ mode: RunMode;
44
+ startedAt: number;
45
+ /** Supersession order: when this run's context was read. Newer context owns the scope. */
46
+ orderAt: number;
47
+ leaseExpiresAt: number;
48
+ /** The run has seen every write settled at or before this scope sequence number. */
49
+ seenSeq: number;
50
+ /** Wall-clock time of the same moment, used only against TTL-based abandonment. */
51
+ seenAt: number;
52
+ /** Until the run's first claim or ack: writes settled at or after this time are unseen. */
53
+ contextAt: number | null;
54
+ /** The run's original context, restored when a redelivery takes the run over. */
55
+ baseSeq: number;
56
+ baseContextAt: number | null;
57
+ wrote: boolean;
58
+ endedAt: number | null;
59
+ endReason: RunEndReason | null;
60
+ }
61
+ /** Everything replyonce knows about one scope (usually one conversation or thread). */
62
+ interface ScopeState {
63
+ v: 1;
64
+ /** Logical clock, incremented on every change to the scope. */
65
+ seq: number;
66
+ tokenSeq: number;
67
+ runs: Record<string, RunRecord>;
68
+ effects: Record<string, EffectRecord>;
69
+ }
70
+ interface TransactResult<T> {
71
+ /** New state to persist, or `null` to leave the stored state unchanged. */
72
+ next: ScopeState | null;
73
+ result: T;
74
+ }
75
+ /**
76
+ * A store gives atomic read-modify-write over one scope's state. That is the only
77
+ * primitive replyonce needs; all decision logic lives in the library.
78
+ */
79
+ interface Store {
80
+ /**
81
+ * Atomically apply `fn` to the scope's current state. `fn` is synchronous and pure,
82
+ * and a store may call it more than once (optimistic retries).
83
+ */
84
+ transact<T>(scope: string, fn: (state: ScopeState | null) => TransactResult<T>): Promise<T>;
85
+ read(scope: string): Promise<ScopeState | null>;
86
+ }
87
+ type RefusalReason = 'superseded' | 'run_cancelled' | 'run_ended' | 'unknown_run' | 'busy' | 'conversation_changed' | 'exhausted';
88
+ interface ConflictInfo {
89
+ key: string;
90
+ intent: string;
91
+ status: EffectStatus;
92
+ runId: string | null;
93
+ summary: string | null;
94
+ at: number;
95
+ }
96
+ interface EffectContext {
97
+ key: string;
98
+ scope: string;
99
+ intent: string;
100
+ attempt: number;
101
+ /** Stable across attempts and workers. Pass it to providers that accept an idempotency key. */
102
+ idempotencyKey: string;
103
+ /** Fencing token of the run that claimed this effect, or `null` outside a run. */
104
+ fencingToken: number | null;
105
+ /** Aborts when the claim would be treated as abandoned (`pendingTtlMs`). */
106
+ signal: AbortSignal;
107
+ }
108
+ type OnceResult<T> = {
109
+ status: 'executed';
110
+ ok: true;
111
+ value: T;
112
+ key: string;
113
+ attempt: number;
114
+ lateSettle?: true;
115
+ } | {
116
+ status: 'replayed';
117
+ ok: true;
118
+ value: T;
119
+ key: string;
120
+ entry: EffectRecord;
121
+ } | {
122
+ status: 'in_flight';
123
+ ok: false;
124
+ key: string;
125
+ entry: EffectRecord;
126
+ } | {
127
+ status: 'ambiguous';
128
+ ok: false;
129
+ key: string;
130
+ entry: EffectRecord;
131
+ error?: unknown;
132
+ } | {
133
+ status: 'failed';
134
+ ok: false;
135
+ key: string;
136
+ attempt: number;
137
+ error: unknown;
138
+ retryable: boolean;
139
+ } | {
140
+ status: 'refused';
141
+ ok: false;
142
+ key: string;
143
+ reason: RefusalReason;
144
+ conflicts?: ConflictInfo[];
145
+ entry?: EffectRecord;
146
+ };
147
+ type ReconcileVerdict = {
148
+ outcome: 'committed';
149
+ result?: Json;
150
+ } | {
151
+ outcome: 'not_applied';
152
+ } | {
153
+ outcome: 'unknown';
154
+ };
155
+ type ErrorClass = 'not_applied' | 'ambiguous';
156
+ type ReplyOnceEvent = {
157
+ type: 'claimed';
158
+ scope: string;
159
+ key: string;
160
+ attempt: number;
161
+ runId: string | null;
162
+ } | {
163
+ type: 'committed';
164
+ scope: string;
165
+ key: string;
166
+ attempt: number;
167
+ runId: string | null;
168
+ } | {
169
+ type: 'replayed';
170
+ scope: string;
171
+ key: string;
172
+ } | {
173
+ type: 'in_flight';
174
+ scope: string;
175
+ key: string;
176
+ } | {
177
+ type: 'failed';
178
+ scope: string;
179
+ key: string;
180
+ attempt: number;
181
+ error: unknown;
182
+ } | {
183
+ type: 'ambiguous';
184
+ scope: string;
185
+ key: string;
186
+ error?: unknown;
187
+ } | {
188
+ type: 'refused';
189
+ scope: string;
190
+ key: string;
191
+ reason: RefusalReason;
192
+ runId: string | null;
193
+ } | {
194
+ type: 'reconciled';
195
+ scope: string;
196
+ key: string;
197
+ outcome: ReconcileVerdict['outcome'];
198
+ } | {
199
+ type: 'late_settle';
200
+ scope: string;
201
+ key: string;
202
+ attempt: number;
203
+ } | {
204
+ type: 'unserializable_result';
205
+ scope: string;
206
+ key: string;
207
+ };
208
+
209
+ export type { ConflictInfo as C, EffectContext as E, Json as J, OnceResult as O, RunMode as R, Store as S, TransactResult as T, ScopeState as a, EffectRecord as b, EffectStatus as c, ErrorClass as d, ReconcileVerdict as e, RefusalReason as f, ReplyOnceEvent as g, RunEndReason as h, RunRecord as i };
@@ -0,0 +1,209 @@
1
+ type Json = null | boolean | number | string | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ /**
5
+ * Lifecycle of one effect in the ledger.
6
+ *
7
+ * - `pending`: an attempt claimed the key and may be executing right now.
8
+ * - `committed`: the effect is known to have happened. It is never run again.
9
+ * - `failed`: the effect is known NOT to have happened. A later call may retry.
10
+ * - `ambiguous`: it may or may not have happened (timeout, crash, unknown error).
11
+ * It is never retried blindly; it must be reconciled.
12
+ */
13
+ type EffectStatus = 'pending' | 'committed' | 'failed' | 'ambiguous';
14
+ interface EffectRecord {
15
+ key: string;
16
+ scope: string;
17
+ intent: string;
18
+ status: EffectStatus;
19
+ attempt: number;
20
+ attemptId: string;
21
+ runId: string | null;
22
+ token: number | null;
23
+ summary: string | null;
24
+ result?: Json;
25
+ error: string | null;
26
+ createdAt: number;
27
+ claimedAt: number;
28
+ settledAt: number | null;
29
+ /** Scope sequence number of the claim and of the last settlement (logical clock). */
30
+ claimSeq: number;
31
+ settleSeq: number | null;
32
+ }
33
+ /**
34
+ * `supersede`: the newest run on a scope owns it; older runs are refused at write time.
35
+ * `exclusive`: one live lease holder at a time; a second `begin` is told the scope is busy
36
+ * until the lease expires.
37
+ */
38
+ type RunMode = 'supersede' | 'exclusive';
39
+ type RunEndReason = 'completed' | 'failed' | 'cancelled';
40
+ interface RunRecord {
41
+ runId: string;
42
+ token: number;
43
+ mode: RunMode;
44
+ startedAt: number;
45
+ /** Supersession order: when this run's context was read. Newer context owns the scope. */
46
+ orderAt: number;
47
+ leaseExpiresAt: number;
48
+ /** The run has seen every write settled at or before this scope sequence number. */
49
+ seenSeq: number;
50
+ /** Wall-clock time of the same moment, used only against TTL-based abandonment. */
51
+ seenAt: number;
52
+ /** Until the run's first claim or ack: writes settled at or after this time are unseen. */
53
+ contextAt: number | null;
54
+ /** The run's original context, restored when a redelivery takes the run over. */
55
+ baseSeq: number;
56
+ baseContextAt: number | null;
57
+ wrote: boolean;
58
+ endedAt: number | null;
59
+ endReason: RunEndReason | null;
60
+ }
61
+ /** Everything replyonce knows about one scope (usually one conversation or thread). */
62
+ interface ScopeState {
63
+ v: 1;
64
+ /** Logical clock, incremented on every change to the scope. */
65
+ seq: number;
66
+ tokenSeq: number;
67
+ runs: Record<string, RunRecord>;
68
+ effects: Record<string, EffectRecord>;
69
+ }
70
+ interface TransactResult<T> {
71
+ /** New state to persist, or `null` to leave the stored state unchanged. */
72
+ next: ScopeState | null;
73
+ result: T;
74
+ }
75
+ /**
76
+ * A store gives atomic read-modify-write over one scope's state. That is the only
77
+ * primitive replyonce needs; all decision logic lives in the library.
78
+ */
79
+ interface Store {
80
+ /**
81
+ * Atomically apply `fn` to the scope's current state. `fn` is synchronous and pure,
82
+ * and a store may call it more than once (optimistic retries).
83
+ */
84
+ transact<T>(scope: string, fn: (state: ScopeState | null) => TransactResult<T>): Promise<T>;
85
+ read(scope: string): Promise<ScopeState | null>;
86
+ }
87
+ type RefusalReason = 'superseded' | 'run_cancelled' | 'run_ended' | 'unknown_run' | 'busy' | 'conversation_changed' | 'exhausted';
88
+ interface ConflictInfo {
89
+ key: string;
90
+ intent: string;
91
+ status: EffectStatus;
92
+ runId: string | null;
93
+ summary: string | null;
94
+ at: number;
95
+ }
96
+ interface EffectContext {
97
+ key: string;
98
+ scope: string;
99
+ intent: string;
100
+ attempt: number;
101
+ /** Stable across attempts and workers. Pass it to providers that accept an idempotency key. */
102
+ idempotencyKey: string;
103
+ /** Fencing token of the run that claimed this effect, or `null` outside a run. */
104
+ fencingToken: number | null;
105
+ /** Aborts when the claim would be treated as abandoned (`pendingTtlMs`). */
106
+ signal: AbortSignal;
107
+ }
108
+ type OnceResult<T> = {
109
+ status: 'executed';
110
+ ok: true;
111
+ value: T;
112
+ key: string;
113
+ attempt: number;
114
+ lateSettle?: true;
115
+ } | {
116
+ status: 'replayed';
117
+ ok: true;
118
+ value: T;
119
+ key: string;
120
+ entry: EffectRecord;
121
+ } | {
122
+ status: 'in_flight';
123
+ ok: false;
124
+ key: string;
125
+ entry: EffectRecord;
126
+ } | {
127
+ status: 'ambiguous';
128
+ ok: false;
129
+ key: string;
130
+ entry: EffectRecord;
131
+ error?: unknown;
132
+ } | {
133
+ status: 'failed';
134
+ ok: false;
135
+ key: string;
136
+ attempt: number;
137
+ error: unknown;
138
+ retryable: boolean;
139
+ } | {
140
+ status: 'refused';
141
+ ok: false;
142
+ key: string;
143
+ reason: RefusalReason;
144
+ conflicts?: ConflictInfo[];
145
+ entry?: EffectRecord;
146
+ };
147
+ type ReconcileVerdict = {
148
+ outcome: 'committed';
149
+ result?: Json;
150
+ } | {
151
+ outcome: 'not_applied';
152
+ } | {
153
+ outcome: 'unknown';
154
+ };
155
+ type ErrorClass = 'not_applied' | 'ambiguous';
156
+ type ReplyOnceEvent = {
157
+ type: 'claimed';
158
+ scope: string;
159
+ key: string;
160
+ attempt: number;
161
+ runId: string | null;
162
+ } | {
163
+ type: 'committed';
164
+ scope: string;
165
+ key: string;
166
+ attempt: number;
167
+ runId: string | null;
168
+ } | {
169
+ type: 'replayed';
170
+ scope: string;
171
+ key: string;
172
+ } | {
173
+ type: 'in_flight';
174
+ scope: string;
175
+ key: string;
176
+ } | {
177
+ type: 'failed';
178
+ scope: string;
179
+ key: string;
180
+ attempt: number;
181
+ error: unknown;
182
+ } | {
183
+ type: 'ambiguous';
184
+ scope: string;
185
+ key: string;
186
+ error?: unknown;
187
+ } | {
188
+ type: 'refused';
189
+ scope: string;
190
+ key: string;
191
+ reason: RefusalReason;
192
+ runId: string | null;
193
+ } | {
194
+ type: 'reconciled';
195
+ scope: string;
196
+ key: string;
197
+ outcome: ReconcileVerdict['outcome'];
198
+ } | {
199
+ type: 'late_settle';
200
+ scope: string;
201
+ key: string;
202
+ attempt: number;
203
+ } | {
204
+ type: 'unserializable_result';
205
+ scope: string;
206
+ key: string;
207
+ };
208
+
209
+ export type { ConflictInfo as C, EffectContext as E, Json as J, OnceResult as O, RunMode as R, Store as S, TransactResult as T, ScopeState as a, EffectRecord as b, EffectStatus as c, ErrorClass as d, ReconcileVerdict as e, RefusalReason as f, ReplyOnceEvent as g, RunEndReason as h, RunRecord as i };
package/package.json CHANGED
@@ -1,6 +1,109 @@
1
1
  {
2
2
  "name": "replyonce",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Stop your agent from replying, refunding or ticketing twice. Idempotency keys, per-conversation write fencing and an effect ledger for tool-calling agents, webhook handlers and queue workers.",
5
+ "license": "Apache-2.0",
6
+ "author": "Sarosh Hussain (https://github.com/shmindmaster)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/shmindmaster/replyonce.git"
10
+ },
11
+ "homepage": "https://github.com/shmindmaster/replyonce#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/shmindmaster/replyonce/issues"
14
+ },
15
+ "keywords": [
16
+ "idempotency",
17
+ "exactly-once",
18
+ "ai-agents",
19
+ "llm",
20
+ "tool-calling",
21
+ "side-effects",
22
+ "webhooks",
23
+ "bullmq",
24
+ "fencing-token",
25
+ "outbox",
26
+ "postgres",
27
+ "redis"
28
+ ],
29
+ "type": "module",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "sideEffects": false,
34
+ "main": "./dist/index.cjs",
35
+ "module": "./dist/index.js",
36
+ "types": "./dist/index.d.ts",
37
+ "exports": {
38
+ ".": {
39
+ "import": {
40
+ "types": "./dist/index.d.ts",
41
+ "default": "./dist/index.js"
42
+ },
43
+ "require": {
44
+ "types": "./dist/index.d.cts",
45
+ "default": "./dist/index.cjs"
46
+ }
47
+ },
48
+ "./postgres": {
49
+ "import": {
50
+ "types": "./dist/postgres.d.ts",
51
+ "default": "./dist/postgres.js"
52
+ },
53
+ "require": {
54
+ "types": "./dist/postgres.d.cts",
55
+ "default": "./dist/postgres.cjs"
56
+ }
57
+ },
58
+ "./redis": {
59
+ "import": {
60
+ "types": "./dist/redis.d.ts",
61
+ "default": "./dist/redis.js"
62
+ },
63
+ "require": {
64
+ "types": "./dist/redis.d.cts",
65
+ "default": "./dist/redis.cjs"
66
+ }
67
+ },
68
+ "./adapters": {
69
+ "import": {
70
+ "types": "./dist/adapters.d.ts",
71
+ "default": "./dist/adapters.js"
72
+ },
73
+ "require": {
74
+ "types": "./dist/adapters.d.cts",
75
+ "default": "./dist/adapters.cjs"
76
+ }
77
+ },
78
+ "./package.json": "./package.json"
79
+ },
80
+ "bin": {
81
+ "replyonce": "dist/cli.js"
82
+ },
83
+ "files": [
84
+ "dist",
85
+ "README.md",
86
+ "LICENSE",
87
+ "NOTICE"
88
+ ],
89
+ "scripts": {
90
+ "build": "tsup",
91
+ "typecheck": "tsc --noEmit",
92
+ "test": "vitest run",
93
+ "test:watch": "vitest",
94
+ "smoke": "node test/smoke.mjs && node test/smoke.cjs",
95
+ "demo": "node dist/cli.js demo",
96
+ "prepublishOnly": "npm run typecheck && npm run build && npm test && npm run smoke"
97
+ },
98
+ "devDependencies": {
99
+ "@types/node": "^22.0.0",
100
+ "@types/pg": "^8.11.0",
101
+ "bullmq": "^5.81.5",
102
+ "ioredis": "^5.4.0",
103
+ "pg": "^8.13.0",
104
+ "tsup": "^9.0.0",
105
+ "tsx": "^4.23.15",
106
+ "typescript": "^5.9.0",
107
+ "vitest": "^4.1.0"
108
+ }
109
+ }