did-it-land 0.1.0 → 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,60 @@
1
+ import type { Capsule } from "./capsule.ts";
2
+ import { type CompensationResult, type Outcome, type Transport } from "./reconcile.ts";
3
+ /** The effect had already landed, so guard did not repeat the call. */
4
+ export declare class Skipped {
5
+ readonly capsuleId: string;
6
+ readonly outcome: Outcome;
7
+ constructor(capsuleId: string, outcome: Outcome);
8
+ }
9
+ /** The probe could not tell whether the effect landed, so guard refused to act.
10
+ *
11
+ * Firing a side effect on an unknown answer is a guess, and for money-moving
12
+ * operations a guess in either direction is the bug this library exists to remove.
13
+ * The caller decides how to wait and re-probe: re-throw so the durable engine
14
+ * retries the step later, or catch and re-run guard after a delay. */
15
+ export declare class UnknownOutcome extends Error {
16
+ readonly outcome: Outcome;
17
+ constructor(outcome: Outcome);
18
+ }
19
+ export interface GuardOptions {
20
+ /** How many extra probes to make on an unknown answer before giving up. */
21
+ unknownRetries?: number;
22
+ /** Seconds to wait between those retries. */
23
+ unknownWait?: number;
24
+ /** Injectable wait, so a test never sleeps for real. */
25
+ sleep?: (seconds: number) => void | Promise<void>;
26
+ }
27
+ /** Probe with the capsule, then act only if the effect definitely has not landed.
28
+ *
29
+ * landed skips the call and not_landed runs it. An unknown answer (an outage, a
30
+ * timeout, money still in flight) is never acted on. By default guard throws
31
+ * UnknownOutcome immediately so the engine's own retry policy takes over. Give it
32
+ * unknownRetries and it waits unknownWait seconds and asks again that many times
33
+ * before throwing, a bounded patience rather than an open-ended hang.
34
+ *
35
+ * One freshness caveat stands either way: an eventually consistent probe (Stripe
36
+ * search, for one) can answer not_landed moments after the effect landed, so a
37
+ * recovery path should wait out the vendor's freshness window before trusting a
38
+ * not_landed that follows a crash. */
39
+ export declare function guard<T>(capsule: Capsule, context: Record<string, unknown>, transport: Transport | undefined, perform: () => T | Promise<T>, options?: GuardOptions): Promise<Skipped | Awaited<T>>;
40
+ /** A record of completed effects that can be walked back on failure.
41
+ *
42
+ * The journal lives in process memory. It survives an exception inside the
43
+ * workflow, which is the case it exists for. Under a deterministic-replay engine
44
+ * the record() calls are replayed on recovery, but only when record() lives in
45
+ * workflow-body code: engines skip completed steps on recovery, so a record()
46
+ * inside a step body never replays. Call record() in the workflow with the step's
47
+ * return value, and create one Saga per workflow invocation. A bare process crash
48
+ * with no replaying engine loses the journal either way, so if it must outlive the
49
+ * process, persist each recorded context in your engine's own store. */
50
+ export declare class Saga {
51
+ private steps;
52
+ record(capsule: Capsule, context: Record<string, unknown>, transport?: Transport): void;
53
+ /** Run unwind for every recorded effect, most recent first.
54
+ *
55
+ * One bad step must not strand the rest: an unwind that throws is captured as a
56
+ * CompensationResult with status "error" and the walk continues, so every
57
+ * recorded effect gets its attempt and the caller sees the full list. */
58
+ compensate(): Promise<CompensationResult[]>;
59
+ get size(): number;
60
+ }
@@ -0,0 +1,108 @@
1
+ // Engine-free adapter pieces. A durable engine resumes a crashed workflow from its last
2
+ // completed step, and its soft spot is a step that called a vendor and crashed before the
3
+ // step checkpoint committed: on recovery the step re-runs and repeats the side effect.
4
+ // guard and Saga close that gap without depending on any particular engine, so they stay
5
+ // testable on their own.
6
+ //
7
+ // guard(capsule, context, transport, perform)
8
+ // Probe first. If the effect already landed, skip perform() and return Skipped.
9
+ // Call it inside a step so a re-run reconciles before it re-charges.
10
+ //
11
+ // Saga
12
+ // Record each effect as its step completes. If the workflow fails, compensate()
13
+ // runs unwind for every recorded effect in reverse order.
14
+ import { reconcile, unwind, } from "./reconcile.js";
15
+ /** The effect had already landed, so guard did not repeat the call. */
16
+ export class Skipped {
17
+ capsuleId;
18
+ outcome;
19
+ constructor(capsuleId, outcome) {
20
+ this.capsuleId = capsuleId;
21
+ this.outcome = outcome;
22
+ }
23
+ }
24
+ /** The probe could not tell whether the effect landed, so guard refused to act.
25
+ *
26
+ * Firing a side effect on an unknown answer is a guess, and for money-moving
27
+ * operations a guess in either direction is the bug this library exists to remove.
28
+ * The caller decides how to wait and re-probe: re-throw so the durable engine
29
+ * retries the step later, or catch and re-run guard after a delay. */
30
+ export class UnknownOutcome extends Error {
31
+ outcome;
32
+ constructor(outcome) {
33
+ super(`${outcome.capsuleId}: probe returned unknown ` +
34
+ `(evidence ${JSON.stringify(outcome.evidence)}), refusing to run the side effect`);
35
+ this.name = "UnknownOutcome";
36
+ this.outcome = outcome;
37
+ }
38
+ }
39
+ const realSleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
40
+ /** Probe with the capsule, then act only if the effect definitely has not landed.
41
+ *
42
+ * landed skips the call and not_landed runs it. An unknown answer (an outage, a
43
+ * timeout, money still in flight) is never acted on. By default guard throws
44
+ * UnknownOutcome immediately so the engine's own retry policy takes over. Give it
45
+ * unknownRetries and it waits unknownWait seconds and asks again that many times
46
+ * before throwing, a bounded patience rather than an open-ended hang.
47
+ *
48
+ * One freshness caveat stands either way: an eventually consistent probe (Stripe
49
+ * search, for one) can answer not_landed moments after the effect landed, so a
50
+ * recovery path should wait out the vendor's freshness window before trusting a
51
+ * not_landed that follows a crash. */
52
+ export async function guard(capsule, context, transport, perform, options = {}) {
53
+ const attempts = Math.max(0, Math.trunc(options.unknownRetries ?? 0)) + 1;
54
+ const wait = options.unknownWait ?? 2.0;
55
+ const sleep = options.sleep ?? realSleep;
56
+ let outcome;
57
+ for (let attempt = 0; attempt < attempts; attempt++) {
58
+ outcome = await reconcile(capsule, context, transport);
59
+ if (outcome.status === "landed")
60
+ return new Skipped(capsule.id, outcome);
61
+ if (outcome.status === "not_landed")
62
+ return await perform();
63
+ if (attempt + 1 < attempts)
64
+ await sleep(wait);
65
+ }
66
+ throw new UnknownOutcome(outcome);
67
+ }
68
+ /** A record of completed effects that can be walked back on failure.
69
+ *
70
+ * The journal lives in process memory. It survives an exception inside the
71
+ * workflow, which is the case it exists for. Under a deterministic-replay engine
72
+ * the record() calls are replayed on recovery, but only when record() lives in
73
+ * workflow-body code: engines skip completed steps on recovery, so a record()
74
+ * inside a step body never replays. Call record() in the workflow with the step's
75
+ * return value, and create one Saga per workflow invocation. A bare process crash
76
+ * with no replaying engine loses the journal either way, so if it must outlive the
77
+ * process, persist each recorded context in your engine's own store. */
78
+ export class Saga {
79
+ steps = [];
80
+ record(capsule, context, transport) {
81
+ this.steps.push({ capsule, context, transport });
82
+ }
83
+ /** Run unwind for every recorded effect, most recent first.
84
+ *
85
+ * One bad step must not strand the rest: an unwind that throws is captured as a
86
+ * CompensationResult with status "error" and the walk continues, so every
87
+ * recorded effect gets its attempt and the caller sees the full list. */
88
+ async compensate() {
89
+ const results = [];
90
+ for (let i = this.steps.length - 1; i >= 0; i--) {
91
+ const step = this.steps[i];
92
+ try {
93
+ results.push(await unwind(step.capsule, step.context, step.transport));
94
+ }
95
+ catch (err) {
96
+ results.push({
97
+ status: "error",
98
+ capsuleId: step.capsule.id,
99
+ evidence: { error: String(err) },
100
+ });
101
+ }
102
+ }
103
+ return results;
104
+ }
105
+ get size() {
106
+ return this.steps.length;
107
+ }
108
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from "./capsule.ts";
2
2
  export * from "./reconcile.ts";
3
3
  export * from "./registry.ts";
4
- export declare const VERSION = "0.1.0";
4
+ export * from "./adapters.ts";
5
+ export * from "./native.ts";
6
+ export declare const VERSION = "0.1.1";
package/dist/index.js CHANGED
@@ -2,4 +2,6 @@
2
2
  export * from "./capsule.js";
3
3
  export * from "./reconcile.js";
4
4
  export * from "./registry.js";
5
- export const VERSION = "0.1.0";
5
+ export * from "./adapters.js";
6
+ export * from "./native.js";
7
+ export const VERSION = "0.1.1";
@@ -0,0 +1,7 @@
1
+ export interface QueryResult {
2
+ rows: unknown[];
3
+ rowCount?: number | null;
4
+ }
5
+ export interface Connection {
6
+ query(text: string, values: unknown[]): QueryResult | Promise<QueryResult>;
7
+ }
package/dist/native.js ADDED
@@ -0,0 +1,68 @@
1
+ // Native handlers for capsules that speak a driver protocol rather than HTTP.
2
+ //
3
+ // A native capsule names a handler id; this module resolves it to a small object with a
4
+ // probe and a compensate method, mirroring the Python native handlers. The built-in
5
+ // Postgres handlers are driver-agnostic: the caller passes a connection in the context
6
+ // whose query(text, values) returns { rows, rowCount }, the shape node-postgres uses.
7
+ // Only whitelisted identifiers are ever interpolated into SQL, and values always go
8
+ // through the driver's own parameter binding.
9
+ import { EffectError, registerNative, } from "./reconcile.js";
10
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+ const PLACEHOLDER = {
12
+ numbered: "$1", qmark: "?", format: "%s", pyformat: "%s",
13
+ };
14
+ function ident(name, where) {
15
+ const s = String(name);
16
+ if (!IDENT.test(s))
17
+ throw new EffectError(`${where}: '${s}' is not a safe SQL identifier`);
18
+ return s;
19
+ }
20
+ function placeholder(context) {
21
+ return PLACEHOLDER[String(context.paramstyle ?? "numbered")] ?? "$1";
22
+ }
23
+ function connection(capsule, context) {
24
+ const conn = context.connection;
25
+ if (!conn)
26
+ throw new EffectError(`${capsule.id}: native handler needs context.connection`);
27
+ return conn;
28
+ }
29
+ // Probe: does a row with the natural key exist, and therefore did the insert land.
30
+ class PostgresRowExists {
31
+ async probe(capsule, context) {
32
+ const conn = connection(capsule, context);
33
+ const table = ident(context.table, `${capsule.id}.table`);
34
+ const col = ident(context.key_column, `${capsule.id}.key_column`);
35
+ const ph = placeholder(context);
36
+ const result = await conn.query(`SELECT 1 FROM ${table} WHERE ${col} = ${ph} LIMIT 1`, [context.key_value]);
37
+ const landed = (result.rows?.length ?? 0) > 0;
38
+ return {
39
+ status: landed ? "landed" : "not_landed",
40
+ capsuleId: capsule.id,
41
+ evidence: { table, key_column: col },
42
+ };
43
+ }
44
+ compensate(capsule) {
45
+ throw new EffectError(`${capsule.id}: postgres_row_exists is a probe, not a compensation`);
46
+ }
47
+ }
48
+ // Compensation: delete the row addressed by its natural key.
49
+ class PostgresDeleteByKey {
50
+ probe(capsule) {
51
+ throw new EffectError(`${capsule.id}: postgres_delete_by_key is a compensation, not a probe`);
52
+ }
53
+ async compensate(capsule, context) {
54
+ const conn = connection(capsule, context);
55
+ const table = ident(context.table, `${capsule.id}.table`);
56
+ const col = ident(context.key_column, `${capsule.id}.key_column`);
57
+ const ph = placeholder(context);
58
+ const result = await conn.query(`DELETE FROM ${table} WHERE ${col} = ${ph}`, [context.key_value]);
59
+ const deleted = result.rowCount ?? 0;
60
+ return {
61
+ status: deleted ? "compensated" : "no_compensation",
62
+ capsuleId: capsule.id,
63
+ evidence: { deleted },
64
+ };
65
+ }
66
+ }
67
+ registerNative("postgres_row_exists", new PostgresRowExists());
68
+ registerNative("postgres_delete_by_key", new PostgresDeleteByKey());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "did-it-land",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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
5
  "type": "module",
6
6
  "license": "MIT",