@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.
@@ -0,0 +1,313 @@
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/custody-crypto.ts
10
+ import { gcm } from "@noble/ciphers/aes.js";
11
+ import { x25519 } from "@noble/curves/ed25519.js";
12
+ import { hkdf } from "@noble/hashes/hkdf.js";
13
+ import { sha256 } from "@noble/hashes/sha2.js";
14
+ var KEY_BYTES = 32;
15
+ var NONCE_BYTES = 12;
16
+ var toBase64 = (bytes) => {
17
+ let binary = "";
18
+ for (const byte of bytes)
19
+ binary += String.fromCharCode(byte);
20
+ return btoa(binary);
21
+ };
22
+ var fromBase64 = (value) => {
23
+ const binary = atob(value);
24
+ const bytes = new Uint8Array(binary.length);
25
+ for (let index = 0;index < binary.length; index += 1)
26
+ bytes[index] = binary.charCodeAt(index);
27
+ return bytes;
28
+ };
29
+ var utf8 = (value) => new TextEncoder().encode(value);
30
+ function deriveKey(secret, salt, info, length = KEY_BYTES) {
31
+ return hkdf(sha256, secret, salt, utf8(info), length);
32
+ }
33
+ function sealWithKey(key, plaintext, aad) {
34
+ if (key.length !== KEY_BYTES)
35
+ throw new Error("custody: key must be 32 bytes");
36
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
37
+ const sealed = gcm(key, nonce, utf8(aad)).encrypt(plaintext);
38
+ return { alg: "aes-256-gcm", nonce: toBase64(nonce), ciphertext: toBase64(sealed) };
39
+ }
40
+ function openWithKey(key, box, aad) {
41
+ if (key.length !== KEY_BYTES)
42
+ throw new Error("custody: key must be 32 bytes");
43
+ if (box.alg !== "aes-256-gcm")
44
+ throw new Error(`custody: unknown algorithm ${box.alg}`);
45
+ return gcm(key, fromBase64(box.nonce), utf8(aad)).decrypt(fromBase64(box.ciphertext));
46
+ }
47
+ var WRAP_INFO = "forgezero:custody:wrap:v1";
48
+ var wrapKey = (shared, ephemeral, recipient) => hkdf(sha256, shared, concatBytes(ephemeral, recipient), utf8(WRAP_INFO), 32);
49
+ function concatBytes(left, right) {
50
+ const out = new Uint8Array(left.length + right.length);
51
+ out.set(left, 0);
52
+ out.set(right, left.length);
53
+ return out;
54
+ }
55
+ function sealToKey(recipientPublicKey, plaintext, aad) {
56
+ const ephemeralSecret = x25519.utils.randomSecretKey();
57
+ const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);
58
+ const shared = x25519.getSharedSecret(ephemeralSecret, recipientPublicKey);
59
+ const key = wrapKey(shared, ephemeralPublic, recipientPublicKey);
60
+ try {
61
+ return { ...sealWithKey(key, plaintext, aad), ephemeral: toBase64(ephemeralPublic) };
62
+ } finally {
63
+ key.fill(0);
64
+ ephemeralSecret.fill(0);
65
+ }
66
+ }
67
+ function openFromKey(recipientSecretKey, box, aad) {
68
+ const ephemeralPublic = fromBase64(box.ephemeral);
69
+ const recipientPublic = x25519.getPublicKey(recipientSecretKey);
70
+ const shared = x25519.getSharedSecret(recipientSecretKey, ephemeralPublic);
71
+ const key = wrapKey(shared, ephemeralPublic, recipientPublic);
72
+ try {
73
+ return openWithKey(key, box, aad);
74
+ } finally {
75
+ key.fill(0);
76
+ }
77
+ }
78
+ function wrappingKeyPair(factorMaterial, info) {
79
+ const secretKey = hkdf(sha256, factorMaterial, utf8("forgezero:custody:wrapkey:v1"), utf8(info), 32);
80
+ return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
81
+ }
82
+
83
+ // src/phrase.ts
84
+ import { entropyToMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
85
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
86
+ import { hkdf as hkdf2 } from "@noble/hashes/hkdf.js";
87
+ import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
88
+ import { toHex } from "@forgezero/access/security";
89
+ var ENCODER = new TextEncoder;
90
+ var randomBytes = (length) => crypto.getRandomValues(new Uint8Array(length));
91
+ var PHRASE_WORDS = 24;
92
+ var ENTROPY_BYTES = 32;
93
+ var PHRASE_SALT_BYTES = 16;
94
+ var KEY_BYTES2 = 32;
95
+ var KEY_INFO = "forgezero:custodian:phrase-key:v1";
96
+ var VERIFIER_INFO = "forgezero:custodian:phrase-verifier:v1";
97
+ function canonical(words) {
98
+ return words.map((word) => word.normalize("NFKD").trim().toLowerCase()).join(" ");
99
+ }
100
+ function shapeIsValid(words) {
101
+ if (!Array.isArray(words) || words.length !== PHRASE_WORDS)
102
+ return false;
103
+ return words.every((word) => typeof word === "string" && word.trim().length > 0);
104
+ }
105
+ function generatePhrase() {
106
+ const entropy = randomBytes(ENTROPY_BYTES);
107
+ try {
108
+ const words = entropyToMnemonic(entropy, wordlist).split(" ");
109
+ if (words.length !== PHRASE_WORDS) {
110
+ throw new Error("phrase: wordlist produced an unexpected phrase length");
111
+ }
112
+ return words;
113
+ } finally {
114
+ entropy.fill(0);
115
+ }
116
+ }
117
+ function validatePhrase(words) {
118
+ if (!shapeIsValid(words))
119
+ return false;
120
+ return validateMnemonic(canonical(words), wordlist);
121
+ }
122
+ function newSalt() {
123
+ return Uint8Array.from(randomBytes(PHRASE_SALT_BYTES));
124
+ }
125
+ function assertSalt(salt) {
126
+ if (!(salt instanceof Uint8Array) || salt.length < PHRASE_SALT_BYTES) {
127
+ throw new Error(`phrase: salt must be at least ${PHRASE_SALT_BYTES} bytes`);
128
+ }
129
+ }
130
+ function bip39Seed(words) {
131
+ if (!validatePhrase(words))
132
+ throw new Error("INVALID_PHRASE");
133
+ return mnemonicToSeedSync(canonical(words));
134
+ }
135
+ function phraseToKey(words, salt) {
136
+ assertSalt(salt);
137
+ const seed = bip39Seed(words);
138
+ try {
139
+ return hkdf2(sha2562, seed, salt, ENCODER.encode(KEY_INFO), KEY_BYTES2);
140
+ } finally {
141
+ seed.fill(0);
142
+ }
143
+ }
144
+ function phraseVerifier(words, salt) {
145
+ assertSalt(salt);
146
+ const seed = bip39Seed(words);
147
+ try {
148
+ return toHex(hkdf2(sha2562, seed, salt, ENCODER.encode(VERIFIER_INFO), KEY_BYTES2));
149
+ } finally {
150
+ seed.fill(0);
151
+ }
152
+ }
153
+
154
+ // src/custody-share.ts
155
+ var KEY_BYTES3 = 32;
156
+ var MIN_PRF_BYTES = 32;
157
+ var PASSKEY_KEY_SALT = "forgezero:custodian:passkey:v1";
158
+ var utf82 = (value) => new TextEncoder().encode(value);
159
+ var toBase642 = (bytes) => {
160
+ let binary = "";
161
+ for (const byte of bytes)
162
+ binary += String.fromCharCode(byte);
163
+ return btoa(binary);
164
+ };
165
+ var fromBase642 = (value) => {
166
+ const binary = atob(value);
167
+ const bytes = new Uint8Array(binary.length);
168
+ for (let index = 0;index < binary.length; index += 1)
169
+ bytes[index] = binary.charCodeAt(index);
170
+ return bytes;
171
+ };
172
+ function passkeyKey(custodianKey, prfOutput) {
173
+ if (!(prfOutput instanceof Uint8Array) || prfOutput.length < MIN_PRF_BYTES) {
174
+ throw new Error(`custody-share: PRF output must be at least ${MIN_PRF_BYTES} bytes`);
175
+ }
176
+ const info = `forgezero:custodian:passkey-key:v1:${custodianKey.length}:${custodianKey}`;
177
+ return deriveKey(prfOutput, utf82(PASSKEY_KEY_SALT), info, KEY_BYTES3);
178
+ }
179
+ function shareAad(custodianKey, kind, shareIndex) {
180
+ return `forgezero:share:v1:${kind}:${shareIndex}:${custodianKey.length}:${custodianKey}`;
181
+ }
182
+ function assertCustodianKey(custodianKey) {
183
+ if (typeof custodianKey !== "string" || custodianKey.length === 0) {
184
+ throw new Error("custody-share: custodianKey must be a non-empty string");
185
+ }
186
+ }
187
+ function shareIndexOf(share) {
188
+ if (share.length < 2)
189
+ throw new Error("custody-share: malformed share");
190
+ const x = share[0];
191
+ if (x < 1)
192
+ throw new Error("custody-share: share index 0 is reserved");
193
+ return x;
194
+ }
195
+ function sealShare(args) {
196
+ const { share, custodianKey, passkeyPrfOutput, phraseWords } = args;
197
+ assertCustodianKey(custodianKey);
198
+ const shareIndex = shareIndexOf(share);
199
+ if (!validatePhrase(phraseWords))
200
+ throw new Error("INVALID_PHRASE");
201
+ const salt = newSalt();
202
+ const keyFromPasskey = passkeyKey(custodianKey, passkeyPrfOutput);
203
+ const keyFromPhrase = phraseToKey(phraseWords, salt);
204
+ try {
205
+ return {
206
+ shareIndex,
207
+ passkeyEnvelope: sealWithKey(keyFromPasskey, share, shareAad(custodianKey, "passkey", shareIndex)),
208
+ phraseEnvelope: sealWithKey(keyFromPhrase, share, shareAad(custodianKey, "phrase", shareIndex)),
209
+ phraseSalt: toBase642(salt),
210
+ phraseVerifier: phraseVerifier(phraseWords, salt)
211
+ };
212
+ } finally {
213
+ keyFromPasskey.fill(0);
214
+ keyFromPhrase.fill(0);
215
+ }
216
+ }
217
+ function openShareWithPasskey(sealed, custodianKey, passkeyPrfOutput) {
218
+ assertCustodianKey(custodianKey);
219
+ const key = passkeyKey(custodianKey, passkeyPrfOutput);
220
+ try {
221
+ return openWithKey(key, sealed.passkeyEnvelope, shareAad(custodianKey, "passkey", sealed.shareIndex));
222
+ } finally {
223
+ key.fill(0);
224
+ }
225
+ }
226
+ function openShareWithPhrase(sealed, custodianKey, phraseWords) {
227
+ assertCustodianKey(custodianKey);
228
+ if (!validatePhrase(phraseWords))
229
+ throw new Error("INVALID_PHRASE");
230
+ const salt = fromBase642(sealed.phraseSalt);
231
+ const key = phraseToKey(phraseWords, salt);
232
+ try {
233
+ return openWithKey(key, sealed.phraseEnvelope, shareAad(custodianKey, "phrase", sealed.shareIndex));
234
+ } finally {
235
+ key.fill(0);
236
+ }
237
+ }
238
+ var wrapInfo = (custodianKey, kind) => `forgezero:custody:wrap:v1:${kind}:${custodianKey.length}:${custodianKey}`;
239
+ function passkeyWrappingKey(custodianKey, passkeyPrfOutput) {
240
+ assertCustodianKey(custodianKey);
241
+ return wrappingKeyPair(passkeyKey(custodianKey, passkeyPrfOutput), wrapInfo(custodianKey, "passkey"));
242
+ }
243
+ function phraseWrappingKey(custodianKey, phraseWords, salt) {
244
+ assertCustodianKey(custodianKey);
245
+ if (!validatePhrase(phraseWords))
246
+ throw new Error("INVALID_PHRASE");
247
+ return wrappingKeyPair(phraseToKey(phraseWords, salt), wrapInfo(custodianKey, "phrase"));
248
+ }
249
+ function wrappingKeysFor(args) {
250
+ const salt = newSalt();
251
+ const passkey = passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput);
252
+ const phrase = phraseWrappingKey(args.custodianKey, args.phraseWords, salt);
253
+ try {
254
+ return {
255
+ passkeyPublicKey: toBase642(passkey.publicKey),
256
+ phrasePublicKey: toBase642(phrase.publicKey),
257
+ phraseSalt: toBase642(salt),
258
+ phraseVerifier: phraseVerifier(args.phraseWords, salt)
259
+ };
260
+ } finally {
261
+ passkey.secretKey.fill(0);
262
+ phrase.secretKey.fill(0);
263
+ }
264
+ }
265
+ function openSealedToFactor(args) {
266
+ const pair = args.factor === "passkey" ? passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput) : phraseWrappingKey(args.custodianKey, args.phraseWords, fromBase642(args.phraseSalt));
267
+ try {
268
+ return openFromKey(pair.secretKey, args.box, wrapInfo(args.custodianKey, args.factor));
269
+ } finally {
270
+ pair.secretKey.fill(0);
271
+ }
272
+ }
273
+ var factorWrapAad = (custodianKey, factor) => wrapInfo(custodianKey, factor);
274
+ var PROBE_BYTES = 32;
275
+ function joinProbeAndShare(probe, share) {
276
+ if (probe.length !== PROBE_BYTES)
277
+ throw new Error("custody-share: probe must be 32 bytes");
278
+ const out = new Uint8Array(probe.length + share.length);
279
+ out.set(probe, 0);
280
+ out.set(share, probe.length);
281
+ return out;
282
+ }
283
+ function splitProbeAndShare(opened) {
284
+ if (opened.length <= PROBE_BYTES)
285
+ throw new Error("custody-share: envelope is too short");
286
+ return { probe: opened.slice(0, PROBE_BYTES), share: opened.slice(PROBE_BYTES) };
287
+ }
288
+ function openFactorEnvelope(args) {
289
+ const box = args.factor === "passkey" ? args.sealed.passkeyEnvelope : args.sealed.phraseEnvelope;
290
+ return splitProbeAndShare(openSealedToFactor({
291
+ custodianKey: args.custodianKey,
292
+ factor: args.factor,
293
+ box,
294
+ passkeyPrfOutput: args.passkeyPrfOutput,
295
+ phraseWords: args.phraseWords,
296
+ phraseSalt: args.sealed.phraseSalt
297
+ }));
298
+ }
299
+ export {
300
+ wrappingKeysFor,
301
+ splitProbeAndShare,
302
+ shareIndexOf,
303
+ sealShare,
304
+ phraseWrappingKey,
305
+ passkeyWrappingKey,
306
+ openShareWithPhrase,
307
+ openShareWithPasskey,
308
+ openSealedToFactor,
309
+ openFactorEnvelope,
310
+ joinProbeAndShare,
311
+ factorWrapAad,
312
+ PROBE_BYTES
313
+ };
package/dist/jobs.d.ts CHANGED
@@ -15,11 +15,12 @@
15
15
  *
16
16
  * Each has a specific answer here: reschedule after completion rather than on
17
17
  * an interval, advance only on a fully successful batch, take a fenced lock,
18
- * and await in-flight work on stop.
18
+ * and submit every run through the common awaited queue, which drains on stop.
19
19
  *
20
20
  * Zero dependencies. The lock and the cursor store are interfaces, so this runs
21
21
  * against a database, Redis, or nothing at all in a test.
22
22
  */
23
+ import { type DrainReport } from './queue';
23
24
  /** Injected so a test does not sleep and a resumed run is reproducible. */
24
25
  export interface Clock {
25
26
  now(): number;
@@ -122,7 +123,7 @@ export declare function createScheduler(options: SchedulerOptions): {
122
123
  * leaves a record half-written — the process exits mid-transaction and the
123
124
  * next boot finds state nothing can explain.
124
125
  */
125
- stop(): Promise<void>;
126
+ stop(deadlineMs?: number): Promise<DrainReport>;
126
127
  /** Timers stop firing; in-flight work is left to finish. */
127
128
  pause(): void;
128
129
  resume(): void;
package/dist/jobs.js CHANGED
@@ -6,6 +6,275 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  throw Error('Dynamic require of "' + x + '" is not supported');
7
7
  });
8
8
 
9
+ // src/queue.ts
10
+ class QueueStoppedError extends Error {
11
+ constructor() {
12
+ super("queue: stopped before this task could run");
13
+ this.name = "QueueStoppedError";
14
+ }
15
+ }
16
+
17
+ class QueueKeyStoppedError extends Error {
18
+ key;
19
+ constructor(key) {
20
+ super(`queue: key ${key} is stopped`);
21
+ this.key = key;
22
+ this.name = "QueueKeyStoppedError";
23
+ }
24
+ }
25
+
26
+ class TaskCancelledError extends Error {
27
+ constructor() {
28
+ super("queue: task cancelled");
29
+ this.name = "TaskCancelledError";
30
+ }
31
+ }
32
+ var DEFAULT_RETRY = {
33
+ attempts: 1,
34
+ backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
+ };
36
+ function createQueue(options = {}) {
37
+ const width = options.width ?? 8;
38
+ const retry = { ...DEFAULT_RETRY, ...options.retry };
39
+ if (!Number.isSafeInteger(width) || width < 1) {
40
+ throw new RangeError("queue: width must be a positive integer");
41
+ }
42
+ if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
43
+ throw new RangeError("queue: retry attempts must be a positive integer");
44
+ }
45
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
46
+ const lanes = new Map;
47
+ const running = new Set;
48
+ const paused = new Set;
49
+ const stoppedKeys = new Set;
50
+ let sequence = 0;
51
+ let globallyPaused = false;
52
+ let accepting = true;
53
+ let aborted = false;
54
+ let completed = 0;
55
+ let failed = 0;
56
+ const idle = [];
57
+ const announceIdle = () => {
58
+ if (running.size > 0)
59
+ return;
60
+ for (const lane of lanes.values())
61
+ if (lane.length > 0)
62
+ return;
63
+ while (idle.length > 0)
64
+ idle.shift()();
65
+ };
66
+ function pump() {
67
+ if (globallyPaused || aborted) {
68
+ announceIdle();
69
+ return;
70
+ }
71
+ for (const [key, lane] of lanes) {
72
+ if (running.size >= width)
73
+ break;
74
+ if (running.has(key) || paused.has(key) || lane.length === 0)
75
+ continue;
76
+ execute(key);
77
+ }
78
+ announceIdle();
79
+ }
80
+ async function execute(key) {
81
+ running.add(key);
82
+ try {
83
+ const lane = lanes.get(key);
84
+ const entry = lane?.[0];
85
+ if (entry && !globallyPaused && !paused.has(key) && !aborted) {
86
+ lane.shift();
87
+ await attempt(entry);
88
+ }
89
+ } finally {
90
+ running.delete(key);
91
+ const remaining = lanes.get(key);
92
+ if (remaining?.length === 0)
93
+ lanes.delete(key);
94
+ else if (remaining) {
95
+ lanes.delete(key);
96
+ lanes.set(key, remaining);
97
+ }
98
+ pump();
99
+ }
100
+ }
101
+ async function attempt(entry) {
102
+ if (entry.cancelled) {
103
+ entry.reject(new TaskCancelledError);
104
+ return;
105
+ }
106
+ for (;; ) {
107
+ entry.attempt += 1;
108
+ try {
109
+ const value = await entry.run();
110
+ completed += 1;
111
+ entry.resolve(value);
112
+ return;
113
+ } catch (cause) {
114
+ if (entry.attempt >= retry.attempts || entry.cancelled || aborted) {
115
+ failed += 1;
116
+ entry.reject(cause);
117
+ return;
118
+ }
119
+ const delay = retry.backoffMs(entry.attempt);
120
+ if (!Number.isFinite(delay) || delay < 0) {
121
+ failed += 1;
122
+ entry.reject(new RangeError("queue: retry backoff must be a non-negative finite number"));
123
+ return;
124
+ }
125
+ await sleep(delay);
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ run(key, handler, ...args) {
131
+ const id = `q_${++sequence}`;
132
+ if (!accepting || stoppedKeys.has(key)) {
133
+ const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
134
+ refused.catch(() => {
135
+ return;
136
+ });
137
+ return { id, key, result: refused };
138
+ }
139
+ let resolve;
140
+ let reject;
141
+ const result = new Promise((ok, no) => {
142
+ resolve = ok;
143
+ reject = no;
144
+ });
145
+ const entry = {
146
+ id,
147
+ key,
148
+ run: async () => handler(...args),
149
+ resolve,
150
+ reject,
151
+ attempt: 0,
152
+ cancelled: false
153
+ };
154
+ const lane = lanes.get(key);
155
+ if (lane)
156
+ lane.push(entry);
157
+ else
158
+ lanes.set(key, [entry]);
159
+ pump();
160
+ return { id, key, result };
161
+ },
162
+ cancel(id) {
163
+ for (const [key, lane] of lanes) {
164
+ const index = lane.findIndex((entry2) => entry2.id === id);
165
+ if (index === -1)
166
+ continue;
167
+ const [entry] = lane.splice(index, 1);
168
+ entry.cancelled = true;
169
+ entry.reject(new TaskCancelledError);
170
+ if (lane.length === 0 && !running.has(key))
171
+ lanes.delete(key);
172
+ announceIdle();
173
+ return true;
174
+ }
175
+ return false;
176
+ },
177
+ pauseKey(key) {
178
+ paused.add(key);
179
+ },
180
+ resumeKey(key) {
181
+ paused.delete(key);
182
+ pump();
183
+ },
184
+ stopKey(key) {
185
+ stoppedKeys.add(key);
186
+ paused.delete(key);
187
+ const lane = lanes.get(key);
188
+ if (!lane)
189
+ return 0;
190
+ let removed = 0;
191
+ for (const entry of lane.splice(0)) {
192
+ removed += 1;
193
+ entry.cancelled = true;
194
+ entry.reject(new QueueKeyStoppedError(key));
195
+ }
196
+ if (!running.has(key))
197
+ lanes.delete(key);
198
+ announceIdle();
199
+ return removed;
200
+ },
201
+ startKey(key) {
202
+ const changed = stoppedKeys.delete(key);
203
+ pump();
204
+ return changed;
205
+ },
206
+ pause() {
207
+ globallyPaused = true;
208
+ },
209
+ resume() {
210
+ globallyPaused = false;
211
+ pump();
212
+ },
213
+ snapshot() {
214
+ let queued = 0;
215
+ for (const lane of lanes.values())
216
+ queued += lane.length;
217
+ return {
218
+ running: running.size,
219
+ queued,
220
+ keys: lanes.size,
221
+ paused: globallyPaused,
222
+ pausedKeys: [...paused],
223
+ stoppedKeys: [...stoppedKeys],
224
+ completed,
225
+ failed
226
+ };
227
+ },
228
+ whenIdle() {
229
+ if (running.size === 0 && [...lanes.values()].every((lane) => lane.length === 0)) {
230
+ return Promise.resolve();
231
+ }
232
+ return new Promise((resolve) => idle.push(resolve));
233
+ },
234
+ async stop(deadlineMs = 30000) {
235
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0) {
236
+ throw new RangeError("queue: stop deadline must be a non-negative integer");
237
+ }
238
+ accepting = false;
239
+ const before = { completed, failed };
240
+ globallyPaused = false;
241
+ paused.clear();
242
+ pump();
243
+ let timedOut = false;
244
+ let deadlineHandle;
245
+ const deadline = new Promise((resolve) => {
246
+ deadlineHandle = setTimeout(() => {
247
+ timedOut = true;
248
+ resolve();
249
+ }, deadlineMs);
250
+ });
251
+ await Promise.race([this.whenIdle(), deadline]);
252
+ if (!timedOut && deadlineHandle !== undefined)
253
+ clearTimeout(deadlineHandle);
254
+ if (timedOut)
255
+ aborted = true;
256
+ let abandoned = 0;
257
+ for (const lane of lanes.values()) {
258
+ abandoned += lane.length;
259
+ for (const entry of lane.splice(0))
260
+ entry.reject(new QueueStoppedError);
261
+ }
262
+ abandoned += running.size;
263
+ for (const [key, lane] of lanes) {
264
+ if (lane.length === 0 && !running.has(key))
265
+ lanes.delete(key);
266
+ }
267
+ announceIdle();
268
+ return {
269
+ completed: completed - before.completed,
270
+ failed: failed - before.failed,
271
+ abandoned,
272
+ timedOut
273
+ };
274
+ }
275
+ };
276
+ }
277
+
9
278
  // src/jobs.ts
10
279
  var systemClock = {
11
280
  now: () => Date.now(),
@@ -83,10 +352,13 @@ function createScheduler(options) {
83
352
  { key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0 }
84
353
  ]));
85
354
  const timers = new Map;
86
- const inFlight = new Map;
355
+ let work = createQueue({ width: Math.max(1, jobs.size) });
356
+ let workStopped = false;
357
+ let restartBlocked = false;
87
358
  let controller = new AbortController;
88
359
  let paused = false;
89
360
  let running = false;
361
+ const submit = (job) => work.run(job.key, execute, job).result;
90
362
  async function execute(job) {
91
363
  const report = reports.get(job.key);
92
364
  const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
@@ -132,17 +404,22 @@ function createScheduler(options) {
132
404
  timers.delete(job.key);
133
405
  if (!running || paused)
134
406
  return;
135
- const work = execute(job).finally(() => {
136
- inFlight.delete(job.key);
137
- schedule(job, everyMs(job.every));
407
+ submit(job).then(() => schedule(job, everyMs(job.every)), () => {
408
+ return;
138
409
  });
139
- inFlight.set(job.key, work);
140
410
  }, delayMs));
141
411
  }
142
412
  return {
143
413
  start() {
144
414
  if (running)
145
415
  return;
416
+ if (restartBlocked) {
417
+ throw new Error("scheduler: cannot restart after an incomplete drain while abandoned work may still run");
418
+ }
419
+ if (workStopped) {
420
+ work = createQueue({ width: Math.max(1, jobs.size) });
421
+ workStopped = false;
422
+ }
146
423
  running = true;
147
424
  paused = false;
148
425
  controller = new AbortController;
@@ -152,15 +429,18 @@ function createScheduler(options) {
152
429
  schedule(job, job.startDelayMs ?? spreadOf(job.key, Math.min(interval, 30000)));
153
430
  }
154
431
  },
155
- async stop() {
432
+ async stop(deadlineMs = 30000) {
156
433
  running = false;
157
434
  controller.abort();
158
435
  for (const timer of timers.values())
159
436
  clearTimeout(timer);
160
437
  timers.clear();
161
- await Promise.allSettled([...inFlight.values()]);
438
+ const drained = await work.stop(deadlineMs);
439
+ workStopped = true;
440
+ restartBlocked = drained.timedOut;
162
441
  for (const report of reports.values())
163
442
  report.state = "stopped";
443
+ return drained;
164
444
  },
165
445
  pause() {
166
446
  paused = true;
@@ -185,12 +465,7 @@ function createScheduler(options) {
185
465
  const job = jobs.get(key);
186
466
  if (!job)
187
467
  throw new Error(`No job named "${key}".`);
188
- const existing = inFlight.get(key);
189
- if (existing)
190
- await existing;
191
- const work = execute(job).finally(() => inFlight.delete(key));
192
- inFlight.set(key, work);
193
- await work;
468
+ await submit(job);
194
469
  return { ...reports.get(key) };
195
470
  },
196
471
  status: () => [...reports.values()].map((report) => ({ ...report })),