did-it-land 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Satsawat Natakarnkitkul
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # did-it-land
2
+
3
+ > Your durable worker crashed mid-tool-call. Did the charge actually fire, and how do you
4
+ > reverse it? did-it-land is the per-vendor knowledge that answers both, as data.
5
+
6
+ ```ts
7
+ import { bundled, reconcile, unwind } from "did-it-land";
8
+
9
+ const charge = bundled().get("stripe.charge");
10
+
11
+ // After an ambiguous crash: did it land? created_after is a unix-seconds
12
+ // timestamp your workflow records when the order begins.
13
+ const outcome = await reconcile(
14
+ charge,
15
+ { order_id: "ORD-1", created_after: orderStartedAt },
16
+ stripeTransport,
17
+ );
18
+ if (outcome.status === "landed") {
19
+ // the charge already happened, skip the retry
20
+ }
21
+
22
+ await unwind(charge, { payment_intent_id: "pi_123" }, stripeTransport);
23
+ ```
24
+
25
+ `stripeTransport` is yours to supply: any object with an async `send(request)`
26
+ returning `{ statusCode, body }` and throwing `TransportError` on timeouts, as shown
27
+ in the quick start's fetch example.
28
+
29
+ v1 ships four capsules: `stripe.charge`, `s3.delete_object`, `github.merge_pr`, and
30
+ `postgres.insert`. Same corpus as the Python package, with one caveat: the native
31
+ `postgres.insert` capsule needs a handler you register with `registerNative()`, since
32
+ the TS runtime ships no built-in database handlers. Full docs in the repository.
33
+
34
+ Homepage and docs: https://github.com/netsatsawat/did-it-land
35
+
36
+ Written by Satsawat Natakarnkitkul. License: MIT.
@@ -0,0 +1,66 @@
1
+ export type Method = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE";
2
+ export type Result = "landed" | "not_landed" | "unknown";
3
+ export type ProbeKind = "http" | "native";
4
+ export type CompKind = "http" | "native" | "none";
5
+ export type ReversibilityClass = "reversible" | "conditionally_reversible" | "irreversible";
6
+ export type Strategy = "client_key" | "natural_key" | "none";
7
+ export interface HttpRequest {
8
+ method: Method;
9
+ path: string;
10
+ query: Record<string, string>;
11
+ headers: Record<string, string>;
12
+ }
13
+ export interface Rule {
14
+ result: Result;
15
+ statusIn?: number[];
16
+ jsonPath?: string;
17
+ exists?: boolean;
18
+ hasEquals: boolean;
19
+ equals?: unknown;
20
+ countGte?: number;
21
+ where?: Record<string, unknown>;
22
+ }
23
+ export interface Confirm {
24
+ request: HttpRequest;
25
+ interpret: Rule[];
26
+ }
27
+ export interface Probe {
28
+ kind: ProbeKind;
29
+ handler?: string;
30
+ request?: HttpRequest;
31
+ interpret: Rule[];
32
+ confirm?: Confirm;
33
+ }
34
+ export interface Idempotency {
35
+ strategy: Strategy;
36
+ header?: string;
37
+ keys: string[];
38
+ notes?: string;
39
+ }
40
+ export interface Reversibility {
41
+ cls: ReversibilityClass;
42
+ condition?: string;
43
+ }
44
+ export interface Compensation {
45
+ kind: CompKind;
46
+ handler?: string;
47
+ request?: HttpRequest;
48
+ notes?: string;
49
+ }
50
+ export interface Capsule {
51
+ id: string;
52
+ provider: string;
53
+ operation: string;
54
+ schemaVersion: string;
55
+ idempotency: Idempotency;
56
+ probe: Probe;
57
+ reversibility: Reversibility;
58
+ compensation?: Compensation;
59
+ summary?: string;
60
+ notes?: string;
61
+ source?: string | string[];
62
+ }
63
+ export declare const SCHEMA_VERSION = "1";
64
+ export declare class CapsuleError extends Error {
65
+ }
66
+ export declare function capsuleFromDoc(value: unknown, where?: string): Capsule;
@@ -0,0 +1,184 @@
1
+ // The capsule data model and a validator that mirrors the Python one. The canonical
2
+ // contract is schema/capsule.schema.json; this rebuilds the same rules with clear errors.
3
+ export const SCHEMA_VERSION = "1";
4
+ export class CapsuleError extends Error {
5
+ }
6
+ const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
7
+ const STRATEGIES = new Set(["client_key", "natural_key", "none"]);
8
+ const PROBE_KINDS = new Set(["http", "native"]);
9
+ const COMP_KINDS = new Set(["http", "native", "none"]);
10
+ const REV_CLASSES = new Set([
11
+ "reversible",
12
+ "conditionally_reversible",
13
+ "irreversible",
14
+ ]);
15
+ const RESULTS = new Set(["landed", "not_landed", "unknown"]);
16
+ function rec(value, where, allowed) {
17
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
18
+ throw new CapsuleError(`${where}: expected a mapping`);
19
+ }
20
+ const doc = value;
21
+ if (allowed) {
22
+ // A typo'd key that silently vanished would leave a rule with no
23
+ // conditions, and a rule with no conditions matches every response.
24
+ const extras = Object.keys(doc).filter((k) => !allowed.includes(k));
25
+ if (extras.length) {
26
+ throw new CapsuleError(`${where}: unknown key(s) ${extras.sort().join(", ")}`);
27
+ }
28
+ }
29
+ return doc;
30
+ }
31
+ function require_(doc, key, where) {
32
+ if (!(key in doc)) {
33
+ throw new CapsuleError(`${where}: missing required field '${key}'`);
34
+ }
35
+ return doc[key];
36
+ }
37
+ function oneOf(value, allowed, where) {
38
+ if (typeof value !== "string" || !allowed.has(value)) {
39
+ const opts = [...allowed].sort().join(", ");
40
+ throw new CapsuleError(`${where}: '${String(value)}' must be one of ${opts}`);
41
+ }
42
+ return value;
43
+ }
44
+ function strMap(value) {
45
+ const out = {};
46
+ for (const [k, v] of Object.entries((value ?? {}))) {
47
+ out[k] = String(v);
48
+ }
49
+ return out;
50
+ }
51
+ function keysList(raw, where) {
52
+ if (raw === undefined || raw === null)
53
+ return [];
54
+ if (!Array.isArray(raw) || raw.some((x) => typeof x !== "string")) {
55
+ throw new CapsuleError(`${where}: expected a list of strings`);
56
+ }
57
+ return raw;
58
+ }
59
+ function requestFrom(value, where) {
60
+ const doc = rec(value, where, ["method", "path", "query", "headers"]);
61
+ const method = oneOf(String(require_(doc, "method", where)).toUpperCase(), METHODS, `${where}.method`);
62
+ return {
63
+ method,
64
+ path: String(require_(doc, "path", where)),
65
+ query: strMap(doc.query),
66
+ headers: strMap(doc.headers),
67
+ };
68
+ }
69
+ function ruleFrom(value, where) {
70
+ const doc = rec(value, where, ["when", "result"]);
71
+ const when = rec(doc.when ?? {}, `${where}.when`, [
72
+ "status_in", "json_path", "exists", "equals", "count_gte", "where",
73
+ ]);
74
+ if ("status_in" in when) {
75
+ const raw = when.status_in;
76
+ if (!Array.isArray(raw) || raw.some((x) => typeof x !== "number")) {
77
+ throw new CapsuleError(`${where}.when.status_in: expected a list of numbers`);
78
+ }
79
+ }
80
+ if ("count_gte" in when && typeof when.count_gte !== "number") {
81
+ throw new CapsuleError(`${where}.when.count_gte: expected a number`);
82
+ }
83
+ if ("exists" in when && typeof when.exists !== "boolean") {
84
+ throw new CapsuleError(`${where}.when.exists: expected a boolean`);
85
+ }
86
+ if ("where" in when) {
87
+ const w = when.where;
88
+ if (typeof w !== "object" || w === null || Array.isArray(w)) {
89
+ throw new CapsuleError(`${where}.when.where: expected a mapping`);
90
+ }
91
+ }
92
+ return {
93
+ result: oneOf(require_(doc, "result", where), RESULTS, `${where}.result`),
94
+ statusIn: "status_in" in when ? when.status_in : undefined,
95
+ jsonPath: "json_path" in when ? String(when.json_path) : undefined,
96
+ exists: "exists" in when ? when.exists : undefined,
97
+ hasEquals: "equals" in when,
98
+ equals: "equals" in when ? when.equals : undefined,
99
+ countGte: "count_gte" in when ? when.count_gte : undefined,
100
+ where: "where" in when ? when.where : undefined,
101
+ };
102
+ }
103
+ function probeFrom(value) {
104
+ const where = "probe";
105
+ const doc = rec(value, where, ["kind", "handler", "request", "interpret", "confirm"]);
106
+ const kind = oneOf(require_(doc, "kind", where), PROBE_KINDS, `${where}.kind`);
107
+ if (kind === "native") {
108
+ return { kind, handler: String(require_(doc, "handler", where)), interpret: [] };
109
+ }
110
+ const request = requestFrom(require_(doc, "request", where), `${where}.request`);
111
+ const raw = require_(doc, "interpret", where);
112
+ if (!Array.isArray(raw) || raw.length === 0) {
113
+ throw new CapsuleError(`${where}.interpret: an http probe needs at least one rule`);
114
+ }
115
+ const interpret = raw.map((r, i) => ruleFrom(r, `${where}.interpret[${i}]`));
116
+ let confirm;
117
+ if (doc.confirm !== undefined && doc.confirm !== null) {
118
+ const cdoc = rec(doc.confirm, `${where}.confirm`, ["request", "interpret", "notes"]);
119
+ const creq = requestFrom(require_(cdoc, "request", `${where}.confirm`), `${where}.confirm.request`);
120
+ const craw = require_(cdoc, "interpret", `${where}.confirm`);
121
+ if (!Array.isArray(craw) || craw.length === 0) {
122
+ throw new CapsuleError(`${where}.confirm.interpret: needs at least one rule`);
123
+ }
124
+ confirm = { request: creq, interpret: craw.map((r, i) => ruleFrom(r, `${where}.confirm.interpret[${i}]`)) };
125
+ }
126
+ return { kind, request, interpret, confirm };
127
+ }
128
+ function compensationFrom(value) {
129
+ if (value === undefined || value === null)
130
+ return undefined;
131
+ const where = "compensation";
132
+ const doc = rec(value, where, ["kind", "handler", "request", "notes"]);
133
+ const kind = oneOf(require_(doc, "kind", where), COMP_KINDS, `${where}.kind`);
134
+ if (kind === "none")
135
+ return { kind, notes: doc.notes };
136
+ if (kind === "native") {
137
+ return {
138
+ kind,
139
+ handler: String(require_(doc, "handler", where)),
140
+ notes: doc.notes,
141
+ };
142
+ }
143
+ return {
144
+ kind,
145
+ request: requestFrom(require_(doc, "request", where), `${where}.request`),
146
+ notes: doc.notes,
147
+ };
148
+ }
149
+ export function capsuleFromDoc(value, where = "capsule") {
150
+ const doc = rec(value, where, [
151
+ "id", "provider", "operation", "schema_version", "summary", "idempotency",
152
+ "probe", "reversibility", "compensation", "notes", "source",
153
+ ]);
154
+ const idemDoc = rec(require_(doc, "idempotency", where), `${where}.idempotency`, ["strategy", "header", "keys", "notes"]);
155
+ const idempotency = {
156
+ strategy: oneOf(require_(idemDoc, "strategy", `${where}.idempotency`), STRATEGIES, `${where}.idempotency.strategy`),
157
+ header: idemDoc.header,
158
+ keys: keysList(idemDoc.keys, `${where}.idempotency.keys`),
159
+ notes: idemDoc.notes,
160
+ };
161
+ const revDoc = rec(require_(doc, "reversibility", where), `${where}.reversibility`, ["class", "condition"]);
162
+ const cls = oneOf(require_(revDoc, "class", `${where}.reversibility`), REV_CLASSES, `${where}.reversibility.class`);
163
+ const condition = revDoc.condition;
164
+ if (cls === "conditionally_reversible" && !condition) {
165
+ throw new CapsuleError(`${where}.reversibility: a conditionally_reversible capsule must state its condition`);
166
+ }
167
+ const version = String(require_(doc, "schema_version", where));
168
+ if (version !== SCHEMA_VERSION) {
169
+ throw new CapsuleError(`${where}: schema_version ${version} is not supported, this runtime reads version ${SCHEMA_VERSION}`);
170
+ }
171
+ return {
172
+ id: String(require_(doc, "id", where)),
173
+ provider: String(require_(doc, "provider", where)),
174
+ operation: String(require_(doc, "operation", where)),
175
+ schemaVersion: String(require_(doc, "schema_version", where)),
176
+ idempotency,
177
+ probe: probeFrom(require_(doc, "probe", where)),
178
+ reversibility: { cls, condition },
179
+ compensation: compensationFrom(doc.compensation),
180
+ summary: doc.summary,
181
+ notes: doc.notes,
182
+ source: doc.source,
183
+ };
184
+ }
@@ -0,0 +1 @@
1
+ export declare const CORPUS: unknown[];
@@ -0,0 +1,407 @@
1
+ // Generated from capsules/ by scripts/build_ts_corpus.py. Do not edit by hand.
2
+ export const CORPUS = [
3
+ {
4
+ "id": "github.merge_pr",
5
+ "provider": "github",
6
+ "operation": "merge_pr",
7
+ "schema_version": "1",
8
+ "summary": "Merge a pull request. Answer whether the merge actually happened, telling a real merge apart from a pull request whose branch was merely deleted.\n",
9
+ "idempotency": {
10
+ "strategy": "natural_key",
11
+ "keys": [
12
+ "owner",
13
+ "repo",
14
+ "pull_number"
15
+ ],
16
+ "notes": "A pull request is identified by owner, repo, and number. Merging one that is already merged returns 405, so the natural key is enough to recognize a repeat.\n"
17
+ },
18
+ "probe": {
19
+ "kind": "http",
20
+ "request": {
21
+ "method": "GET",
22
+ "path": "/repos/{owner}/{repo}/pulls/{pull_number}"
23
+ },
24
+ "interpret": [
25
+ {
26
+ "when": {
27
+ "status_in": [
28
+ 200
29
+ ],
30
+ "json_path": "merged",
31
+ "equals": true
32
+ },
33
+ "result": "landed"
34
+ },
35
+ {
36
+ "when": {
37
+ "status_in": [
38
+ 200
39
+ ],
40
+ "json_path": "merged",
41
+ "equals": false
42
+ },
43
+ "result": "not_landed"
44
+ },
45
+ {
46
+ "when": {
47
+ "status_in": [
48
+ 404,
49
+ 500,
50
+ 502,
51
+ 503
52
+ ]
53
+ },
54
+ "result": "unknown"
55
+ }
56
+ ]
57
+ },
58
+ "reversibility": {
59
+ "class": "irreversible"
60
+ },
61
+ "compensation": {
62
+ "kind": "none",
63
+ "notes": "A merge commit is part of history and cannot be truly reversed. The only recourse is a revert, which is a new forward commit rather than an undo, so it is out of scope for a compensation and is left to the caller.\n"
64
+ },
65
+ "notes": "The merged field is authoritative. A pull request can look finished because its branch was deleted, yet merged is false, meaning the change never landed. Trusting branch state instead of the merged field is the trap this capsule removes.\n",
66
+ "source": "https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request"
67
+ },
68
+ {
69
+ "id": "postgres.insert",
70
+ "provider": "postgres",
71
+ "operation": "insert",
72
+ "schema_version": "1",
73
+ "summary": "Insert a row through a natural key. Answer whether the row committed after a crash, and reverse it with a keyed delete when nothing depends on it yet.\n",
74
+ "idempotency": {
75
+ "strategy": "natural_key",
76
+ "keys": [
77
+ "table",
78
+ "key_column",
79
+ "key_value"
80
+ ],
81
+ "notes": "The row carries a business key backed by a unique constraint. Re-running the insert then fails on the constraint rather than duplicating, which is what makes the natural key a safe idempotency handle.\n"
82
+ },
83
+ "probe": {
84
+ "kind": "native",
85
+ "handler": "postgres_row_exists"
86
+ },
87
+ "reversibility": {
88
+ "class": "conditionally_reversible",
89
+ "condition": "The row is addressable by its natural key and nothing else references it yet, so a keyed delete returns the table to its prior state. Once dependent rows exist the delete is no longer a clean reversal.\n"
90
+ },
91
+ "compensation": {
92
+ "kind": "native",
93
+ "handler": "postgres_delete_by_key",
94
+ "notes": "Delete the row by its natural key inside its own transaction.\n"
95
+ },
96
+ "notes": "This capsule is native rather than http because the operation speaks the Postgres wire protocol through a driver, not REST. The probe and compensation are resolved to per-language handlers keyed by id, while the corpus stays the source of the meaning.\n",
97
+ "source": "https://www.postgresql.org/docs/current/tutorial-transactions.html"
98
+ },
99
+ {
100
+ "id": "s3.delete_object",
101
+ "provider": "aws_s3",
102
+ "operation": "delete_object",
103
+ "schema_version": "1",
104
+ "summary": "Delete an object from an S3 bucket. Answer whether the object is gone, and restore it only when bucket versioning kept the prior version.\n",
105
+ "idempotency": {
106
+ "strategy": "none",
107
+ "notes": "Deleting an object is naturally idempotent. Repeating the delete against a missing key still returns success, so no idempotency key is needed.\n"
108
+ },
109
+ "probe": {
110
+ "kind": "http",
111
+ "request": {
112
+ "method": "HEAD",
113
+ "path": "/{bucket}/{key}"
114
+ },
115
+ "interpret": [
116
+ {
117
+ "when": {
118
+ "status_in": [
119
+ 404
120
+ ]
121
+ },
122
+ "result": "landed"
123
+ },
124
+ {
125
+ "when": {
126
+ "status_in": [
127
+ 200
128
+ ]
129
+ },
130
+ "result": "not_landed"
131
+ },
132
+ {
133
+ "when": {
134
+ "status_in": [
135
+ 500,
136
+ 503
137
+ ]
138
+ },
139
+ "result": "unknown"
140
+ }
141
+ ]
142
+ },
143
+ "reversibility": {
144
+ "class": "conditionally_reversible",
145
+ "condition": "Bucket versioning was enabled at delete time, so the delete wrote a delete marker and retained the previous version. Without versioning the object is gone for good.\n"
146
+ },
147
+ "compensation": {
148
+ "kind": "http",
149
+ "request": {
150
+ "method": "DELETE",
151
+ "path": "/{bucket}/{key}",
152
+ "query": {
153
+ "versionId": "{delete_marker_version_id}"
154
+ }
155
+ },
156
+ "notes": "Restore by deleting the delete marker, which makes the most recent prior version current again. This needs the delete marker version id returned in the original delete response as x-amz-version-id, so capture it at delete time.\n"
157
+ },
158
+ "notes": "The probe reports whether the object currently resolves. On a versioned bucket a landed delete still leaves the data recoverable, which is why reversibility is conditional rather than outright.\n",
159
+ "source": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeleteMarker.html"
160
+ },
161
+ {
162
+ "id": "stripe.charge",
163
+ "provider": "stripe",
164
+ "operation": "charge",
165
+ "schema_version": "1",
166
+ "summary": "Create a payment on Stripe. After an ambiguous crash, answer whether money actually moved, and reverse it with a refund.\n",
167
+ "idempotency": {
168
+ "strategy": "client_key",
169
+ "header": "Idempotency-Key",
170
+ "notes": "Send a client-generated Idempotency-Key with the create request. A retry with the same key returns the saved result of the first attempt instead of charging again. The key must be derived from state that survives the crash, such as the order id, never minted inside the step, or a re-run bypasses the deduplication. Stripe prunes idempotency keys after 24 hours, so a late recovery cannot rely on key replay and needs the probe below.\n"
171
+ },
172
+ "probe": {
173
+ "kind": "http",
174
+ "request": {
175
+ "method": "GET",
176
+ "path": "/v1/payment_intents/search",
177
+ "query": {
178
+ "query": "metadata[\"order_id\"]:\"{order_id}\""
179
+ }
180
+ },
181
+ "interpret": [
182
+ {
183
+ "when": {
184
+ "status_in": [
185
+ 200
186
+ ],
187
+ "json_path": "data",
188
+ "where": {
189
+ "status": "succeeded"
190
+ },
191
+ "count_gte": 1
192
+ },
193
+ "result": "landed"
194
+ },
195
+ {
196
+ "when": {
197
+ "status_in": [
198
+ 200
199
+ ],
200
+ "json_path": "data",
201
+ "where": {
202
+ "status": "processing"
203
+ },
204
+ "count_gte": 1
205
+ },
206
+ "result": "unknown"
207
+ },
208
+ {
209
+ "when": {
210
+ "status_in": [
211
+ 200
212
+ ],
213
+ "json_path": "data",
214
+ "where": {
215
+ "status": "requires_action"
216
+ },
217
+ "count_gte": 1
218
+ },
219
+ "result": "unknown"
220
+ },
221
+ {
222
+ "when": {
223
+ "status_in": [
224
+ 200
225
+ ],
226
+ "json_path": "data",
227
+ "where": {
228
+ "status": "requires_confirmation"
229
+ },
230
+ "count_gte": 1
231
+ },
232
+ "result": "unknown"
233
+ },
234
+ {
235
+ "when": {
236
+ "status_in": [
237
+ 200
238
+ ],
239
+ "json_path": "data",
240
+ "where": {
241
+ "status": "requires_capture"
242
+ },
243
+ "count_gte": 1
244
+ },
245
+ "result": "unknown"
246
+ },
247
+ {
248
+ "when": {
249
+ "status_in": [
250
+ 200
251
+ ]
252
+ },
253
+ "result": "not_landed"
254
+ },
255
+ {
256
+ "when": {
257
+ "status_in": [
258
+ 429,
259
+ 500,
260
+ 502,
261
+ 503
262
+ ]
263
+ },
264
+ "result": "unknown"
265
+ }
266
+ ],
267
+ "confirm": {
268
+ "request": {
269
+ "method": "GET",
270
+ "path": "/v1/payment_intents",
271
+ "query": {
272
+ "limit": "100",
273
+ "created[gte]": "{created_after}"
274
+ }
275
+ },
276
+ "interpret": [
277
+ {
278
+ "when": {
279
+ "status_in": [
280
+ 200
281
+ ],
282
+ "json_path": "data",
283
+ "where": {
284
+ "metadata.order_id": "{order_id}",
285
+ "status": "succeeded"
286
+ },
287
+ "count_gte": 1
288
+ },
289
+ "result": "landed"
290
+ },
291
+ {
292
+ "when": {
293
+ "status_in": [
294
+ 200
295
+ ],
296
+ "json_path": "data",
297
+ "where": {
298
+ "metadata.order_id": "{order_id}",
299
+ "status": "processing"
300
+ },
301
+ "count_gte": 1
302
+ },
303
+ "result": "unknown"
304
+ },
305
+ {
306
+ "when": {
307
+ "status_in": [
308
+ 200
309
+ ],
310
+ "json_path": "data",
311
+ "where": {
312
+ "metadata.order_id": "{order_id}",
313
+ "status": "requires_action"
314
+ },
315
+ "count_gte": 1
316
+ },
317
+ "result": "unknown"
318
+ },
319
+ {
320
+ "when": {
321
+ "status_in": [
322
+ 200
323
+ ],
324
+ "json_path": "data",
325
+ "where": {
326
+ "metadata.order_id": "{order_id}",
327
+ "status": "requires_confirmation"
328
+ },
329
+ "count_gte": 1
330
+ },
331
+ "result": "unknown"
332
+ },
333
+ {
334
+ "when": {
335
+ "status_in": [
336
+ 200
337
+ ],
338
+ "json_path": "data",
339
+ "where": {
340
+ "metadata.order_id": "{order_id}",
341
+ "status": "requires_capture"
342
+ },
343
+ "count_gte": 1
344
+ },
345
+ "result": "unknown"
346
+ },
347
+ {
348
+ "when": {
349
+ "status_in": [
350
+ 200
351
+ ],
352
+ "json_path": "has_more",
353
+ "equals": true
354
+ },
355
+ "result": "unknown"
356
+ },
357
+ {
358
+ "when": {
359
+ "status_in": [
360
+ 200
361
+ ]
362
+ },
363
+ "result": "not_landed"
364
+ },
365
+ {
366
+ "when": {
367
+ "status_in": [
368
+ 429,
369
+ 500,
370
+ 502,
371
+ 503
372
+ ]
373
+ },
374
+ "result": "unknown"
375
+ }
376
+ ],
377
+ "notes": "Search can lag a just-created charge, so an empty search answer is never trusted on its own. This second question walks the List API, which Stripe does not subject to search indexing lag, bounded to intents created since the order began via the created_after context value, and filtered client-side by the caller's order id. Only after that bounded page also comes back empty does the capsule answer not_landed. The bound is what makes has_more meaningful: an unbounded List pages over the whole account history and has_more is simply always true on a mature account, but within the order's own window has_more means more than a hundred intents arrived since this order started, and only then is the absence inconclusive.\n"
378
+ }
379
+ },
380
+ "reversibility": {
381
+ "class": "reversible"
382
+ },
383
+ "compensation": {
384
+ "kind": "http",
385
+ "request": {
386
+ "method": "POST",
387
+ "path": "/v1/refunds",
388
+ "query": {
389
+ "payment_intent": "{payment_intent_id}"
390
+ },
391
+ "headers": {
392
+ "Idempotency-Key": "did-it-land-refund-{payment_intent_id}"
393
+ }
394
+ },
395
+ "notes": "Refund the payment intent found by the probe, with its own idempotency key derived from the payment intent id, so a crashed compensation cannot refund twice either. A refund reverses the money movement but Stripe keeps the original processing fees, and on some asynchronous payment methods a refund can fail after being accepted, so treat compensated as accepted, not settled. One more Stripe behavior to know: the key pins the FIRST response for about 24 hours, a failure included. A refund that failed for a fixable reason will replay that same failure on retry under this key, so a deliberate second attempt after fixing the cause needs a fresh key of your own choosing.\n"
396
+ },
397
+ "notes": "Context contract: order_id is the caller's own order identifier, attached as metadata when the charge is created, and created_after is a unix timestamp in seconds from just before the order began, which the workflow always owns. The probe asks a sharper question than \"does a payment intent exist\". It filters the search results by status, because an intent stuck at requires_payment_method is a declined card, not a landed charge, and one in processing is money in flight, which is honestly unknown. A matched count above one means a duplicate already exists and one intent needs refunding; the count is surfaced in the outcome's evidence. Two consistency caveats from Stripe's own documentation: search is not for read-after-write flows, since new records can take a short while to become searchable, so an empty result immediately after a crash should be re-checked after a delay (the List API is not subject to that lag and can serve as the fallback); and status filtered in the query text can be served from a cache, which is why the filtering here happens client-side against the returned objects.\n",
398
+ "source": [
399
+ "https://docs.stripe.com/api/payment_intents/search",
400
+ "https://docs.stripe.com/api/payment_intents/list",
401
+ "https://docs.stripe.com/api/idempotent_requests",
402
+ "https://docs.stripe.com/metadata",
403
+ "https://docs.stripe.com/search",
404
+ "https://docs.stripe.com/payments/paymentintents/lifecycle"
405
+ ]
406
+ }
407
+ ];
@@ -0,0 +1,4 @@
1
+ export * from "./capsule.ts";
2
+ export * from "./reconcile.ts";
3
+ export * from "./registry.ts";
4
+ export declare const VERSION = "0.1.0";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // did-it-land: know whether a side-effect landed, and how to reverse it.
2
+ export * from "./capsule.js";
3
+ export * from "./reconcile.js";
4
+ export * from "./registry.js";
5
+ export const VERSION = "0.1.0";
@@ -0,0 +1,32 @@
1
+ import type { Capsule, HttpRequest, Result } from "./capsule.ts";
2
+ export interface Response {
3
+ statusCode: number;
4
+ body?: unknown;
5
+ }
6
+ export interface Transport {
7
+ send(req: HttpRequest): Response | Promise<Response>;
8
+ }
9
+ export interface Outcome {
10
+ status: Result;
11
+ capsuleId: string;
12
+ evidence?: Record<string, unknown>;
13
+ }
14
+ export interface CompensationResult {
15
+ status: string;
16
+ capsuleId: string;
17
+ evidence?: Record<string, unknown>;
18
+ }
19
+ export declare class EffectError extends Error {
20
+ }
21
+ /** The service could not be reached or did not answer in time. A transport that
22
+ * throws this tells reconcile and unwind the question went unanswered, which is an
23
+ * unknown outcome, not a no. */
24
+ export declare class TransportError extends Error {
25
+ }
26
+ export interface NativeHandler {
27
+ probe(capsule: Capsule, context: Record<string, unknown>): Outcome | Promise<Outcome>;
28
+ compensate(capsule: Capsule, context: Record<string, unknown>): CompensationResult | Promise<CompensationResult>;
29
+ }
30
+ export declare function registerNative(id: string, handler: NativeHandler): void;
31
+ export declare function reconcile(capsule: Capsule, context: Record<string, unknown>, transport?: Transport): Promise<Outcome>;
32
+ export declare function unwind(capsule: Capsule, context: Record<string, unknown>, transport?: Transport): Promise<CompensationResult>;
@@ -0,0 +1,171 @@
1
+ // reconcile answers "did it land"; unwind runs the inverse. Both are deterministic given
2
+ // the capsule and the context.
3
+ export class EffectError extends Error {
4
+ }
5
+ /** The service could not be reached or did not answer in time. A transport that
6
+ * throws this tells reconcile and unwind the question went unanswered, which is an
7
+ * unknown outcome, not a no. */
8
+ export class TransportError extends Error {
9
+ }
10
+ const HANDLERS = new Map();
11
+ export function registerNative(id, handler) {
12
+ HANDLERS.set(id, handler);
13
+ }
14
+ function getHandler(id) {
15
+ const handler = id === undefined ? undefined : HANDLERS.get(id);
16
+ if (!handler) {
17
+ const known = [...HANDLERS.keys()].sort().join(", ") || "none";
18
+ throw new EffectError(`no native handler registered for '${id}' (registered: ${known})`);
19
+ }
20
+ return handler;
21
+ }
22
+ function bind(template, context, capsuleId) {
23
+ return template.replace(/\{([^}]+)\}/g, (_match, name) => {
24
+ if (!(name in context)) {
25
+ throw new EffectError(`${capsuleId}: context is missing '${name}', needed by '${template}'`);
26
+ }
27
+ return String(context[name]);
28
+ });
29
+ }
30
+ function bindRequest(req, context, capsuleId) {
31
+ const query = {};
32
+ for (const [k, v] of Object.entries(req.query))
33
+ query[k] = bind(v, context, capsuleId);
34
+ const headers = {};
35
+ for (const [k, v] of Object.entries(req.headers))
36
+ headers[k] = bind(v, context, capsuleId);
37
+ return { method: req.method, path: bind(req.path, context, capsuleId), query, headers };
38
+ }
39
+ function navigate(body, path) {
40
+ let cur = body;
41
+ for (const part of path.split(".")) {
42
+ if (cur !== null && typeof cur === "object" && !Array.isArray(cur) && part in cur) {
43
+ cur = cur[part];
44
+ }
45
+ else if (Array.isArray(cur) && /^\d+$/.test(part) && Number(part) < cur.length) {
46
+ cur = cur[Number(part)];
47
+ }
48
+ else {
49
+ return [false, undefined];
50
+ }
51
+ }
52
+ return [true, cur];
53
+ }
54
+ function fieldOf(item, dotted) {
55
+ let cur = item;
56
+ for (const part of dotted.split(".")) {
57
+ if (cur === null || typeof cur !== "object" || !(part in cur))
58
+ return undefined;
59
+ cur = cur[part];
60
+ }
61
+ return cur;
62
+ }
63
+ function filtered(value, where, context, capsuleId) {
64
+ if (!where || !Array.isArray(value))
65
+ return value;
66
+ const bound = Object.fromEntries(Object.entries(where).map(([k, v]) => [
67
+ k,
68
+ typeof v === "string" ? bind(v, context, capsuleId) : v,
69
+ ]));
70
+ return value.filter((item) => item !== null &&
71
+ typeof item === "object" &&
72
+ Object.entries(bound).every(([k, v]) => fieldOf(item, k) === v));
73
+ }
74
+ function match(rule, resp, context, capsuleId) {
75
+ const evidence = {};
76
+ if (rule.statusIn !== undefined && !rule.statusIn.includes(resp.statusCode)) {
77
+ return [false, evidence];
78
+ }
79
+ if (rule.jsonPath !== undefined) {
80
+ const [found, raw] = navigate(resp.body, rule.jsonPath);
81
+ const value = filtered(raw, rule.where, context, capsuleId);
82
+ if (rule.exists !== undefined && found !== rule.exists)
83
+ return [false, evidence];
84
+ if (rule.countGte !== undefined) {
85
+ if (!found || !Array.isArray(value) || value.length < rule.countGte) {
86
+ return [false, evidence];
87
+ }
88
+ evidence.matched = value.length;
89
+ const ids = value
90
+ .filter((x) => x !== null && typeof x === "object" && "id" in x)
91
+ .map((x) => x.id);
92
+ if (ids.length)
93
+ evidence.ids = ids;
94
+ }
95
+ if (rule.hasEquals && (!found || value !== rule.equals))
96
+ return [false, evidence];
97
+ }
98
+ return [true, evidence];
99
+ }
100
+ export async function reconcile(capsule, context, transport) {
101
+ const probe = capsule.probe;
102
+ if (probe.kind === "native") {
103
+ return getHandler(probe.handler).probe(capsule, context);
104
+ }
105
+ if (!transport)
106
+ throw new EffectError(`${capsule.id}: an http probe needs a transport`);
107
+ const outcome = await httpProbe(capsule, probe.request, probe.interpret, context, transport);
108
+ if (outcome.status === "not_landed" && probe.confirm) {
109
+ const confirmed = await httpProbe(capsule, probe.confirm.request, probe.confirm.interpret, context, transport);
110
+ return {
111
+ status: confirmed.status,
112
+ capsuleId: capsule.id,
113
+ evidence: { ...confirmed.evidence, confirmed: true },
114
+ };
115
+ }
116
+ return outcome;
117
+ }
118
+ async function httpProbe(capsule, request, interpret, context, transport) {
119
+ let resp;
120
+ try {
121
+ resp = await transport.send(bindRequest(request, context, capsule.id));
122
+ }
123
+ catch (err) {
124
+ if (err instanceof TransportError) {
125
+ return { status: "unknown", capsuleId: capsule.id, evidence: { transport_error: String(err) } };
126
+ }
127
+ throw err;
128
+ }
129
+ for (const rule of interpret) {
130
+ const [ok, evidence] = match(rule, resp, context, capsule.id);
131
+ if (ok) {
132
+ evidence.status_code = resp.statusCode;
133
+ return { status: rule.result, capsuleId: capsule.id, evidence };
134
+ }
135
+ }
136
+ return { status: "unknown", capsuleId: capsule.id, evidence: { status_code: resp.statusCode } };
137
+ }
138
+ export async function unwind(capsule, context, transport) {
139
+ const comp = capsule.compensation;
140
+ if (capsule.reversibility.cls === "irreversible") {
141
+ return { status: "irreversible", capsuleId: capsule.id };
142
+ }
143
+ if (!comp || comp.kind === "none") {
144
+ return { status: "no_compensation", capsuleId: capsule.id };
145
+ }
146
+ if (comp.kind === "native") {
147
+ return getHandler(comp.handler).compensate(capsule, context);
148
+ }
149
+ if (!transport)
150
+ throw new EffectError(`${capsule.id}: an http compensation needs a transport`);
151
+ let resp;
152
+ try {
153
+ resp = await transport.send(bindRequest(comp.request, context, capsule.id));
154
+ }
155
+ catch (err) {
156
+ if (err instanceof TransportError) {
157
+ return {
158
+ status: "unknown",
159
+ capsuleId: capsule.id,
160
+ evidence: { transport_error: String(err) },
161
+ };
162
+ }
163
+ throw err;
164
+ }
165
+ const ok = resp.statusCode >= 200 && resp.statusCode < 300;
166
+ return {
167
+ status: ok ? "compensated" : "failed",
168
+ capsuleId: capsule.id,
169
+ evidence: { status_code: resp.statusCode },
170
+ };
171
+ }
@@ -0,0 +1,10 @@
1
+ import type { Capsule } from "./capsule.ts";
2
+ export declare class Registry {
3
+ byId: Map<string, Capsule>;
4
+ constructor(capsules: Capsule[]);
5
+ get(id: string): Capsule;
6
+ all(): Capsule[];
7
+ ids(): string[];
8
+ get size(): number;
9
+ }
10
+ export declare function bundled(): Registry;
@@ -0,0 +1,35 @@
1
+ // Load the generated corpus and index it by id.
2
+ import { capsuleFromDoc } from "./capsule.js";
3
+ import { CORPUS } from "./corpus.generated.js";
4
+ import { EffectError } from "./reconcile.js";
5
+ export class Registry {
6
+ byId;
7
+ constructor(capsules) {
8
+ this.byId = new Map();
9
+ for (const cap of capsules) {
10
+ if (this.byId.has(cap.id))
11
+ throw new EffectError(`duplicate capsule id '${cap.id}'`);
12
+ this.byId.set(cap.id, cap);
13
+ }
14
+ }
15
+ get(id) {
16
+ const cap = this.byId.get(id);
17
+ if (!cap) {
18
+ const known = [...this.byId.keys()].sort().join(", ") || "none";
19
+ throw new EffectError(`no capsule '${id}' (have: ${known})`);
20
+ }
21
+ return cap;
22
+ }
23
+ all() {
24
+ return [...this.byId.values()];
25
+ }
26
+ ids() {
27
+ return [...this.byId.keys()].sort();
28
+ }
29
+ get size() {
30
+ return this.byId.size;
31
+ }
32
+ }
33
+ export function bundled() {
34
+ return new Registry(CORPUS.map((doc, i) => capsuleFromDoc(doc, `capsule[${i}]`)));
35
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "did-it-land",
3
+ "version": "0.1.0",
4
+ "description": "Know whether a side-effect landed, and how to reverse it: a corpus of per-vendor effect capsules for durable and saga workflows.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Satsawat Natakarnkitkul",
8
+ "homepage": "https://github.com/netsatsawat/did-it-land",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/netsatsawat/did-it-land.git",
12
+ "directory": "typescript"
13
+ },
14
+ "keywords": [
15
+ "durable-execution",
16
+ "saga",
17
+ "idempotency",
18
+ "reconciliation",
19
+ "agents"
20
+ ],
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "test": "node --test test/*.test.ts",
37
+ "prepublishOnly": "npm run build"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "devDependencies": {
43
+ "typescript": "^5.7.0",
44
+ "@types/node": "^22.0.0"
45
+ }
46
+ }