@forgezero/runtime 0.1.3 → 0.1.5

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.
@@ -33,8 +33,8 @@ import { type Money } from './money';
33
33
  * must balance, so a split that loses a unit cannot be committed at all.
34
34
  */
35
35
  export declare class LedgerError extends Error {
36
- readonly code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE';
37
- constructor(code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE', message: string);
36
+ readonly code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'TOO_MANY_ENTRIES' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE';
37
+ constructor(code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'TOO_MANY_ENTRIES' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE', message: string);
38
38
  }
39
39
  /**
40
40
  * The sub-accounts every owner has.
@@ -96,6 +96,12 @@ export interface Transaction {
96
96
  atMs: number;
97
97
  memo?: string;
98
98
  }
99
+ /**
100
+ * A ledger transaction is one atomic business movement, not a batch transport.
101
+ * Keeping the posting set small bounds both the durable document and the
102
+ * multikey `accountIds[*]` index entry fan-out derived from it by the API.
103
+ */
104
+ export declare const MAX_LEDGER_ENTRIES = 64;
99
105
  /**
100
106
  * Every transaction sums to zero, per asset.
101
107
  *
@@ -168,10 +168,14 @@ function parseAccount(id) {
168
168
  return { kind, owner, bucket };
169
169
  }
170
170
  var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
171
+ var MAX_LEDGER_ENTRIES = 64;
171
172
  function assertBalanced(transaction) {
172
- if (transaction.entries.length === 0) {
173
+ if (transaction.entries.length < 2) {
173
174
  throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
174
175
  }
176
+ if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
177
+ throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
178
+ }
175
179
  const totals = new Map;
176
180
  for (const entry of transaction.entries) {
177
181
  if (entry.amount.units === 0n) {
@@ -302,6 +306,7 @@ export {
302
306
  assertAvailable,
303
307
  accountId,
304
308
  VERSION2 as VERSION,
309
+ MAX_LEDGER_ENTRIES,
305
310
  LedgerError,
306
311
  BUCKETS,
307
312
  ACCOUNT_KINDS
package/dist/jobs.d.ts CHANGED
@@ -20,7 +20,7 @@
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
+ import { type DrainReport, type QueueResourcePolicy } from './queue';
24
24
  /** Injected so a test does not sleep and a resumed run is reproducible. */
25
25
  export interface Clock {
26
26
  now(): number;
@@ -29,6 +29,20 @@ export interface Clock {
29
29
  export declare const systemClock: Clock;
30
30
  /** `30s` → 30000. Throws rather than guessing — a wrong interval is silent. */
31
31
  export declare function everyMs(interval: string | number): number;
32
+ export interface WallClockSchedule {
33
+ /** IANA timezone such as `Asia/Kolkata` or `UTC`. */
34
+ timezone: string;
35
+ /** Local wall time, including optional seconds: `HH:MM` or `HH:MM:SS`. */
36
+ time: string;
37
+ /** Optional local weekdays, Sunday=0 through Saturday=6. */
38
+ weekdays?: readonly number[];
39
+ }
40
+ /**
41
+ * Find the next occurrence strictly after `afterMs` in the requested timezone.
42
+ * Intl owns daylight-saving and historical offset rules; the bounded minute
43
+ * scan avoids implementing a second, inevitably-wrong timezone database here.
44
+ */
45
+ export declare function nextWallClockAt(schedule: WallClockSchedule, afterMs: number): number;
32
46
  /**
33
47
  * A lease, not a mutex.
34
48
  *
@@ -72,6 +86,8 @@ export interface JobContext {
72
86
  log(message: string, detail?: Record<string, unknown>): void;
73
87
  }
74
88
  export interface JobResult {
89
+ /** A resolved run may still report an operational failure without throwing. */
90
+ ok?: boolean;
75
91
  /** Anything worth showing on a status screen. Kept in the report verbatim. */
76
92
  [key: string]: unknown;
77
93
  }
@@ -80,6 +96,14 @@ export interface JobSpec {
80
96
  label: string;
81
97
  /** `30s`, `5m`, or milliseconds. Omit for a job only ever run by hand. */
82
98
  every?: string | number;
99
+ /** Local daily/weekly schedule. Mutually exclusive with `every`. */
100
+ schedule?: WallClockSchedule;
101
+ /**
102
+ * `wait` schedules the next occurrence after this run settles. `skip` keeps
103
+ * clock time and drops a tick when the previous run is still queued/running.
104
+ * Neither mode overlaps the same key.
105
+ */
106
+ overlap?: 'wait' | 'skip';
83
107
  run(context: JobContext): Promise<JobResult | void>;
84
108
  /**
85
109
  * Lease length. Defaults to four intervals, so a slow run is not evicted the
@@ -107,6 +131,8 @@ export interface JobReport {
107
131
  consecutiveFailures: number;
108
132
  runs: number;
109
133
  skippedLocked: number;
134
+ skippedOverlap: number;
135
+ nextRunAtMs?: number;
110
136
  }
111
137
  export interface SchedulerOptions {
112
138
  jobs: readonly JobSpec[];
@@ -115,6 +141,10 @@ export interface SchedulerOptions {
115
141
  /** Called on every failure. Wire to telemetry; must not throw. */
116
142
  onError?: (key: string, error: unknown) => void;
117
143
  onLog?: (key: string, message: string, detail?: Record<string, unknown>) => void;
144
+ /** Exact lane ceiling. Mutually exclusive with `resources`. */
145
+ width?: number;
146
+ /** Dynamic resource admission for different job keys. */
147
+ resources?: QueueResourcePolicy;
118
148
  }
119
149
  export declare function createScheduler(options: SchedulerOptions): {
120
150
  start(): void;
package/dist/jobs.js CHANGED
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
33
33
  attempts: 1,
34
34
  backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
35
  };
36
+ var reportedParallelism = () => {
37
+ const reported = globalThis.navigator?.hardwareConcurrency;
38
+ return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
39
+ };
40
+ function queueWidthFor(policy = {}) {
41
+ const percent = policy.percent ?? 60;
42
+ const reserve = policy.reserve ?? 1;
43
+ const min = policy.min ?? 1;
44
+ const max = policy.max ?? Number.MAX_SAFE_INTEGER;
45
+ const available = (policy.available ?? reportedParallelism)();
46
+ if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
47
+ throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
48
+ }
49
+ for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
50
+ if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
51
+ throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
52
+ }
53
+ }
54
+ if (!Number.isSafeInteger(available) || available < 1) {
55
+ throw new RangeError("queue: available parallelism must be a positive integer");
56
+ }
57
+ if (min > max)
58
+ throw new RangeError("queue: resource min cannot exceed max");
59
+ const usable = Math.max(1, available - reserve);
60
+ return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
61
+ }
36
62
  function createQueue(options = {}) {
37
- const width = options.width ?? 8;
63
+ if (options.width !== undefined && options.resources !== undefined) {
64
+ throw new Error("queue: choose either width or resources, not both");
65
+ }
66
+ const configuredWidth = options.width;
67
+ const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
68
+ const initialWidth = widthNow();
38
69
  const retry = { ...DEFAULT_RETRY, ...options.retry };
39
- if (!Number.isSafeInteger(width) || width < 1) {
70
+ if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
40
71
  throw new RangeError("queue: width must be a positive integer");
41
72
  }
42
73
  if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
68
99
  announceIdle();
69
100
  return;
70
101
  }
102
+ const width = widthNow();
71
103
  for (const [key, lane] of lanes) {
72
104
  if (running.size >= width)
73
105
  break;
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
215
247
  for (const lane of lanes.values())
216
248
  queued += lane.length;
217
249
  return {
250
+ width: widthNow(),
218
251
  running: running.size,
219
252
  queued,
220
253
  keys: lanes.size,
@@ -297,6 +330,53 @@ function everyMs(interval) {
297
330
  throw new Error(`"${interval}" is not an interval like 30s, 5m, 1h, 1d.`);
298
331
  return Number(match[1]) * UNITS[match[2]];
299
332
  }
333
+ var WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
334
+ function wallTime(schedule) {
335
+ const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(schedule.time);
336
+ if (!match)
337
+ throw new Error(`"${schedule.time}" is not a wall time like 09:30 or 09:30:15.`);
338
+ const hour = Number(match[1]);
339
+ const minute = Number(match[2]);
340
+ const second = Number(match[3] ?? 0);
341
+ if (hour > 23 || minute > 59 || second > 59)
342
+ throw new Error(`"${schedule.time}" is not a valid wall time.`);
343
+ return { hour, minute, second };
344
+ }
345
+ function nextWallClockAt(schedule, afterMs) {
346
+ if (!Number.isFinite(afterMs))
347
+ throw new RangeError("job: schedule start must be finite");
348
+ const target = wallTime(schedule);
349
+ const weekdays = schedule.weekdays ? new Set(schedule.weekdays) : undefined;
350
+ if (weekdays?.size === 0 || [...weekdays ?? []].some((day) => !Number.isSafeInteger(day) || day < 0 || day > 6)) {
351
+ throw new Error("job: weekdays must contain Sunday=0 through Saturday=6");
352
+ }
353
+ let formatter;
354
+ try {
355
+ formatter = new Intl.DateTimeFormat("en-US", {
356
+ timeZone: schedule.timezone,
357
+ hour: "2-digit",
358
+ minute: "2-digit",
359
+ second: "2-digit",
360
+ weekday: "short",
361
+ hourCycle: "h23"
362
+ });
363
+ } catch {
364
+ throw new Error(`job: unknown IANA timezone "${schedule.timezone}"`);
365
+ }
366
+ const minuteFloor = Math.floor(afterMs / 60000) * 60000;
367
+ let candidate = minuteFloor + target.second * 1000;
368
+ if (candidate <= afterMs)
369
+ candidate += 60000;
370
+ for (let checked = 0;checked < 8 * 24 * 60; checked += 1, candidate += 60000) {
371
+ const parts = Object.fromEntries(formatter.formatToParts(candidate).map((part) => [part.type, part.value]));
372
+ if (Number(parts.hour) !== target.hour || Number(parts.minute) !== target.minute || Number(parts.second) !== target.second)
373
+ continue;
374
+ const weekday = WEEKDAY[parts.weekday];
375
+ if (!weekdays || weekdays.has(weekday))
376
+ return candidate;
377
+ }
378
+ throw new Error("job: no matching wall-clock occurrence was found in the next eight days");
379
+ }
300
380
  function memoryLock(clock = systemClock) {
301
381
  const held = new Map;
302
382
  let fences = 0;
@@ -335,6 +415,10 @@ function defineJob(spec) {
335
415
  throw new Error("A job needs a key — it is the lock key and the report key.");
336
416
  if (spec.every !== undefined)
337
417
  everyMs(spec.every);
418
+ if (spec.every !== undefined && spec.schedule)
419
+ throw new Error("A job must choose either every or schedule.");
420
+ if (spec.schedule)
421
+ nextWallClockAt(spec.schedule, Date.now());
338
422
  return spec;
339
423
  }
340
424
  function spreadOf(key, ceiling) {
@@ -349,16 +433,29 @@ function createScheduler(options) {
349
433
  const jobs = new Map(options.jobs.map((job) => [job.key, job]));
350
434
  const reports = new Map(options.jobs.map((job) => [
351
435
  job.key,
352
- { key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0 }
436
+ { key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0, skippedOverlap: 0 }
353
437
  ]));
354
438
  const timers = new Map;
355
- let work = createQueue({ width: Math.max(1, jobs.size) });
439
+ const queueOptions = options.width !== undefined ? { width: options.width } : options.resources !== undefined ? { resources: options.resources } : { width: Math.max(1, jobs.size) };
440
+ let work = createQueue(queueOptions);
356
441
  let workStopped = false;
357
442
  let restartBlocked = false;
358
443
  let controller = new AbortController;
359
444
  let paused = false;
360
445
  let running = false;
361
- const submit = (job) => work.run(job.key, execute, job).result;
446
+ const outstanding = new Map;
447
+ const submit = async (job) => {
448
+ outstanding.set(job.key, (outstanding.get(job.key) ?? 0) + 1);
449
+ try {
450
+ await work.run(job.key, execute, job).result;
451
+ } finally {
452
+ const left = (outstanding.get(job.key) ?? 1) - 1;
453
+ if (left === 0)
454
+ outstanding.delete(job.key);
455
+ else
456
+ outstanding.set(job.key, left);
457
+ }
458
+ };
362
459
  async function execute(job) {
363
460
  const report = reports.get(job.key);
364
461
  const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
@@ -380,8 +477,15 @@ function createScheduler(options) {
380
477
  log: (message, detail) => options.onLog?.(job.key, message, detail)
381
478
  });
382
479
  report.lastResult = result ?? undefined;
383
- report.lastError = undefined;
384
- report.consecutiveFailures = 0;
480
+ if (result?.ok === false) {
481
+ const error = new Error("job returned ok=false");
482
+ report.lastError = error.message;
483
+ report.consecutiveFailures += 1;
484
+ options.onError?.(job.key, error);
485
+ } else {
486
+ report.lastError = undefined;
487
+ report.consecutiveFailures = 0;
488
+ }
385
489
  } catch (error) {
386
490
  report.lastError = error instanceof Error ? error.message : String(error);
387
491
  report.consecutiveFailures += 1;
@@ -397,17 +501,37 @@ function createScheduler(options) {
397
501
  });
398
502
  }
399
503
  }
504
+ function nextDelay(job) {
505
+ if (job.every !== undefined)
506
+ return everyMs(job.every);
507
+ if (job.schedule)
508
+ return Math.max(0, nextWallClockAt(job.schedule, clock.now()) - clock.now());
509
+ return;
510
+ }
400
511
  function schedule(job, delayMs) {
401
- if (!running || job.every === undefined)
512
+ const next = delayMs ?? nextDelay(job);
513
+ if (!running || next === undefined)
402
514
  return;
515
+ reports.get(job.key).nextRunAtMs = clock.now() + next;
403
516
  timers.set(job.key, setTimeout(() => {
404
517
  timers.delete(job.key);
405
518
  if (!running || paused)
406
519
  return;
407
- submit(job).then(() => schedule(job, everyMs(job.every)), () => {
520
+ if (job.overlap === "skip") {
521
+ schedule(job);
522
+ if ((outstanding.get(job.key) ?? 0) > 0) {
523
+ reports.get(job.key).skippedOverlap += 1;
524
+ return;
525
+ }
526
+ submit(job).catch(() => {
527
+ return;
528
+ });
529
+ return;
530
+ }
531
+ submit(job).then(() => schedule(job), () => {
408
532
  return;
409
533
  });
410
- }, delayMs));
534
+ }, next));
411
535
  }
412
536
  return {
413
537
  start() {
@@ -417,7 +541,7 @@ function createScheduler(options) {
417
541
  throw new Error("scheduler: cannot restart after an incomplete drain while abandoned work may still run");
418
542
  }
419
543
  if (workStopped) {
420
- work = createQueue({ width: Math.max(1, jobs.size) });
544
+ work = createQueue(queueOptions);
421
545
  workStopped = false;
422
546
  }
423
547
  running = true;
@@ -425,8 +549,9 @@ function createScheduler(options) {
425
549
  controller = new AbortController;
426
550
  for (const job of jobs.values()) {
427
551
  reports.get(job.key).state = "idle";
428
- const interval = job.every === undefined ? 0 : everyMs(job.every);
429
- schedule(job, job.startDelayMs ?? spreadOf(job.key, Math.min(interval, 30000)));
552
+ const interval = job.every === undefined ? undefined : everyMs(job.every);
553
+ const initial = job.startDelayMs ?? (interval === undefined ? undefined : spreadOf(job.key, Math.min(interval, 30000)));
554
+ schedule(job, initial);
430
555
  }
431
556
  },
432
557
  async stop(deadlineMs = 30000) {
@@ -440,6 +565,8 @@ function createScheduler(options) {
440
565
  restartBlocked = drained.timedOut;
441
566
  for (const report of reports.values())
442
567
  report.state = "stopped";
568
+ for (const report of reports.values())
569
+ report.nextRunAtMs = undefined;
443
570
  return drained;
444
571
  },
445
572
  pause() {
@@ -450,6 +577,7 @@ function createScheduler(options) {
450
577
  for (const report of reports.values()) {
451
578
  if (report.state !== "running")
452
579
  report.state = "paused";
580
+ report.nextRunAtMs = undefined;
453
581
  }
454
582
  },
455
583
  resume() {
@@ -458,7 +586,7 @@ function createScheduler(options) {
458
586
  paused = false;
459
587
  for (const job of jobs.values()) {
460
588
  reports.get(job.key).state = "idle";
461
- schedule(job, 0);
589
+ schedule(job, job.every !== undefined ? 0 : undefined);
462
590
  }
463
591
  },
464
592
  async runNow(key) {
@@ -483,6 +611,8 @@ function cursorJob(spec) {
483
611
  key: spec.key,
484
612
  label: spec.label,
485
613
  every: spec.every,
614
+ schedule: spec.schedule,
615
+ overlap: spec.overlap,
486
616
  leaseMs: spec.leaseMs,
487
617
  unlocked: spec.unlocked,
488
618
  startDelayMs: spec.startDelayMs,
@@ -516,6 +646,7 @@ var VERSION = "0.1.0";
516
646
  export {
517
647
  systemClock,
518
648
  storeLock,
649
+ nextWallClockAt,
519
650
  memoryLock,
520
651
  everyMs,
521
652
  defineJob,
@@ -0,0 +1,47 @@
1
+ /** Provider-neutral, function-based typed query contracts. */
2
+ export type QueryIssue = Readonly<{
3
+ path: string;
4
+ message: string;
5
+ }>;
6
+ export type QueryDecode<T> = Readonly<{
7
+ ok: true;
8
+ value: T;
9
+ }> | Readonly<{
10
+ ok: false;
11
+ issues: readonly QueryIssue[];
12
+ }>;
13
+ /** Inputs may coerce wire values; outputs must validate strictly. */
14
+ export interface QueryCodec<T> {
15
+ readonly schema?: unknown;
16
+ decode(value: unknown): QueryDecode<T>;
17
+ encode(value: unknown): QueryDecode<T>;
18
+ }
19
+ export interface QueryContract<Name extends string, Input, Output> {
20
+ readonly name: Name;
21
+ readonly input: QueryCodec<Input>;
22
+ readonly output: QueryCodec<Output>;
23
+ }
24
+ export declare class QueryContractError extends Error {
25
+ readonly query: string;
26
+ readonly phase: 'input' | 'output' | 'aborted';
27
+ readonly issues: readonly QueryIssue[];
28
+ constructor(query: string, phase: 'input' | 'output' | 'aborted', issues?: readonly QueryIssue[]);
29
+ }
30
+ export declare function defineQuery<const Name extends string, Input, Output>(definition: {
31
+ name: Name;
32
+ input: QueryCodec<Input>;
33
+ output: QueryCodec<Output>;
34
+ }): QueryContract<Name, Input, Output>;
35
+ export interface QueryExecution {
36
+ readonly signal: AbortSignal;
37
+ }
38
+ export interface QueryImplementation<Context, Output> {
39
+ execute(context: Context, input: unknown, options?: {
40
+ signal?: AbortSignal;
41
+ }): Promise<Output>;
42
+ }
43
+ export declare function implementQuery<Context, Name extends string, Input, Output>(contract: QueryContract<Name, Input, Output>, handler: (context: Context, input: Input, execution: QueryExecution) => Output | Promise<Output>): QueryImplementation<Context, Output> & {
44
+ readonly contract: typeof contract;
45
+ };
46
+ export type QueryInput<Q> = Q extends QueryContract<string, infer Input, unknown> ? Input : never;
47
+ export type QueryOutput<Q> = Q extends QueryContract<string, unknown, infer Output> ? Output : never;
package/dist/query.js ADDED
@@ -0,0 +1,51 @@
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/query.ts
10
+ class QueryContractError extends Error {
11
+ query;
12
+ phase;
13
+ issues;
14
+ constructor(query, phase, issues = []) {
15
+ super(`Query ${query} failed ${phase} validation`);
16
+ this.query = query;
17
+ this.phase = phase;
18
+ this.issues = issues;
19
+ this.name = "QueryContractError";
20
+ }
21
+ }
22
+ function defineQuery(definition) {
23
+ if (!definition.name.trim())
24
+ throw new Error("A query contract requires a stable name");
25
+ return Object.freeze({ ...definition });
26
+ }
27
+ function implementQuery(contract, handler) {
28
+ return {
29
+ contract,
30
+ async execute(context, rawInput, options = {}) {
31
+ const signal = options.signal ?? new AbortController().signal;
32
+ if (signal.aborted)
33
+ throw new QueryContractError(contract.name, "aborted");
34
+ const decoded = contract.input.decode(rawInput);
35
+ if (!decoded.ok)
36
+ throw new QueryContractError(contract.name, "input", decoded.issues);
37
+ const rawOutput = await handler(context, decoded.value, { signal });
38
+ if (signal.aborted)
39
+ throw new QueryContractError(contract.name, "aborted");
40
+ const encoded = contract.output.encode(rawOutput);
41
+ if (!encoded.ok)
42
+ throw new QueryContractError(contract.name, "output", encoded.issues);
43
+ return encoded.value;
44
+ }
45
+ };
46
+ }
47
+ export {
48
+ implementQuery,
49
+ defineQuery,
50
+ QueryContractError
51
+ };
package/dist/queue.d.ts CHANGED
@@ -43,10 +43,33 @@ export interface RetryPolicy {
43
43
  export interface QueueOptions {
44
44
  /** How many keys may run at once. Ordering within a key is unaffected. */
45
45
  width?: number;
46
+ /**
47
+ * Resource-aware admission. Mutually exclusive with `width`.
48
+ *
49
+ * This limits concurrent KEY lanes; it does not claim that an arbitrary
50
+ * JavaScript closure becomes CPU-parallel. Async I/O overlaps naturally.
51
+ * CPU-bound handlers should use Bun/standard Workers and await them here.
52
+ */
53
+ resources?: QueueResourcePolicy;
46
54
  retry?: Partial<RetryPolicy>;
47
55
  /** Retry delay injection. Shutdown deadlines use a cancellable native timer. */
48
56
  sleep?: (ms: number) => Promise<void>;
49
57
  }
58
+ export interface QueueResourcePolicy {
59
+ /** Percentage of the currently reported logical processors. Default 60. */
60
+ percent?: number;
61
+ /** Logical processors kept outside this queue. Default 1 when possible. */
62
+ reserve?: number;
63
+ /** Never admit fewer lanes than this. Default 1. */
64
+ min?: number;
65
+ /** Optional hard ceiling after percentage and reserve are applied. */
66
+ max?: number;
67
+ /**
68
+ * Re-read on every pump, so a container/runtime can expose a changing quota.
69
+ * The default uses `navigator.hardwareConcurrency` and safely falls back to 1.
70
+ */
71
+ available?: () => number;
72
+ }
50
73
  export interface DrainReport {
51
74
  completed: number;
52
75
  failed: number;
@@ -64,6 +87,8 @@ export declare class QueueKeyStoppedError extends Error {
64
87
  export declare class TaskCancelledError extends Error {
65
88
  constructor();
66
89
  }
90
+ /** Resolve a resource policy to a safe positive queue width. */
91
+ export declare function queueWidthFor(policy?: QueueResourcePolicy): number;
67
92
  export declare function createQueue(options?: QueueOptions): {
68
93
  /**
69
94
  * Submit work and await its value.
@@ -93,6 +118,7 @@ export declare function createQueue(options?: QueueOptions): {
93
118
  resume(): void;
94
119
  /** How much is outstanding, for a health endpoint or a drain decision. */
95
120
  snapshot(): {
121
+ width: number;
96
122
  running: number;
97
123
  queued: number;
98
124
  keys: number;
package/dist/queue.js CHANGED
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
33
33
  attempts: 1,
34
34
  backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
35
  };
36
+ var reportedParallelism = () => {
37
+ const reported = globalThis.navigator?.hardwareConcurrency;
38
+ return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
39
+ };
40
+ function queueWidthFor(policy = {}) {
41
+ const percent = policy.percent ?? 60;
42
+ const reserve = policy.reserve ?? 1;
43
+ const min = policy.min ?? 1;
44
+ const max = policy.max ?? Number.MAX_SAFE_INTEGER;
45
+ const available = (policy.available ?? reportedParallelism)();
46
+ if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
47
+ throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
48
+ }
49
+ for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
50
+ if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
51
+ throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
52
+ }
53
+ }
54
+ if (!Number.isSafeInteger(available) || available < 1) {
55
+ throw new RangeError("queue: available parallelism must be a positive integer");
56
+ }
57
+ if (min > max)
58
+ throw new RangeError("queue: resource min cannot exceed max");
59
+ const usable = Math.max(1, available - reserve);
60
+ return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
61
+ }
36
62
  function createQueue(options = {}) {
37
- const width = options.width ?? 8;
63
+ if (options.width !== undefined && options.resources !== undefined) {
64
+ throw new Error("queue: choose either width or resources, not both");
65
+ }
66
+ const configuredWidth = options.width;
67
+ const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
68
+ const initialWidth = widthNow();
38
69
  const retry = { ...DEFAULT_RETRY, ...options.retry };
39
- if (!Number.isSafeInteger(width) || width < 1) {
70
+ if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
40
71
  throw new RangeError("queue: width must be a positive integer");
41
72
  }
42
73
  if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
68
99
  announceIdle();
69
100
  return;
70
101
  }
102
+ const width = widthNow();
71
103
  for (const [key, lane] of lanes) {
72
104
  if (running.size >= width)
73
105
  break;
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
215
247
  for (const lane of lanes.values())
216
248
  queued += lane.length;
217
249
  return {
250
+ width: widthNow(),
218
251
  running: running.size,
219
252
  queued,
220
253
  keys: lanes.size,
@@ -275,6 +308,7 @@ function createQueue(options = {}) {
275
308
  };
276
309
  }
277
310
  export {
311
+ queueWidthFor,
278
312
  createQueue,
279
313
  TaskCancelledError,
280
314
  QueueStoppedError,
@@ -1,4 +1,5 @@
1
1
  import type { TSchema, Static } from '@sinclair/typebox';
2
+ import type { QueryCodec } from './query';
2
3
  import { type SchemaValidator } from './schema';
3
4
  /**
4
5
  * TypeBox implementation of `SchemaValidator`.
@@ -20,5 +21,7 @@ export declare const typebox: SchemaValidator<TSchema>;
20
21
  * refusing to start.
21
22
  */
22
23
  export declare function parse<T extends TSchema>(schema: T, value: unknown): Static<T>;
24
+ /** TypeBox codec for `@forgezero/runtime/query`. */
25
+ export declare function typeboxQueryCodec<T extends TSchema>(schema: T): QueryCodec<Static<T>>;
23
26
  export { Type as T } from '@sinclair/typebox';
24
27
  export type { TSchema, Static } from '@sinclair/typebox';