@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.
- package/README.md +46 -3
- package/dist/audit.js +35 -2
- package/dist/backup.d.ts +19 -4
- package/dist/backup.js +252 -45
- package/dist/finance/chain-deposits.js +5 -1
- package/dist/finance/chain-withdrawals.js +5 -1
- package/dist/finance/commission.js +5 -1
- package/dist/finance/ledger.d.ts +8 -2
- package/dist/finance/ledger.js +6 -1
- package/dist/jobs.d.ts +31 -1
- package/dist/jobs.js +145 -14
- package/dist/query.d.ts +47 -0
- package/dist/query.js +51 -0
- package/dist/queue.d.ts +26 -0
- package/dist/queue.js +36 -2
- package/dist/schema-typebox.d.ts +3 -0
- package/dist/schema-typebox.js +32 -2
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +15 -2
- package/package.json +10 -6
package/dist/finance/ledger.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/finance/ledger.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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(
|
|
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 ?
|
|
429
|
-
|
|
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,
|
package/dist/query.d.ts
ADDED
|
@@ -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
|
-
|
|
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(
|
|
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,
|
package/dist/schema-typebox.d.ts
CHANGED
|
@@ -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';
|