@forgezero/runtime 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 +21 -0
- package/README.md +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- package/package.json +248 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding whether a push should cause a deploy, and what that deploy is.
|
|
3
|
+
*
|
|
4
|
+
* A webhook receiver is a public endpoint that runs commands on your machines
|
|
5
|
+
* when something posts to it. Everything worth getting right is in the gap
|
|
6
|
+
* between those two facts, so this module holds the decisions and none of the
|
|
7
|
+
* doing: no network, no shell, no clone. What it produces is a PLAN, and
|
|
8
|
+
* something else carries it out.
|
|
9
|
+
*
|
|
10
|
+
* That split is not tidiness. It means the interesting cases — a forged
|
|
11
|
+
* signature, a push to a branch nobody deploys, two pushes racing, a repository
|
|
12
|
+
* that is not the one configured — are all testable without a git server, and
|
|
13
|
+
* they are the cases that decide whether this endpoint is a deploy trigger or a
|
|
14
|
+
* remote shell.
|
|
15
|
+
*
|
|
16
|
+
* ## The signature is checked before anything is parsed
|
|
17
|
+
*
|
|
18
|
+
* A JSON body from an unauthenticated caller is attacker-controlled input. It
|
|
19
|
+
* gets verified first and parsed second, so a malformed payload from a forged
|
|
20
|
+
* sender is rejected as forged rather than as malformed — the distinction
|
|
21
|
+
* matters when reading logs during an incident.
|
|
22
|
+
*/
|
|
23
|
+
export declare class PipelineError extends Error {
|
|
24
|
+
readonly code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'WRONG_REPOSITORY' | 'NO_SECRET';
|
|
25
|
+
constructor(code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'WRONG_REPOSITORY' | 'NO_SECRET', message: string);
|
|
26
|
+
}
|
|
27
|
+
export declare const GIT_PROVIDERS: readonly ["github", "gitlab", "generic"];
|
|
28
|
+
export type GitProvider = (typeof GIT_PROVIDERS)[number];
|
|
29
|
+
/**
|
|
30
|
+
* Is this delivery really from the repository we configured?
|
|
31
|
+
*
|
|
32
|
+
* Each provider proves it differently, and the differences are not cosmetic:
|
|
33
|
+
*
|
|
34
|
+
* GITHUB `x-hub-signature-256: sha256=<hex>` — an HMAC over the raw body.
|
|
35
|
+
* GITLAB `x-gitlab-token: <secret>` — the secret itself, compared whole.
|
|
36
|
+
* GENERIC the same HMAC shape as GitHub, for anything self-hosted.
|
|
37
|
+
*
|
|
38
|
+
* GitLab's scheme is weaker — the secret travels on every delivery rather than
|
|
39
|
+
* a signature derived from it — and that is GitLab's decision, not one made
|
|
40
|
+
* here. It is compared in constant time anyway, because the alternative leaks
|
|
41
|
+
* how much of a guess was right.
|
|
42
|
+
*
|
|
43
|
+
* The RAW body is required, not a re-serialised object. `JSON.parse` followed by
|
|
44
|
+
* `JSON.stringify` reorders keys and drops whitespace, so the bytes hashed stop
|
|
45
|
+
* being the bytes sent and every signature fails — which reads as a
|
|
46
|
+
* misconfigured secret and sends people to change the wrong thing.
|
|
47
|
+
*/
|
|
48
|
+
export declare function verifyWebhook(args: {
|
|
49
|
+
provider: GitProvider;
|
|
50
|
+
secret: string;
|
|
51
|
+
rawBody: string;
|
|
52
|
+
headers: Record<string, string | undefined>;
|
|
53
|
+
}): void;
|
|
54
|
+
export interface PushEvent {
|
|
55
|
+
/** `refs/heads/main` reduced to `main`. */
|
|
56
|
+
branch: string;
|
|
57
|
+
commit: string;
|
|
58
|
+
/** `owner/name`, however the provider spells it. */
|
|
59
|
+
repository: string;
|
|
60
|
+
pushedBy?: string;
|
|
61
|
+
message?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What happened, in one shape.
|
|
65
|
+
*
|
|
66
|
+
* Providers disagree about every field name, and normalising here means the
|
|
67
|
+
* deploy logic never learns which forge it is talking to — that is the point of
|
|
68
|
+
* `generic` existing at all.
|
|
69
|
+
*
|
|
70
|
+
* A delivery that is not a branch push returns `null` rather than throwing. Tag
|
|
71
|
+
* pushes, branch deletions, pings and issue comments all arrive on the same
|
|
72
|
+
* endpoint, and treating them as errors would make a healthy integration log a
|
|
73
|
+
* failure every time somebody opened an issue.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parsePush(provider: GitProvider, body: unknown): PushEvent | null;
|
|
76
|
+
export interface PipelineConfig {
|
|
77
|
+
provider: GitProvider;
|
|
78
|
+
/** `owner/name`. Compared against the delivery. */
|
|
79
|
+
repository: string;
|
|
80
|
+
/** Only this branch deploys. One branch per pipeline, deliberately. */
|
|
81
|
+
branch: string;
|
|
82
|
+
/** Where the checkout lives on the compute. */
|
|
83
|
+
workdir: string;
|
|
84
|
+
/** Shell steps, in order. Empty means clone only. */
|
|
85
|
+
steps: readonly string[];
|
|
86
|
+
cloneUrl: string;
|
|
87
|
+
}
|
|
88
|
+
export declare const PLAN_ACTIONS: readonly ["clone", "fetch", "checkout", "run"];
|
|
89
|
+
export type PlanAction = (typeof PLAN_ACTIONS)[number];
|
|
90
|
+
export interface PlanStep {
|
|
91
|
+
action: PlanAction;
|
|
92
|
+
command: string;
|
|
93
|
+
/** Shown to an operator. Never contains a credential. */
|
|
94
|
+
label: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The commands that would deploy this push, in order.
|
|
98
|
+
*
|
|
99
|
+
* `clone` and `fetch` are both emitted, guarded on the directory existing,
|
|
100
|
+
* because a runner cannot know in advance whether the first deploy has
|
|
101
|
+
* happened — and branching on that in the caller means two code paths where one
|
|
102
|
+
* of them is exercised once per compute, ever.
|
|
103
|
+
*
|
|
104
|
+
* The commit is checked out by SHA rather than by branch. A branch moves: a
|
|
105
|
+
* deploy that fetched and then checked out `main` could deploy a commit that
|
|
106
|
+
* arrived after the one that triggered it, so the thing tested is not the thing
|
|
107
|
+
* shipped. The SHA is what the webhook said, so what deploys is what fired.
|
|
108
|
+
*/
|
|
109
|
+
export declare function planDeploy(config: PipelineConfig, event: PushEvent): PlanStep[];
|
|
110
|
+
/**
|
|
111
|
+
* Should this delivery deploy at all?
|
|
112
|
+
*
|
|
113
|
+
* Separate from `planDeploy` so "we received it and deliberately did nothing"
|
|
114
|
+
* is a first-class outcome with a reason attached. A receiver that silently
|
|
115
|
+
* ignored non-matching branches would be indistinguishable from one that is
|
|
116
|
+
* broken, and the first question during an incident is always whether the hook
|
|
117
|
+
* arrived.
|
|
118
|
+
*/
|
|
119
|
+
export declare function shouldDeploy(config: PipelineConfig, event: PushEvent | null): {
|
|
120
|
+
deploy: boolean;
|
|
121
|
+
reason: string;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* The webhook URL a forge should be pointed at.
|
|
125
|
+
*
|
|
126
|
+
* The TENANT is in the path and the pipeline key is the unguessable part. That
|
|
127
|
+
* split is not cosmetic: without the tenant, resolving a delivery means
|
|
128
|
+
* searching every realm for a matching key, and the only way to do that is a
|
|
129
|
+
* cross-realm query — which in this codebase means widening the allow-list that
|
|
130
|
+
* exists to make realm-boundary erosion visible. Naming the tenant makes the
|
|
131
|
+
* lookup an ordinary scoped read.
|
|
132
|
+
*
|
|
133
|
+
* The tenant slug is not a secret; it is in every URL a tenant uses. The key is
|
|
134
|
+
* 12 random bytes, and the SIGNATURE is what authorises — a correct URL with a
|
|
135
|
+
* wrong signature deploys nothing.
|
|
136
|
+
*/
|
|
137
|
+
export declare const webhookPath: (tenant: string, pipelineKey: string) => string;
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/pipeline.ts
|
|
10
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
11
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
12
|
+
|
|
13
|
+
class PipelineError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = "PipelineError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
var GIT_PROVIDERS = ["github", "gitlab", "generic"];
|
|
22
|
+
function equal(a, b) {
|
|
23
|
+
if (a.length !== b.length)
|
|
24
|
+
return false;
|
|
25
|
+
let difference = 0;
|
|
26
|
+
for (let at = 0;at < a.length; at += 1)
|
|
27
|
+
difference |= a[at] ^ b[at];
|
|
28
|
+
return difference === 0;
|
|
29
|
+
}
|
|
30
|
+
var encoder = new TextEncoder;
|
|
31
|
+
var hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
32
|
+
function verifyWebhook(args) {
|
|
33
|
+
if (!args.secret) {
|
|
34
|
+
throw new PipelineError("NO_SECRET", "This connection has no webhook secret, so nothing can be verified.");
|
|
35
|
+
}
|
|
36
|
+
const lowered = new Map(Object.entries(args.headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
37
|
+
const header = (name) => lowered.get(name.toLowerCase());
|
|
38
|
+
if (args.provider === "gitlab") {
|
|
39
|
+
const presented2 = header("x-gitlab-token") ?? "";
|
|
40
|
+
if (!equal(encoder.encode(presented2), encoder.encode(args.secret))) {
|
|
41
|
+
throw new PipelineError("BAD_SIGNATURE", "That delivery did not carry the configured token.");
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const presented = header("x-hub-signature-256") ?? header("x-fz-signature-256") ?? "";
|
|
46
|
+
const expected = `sha256=${hex(hmac(sha256, encoder.encode(args.secret), encoder.encode(args.rawBody)))}`;
|
|
47
|
+
if (!equal(encoder.encode(presented), encoder.encode(expected))) {
|
|
48
|
+
throw new PipelineError("BAD_SIGNATURE", "That delivery did not match the configured secret.");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
var branchOf = (ref) => ref.replace(/^refs\/heads\//, "");
|
|
52
|
+
function parsePush(provider, body) {
|
|
53
|
+
if (typeof body !== "object" || body === null) {
|
|
54
|
+
throw new PipelineError("MALFORMED_EVENT", "That delivery had no object body.");
|
|
55
|
+
}
|
|
56
|
+
const payload = body;
|
|
57
|
+
const ref = typeof payload.ref === "string" ? payload.ref : "";
|
|
58
|
+
if (!ref.startsWith("refs/heads/"))
|
|
59
|
+
return null;
|
|
60
|
+
if (payload.deleted === true || payload.after === "0000000000000000000000000000000000000000")
|
|
61
|
+
return null;
|
|
62
|
+
const repository = provider === "gitlab" ? String(payload.project?.path_with_namespace ?? "") : String(payload.repository?.full_name ?? "");
|
|
63
|
+
const commit = typeof payload.after === "string" ? payload.after : typeof payload.checkout_sha === "string" ? payload.checkout_sha : "";
|
|
64
|
+
if (!repository || !commit) {
|
|
65
|
+
throw new PipelineError("MALFORMED_EVENT", "That delivery named no repository or no commit.");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
branch: branchOf(ref),
|
|
69
|
+
commit,
|
|
70
|
+
repository,
|
|
71
|
+
pushedBy: typeof payload.user_username === "string" ? payload.user_username : typeof payload.pusher?.name === "string" ? String(payload.pusher.name) : undefined,
|
|
72
|
+
message: typeof payload.head_commit?.message === "string" ? String(payload.head_commit.message) : undefined
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
var PLAN_ACTIONS = ["clone", "fetch", "checkout", "run"];
|
|
76
|
+
function planDeploy(config, event) {
|
|
77
|
+
if (event.repository !== config.repository) {
|
|
78
|
+
throw new PipelineError("WRONG_REPOSITORY", `That delivery is for ${event.repository}, and this pipeline deploys ${config.repository}.`);
|
|
79
|
+
}
|
|
80
|
+
const dir = config.workdir;
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
action: "clone",
|
|
84
|
+
command: `[ -d ${dir}/.git ] || git clone ${config.cloneUrl} ${dir}`,
|
|
85
|
+
label: "clone if this is the first deploy"
|
|
86
|
+
},
|
|
87
|
+
{ action: "fetch", command: `git -C ${dir} fetch --prune origin`, label: "fetch" },
|
|
88
|
+
{
|
|
89
|
+
action: "checkout",
|
|
90
|
+
command: `git -C ${dir} checkout --detach ${event.commit}`,
|
|
91
|
+
label: `check out ${event.commit.slice(0, 8)}`
|
|
92
|
+
},
|
|
93
|
+
...config.steps.map((step) => ({
|
|
94
|
+
action: "run",
|
|
95
|
+
command: `cd ${dir} && ${step}`,
|
|
96
|
+
label: step
|
|
97
|
+
}))
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
function shouldDeploy(config, event) {
|
|
101
|
+
if (!event)
|
|
102
|
+
return { deploy: false, reason: "Not a branch push — nothing to deploy." };
|
|
103
|
+
if (event.repository !== config.repository) {
|
|
104
|
+
return { deploy: false, reason: `Delivery is for ${event.repository}, not ${config.repository}.` };
|
|
105
|
+
}
|
|
106
|
+
if (event.branch !== config.branch) {
|
|
107
|
+
return { deploy: false, reason: `Pushed to ${event.branch}; this pipeline follows ${config.branch}.` };
|
|
108
|
+
}
|
|
109
|
+
return { deploy: true, reason: `${event.branch} at ${event.commit.slice(0, 8)}` };
|
|
110
|
+
}
|
|
111
|
+
var webhookPath = (tenant, pipelineKey) => `/v1/hooks/${tenant}/${pipelineKey}`;
|
|
112
|
+
export {
|
|
113
|
+
webhookPath,
|
|
114
|
+
verifyWebhook,
|
|
115
|
+
shouldDeploy,
|
|
116
|
+
planDeploy,
|
|
117
|
+
parsePush,
|
|
118
|
+
PipelineError,
|
|
119
|
+
PLAN_ACTIONS,
|
|
120
|
+
GIT_PROVIDERS
|
|
121
|
+
};
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A keyed work queue — parallel across keys, strictly sequential within one.
|
|
3
|
+
*
|
|
4
|
+
* The problem this solves is narrow and very common: most work can run in any
|
|
5
|
+
* order, but some of it must not. Two credits to the same wallet, two writes to
|
|
6
|
+
* the same vault entry, two deploys of the same application — each pair has to
|
|
7
|
+
* happen one after the other, while everything touching a DIFFERENT wallet,
|
|
8
|
+
* entry or application should run at full width.
|
|
9
|
+
*
|
|
10
|
+
* A global lock gives you the ordering and throws away the throughput. A plain
|
|
11
|
+
* worker pool gives you the throughput and loses the ordering. The answer is to
|
|
12
|
+
* partition by key: the queue is really thousands of tiny FIFOs, and a worker
|
|
13
|
+
* claims a KEY rather than a message.
|
|
14
|
+
*
|
|
15
|
+
* ## Why claim the key, not the message
|
|
16
|
+
*
|
|
17
|
+
* Claiming a message and processing it is the obvious design and it does not
|
|
18
|
+
* order anything: two workers can claim message 1 and message 2 of the same key
|
|
19
|
+
* at the same instant and finish in either order. Claiming the KEY makes
|
|
20
|
+
* ordering structural — while a worker holds `wallet:42`, nobody else can take
|
|
21
|
+
* ANY message for it, so its messages can only be processed in the order they
|
|
22
|
+
* were written.
|
|
23
|
+
*
|
|
24
|
+
* ## What this guarantees, and what it does not
|
|
25
|
+
*
|
|
26
|
+
* GUARANTEED per-key FIFO order; at-least-once delivery; at most one worker
|
|
27
|
+
* per key at a time, across the whole cluster; a message that
|
|
28
|
+
* exhausts its attempts moves to a dead letter INSTEAD of blocking
|
|
29
|
+
* its key forever.
|
|
30
|
+
*
|
|
31
|
+
* NOT GUARANTEED exactly-once execution. Nothing can guarantee that: a worker
|
|
32
|
+
* can complete the side effect and die before recording that it
|
|
33
|
+
* did. `dedupeKey` collapses duplicate ENQUEUES, and the lease
|
|
34
|
+
* stops concurrent delivery, but a crash between "money moved" and
|
|
35
|
+
* "message acknowledged" will redeliver.
|
|
36
|
+
*
|
|
37
|
+
* ## So for money, read this
|
|
38
|
+
*
|
|
39
|
+
* The queue is not what makes a financial operation safe — the HANDLER is. Make
|
|
40
|
+
* the effect idempotent at the point of effect: a ledger posting keyed on the
|
|
41
|
+
* message id and refused by a unique index, or a transfer keyed on an
|
|
42
|
+
* idempotency key the payment provider also honours. Then redelivery is free
|
|
43
|
+
* and the queue only has to provide ordering and durability, which it does.
|
|
44
|
+
*
|
|
45
|
+
* Anything that relies on "the queue delivered it once" is relying on something
|
|
46
|
+
* no queue provides, including this one, including the expensive ones.
|
|
47
|
+
*/
|
|
48
|
+
export interface Clock {
|
|
49
|
+
now(): number;
|
|
50
|
+
}
|
|
51
|
+
export declare const systemClock: Clock;
|
|
52
|
+
/** `30s` → 30000. Throws rather than guessing — a wrong window is silent. */
|
|
53
|
+
export declare function durationMs(value: string | number): number;
|
|
54
|
+
export type MessageStatus = 'ready' | 'leased' | 'done' | 'dead';
|
|
55
|
+
export interface Message<Body = unknown> {
|
|
56
|
+
id: string;
|
|
57
|
+
/** The partition. Everything sharing it runs in order, one at a time. */
|
|
58
|
+
key: string;
|
|
59
|
+
body: Body;
|
|
60
|
+
/** Monotonic per queue. Decides order within a key. */
|
|
61
|
+
sequence: number;
|
|
62
|
+
status: MessageStatus;
|
|
63
|
+
attempts: number;
|
|
64
|
+
/** Earliest time this may be delivered. Set by a retry backoff. */
|
|
65
|
+
availableAtMs: number;
|
|
66
|
+
/** Collapses repeat enqueues within a window. See `enqueue`. */
|
|
67
|
+
dedupeKey?: string;
|
|
68
|
+
lastError?: string;
|
|
69
|
+
enqueuedAtMs: number;
|
|
70
|
+
completedAtMs?: number;
|
|
71
|
+
}
|
|
72
|
+
export interface KeyLease {
|
|
73
|
+
key: string;
|
|
74
|
+
/** Rises every time the key is granted. A stale holder cannot write. */
|
|
75
|
+
fence: number;
|
|
76
|
+
untilMs: number;
|
|
77
|
+
owner: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Where the queue lives. Memory for tests, a database for a cluster.
|
|
81
|
+
*
|
|
82
|
+
* `claimKey` MUST be atomic. A read-then-write implementation hands the same key
|
|
83
|
+
* to two workers under exactly the load that makes ordering matter, and the bug
|
|
84
|
+
* looks like "the queue occasionally processes out of order" — which is the
|
|
85
|
+
* hardest possible thing to reproduce.
|
|
86
|
+
*/
|
|
87
|
+
export interface QueueStore<Body = unknown> {
|
|
88
|
+
/** Which deployment shape this store can serve. See `MODES`. */
|
|
89
|
+
readonly mode: QueueMode;
|
|
90
|
+
append(queue: string, message: Message<Body>): Promise<void>;
|
|
91
|
+
/** Existing message with this dedupe key, if the window has not passed. */
|
|
92
|
+
findByDedupe(queue: string, dedupeKey: string, sinceMs: number): Promise<Message<Body> | null>;
|
|
93
|
+
/**
|
|
94
|
+
* Take the next key that has ready work and is not leased. Atomic.
|
|
95
|
+
* `exclude` skips keys this worker already holds.
|
|
96
|
+
*/
|
|
97
|
+
claimKey(queue: string, owner: string, ttlMs: number, nowMs: number): Promise<KeyLease | null>;
|
|
98
|
+
renewKey(queue: string, lease: KeyLease, ttlMs: number, nowMs: number): Promise<boolean>;
|
|
99
|
+
releaseKey(queue: string, lease: KeyLease): Promise<void>;
|
|
100
|
+
/** Ready messages for one key, in sequence order. */
|
|
101
|
+
readKey(queue: string, key: string, nowMs: number, limit: number): Promise<Message<Body>[]>;
|
|
102
|
+
update(queue: string, id: string, patch: Partial<Message<Body>>): Promise<void>;
|
|
103
|
+
stats(queue: string): Promise<QueueStats>;
|
|
104
|
+
/** Dead-lettered messages, newest first. */
|
|
105
|
+
dead(queue: string, limit: number): Promise<Message<Body>[]>;
|
|
106
|
+
}
|
|
107
|
+
export interface QueueStats {
|
|
108
|
+
ready: number;
|
|
109
|
+
leased: number;
|
|
110
|
+
dead: number;
|
|
111
|
+
keys: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Standalone and cluster are the same queue with a different store.
|
|
115
|
+
*
|
|
116
|
+
* standalone one process, state in memory. No coordination, nothing to
|
|
117
|
+
* install, and the ordering guarantee still holds — because a
|
|
118
|
+
* single process claiming its own keys cannot race itself.
|
|
119
|
+
* Everything is lost on restart, which is correct for a
|
|
120
|
+
* development machine and wrong for anything holding money.
|
|
121
|
+
*
|
|
122
|
+
* cluster several processes over shared storage. The guarantee is the
|
|
123
|
+
* same and the requirement is stricter: `claimKey` must be
|
|
124
|
+
* ATOMIC. A read-then-write implementation hands one key to two
|
|
125
|
+
* workers under exactly the load that makes ordering matter, and
|
|
126
|
+
* the bug presents as "the queue occasionally processes out of
|
|
127
|
+
* order" — the hardest possible thing to reproduce.
|
|
128
|
+
*
|
|
129
|
+
* The distinction is declared on the store rather than inferred, so a
|
|
130
|
+
* deployment can refuse to start when a durable queue was expected and a memory
|
|
131
|
+
* one was wired in. That mistake is otherwise invisible until a restart.
|
|
132
|
+
*/
|
|
133
|
+
export declare const MODES: readonly ["standalone", "cluster"];
|
|
134
|
+
export type QueueMode = (typeof MODES)[number];
|
|
135
|
+
/**
|
|
136
|
+
* Single process. Correct for tests and for one instance, and honest about
|
|
137
|
+
* being no more — nothing here coordinates across processes.
|
|
138
|
+
*/
|
|
139
|
+
export declare function memoryStore<Body = unknown>(clock?: Clock): QueueStore<Body>;
|
|
140
|
+
export interface Handler<Body> {
|
|
141
|
+
(message: Message<Body>, context: HandlerContext): Promise<void>;
|
|
142
|
+
}
|
|
143
|
+
export interface HandlerContext {
|
|
144
|
+
/** Still ours? False means the lease expired and nothing further may be written. */
|
|
145
|
+
holdsKey(): Promise<boolean>;
|
|
146
|
+
log(message: string): void;
|
|
147
|
+
attempt: number;
|
|
148
|
+
}
|
|
149
|
+
export interface QueueOptions<Body> {
|
|
150
|
+
name: string;
|
|
151
|
+
store: QueueStore<Body>;
|
|
152
|
+
handler: Handler<Body>;
|
|
153
|
+
/** How many KEYS one worker processes at once. Not messages — see the note. */
|
|
154
|
+
concurrency?: number;
|
|
155
|
+
/** How long a key lease lasts. Renewed between messages. */
|
|
156
|
+
leaseMs?: number;
|
|
157
|
+
/** Attempts before a message is dead-lettered. */
|
|
158
|
+
maxAttempts?: number;
|
|
159
|
+
/** Backoff between attempts. Doubles, capped. */
|
|
160
|
+
backoffMs?: number;
|
|
161
|
+
maxBackoffMs?: number;
|
|
162
|
+
/** Repeat enqueues with the same `dedupeKey` inside this window collapse. */
|
|
163
|
+
dedupeWindowMs?: number;
|
|
164
|
+
/** Messages taken per key per turn, so one hot key cannot hold a slot forever. */
|
|
165
|
+
batch?: number;
|
|
166
|
+
clock?: Clock;
|
|
167
|
+
owner?: string;
|
|
168
|
+
onError?: (message: Message<Body>, error: unknown) => void;
|
|
169
|
+
/**
|
|
170
|
+
* Refuse to start unless the store is durable.
|
|
171
|
+
*
|
|
172
|
+
* Set it wherever losing queued work on a restart would be a real loss.
|
|
173
|
+
* Without it, wiring the memory store into a cluster is a mistake nothing
|
|
174
|
+
* reports until the first restart, by which point the work is gone.
|
|
175
|
+
*/
|
|
176
|
+
require?: QueueMode;
|
|
177
|
+
}
|
|
178
|
+
export interface EnqueueResult {
|
|
179
|
+
id: string;
|
|
180
|
+
/** True when an identical `dedupeKey` was already queued and this was dropped. */
|
|
181
|
+
duplicate: boolean;
|
|
182
|
+
}
|
|
183
|
+
export declare function createQueue<Body = unknown>(options: QueueOptions<Body>): {
|
|
184
|
+
enqueue: (args: {
|
|
185
|
+
key: string;
|
|
186
|
+
body: Body;
|
|
187
|
+
dedupeKey?: string;
|
|
188
|
+
delayMs?: number;
|
|
189
|
+
}) => Promise<EnqueueResult>;
|
|
190
|
+
tick: () => Promise<number>;
|
|
191
|
+
/** Drain until nothing is ready. For tests and for a one-shot worker. */
|
|
192
|
+
drain(maxPasses?: number): Promise<void>;
|
|
193
|
+
/** Poll forever. `stop()` lets in-flight keys finish. */
|
|
194
|
+
start(intervalMs?: number): void;
|
|
195
|
+
stop(): void;
|
|
196
|
+
mode: "standalone" | "cluster";
|
|
197
|
+
stats: () => Promise<QueueStats>;
|
|
198
|
+
dead: (limit?: number) => Promise<Message<Body>[]>;
|
|
199
|
+
/** Put a dead message back at the front of its key. An operator decision. */
|
|
200
|
+
revive(id: string): Promise<void>;
|
|
201
|
+
};
|
|
202
|
+
export type Queue<Body = unknown> = ReturnType<typeof createQueue<Body>>;
|
|
203
|
+
export declare const VERSION = "0.1.0";
|
|
204
|
+
/**
|
|
205
|
+
* The four operations a shared database has to provide, and the one that is
|
|
206
|
+
* hard.
|
|
207
|
+
*
|
|
208
|
+
* Everything except `claimKey` is an ordinary read or write. `claimKey` has to
|
|
209
|
+
* find a key with ready work, check that nobody holds it, and take it — as ONE
|
|
210
|
+
* atomic step. Split into a read and a write, two workers pass the check
|
|
211
|
+
* together and both proceed, and the queue silently stops ordering anything.
|
|
212
|
+
*
|
|
213
|
+
* Written as a small adapter rather than a driver so the same queue runs over
|
|
214
|
+
* ArangoDB, Postgres or Redis, and so this package keeps no dependency.
|
|
215
|
+
*/
|
|
216
|
+
export interface ClusterAdapter<Body = unknown> {
|
|
217
|
+
insert(queue: string, message: Message<Body>): Promise<void>;
|
|
218
|
+
findDuplicate(queue: string, dedupeKey: string, sinceMs: number): Promise<Message<Body> | null>;
|
|
219
|
+
/**
|
|
220
|
+
* ATOMIC. Must return a key that has ready work and no live lease, marking it
|
|
221
|
+
* leased in the same operation. Returning null means there is nothing to do.
|
|
222
|
+
*/
|
|
223
|
+
takeKey(args: {
|
|
224
|
+
queue: string;
|
|
225
|
+
owner: string;
|
|
226
|
+
untilMs: number;
|
|
227
|
+
nowMs: number;
|
|
228
|
+
}): Promise<KeyLease | null>;
|
|
229
|
+
extendKey(queue: string, key: string, fence: number, untilMs: number): Promise<boolean>;
|
|
230
|
+
dropKey(queue: string, key: string, fence: number): Promise<void>;
|
|
231
|
+
readReady(queue: string, key: string, nowMs: number, limit: number): Promise<Message<Body>[]>;
|
|
232
|
+
patch(queue: string, id: string, patch: Partial<Message<Body>>): Promise<void>;
|
|
233
|
+
counts(queue: string): Promise<QueueStats>;
|
|
234
|
+
deadLetter(queue: string, limit: number): Promise<Message<Body>[]>;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A durable store over any shared database.
|
|
238
|
+
*
|
|
239
|
+
* Declares `mode: 'cluster'`, which is what lets a deployment refuse to start if
|
|
240
|
+
* somebody wires the memory store into production by mistake — a mistake that
|
|
241
|
+
* is otherwise invisible until the first restart takes the queue with it.
|
|
242
|
+
*/
|
|
243
|
+
export declare function clusterStore<Body = unknown>(adapter: ClusterAdapter<Body>): QueueStore<Body>;
|