@forgezero/runtime 0.1.0 → 0.1.2

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/outbox.d.ts CHANGED
@@ -47,6 +47,8 @@ export declare class OutboxError extends Error {
47
47
  export declare const EVENT_STATES: readonly ["pending", "delivering", "delivered", "dead"];
48
48
  export type EventState = (typeof EVENT_STATES)[number];
49
49
  export interface OutboxEvent {
50
+ /** Set when a drainer claims it; proves ownership at settle time. */
51
+ claimToken?: string;
50
52
  id: string;
51
53
  type: string;
52
54
  /** The aggregate this concerns. Events sharing one are delivered in order. */
@@ -134,7 +136,14 @@ export interface OutboxStore {
134
136
  nowMs: number;
135
137
  claimTtlMs: number;
136
138
  }): Promise<OutboxEvent[]>;
137
- settle(id: string, patch: Partial<OutboxEvent>): Promise<void>;
139
+ /**
140
+ * `claimToken` proves the caller still owns this event.
141
+ *
142
+ * Without it a drainer whose lease had expired could overwrite the result of
143
+ * the drainer that took over, landing an old outcome on top of a newer one.
144
+ * Optional so an in-memory store that cannot lose ownership need not carry it.
145
+ */
146
+ settle(id: string, patch: Partial<OutboxEvent>, claimToken?: string): Promise<void>;
138
147
  byState(state: EventState, limit?: number): Promise<OutboxEvent[]>;
139
148
  get(id: string): Promise<OutboxEvent | null>;
140
149
  }
package/dist/outbox.js CHANGED
@@ -27,13 +27,18 @@ function backoffMs(attempts, policy = DEFAULT_POLICY, random = Math.random) {
27
27
  const exponential = Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempts - 1), policy.maxDelayMs);
28
28
  return Math.round(exponential * (1 + policy.jitter * random()));
29
29
  }
30
+ function randomSuffix() {
31
+ const bytes = new Uint8Array(4);
32
+ crypto.getRandomValues(bytes);
33
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
34
+ }
30
35
  function createOutbox(options) {
31
36
  const policy = { ...DEFAULT_POLICY, ...options.policy };
32
37
  const now = options.now ?? Date.now;
33
38
  const random = options.random ?? Math.random;
34
39
  const drainerId = options.drainerId ?? `drainer-${Math.floor(Math.random() * 1e9).toString(36)}`;
35
40
  let sequence = 0;
36
- const nextId = () => `evt_${now().toString(36)}_${(sequence += 1).toString(36)}`;
41
+ const nextId = () => `evt_${now().toString(36)}_${(sequence += 1).toString(36)}_${randomSuffix()}`;
37
42
  async function settleOne(event) {
38
43
  let result;
39
44
  try {
@@ -47,7 +52,7 @@ function createOutbox(options) {
47
52
  deliveredAtMs: now(),
48
53
  claimedBy: undefined,
49
54
  claimedUntilMs: undefined
50
- });
55
+ }, event.claimToken);
51
56
  return "delivered";
52
57
  }
53
58
  const attempts = event.attempts + 1;
@@ -67,7 +72,7 @@ function createOutbox(options) {
67
72
  lastError: result.error,
68
73
  claimedBy: undefined,
69
74
  claimedUntilMs: undefined
70
- });
75
+ }, event.claimToken);
71
76
  options.onDeadLetter?.(dead);
72
77
  return "dead";
73
78
  }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Deciding whether a push should cause a deploy, and what that deploy is.
2
+ * Deciding whether a push should cause a deploy.
3
3
  *
4
4
  * A webhook receiver is a public endpoint that runs commands on your machines
5
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.
6
+ * between those two facts, so this module holds trigger verification and none
7
+ * of the doing: no network, no shell, no clone, and no executable plan. The
8
+ * agent later reads the only deployment definition from the exact checked-out
9
+ * commit.
9
10
  *
10
11
  * That split is not tidiness. It means the interesting cases — a forged
11
12
  * signature, a push to a branch nobody deploys, two pushes racing, a repository
@@ -21,8 +22,8 @@
21
22
  * matters when reading logs during an incident.
22
23
  */
23
24
  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);
25
+ readonly code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'NO_SECRET';
26
+ constructor(code: 'BAD_SIGNATURE' | 'UNSUPPORTED_PROVIDER' | 'MALFORMED_EVENT' | 'NO_SECRET', message: string);
26
27
  }
27
28
  export declare const GIT_PROVIDERS: readonly ["github", "gitlab", "generic"];
28
29
  export type GitProvider = (typeof GIT_PROVIDERS)[number];
@@ -73,50 +74,23 @@ export interface PushEvent {
73
74
  * failure every time somebody opened an issue.
74
75
  */
75
76
  export declare function parsePush(provider: GitProvider, body: unknown): PushEvent | null;
76
- export interface PipelineConfig {
77
+ export interface DeployTrigger {
77
78
  provider: GitProvider;
78
79
  /** `owner/name`. Compared against the delivery. */
79
80
  repository: string;
80
81
  /** Only this branch deploys. One branch per pipeline, deliberately. */
81
82
  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
83
  }
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
84
  /**
111
85
  * Should this delivery deploy at all?
112
86
  *
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
87
+ * "We received it and deliberately did nothing" is a first-class outcome with
88
+ * a reason attached. A receiver that silently
115
89
  * ignored non-matching branches would be indistinguishable from one that is
116
90
  * broken, and the first question during an incident is always whether the hook
117
91
  * arrived.
118
92
  */
119
- export declare function shouldDeploy(config: PipelineConfig, event: PushEvent | null): {
93
+ export declare function shouldDeploy(config: Pick<DeployTrigger, 'repository' | 'branch'>, event: PushEvent | null): {
120
94
  deploy: boolean;
121
95
  reason: string;
122
96
  };
package/dist/pipeline.js CHANGED
@@ -72,31 +72,6 @@ function parsePush(provider, body) {
72
72
  message: typeof payload.head_commit?.message === "string" ? String(payload.head_commit.message) : undefined
73
73
  };
74
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
75
  function shouldDeploy(config, event) {
101
76
  if (!event)
102
77
  return { deploy: false, reason: "Not a branch push — nothing to deploy." };
@@ -113,9 +88,7 @@ export {
113
88
  webhookPath,
114
89
  verifyWebhook,
115
90
  shouldDeploy,
116
- planDeploy,
117
91
  parsePush,
118
92
  PipelineError,
119
- PLAN_ACTIONS,
120
93
  GIT_PROVIDERS
121
94
  };
package/dist/queue.d.ts CHANGED
@@ -1,243 +1,116 @@
1
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.
2
+ * Ordered work: sequential within a key, parallel across keys.
3
+ *
4
+ * That sentence is the whole contract, and the previous version buried it under
5
+ * a persistence framework `QueueStore`, leases, standalone/cluster modes and
6
+ * an unfinished cluster adapter that nothing in production ever used. The
7
+ * framework was also wrong on its own terms: a message left `leased` after its
8
+ * lease expired became invisible to both the claim and ready reads, so a stale
9
+ * lease made work unrunnable rather than runnable again.
10
+ *
11
+ * Worse for the property that matters, retry backoff did not hold the key. A
12
+ * task waiting to retry was skipped and a LATER task for the same key ran
13
+ * first, which is precisely the ordering this module exists to guarantee.
14
+ *
15
+ * So persistence is gone. The layer that knows what a pending business request
16
+ * means is the runtime that issued it — ForgeZero's own durability record is an
17
+ * Arango row written in the same transaction as the change it describes, and
18
+ * the outbox drains through here rather than reimplementing ordering.
19
+ *
20
+ * ## What it does guarantee
21
+ *
22
+ * - Tasks for one key run one at a time, in submission order, INCLUDING across
23
+ * a retry: a task that is waiting to try again blocks its key.
24
+ * - Different keys run concurrently, up to `width`.
25
+ * - `run()` returns the handler's own value, typed, so a caller can await work
26
+ * instead of polling for it.
27
+ * - `stop()` awaits what is running and what is queued, with a deadline, and
28
+ * reports what it managed to finish.
47
29
  */
48
- export interface Clock {
49
- now(): number;
30
+ export interface QueueTask<T> {
31
+ /** Resolves with the handler's value, or rejects with its final error. */
32
+ readonly result: Promise<T>;
33
+ /** Stable id, so a caller can cancel or look up this task. */
34
+ readonly id: string;
35
+ readonly key: string;
50
36
  }
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;
37
+ export interface RetryPolicy {
38
+ /** How many ATTEMPTS in total, including the first. */
63
39
  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;
40
+ /** Milliseconds before attempt n+1. Called with the 1-based attempt made. */
41
+ backoffMs: (attempt: number) => number;
71
42
  }
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;
43
+ export interface QueueOptions {
44
+ /** How many keys may run at once. Ordering within a key is unaffected. */
45
+ width?: number;
46
+ retry?: Partial<RetryPolicy>;
47
+ /** Retry delay injection. Shutdown deadlines use a cancellable native timer. */
48
+ sleep?: (ms: number) => Promise<void>;
78
49
  }
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>[]>;
50
+ export interface DrainReport {
51
+ completed: number;
52
+ failed: number;
53
+ /** Still queued or running when the deadline passed. */
54
+ abandoned: number;
55
+ timedOut: boolean;
106
56
  }
107
- export interface QueueStats {
108
- ready: number;
109
- leased: number;
110
- dead: number;
111
- keys: number;
57
+ export declare class QueueStoppedError extends Error {
58
+ constructor();
112
59
  }
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>;
60
+ export declare class QueueKeyStoppedError extends Error {
61
+ readonly key: string;
62
+ constructor(key: string);
142
63
  }
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;
64
+ export declare class TaskCancelledError extends Error {
65
+ constructor();
148
66
  }
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;
67
+ export declare function createQueue(options?: QueueOptions): {
169
68
  /**
170
- * Refuse to start unless the store is durable.
69
+ * Submit work and await its value.
171
70
  *
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.
71
+ * The handler's return type flows through, so a caller gets a result
72
+ * rather than an id to poll the previous `enqueue` returned only
73
+ * `{ id, duplicate }` and had nowhere to put an answer.
175
74
  */
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>;
75
+ run<Args extends unknown[], T>(key: string, handler: (...args: Args) => Promise<T> | T, ...args: Args): QueueTask<T>;
76
+ /** Remove a task that has not started. Running work is left alone. */
77
+ cancel(id: string): boolean;
78
+ /** Hold one key. Work already running for it finishes. */
79
+ pauseKey(key: string): void;
80
+ resumeKey(key: string): void;
219
81
  /**
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.
82
+ * Close one key and reject everything for it that has not started.
83
+ *
84
+ * JavaScript cannot safely kill an arbitrary running function. The current
85
+ * handler is therefore allowed to finish; every pending handler is removed,
86
+ * and future submissions are refused until `startKey()` is explicit.
222
87
  */
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>;
88
+ stopKey(key: string): number;
89
+ /** Re-open a key deliberately; pausing and stopping are not aliases. */
90
+ startKey(key: string): boolean;
91
+ /** Hold everything. New submissions are accepted and wait. */
92
+ pause(): void;
93
+ resume(): void;
94
+ /** How much is outstanding, for a health endpoint or a drain decision. */
95
+ snapshot(): {
96
+ running: number;
97
+ queued: number;
98
+ keys: number;
99
+ paused: boolean;
100
+ pausedKeys: string[];
101
+ stoppedKeys: string[];
102
+ completed: number;
103
+ failed: number;
104
+ };
105
+ /** Resolve when nothing is running and nothing is queued. */
106
+ whenIdle(): Promise<void>;
107
+ /**
108
+ * Stop accepting work and await what is left, with a deadline.
109
+ *
110
+ * `stop()` used to flip a boolean and return `void`, which is not a
111
+ * shutdown — a process could exit with work in flight and report success.
112
+ * This reports what it actually managed.
113
+ */
114
+ stop(deadlineMs?: number): Promise<DrainReport>;
115
+ };
116
+ export type Queue = ReturnType<typeof createQueue>;