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.
- package/dist/adapters.d.ts +60 -0
- package/dist/adapters.js +108 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/native.d.ts +7 -0
- package/dist/native.js +68 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/adapters.js
ADDED
|
@@ -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
package/dist/index.js
CHANGED
package/dist/native.d.ts
ADDED
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