@forgezero/runtime 0.1.1 → 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/README.md CHANGED
@@ -5,7 +5,7 @@ themselves, keyed queues, a transactional outbox, a hash-chained audit trail,
5
5
  templated mail, encrypted backups, schema validation, and money that is never a
6
6
  floating-point number.
7
7
 
8
- Twenty-six modules. Each is its own entry point, so you install one package and
8
+ Thirty-three public modules. Each is its own entry point, so you install one package and
9
9
  your bundler includes only what you imported.
10
10
 
11
11
  ```bash
@@ -24,7 +24,7 @@ import { createScheduler } from '@forgezero/runtime/jobs';
24
24
 
25
25
  | | |
26
26
  |---|---|
27
- | **Spine** | `jobs` `queue` `serial` `outbox` |
27
+ | **Spine** | `jobs` `queue` `outbox` |
28
28
  | **Record** | `audit` `backup` `compliance` `calendar` |
29
29
  | **Identity** | `identity` `totp` `notify` `notify/templates` `schema` `schema/typebox` |
30
30
  | **Finance** | `finance/money` `finance/storage` `finance/ledger` `finance/commission` `finance/rates` `finance/transfers` `finance/tax` `finance/chain` `finance/custody` `finance/derive` `finance/venues` `finance/binance` |
@@ -32,6 +32,29 @@ import { createScheduler } from '@forgezero/runtime/jobs';
32
32
  None of it knows what your product does. You bind your own rules to these; you
33
33
  do not fork them.
34
34
 
35
+ ## One reusable async queue
36
+
37
+ `queue` is deliberately memory-only: it accepts a function, returns that
38
+ function's value through a Promise, runs one key sequentially, and runs
39
+ different keys in parallel. Persistence and cluster ownership remain in the
40
+ business layer that knows what a pending request means; ForgeZero uses an
41
+ ArangoDB unique claim, while another caller may use any database or no database.
42
+
43
+ ```ts
44
+ import { createQueue } from '@forgezero/runtime/queue';
45
+
46
+ const queue = createQueue({ width: 8 });
47
+ const task = queue.run('tenant-a:wallet-7', transfer, amount, destination);
48
+ const receipt = await task.result;
49
+
50
+ queue.pauseKey('tenant-a:wallet-7');
51
+ queue.resumeKey('tenant-a:wallet-7');
52
+ queue.stopKey('tenant-a:wallet-7'); // running finishes; pending is rejected
53
+ queue.startKey('tenant-a:wallet-7');
54
+ queue.cancel(task.id); // pending task only
55
+ await queue.stop(30_000); // close intake and drain all work
56
+ ```
57
+
35
58
  ## Three things worth knowing before you use it
36
59
 
37
60
  **Money is never a number.** An amount is minor units as a `bigint` with its
@@ -81,9 +104,9 @@ node_modules/@forgezero/runtime/contracts/src/
81
104
  ColdVault.sol M-of-N EIP-712, signatures ordered by ascending signer
82
105
  ```
83
106
 
84
- Full documentation: **https://forgezero.net/docs/runtime**
107
+ Full documentation: **https://www.forgezero.net/docs/runtime**
85
108
 
86
109
  ## Licence
87
110
 
88
- MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
111
+ MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
89
112
  deploys — and usable entirely on its own, with no ForgeZero account.
package/dist/audit.js CHANGED
@@ -6,32 +6,271 @@ 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/serial.ts
10
- function createKeyedSerial(options = {}) {
11
- const maxKeys = options.maxKeys ?? 1e4;
12
- const chains = new Map;
13
- return {
14
- run(key, work) {
15
- const previous = chains.get(key) ?? Promise.resolve();
16
- const next = previous.then(work, work);
17
- const settled = next.then(() => {
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)
18
62
  return;
19
- }, () => {
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);
20
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;
21
144
  });
22
- chains.set(key, settled);
23
- settled.then(() => {
24
- if (chains.get(key) === settled)
25
- chains.delete(key);
26
- });
27
- if (chains.size > maxKeys) {
28
- console.warn(`[serial] ${chains.size} keys in flight, above the ${maxKeys} guideline.`);
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();
29
231
  }
30
- return next;
232
+ return new Promise((resolve) => idle.push(resolve));
31
233
  },
32
- size: () => chains.size,
33
- drain: async () => {
34
- await Promise.allSettled([...chains.values()]);
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
+ };
35
274
  }
36
275
  };
37
276
  }
@@ -201,8 +440,8 @@ async function verifyExport(slice, options = {}) {
201
440
  }
202
441
  function createAuditChain(options) {
203
442
  const digester = options.digester ?? hashDigester;
204
- const serial = createKeyedSerial();
205
- const enqueue = (realm, run) => serial.run(realm ?? "\x00platform", run);
443
+ const queue = createQueue();
444
+ const enqueue = (realm, run) => queue.run(realm ?? "\x00platform", run).result;
206
445
  return {
207
446
  append: (entry) => enqueue(entry.realm, () => appendRecord(options.store, entry, { ...options, digester })),
208
447
  verify: async (realm, fromSequence = 1, toSequence = Number.MAX_SAFE_INTEGER) => {
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 })),
@@ -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
@@ -44,9 +44,8 @@ export interface QueueOptions {
44
44
  /** How many keys may run at once. Ordering within a key is unaffected. */
45
45
  width?: number;
46
46
  retry?: Partial<RetryPolicy>;
47
- /** Injected so tests need no timers and no wall clock. */
47
+ /** Retry delay injection. Shutdown deadlines use a cancellable native timer. */
48
48
  sleep?: (ms: number) => Promise<void>;
49
- now?: () => number;
50
49
  }
51
50
  export interface DrainReport {
52
51
  completed: number;
@@ -58,6 +57,10 @@ export interface DrainReport {
58
57
  export declare class QueueStoppedError extends Error {
59
58
  constructor();
60
59
  }
60
+ export declare class QueueKeyStoppedError extends Error {
61
+ readonly key: string;
62
+ constructor(key: string);
63
+ }
61
64
  export declare class TaskCancelledError extends Error {
62
65
  constructor();
63
66
  }
@@ -69,12 +72,22 @@ export declare function createQueue(options?: QueueOptions): {
69
72
  * rather than an id to poll — the previous `enqueue` returned only
70
73
  * `{ id, duplicate }` and had nowhere to put an answer.
71
74
  */
72
- run<T>(key: string, handler: () => Promise<T> | T): QueueTask<T>;
75
+ run<Args extends unknown[], T>(key: string, handler: (...args: Args) => Promise<T> | T, ...args: Args): QueueTask<T>;
73
76
  /** Remove a task that has not started. Running work is left alone. */
74
77
  cancel(id: string): boolean;
75
78
  /** Hold one key. Work already running for it finishes. */
76
79
  pauseKey(key: string): void;
77
80
  resumeKey(key: string): void;
81
+ /**
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.
87
+ */
88
+ stopKey(key: string): number;
89
+ /** Re-open a key deliberately; pausing and stopping are not aliases. */
90
+ startKey(key: string): boolean;
78
91
  /** Hold everything. New submissions are accepted and wait. */
79
92
  pause(): void;
80
93
  resume(): void;
@@ -85,6 +98,7 @@ export declare function createQueue(options?: QueueOptions): {
85
98
  keys: number;
86
99
  paused: boolean;
87
100
  pausedKeys: string[];
101
+ stoppedKeys: string[];
88
102
  completed: number;
89
103
  failed: number;
90
104
  };
package/dist/queue.js CHANGED
@@ -14,6 +14,15 @@ class QueueStoppedError extends Error {
14
14
  }
15
15
  }
16
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
+
17
26
  class TaskCancelledError extends Error {
18
27
  constructor() {
19
28
  super("queue: task cancelled");
@@ -25,12 +34,19 @@ var DEFAULT_RETRY = {
25
34
  backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
26
35
  };
27
36
  function createQueue(options = {}) {
28
- const width = Math.max(1, options.width ?? 8);
37
+ const width = options.width ?? 8;
29
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
+ }
30
45
  const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
31
46
  const lanes = new Map;
32
47
  const running = new Set;
33
48
  const paused = new Set;
49
+ const stoppedKeys = new Set;
34
50
  let sequence = 0;
35
51
  let globallyPaused = false;
36
52
  let accepting = true;
@@ -48,8 +64,10 @@ function createQueue(options = {}) {
48
64
  idle.shift()();
49
65
  };
50
66
  function pump() {
51
- if (globallyPaused || aborted)
67
+ if (globallyPaused || aborted) {
68
+ announceIdle();
52
69
  return;
70
+ }
53
71
  for (const [key, lane] of lanes) {
54
72
  if (running.size >= width)
55
73
  break;
@@ -62,18 +80,21 @@ function createQueue(options = {}) {
62
80
  async function execute(key) {
63
81
  running.add(key);
64
82
  try {
65
- for (;; ) {
66
- const lane = lanes.get(key);
67
- const entry = lane?.[0];
68
- if (!entry || globallyPaused || paused.has(key) || aborted)
69
- break;
83
+ const lane = lanes.get(key);
84
+ const entry = lane?.[0];
85
+ if (entry && !globallyPaused && !paused.has(key) && !aborted) {
70
86
  lane.shift();
71
87
  await attempt(entry);
72
88
  }
73
89
  } finally {
74
90
  running.delete(key);
75
- if (lanes.get(key)?.length === 0)
91
+ const remaining = lanes.get(key);
92
+ if (remaining?.length === 0)
76
93
  lanes.delete(key);
94
+ else if (remaining) {
95
+ lanes.delete(key);
96
+ lanes.set(key, remaining);
97
+ }
77
98
  pump();
78
99
  }
79
100
  }
@@ -95,15 +116,21 @@ function createQueue(options = {}) {
95
116
  entry.reject(cause);
96
117
  return;
97
118
  }
98
- await sleep(retry.backoffMs(entry.attempt));
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);
99
126
  }
100
127
  }
101
128
  }
102
129
  return {
103
- run(key, handler) {
130
+ run(key, handler, ...args) {
104
131
  const id = `q_${++sequence}`;
105
- if (!accepting) {
106
- const refused = Promise.reject(new QueueStoppedError);
132
+ if (!accepting || stoppedKeys.has(key)) {
133
+ const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
107
134
  refused.catch(() => {
108
135
  return;
109
136
  });
@@ -118,7 +145,7 @@ function createQueue(options = {}) {
118
145
  const entry = {
119
146
  id,
120
147
  key,
121
- run: async () => handler(),
148
+ run: async () => handler(...args),
122
149
  resolve,
123
150
  reject,
124
151
  attempt: 0,
@@ -142,6 +169,7 @@ function createQueue(options = {}) {
142
169
  entry.reject(new TaskCancelledError);
143
170
  if (lane.length === 0 && !running.has(key))
144
171
  lanes.delete(key);
172
+ announceIdle();
145
173
  return true;
146
174
  }
147
175
  return false;
@@ -153,6 +181,28 @@ function createQueue(options = {}) {
153
181
  paused.delete(key);
154
182
  pump();
155
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
+ },
156
206
  pause() {
157
207
  globallyPaused = true;
158
208
  },
@@ -170,6 +220,7 @@ function createQueue(options = {}) {
170
220
  keys: lanes.size,
171
221
  paused: globallyPaused,
172
222
  pausedKeys: [...paused],
223
+ stoppedKeys: [...stoppedKeys],
173
224
  completed,
174
225
  failed
175
226
  };
@@ -181,15 +232,25 @@ function createQueue(options = {}) {
181
232
  return new Promise((resolve) => idle.push(resolve));
182
233
  },
183
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
+ }
184
238
  accepting = false;
185
239
  const before = { completed, failed };
240
+ globallyPaused = false;
241
+ paused.clear();
242
+ pump();
186
243
  let timedOut = false;
187
- await Promise.race([
188
- this.whenIdle(),
189
- sleep(deadlineMs).then(() => {
244
+ let deadlineHandle;
245
+ const deadline = new Promise((resolve) => {
246
+ deadlineHandle = setTimeout(() => {
190
247
  timedOut = true;
191
- })
192
- ]);
248
+ resolve();
249
+ }, deadlineMs);
250
+ });
251
+ await Promise.race([this.whenIdle(), deadline]);
252
+ if (!timedOut && deadlineHandle !== undefined)
253
+ clearTimeout(deadlineHandle);
193
254
  if (timedOut)
194
255
  aborted = true;
195
256
  let abandoned = 0;
@@ -199,6 +260,11 @@ function createQueue(options = {}) {
199
260
  entry.reject(new QueueStoppedError);
200
261
  }
201
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();
202
268
  return {
203
269
  completed: completed - before.completed,
204
270
  failed: failed - before.failed,
@@ -211,5 +277,6 @@ function createQueue(options = {}) {
211
277
  export {
212
278
  createQueue,
213
279
  TaskCancelledError,
214
- QueueStoppedError
280
+ QueueStoppedError,
281
+ QueueKeyStoppedError
215
282
  };
package/dist/snp.d.ts CHANGED
@@ -17,10 +17,9 @@
17
17
  * ## What this does NOT do
18
18
  *
19
19
  * It does not verify the signature. Chaining a report to AMD's root needs the
20
- * VCEK certificate for that specific chip at that specific TCB, fetched from
21
- * AMD's KDS a network dependency with its own failure modes, and a
22
- * hand-rolled ECDSA-P384 verification here would be worse than none because it
23
- * would produce `verified: true` for a report nobody signed.
20
+ * VCEK certificate for that specific chip at that specific TCB and is performed
21
+ * by the API's pinned KDS verifier. Keeping network trust out of this parser
22
+ * preserves a total byte-to-fields function.
24
23
  *
25
24
  * So `parseSnpReport` returns the signature bytes and says nothing about them.
26
25
  * The caller supplies a verifier, which is the same shape every other trust
@@ -29,8 +28,8 @@
29
28
  /** The structure is exactly this long. Anything else is not a report. */
30
29
  export declare const REPORT_BYTES = 1184;
31
30
  export declare class SnpError extends Error {
32
- readonly code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL';
33
- constructor(code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL', message: string);
31
+ readonly code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL' | 'BAD_SIGNATURE_ALGORITHM';
32
+ constructor(code: 'BAD_LENGTH' | 'BAD_VERSION' | 'BAD_VMPL' | 'BAD_SIGNATURE_ALGORITHM', message: string);
34
33
  }
35
34
  /**
36
35
  * Guest policy bits.
@@ -61,6 +60,8 @@ export interface SnpReport {
61
60
  guestSvn: number;
62
61
  policy: GuestPolicy;
63
62
  vmpl: number;
63
+ /** 1 is ECDSA P-384 with SHA-384. */
64
+ signatureAlgorithm: number;
64
65
  /** 48 bytes of hex. What the guest actually booted. */
65
66
  measurement: string;
66
67
  /** 64 bytes of hex. Whatever the guest asked the PSP to bind in — our nonce. */
package/dist/snp.js CHANGED
@@ -69,19 +69,24 @@ function parseSnpReport(bytes) {
69
69
  }
70
70
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
71
71
  const version = view.getUint32(OFFSET.version, true);
72
- if (version !== 2 && version !== 3) {
72
+ if (version !== 2 && version !== 3 && version !== 5) {
73
73
  throw new SnpError("BAD_VERSION", `Report version ${version} is not one this parser understands.`);
74
74
  }
75
75
  const vmpl = view.getUint32(OFFSET.vmpl, true);
76
76
  if (vmpl > 3) {
77
77
  throw new SnpError("BAD_VMPL", `VMPL ${vmpl} is outside the defined range.`);
78
78
  }
79
+ const signatureAlgorithm = view.getUint32(OFFSET.signatureAlgo, true);
80
+ if (signatureAlgorithm !== 1) {
81
+ throw new SnpError("BAD_SIGNATURE_ALGORITHM", `Signature algorithm ${signatureAlgorithm} is not ECDSA P-384 with SHA-384.`);
82
+ }
79
83
  const slice = (offset, length) => hex(bytes.subarray(offset, offset + length));
80
84
  return {
81
85
  version,
82
86
  guestSvn: view.getUint32(OFFSET.guestSvn, true),
83
87
  policy: readPolicy(view, OFFSET.policy),
84
88
  vmpl,
89
+ signatureAlgorithm,
85
90
  measurement: slice(OFFSET.measurement, 48),
86
91
  reportData: slice(OFFSET.reportData, 64),
87
92
  hostData: slice(OFFSET.hostData, 32),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/runtime",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -59,10 +59,6 @@
59
59
  "types": "./dist/otpauth.d.ts",
60
60
  "default": "./dist/otpauth.js"
61
61
  },
62
- "./serial": {
63
- "types": "./dist/serial.d.ts",
64
- "default": "./dist/serial.js"
65
- },
66
62
  "./pipeline": {
67
63
  "types": "./dist/pipeline.d.ts",
68
64
  "default": "./dist/pipeline.js"
@@ -182,7 +178,7 @@
182
178
  },
183
179
  "scripts": {
184
180
  "check": "tsc --noEmit",
185
- "build": "bun build src/jobs.ts src/queue.ts src/serial.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/ssh-agent.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
181
+ "build": "bun build src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/ssh-agent.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
186
182
  "prepublishOnly": "bun run check && bun run build"
187
183
  },
188
184
  "dependencies": {
@@ -241,7 +237,7 @@
241
237
  "working-days"
242
238
  ],
243
239
  "license": "MIT",
244
- "homepage": "https://forgezero.net/docs/runtime",
240
+ "homepage": "https://www.forgezero.net/docs/runtime",
245
241
  "repository": {
246
242
  "type": "git",
247
243
  "url": "git+https://github.com/axxra/forgezero.git",