@vxil/sdk 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +272 -47
- package/dist/index.js +119 -14
- package/dist/retry.d.ts +24 -0
- package/dist/retry.js +42 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** The 429 error codes `retryOnCapacity` treats as "at a capacity cap — try the
|
|
2
|
+
* SAME request again shortly". Declared here with an explicit type (not a
|
|
3
|
+
* re-export) so the served single-file index.d.ts never imports ./retry. */
|
|
4
|
+
export declare const CAPACITY_ERROR_CODES: ReadonlySet<string>;
|
|
1
5
|
export type ApiVersion = 'v1';
|
|
2
6
|
/** A per-feature API-version override key: the URL namespace that leads a path
|
|
3
7
|
* (`/v1/<namespace>/…`). Matches the segment `versionedPath` rewrites, so an
|
|
@@ -361,7 +365,9 @@ export interface JobRun {
|
|
|
361
365
|
queued_at: string;
|
|
362
366
|
started_at?: string | null;
|
|
363
367
|
completed_at: string | null;
|
|
364
|
-
/** generation runs (POST /v1/jobs/generation) only: the provider-side status
|
|
368
|
+
/** generation runs (POST /v1/jobs/generation) only: the provider-side status
|
|
369
|
+
* (pending | processing | completed | failed); null for a plain run. On the
|
|
370
|
+
* list (`vx.jobs.runs`) and the single-run read. */
|
|
365
371
|
generation_status?: string | null;
|
|
366
372
|
generation_json?: Record<string, unknown> | null;
|
|
367
373
|
/** the single-run read (`vx.jobs.run` / `waitForRun`) only: your enqueue
|
|
@@ -392,6 +398,115 @@ export interface JobRun {
|
|
|
392
398
|
* `processing` ping on its signed callback (plain run) or its provider's
|
|
393
399
|
* webhook (generation run). null until one arrives. */
|
|
394
400
|
progress?: JobRunProgress | null;
|
|
401
|
+
/** the single-run read, generation runs only: the LAST `status_mirror`
|
|
402
|
+
* write the target refused, or null. A full write refused as a body
|
|
403
|
+
* problem (400 / 413 / 422 — e.g. a callback key the collection does not
|
|
404
|
+
* declare) is retried with only the status column (a progress update tries
|
|
405
|
+
* the declared `progress_fields` first), so an undeclared key no longer
|
|
406
|
+
* strands the row; this says what was dropped. Never flips the run; not cleared by a later
|
|
407
|
+
* successful mirror (compare `generation_status` / `at`). */
|
|
408
|
+
mirror_error?: JobRunMirrorError | null;
|
|
409
|
+
/** the single-run read only: the enqueue `concurrency_key` this run holds
|
|
410
|
+
* while running or waiting; null for an unkeyed run */
|
|
411
|
+
concurrency_key?: string | null;
|
|
412
|
+
/** the single-run read only: how many runs sharing the key may hold it at
|
|
413
|
+
* once; null for an unkeyed run */
|
|
414
|
+
concurrency_limit?: number | null;
|
|
415
|
+
/** the fan-in batch the run joined (enqueue `batch_id`); null otherwise */
|
|
416
|
+
batch_id?: string | null;
|
|
417
|
+
/** a plain run enqueued with `ttl_seconds`: its START deadline (it is
|
|
418
|
+
* dead-lettered `Expired` if not started by then); null otherwise */
|
|
419
|
+
expires_at?: string | null;
|
|
420
|
+
/** the single-run read only: the debounce key of a debounced run */
|
|
421
|
+
debounce_key?: string | null;
|
|
422
|
+
}
|
|
423
|
+
/** The plain-run enqueue input shared by `enqueue` and `enqueueBatch` items. */
|
|
424
|
+
export interface JobEnqueueInput {
|
|
425
|
+
job_name: string;
|
|
426
|
+
target_url: string;
|
|
427
|
+
payload?: Record<string, unknown>;
|
|
428
|
+
idempotency_key?: string;
|
|
429
|
+
max_attempts?: number;
|
|
430
|
+
deliver_after?: string;
|
|
431
|
+
delay_seconds?: number;
|
|
432
|
+
/** 60..2678400: dead-letter the run (`Expired`) if not started this long after it is due */
|
|
433
|
+
ttl_seconds?: number;
|
|
434
|
+
/** per-key concurrency: at most `concurrency_limit` runs sharing this key hold it at once */
|
|
435
|
+
concurrency_key?: string;
|
|
436
|
+
/** 1..100, default 1; requires concurrency_key */
|
|
437
|
+
concurrency_limit?: number;
|
|
438
|
+
}
|
|
439
|
+
/** The answer of `vx.jobs.enqueue`. */
|
|
440
|
+
export interface JobEnqueueResult {
|
|
441
|
+
run_id: string;
|
|
442
|
+
state: string;
|
|
443
|
+
deduplicated?: boolean;
|
|
444
|
+
/** a debounced enqueue that pushed the open run (same run_id) */
|
|
445
|
+
debounced?: boolean;
|
|
446
|
+
deliver_after?: string;
|
|
447
|
+
callback_url?: string;
|
|
448
|
+
/** the START deadline when `ttl_seconds` was given */
|
|
449
|
+
expires_at?: string;
|
|
450
|
+
batch_id?: string;
|
|
451
|
+
/** true when the run's first attempt is already in flight (it started on
|
|
452
|
+
* the enqueue request, no queue in front of it); absent when it was queued */
|
|
453
|
+
direct?: boolean;
|
|
454
|
+
}
|
|
455
|
+
/** `vx.jobs.runs` / `runsPage` filters. */
|
|
456
|
+
export interface JobRunsQuery {
|
|
457
|
+
job_name?: string;
|
|
458
|
+
state?: JobRun['state'] | string;
|
|
459
|
+
ids?: string[];
|
|
460
|
+
batch_id?: string;
|
|
461
|
+
/** ISO: runs created at or after */
|
|
462
|
+
since?: string;
|
|
463
|
+
/** ISO: runs created before */
|
|
464
|
+
until?: string;
|
|
465
|
+
limit?: number;
|
|
466
|
+
}
|
|
467
|
+
/** One page of `vx.jobs.runsPage`. */
|
|
468
|
+
export interface JobRunsPage {
|
|
469
|
+
runs: JobRun[];
|
|
470
|
+
/** pass back as `cursor` for the next older page; null on the last page */
|
|
471
|
+
next_cursor: string | null;
|
|
472
|
+
}
|
|
473
|
+
/** `GET /v1/jobs/batches/{batch_id}` — a fan-in batch. */
|
|
474
|
+
export interface JobBatch {
|
|
475
|
+
batch_id: string;
|
|
476
|
+
/** 'completed' once every run is terminal (the event fired, or fires within a minute) */
|
|
477
|
+
state: 'open' | 'completed';
|
|
478
|
+
total: number;
|
|
479
|
+
/** the declared size (`batch_total`), or null */
|
|
480
|
+
expected_total: number | null;
|
|
481
|
+
open: number;
|
|
482
|
+
succeeded: number;
|
|
483
|
+
dead_lettered: number;
|
|
484
|
+
cancelled: number;
|
|
485
|
+
/** live count of the batch's RETAINED runs by state (runs past retention drop out; the counters above do not) */
|
|
486
|
+
runs_by_state: Partial<Record<JobRun['state'], number>>;
|
|
487
|
+
created_at: string;
|
|
488
|
+
completed_at: string | null;
|
|
489
|
+
/** when `job.batch.completed` was written */
|
|
490
|
+
event_emitted_at: string | null;
|
|
491
|
+
}
|
|
492
|
+
/** A refused status-mirror write (`JobRun.mirror_error`). */
|
|
493
|
+
export interface JobRunMirrorError {
|
|
494
|
+
/** the status word the refused write carried */
|
|
495
|
+
generation_status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
496
|
+
/** the target's HTTP status on the full write; 0 = no answer (network / timeout) */
|
|
497
|
+
status: number;
|
|
498
|
+
/** the target's error code (e.g. `validation_failed`), `network_error` / `timeout`, or null */
|
|
499
|
+
code: string | null;
|
|
500
|
+
/** the target's message (≤ 200 chars), e.g. "unknown field 'video_url'" */
|
|
501
|
+
message: string | null;
|
|
502
|
+
/** when it was refused (ISO) */
|
|
503
|
+
at: string;
|
|
504
|
+
/** present when a narrower retry LANDED: the keys that were not written */
|
|
505
|
+
fields_dropped?: string[];
|
|
506
|
+
/** present when every narrower retry was refused too (the last one's HTTP
|
|
507
|
+
* status, 0 = no answer): the status word itself was refused and the
|
|
508
|
+
* record still holds the previous status */
|
|
509
|
+
retry_status?: number;
|
|
395
510
|
}
|
|
396
511
|
/** A run's latest progress report (`JobRun.progress`): only these keys pass. */
|
|
397
512
|
export interface JobRunProgress {
|
|
@@ -481,8 +596,11 @@ export interface JobScheduleHeld {
|
|
|
481
596
|
export interface JobSchedule {
|
|
482
597
|
schedule_id: string;
|
|
483
598
|
job_name: string;
|
|
484
|
-
/** the
|
|
599
|
+
/** the cron expression under the request field's name — every schedule
|
|
600
|
+
* answer (create, update, list) carries it, equal to `cron_expr`; null for
|
|
601
|
+
* a one-shot `run_at` schedule. (Before 2026-10-03 only `cron_expr`.) */
|
|
485
602
|
cron?: string | null;
|
|
603
|
+
/** the same value under the stored column's name */
|
|
486
604
|
cron_expr?: string | null;
|
|
487
605
|
run_at?: string | null;
|
|
488
606
|
next_run_at: string | null;
|
|
@@ -497,7 +615,41 @@ export interface JobSchedule {
|
|
|
497
615
|
last_skipped_at?: string | null;
|
|
498
616
|
/** non-null while the schedule is HELD (list only) */
|
|
499
617
|
held?: JobScheduleHeld | null;
|
|
618
|
+
/** the IANA zone the cron is read in (default `'UTC'`); DST-correct */
|
|
619
|
+
timezone?: string;
|
|
620
|
+
/** your upsert key — a create naming a live schedule's external_id updates
|
|
621
|
+
* that schedule instead of adding one */
|
|
622
|
+
external_id?: string | null;
|
|
623
|
+
/** each fired run's attempt budget; null = the project's
|
|
624
|
+
* `jobs.retry.defaultMaxAttempts` */
|
|
625
|
+
max_attempts?: number | null;
|
|
626
|
+
/** on a create: true = a new schedule (201); false = the external_id
|
|
627
|
+
* matched a live schedule, which was updated in place (200) */
|
|
628
|
+
created?: boolean;
|
|
629
|
+
}
|
|
630
|
+
/** The fields of a schedule create (`vx.jobs.schedules.create`). */
|
|
631
|
+
export interface JobScheduleInput {
|
|
632
|
+
job_name: string;
|
|
633
|
+
target_url: string;
|
|
634
|
+
payload?: Record<string, unknown>;
|
|
635
|
+
/** 5-field cron, read in `timezone` — exactly one of cron / run_at */
|
|
636
|
+
cron?: string;
|
|
637
|
+
/** a future ISO timestamp (a one-shot) */
|
|
638
|
+
run_at?: string;
|
|
639
|
+
overlap?: 'allow' | 'skip';
|
|
640
|
+
/** an IANA zone, e.g. `'Asia/Amman'`, `'Europe/London'` (default `'UTC'`) */
|
|
641
|
+
timezone?: string;
|
|
642
|
+
/** ≤ 200 printable ASCII, no spaces — makes the create an upsert */
|
|
643
|
+
external_id?: string;
|
|
644
|
+
/** 1..20 */
|
|
645
|
+
max_attempts?: number;
|
|
500
646
|
}
|
|
647
|
+
/** The fields of a schedule PATCH (`vx.jobs.schedules.update`): any create
|
|
648
|
+
* field; `external_id` / `max_attempts` accept null (clear). */
|
|
649
|
+
export type JobSchedulePatch = Partial<Omit<JobScheduleInput, 'external_id' | 'max_attempts'>> & {
|
|
650
|
+
external_id?: string | null;
|
|
651
|
+
max_attempts?: number | null;
|
|
652
|
+
};
|
|
501
653
|
export interface AuthSession {
|
|
502
654
|
token: string;
|
|
503
655
|
refresh_token: string;
|
|
@@ -1042,9 +1194,25 @@ export interface JobRunEventPayload {
|
|
|
1042
1194
|
* `queue_backstop` (the queue's own retries ran out), `callback_failed`
|
|
1043
1195
|
* (the run's signed callback reported `status: 'failed'`) or
|
|
1044
1196
|
* `callback_timeout` (the handler handed the run off with a 202 and no
|
|
1045
|
-
* callback completed it within its lifetime)
|
|
1197
|
+
* callback completed it within its lifetime) or `expired` (a run enqueued
|
|
1198
|
+
* with `ttl_seconds` that had not started by its deadline — never
|
|
1199
|
+
* delivered; `last_error_class: 'Expired'`). Absent when the run
|
|
1046
1200
|
* exhausted its attempts normally. */
|
|
1047
|
-
reason?: 'reaped' | 'queue_backstop' | 'callback_failed' | 'callback_timeout';
|
|
1201
|
+
reason?: 'reaped' | 'queue_backstop' | 'callback_failed' | 'callback_timeout' | 'expired';
|
|
1202
|
+
}
|
|
1203
|
+
/** The `data` of `job.batch.completed`: EVERY run enqueued with this
|
|
1204
|
+
* `batch_id` reached a terminal state (and, with a declared `batch_total`,
|
|
1205
|
+
* that many runs joined). Emitted exactly ONCE per batch — a run replayed
|
|
1206
|
+
* later is a new run outside the batch. The fan-in signal: subscribe a
|
|
1207
|
+
* function to `job.batch.` and aggregate there; vxil runs no next step. */
|
|
1208
|
+
export interface JobBatchCompletedEventPayload {
|
|
1209
|
+
batch_id: string;
|
|
1210
|
+
/** runs that joined the batch */
|
|
1211
|
+
total: number;
|
|
1212
|
+
succeeded: number;
|
|
1213
|
+
/** ended `dead` (retries exhausted, reaped, expired, callback failed …) */
|
|
1214
|
+
dead_lettered: number;
|
|
1215
|
+
cancelled: number;
|
|
1048
1216
|
}
|
|
1049
1217
|
/** The provider-reported environment of the money (`production` | `sandbox`). */
|
|
1050
1218
|
export type PaymentsEventEnvironment = 'production' | 'sandbox';
|
|
@@ -1291,6 +1459,7 @@ export interface VxilEventPayloads {
|
|
|
1291
1459
|
'job.generation.failed': JobGenerationSettledEventPayload;
|
|
1292
1460
|
'job.succeeded': JobRunEventPayload;
|
|
1293
1461
|
'job.dead_lettered': JobRunEventPayload;
|
|
1462
|
+
'job.batch.completed': JobBatchCompletedEventPayload;
|
|
1294
1463
|
'payments.charge.succeeded': PaymentsChargeEventPayload;
|
|
1295
1464
|
'payments.charge.completed': PaymentsChargeEventPayload;
|
|
1296
1465
|
'payments.charge.refunded': PaymentsChargeRefundedEventPayload;
|
|
@@ -2925,44 +3094,72 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2925
3094
|
* delivery): an external worker POSTs it to complete / fail the run, report
|
|
2926
3095
|
* progress, or wake a `wait`. A handler that answers 202 HANDS the run off
|
|
2927
3096
|
* — it waits for that callback (dead-lettered as `CallbackTimeout` when the
|
|
2928
|
-
* lifetime passes). See `postRunCallback`.
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
3097
|
+
* lifetime passes). See `postRunCallback`.
|
|
3098
|
+
*
|
|
3099
|
+
* `concurrency_key` (≤ 200 chars) + `concurrency_limit` (1..100, default
|
|
3100
|
+
* 1): at most that many runs of this job_name sharing the key hold it at
|
|
3101
|
+
* once — e.g. the end user's id for "one import per user". A run holds its
|
|
3102
|
+
* key from its first start until it finishes (running, waiting, and
|
|
3103
|
+
* between retries). The rest stay queued and start oldest first (never
|
|
3104
|
+
* rejected).
|
|
3105
|
+
*
|
|
3106
|
+
* Queue controls (2026-10-03):
|
|
3107
|
+
* - `ttl_seconds` (60 s..31 d): a run not STARTED within this long of
|
|
3108
|
+
* being due is dead-lettered (`last_error_class: 'Expired'`,
|
|
3109
|
+
* `job.dead_lettered` with `reason: 'expired'`) and never delivered.
|
|
3110
|
+
* - `debounce: { key, delay_seconds (1..86400), max_delay_seconds? }`: the
|
|
3111
|
+
* first enqueue for (job_name, key) makes a run due in delay_seconds;
|
|
3112
|
+
* each later one while it has not started REPLACES its payload and pushes
|
|
3113
|
+
* it to now + delay_seconds (never past first enqueue +
|
|
3114
|
+
* max_delay_seconds — default 10 × delay_seconds, clamped to 1..30 d)
|
|
3115
|
+
* and answers the SAME run_id with `debounced: true`.
|
|
3116
|
+
* Not combinable with idempotency_key / deliver_after / delay_seconds /
|
|
3117
|
+
* callback / batch_id.
|
|
3118
|
+
* - `batch_id` (+ optional `batch_total`): the run joins a fan-in batch —
|
|
3119
|
+
* when every run of the batch is terminal, ONE `job.batch.completed`
|
|
3120
|
+
* event fires (see `batch()`). A completed batch takes no new runs (409
|
|
3121
|
+
* `batch_closed`). */
|
|
3122
|
+
enqueue: (input: JobEnqueueInput & {
|
|
2937
3123
|
callback?: boolean | {
|
|
2938
3124
|
ttl_seconds?: number;
|
|
2939
3125
|
};
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
3126
|
+
concurrency_key?: string;
|
|
3127
|
+
concurrency_limit?: number;
|
|
3128
|
+
debounce?: {
|
|
3129
|
+
key: string;
|
|
3130
|
+
delay_seconds: number;
|
|
3131
|
+
max_delay_seconds?: number;
|
|
3132
|
+
};
|
|
3133
|
+
batch_id?: string;
|
|
3134
|
+
batch_total?: number;
|
|
3135
|
+
}) => Promise<JobEnqueueResult>;
|
|
2947
3136
|
/** Atomic multi-enqueue (≤100 items; any invalid item rejects the whole
|
|
2948
|
-
* batch). Each item = the enqueue input, incl. per-item idempotency_key
|
|
2949
|
-
* and
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
3137
|
+
* batch). Each item = the enqueue input, incl. per-item idempotency_key,
|
|
3138
|
+
* deliver_after/delay_seconds, ttl_seconds and concurrency_key/concurrency_limit. Results align with the
|
|
3139
|
+
* input order.
|
|
3140
|
+
*
|
|
3141
|
+
* FAN-IN: `opts.batch_id` puts every item in one batch; when every run of
|
|
3142
|
+
* the batch is terminal, ONE `job.batch.completed` event fires ({ batch_id,
|
|
3143
|
+
* total, succeeded, dead_lettered, cancelled }) — subscribe a function to
|
|
3144
|
+
* `job.batch.` to aggregate. A batch may span several calls: pass the same
|
|
3145
|
+
* batch_id and declare `batch_total` (the full size) so it cannot complete
|
|
3146
|
+
* between calls. */
|
|
3147
|
+
enqueueBatch: (items: JobEnqueueInput[], opts?: {
|
|
3148
|
+
batch_id?: string;
|
|
3149
|
+
batch_total?: number;
|
|
3150
|
+
}) => Promise<{
|
|
2959
3151
|
runs: Array<{
|
|
2960
3152
|
run_id: string;
|
|
2961
3153
|
state: string;
|
|
2962
3154
|
deduplicated?: boolean;
|
|
2963
3155
|
}>;
|
|
2964
3156
|
count: number;
|
|
3157
|
+
batch_id?: string;
|
|
2965
3158
|
}>;
|
|
3159
|
+
/** A fan-in batch: its exact counters (`total`, `open`, `succeeded`,
|
|
3160
|
+
* `dead_lettered`, `cancelled`), `state` ('open' | 'completed') and the
|
|
3161
|
+
* live count of its retained runs by state. 404 for an unknown batch. */
|
|
3162
|
+
batch: (batchId: string) => Promise<JobBatch>;
|
|
2966
3163
|
/** Enqueue a long-running EXTERNAL generation run (guide ch. 6, jobs): Vxil calls
|
|
2967
3164
|
* the provider (BYO key), tracks completion via poll/webhook, mirrors a typed
|
|
2968
3165
|
* generation_status onto a tenant record, enforces a built-in timeout, and
|
|
@@ -2987,7 +3184,18 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2987
3184
|
* sends `provider.body` exactly; `completion.status_map` maps up to 8
|
|
2988
3185
|
* provider words to completed | failed | processing (`{ OK: 'completed',
|
|
2989
3186
|
* ERROR: 'failed' }`); `completion.result_path` names the value the run
|
|
2990
|
-
* settles with — the status mirror receives it as `result`.
|
|
3187
|
+
* settles with — the status mirror receives it as `result`.
|
|
3188
|
+
*
|
|
3189
|
+
* A mirror write the record refuses never fails the run: a body refusal
|
|
3190
|
+
* (e.g. a callback key the collection does not declare) is retried with
|
|
3191
|
+
* only the status column, so the row still reaches `completed`; the run then reports `mirror_error`
|
|
3192
|
+
* (`JobRun.mirror_error` on `vx.jobs.run(run_id)`).
|
|
3193
|
+
*
|
|
3194
|
+
* `opts.retryOnCapacity: { maxWaitMs }` retries a CAPACITY 429
|
|
3195
|
+
* (`generation_concurrency_exceeded`, `reserve_holds_exceeded`,
|
|
3196
|
+
* `queue_full`) honouring Retry-After with jitter, with the SAME
|
|
3197
|
+
* idempotency_key (one is generated when the input has none), until
|
|
3198
|
+
* maxWaitMs has passed — then the last 429 is thrown. */
|
|
2991
3199
|
generation: (input: {
|
|
2992
3200
|
job_name: string;
|
|
2993
3201
|
provider: {
|
|
@@ -3045,18 +3253,26 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
3045
3253
|
payload?: Record<string, unknown>;
|
|
3046
3254
|
idempotency_key?: string;
|
|
3047
3255
|
max_attempts?: number;
|
|
3256
|
+
}, opts?: {
|
|
3257
|
+
retryOnCapacity?: {
|
|
3258
|
+
maxWaitMs: number;
|
|
3259
|
+
};
|
|
3048
3260
|
}) => Promise<{
|
|
3049
3261
|
run_id: string;
|
|
3050
3262
|
generation_status: string;
|
|
3051
3263
|
state?: string;
|
|
3052
3264
|
deduplicated?: boolean;
|
|
3053
3265
|
}>;
|
|
3054
|
-
runs
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3266
|
+
/** The newest runs (≤ `limit`, default 50, max 100). Filters: job_name,
|
|
3267
|
+
* state, batch_id, ids, since/until (creation time, ISO). For more than
|
|
3268
|
+
* one page use `runsPage` (it returns the `next_cursor`). */
|
|
3269
|
+
runs: (q?: JobRunsQuery) => Promise<JobRun[]>;
|
|
3270
|
+
/** One page of runs, newest first, plus `next_cursor` (null on the last
|
|
3271
|
+
* page): pass it back as `cursor` for the next older page. The keyset
|
|
3272
|
+
* keeps pages stable while new runs arrive. */
|
|
3273
|
+
runsPage: (q?: JobRunsQuery & {
|
|
3274
|
+
cursor?: string | null;
|
|
3275
|
+
}) => Promise<JobRunsPage>;
|
|
3060
3276
|
run: (runId: string) => Promise<JobRun>;
|
|
3061
3277
|
/** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
|
|
3062
3278
|
* the request platform-side (re-reading the run on one bounded connection,
|
|
@@ -3078,6 +3294,12 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
3078
3294
|
/** The live queue: depth by state, the oldest waiting run's age (the
|
|
3079
3295
|
* number to alarm on), in-flight runs per lane, dead letters in 24 h. */
|
|
3080
3296
|
queue: () => Promise<JobsQueue>;
|
|
3297
|
+
/** Cancel a run that has not started its current attempt (`queued`,
|
|
3298
|
+
* `delayed`, `retrying`) or a plain run handed off to its signed callback
|
|
3299
|
+
* (`waiting` after its handler answered 202 — the callback URL is
|
|
3300
|
+
* consumed). A `running` run (a delivery in flight, or a generation
|
|
3301
|
+
* waiting on its provider), a run waiting on an event, or a terminal run
|
|
3302
|
+
* answers `409 not_cancellable`. A generation's credit hold is released. */
|
|
3081
3303
|
cancel: (runId: string) => Promise<{
|
|
3082
3304
|
run_id: string;
|
|
3083
3305
|
state: string;
|
|
@@ -3110,17 +3332,20 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
3110
3332
|
/** Secret for verifying X-Vxil-Jobs-Signature on your callback endpoints. */
|
|
3111
3333
|
signingSecret: () => Promise<string>;
|
|
3112
3334
|
schedules: {
|
|
3113
|
-
/** Recurring (5-field cron
|
|
3335
|
+
/** Recurring (5-field cron) or one-shot (run_at). Exactly one of cron/run_at.
|
|
3336
|
+
* `timezone` (IANA, default UTC) is the zone the cron is read in, DST-
|
|
3337
|
+
* correct. `external_id` makes it an UPSERT: a live schedule with the
|
|
3338
|
+
* same key is updated in place (`created: false`) — one schedule per
|
|
3339
|
+
* key, safe to call on every sign-in / settings save (an identical
|
|
3340
|
+
* repeat writes nothing). Server mode only: in end-user mode a create
|
|
3341
|
+
* with `external_id` is `403 server_only` (the key is tenant-wide).
|
|
3114
3342
|
* `overlap: 'skip'` (cron only): no new run while the previous one is
|
|
3115
3343
|
* still open — a missed window is never caught up either way. */
|
|
3116
|
-
create: (input:
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
run_at?: string;
|
|
3122
|
-
overlap?: "allow" | "skip";
|
|
3123
|
-
}) => Promise<JobSchedule>;
|
|
3344
|
+
create: (input: JobScheduleInput) => Promise<JobSchedule>;
|
|
3345
|
+
/** Change a live schedule in place (its id, state and counters kept). A
|
|
3346
|
+
* cron / run_at / timezone change recomputes the next fire. Server mode
|
|
3347
|
+
* only (`403 server_only` in end-user mode). */
|
|
3348
|
+
update: (scheduleId: string, patch: JobSchedulePatch) => Promise<JobSchedule>;
|
|
3124
3349
|
list: () => Promise<JobSchedule[]>;
|
|
3125
3350
|
delete: (scheduleId: string) => Promise<void>;
|
|
3126
3351
|
/** Stop an active schedule firing (the row + next_run_at survive). */
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* widens this union. Every path the SDK builds is major-versioned; the default
|
|
9
9
|
* is the compile-time constant `'v1'` (never a floating `latest` alias). */
|
|
10
10
|
import { qs } from './qs.js';
|
|
11
|
-
import { createTransport, parseRetryAfter } from './retry.js';
|
|
11
|
+
import { createTransport, parseRetryAfter, retryOnCapacity, CAPACITY_ERROR_CODES as CAPACITY_CODES } from './retry.js';
|
|
12
|
+
/** The 429 error codes `retryOnCapacity` treats as "at a capacity cap — try the
|
|
13
|
+
* SAME request again shortly". Declared here with an explicit type (not a
|
|
14
|
+
* re-export) so the served single-file index.d.ts never imports ./retry. */
|
|
15
|
+
export const CAPACITY_ERROR_CODES = CAPACITY_CODES;
|
|
12
16
|
/** Rewrite a built `/v1/<ns>/…` path onto the version configured for its
|
|
13
17
|
* namespace: the per-namespace override wins, else the global default. PURE and
|
|
14
18
|
* exported so it is unit-testable with a hypothetical future major (the runtime
|
|
@@ -688,12 +692,52 @@ export class Vxil {
|
|
|
688
692
|
* delivery): an external worker POSTs it to complete / fail the run, report
|
|
689
693
|
* progress, or wake a `wait`. A handler that answers 202 HANDS the run off
|
|
690
694
|
* — it waits for that callback (dead-lettered as `CallbackTimeout` when the
|
|
691
|
-
* lifetime passes). See `postRunCallback`.
|
|
695
|
+
* lifetime passes). See `postRunCallback`.
|
|
696
|
+
*
|
|
697
|
+
* `concurrency_key` (≤ 200 chars) + `concurrency_limit` (1..100, default
|
|
698
|
+
* 1): at most that many runs of this job_name sharing the key hold it at
|
|
699
|
+
* once — e.g. the end user's id for "one import per user". A run holds its
|
|
700
|
+
* key from its first start until it finishes (running, waiting, and
|
|
701
|
+
* between retries). The rest stay queued and start oldest first (never
|
|
702
|
+
* rejected).
|
|
703
|
+
*
|
|
704
|
+
* Queue controls (2026-10-03):
|
|
705
|
+
* - `ttl_seconds` (60 s..31 d): a run not STARTED within this long of
|
|
706
|
+
* being due is dead-lettered (`last_error_class: 'Expired'`,
|
|
707
|
+
* `job.dead_lettered` with `reason: 'expired'`) and never delivered.
|
|
708
|
+
* - `debounce: { key, delay_seconds (1..86400), max_delay_seconds? }`: the
|
|
709
|
+
* first enqueue for (job_name, key) makes a run due in delay_seconds;
|
|
710
|
+
* each later one while it has not started REPLACES its payload and pushes
|
|
711
|
+
* it to now + delay_seconds (never past first enqueue +
|
|
712
|
+
* max_delay_seconds — default 10 × delay_seconds, clamped to 1..30 d)
|
|
713
|
+
* and answers the SAME run_id with `debounced: true`.
|
|
714
|
+
* Not combinable with idempotency_key / deliver_after / delay_seconds /
|
|
715
|
+
* callback / batch_id.
|
|
716
|
+
* - `batch_id` (+ optional `batch_total`): the run joins a fan-in batch —
|
|
717
|
+
* when every run of the batch is terminal, ONE `job.batch.completed`
|
|
718
|
+
* event fires (see `batch()`). A completed batch takes no new runs (409
|
|
719
|
+
* `batch_closed`). */
|
|
692
720
|
enqueue: async (input) => (await this.call('POST', '/v1/jobs/enqueue', input)).data,
|
|
693
721
|
/** Atomic multi-enqueue (≤100 items; any invalid item rejects the whole
|
|
694
|
-
* batch). Each item = the enqueue input, incl. per-item idempotency_key
|
|
695
|
-
* and
|
|
696
|
-
|
|
722
|
+
* batch). Each item = the enqueue input, incl. per-item idempotency_key,
|
|
723
|
+
* deliver_after/delay_seconds, ttl_seconds and concurrency_key/concurrency_limit. Results align with the
|
|
724
|
+
* input order.
|
|
725
|
+
*
|
|
726
|
+
* FAN-IN: `opts.batch_id` puts every item in one batch; when every run of
|
|
727
|
+
* the batch is terminal, ONE `job.batch.completed` event fires ({ batch_id,
|
|
728
|
+
* total, succeeded, dead_lettered, cancelled }) — subscribe a function to
|
|
729
|
+
* `job.batch.` to aggregate. A batch may span several calls: pass the same
|
|
730
|
+
* batch_id and declare `batch_total` (the full size) so it cannot complete
|
|
731
|
+
* between calls. */
|
|
732
|
+
enqueueBatch: async (items, opts) => (await this.call('POST', '/v1/jobs/enqueue-batch', {
|
|
733
|
+
jobs: items,
|
|
734
|
+
...(opts?.batch_id !== undefined ? { batch_id: opts.batch_id } : {}),
|
|
735
|
+
...(opts?.batch_total !== undefined ? { batch_total: opts.batch_total } : {}),
|
|
736
|
+
})).data,
|
|
737
|
+
/** A fan-in batch: its exact counters (`total`, `open`, `succeeded`,
|
|
738
|
+
* `dead_lettered`, `cancelled`), `state` ('open' | 'completed') and the
|
|
739
|
+
* live count of its retained runs by state. 404 for an unknown batch. */
|
|
740
|
+
batch: async (batchId) => (await this.call('GET', `/v1/jobs/batches/${encodeURIComponent(batchId)}`)).data,
|
|
697
741
|
/** Enqueue a long-running EXTERNAL generation run (guide ch. 6, jobs): Vxil calls
|
|
698
742
|
* the provider (BYO key), tracks completion via poll/webhook, mirrors a typed
|
|
699
743
|
* generation_status onto a tenant record, enforces a built-in timeout, and
|
|
@@ -718,17 +762,42 @@ export class Vxil {
|
|
|
718
762
|
* sends `provider.body` exactly; `completion.status_map` maps up to 8
|
|
719
763
|
* provider words to completed | failed | processing (`{ OK: 'completed',
|
|
720
764
|
* ERROR: 'failed' }`); `completion.result_path` names the value the run
|
|
721
|
-
* settles with — the status mirror receives it as `result`.
|
|
722
|
-
|
|
765
|
+
* settles with — the status mirror receives it as `result`.
|
|
766
|
+
*
|
|
767
|
+
* A mirror write the record refuses never fails the run: a body refusal
|
|
768
|
+
* (e.g. a callback key the collection does not declare) is retried with
|
|
769
|
+
* only the status column, so the row still reaches `completed`; the run then reports `mirror_error`
|
|
770
|
+
* (`JobRun.mirror_error` on `vx.jobs.run(run_id)`).
|
|
771
|
+
*
|
|
772
|
+
* `opts.retryOnCapacity: { maxWaitMs }` retries a CAPACITY 429
|
|
773
|
+
* (`generation_concurrency_exceeded`, `reserve_holds_exceeded`,
|
|
774
|
+
* `queue_full`) honouring Retry-After with jitter, with the SAME
|
|
775
|
+
* idempotency_key (one is generated when the input has none), until
|
|
776
|
+
* maxWaitMs has passed — then the last 429 is thrown. */
|
|
777
|
+
generation: async (input, opts) => {
|
|
778
|
+
if (!opts?.retryOnCapacity) {
|
|
779
|
+
return (await this.call('POST', '/v1/jobs/generation', input)).data;
|
|
780
|
+
}
|
|
781
|
+
// the SAME request every time: a retry that races an accepted earlier
|
|
782
|
+
// one is deduplicated server-side by this key
|
|
783
|
+
const body = input.idempotency_key ? input : { ...input, idempotency_key: randomIdempotencyKey() };
|
|
784
|
+
return retryOnCapacity(async () => (await this.call('POST', '/v1/jobs/generation', body)).data, { maxWaitMs: opts.retryOnCapacity.maxWaitMs });
|
|
785
|
+
},
|
|
786
|
+
/** The newest runs (≤ `limit`, default 50, max 100). Filters: job_name,
|
|
787
|
+
* state, batch_id, ids, since/until (creation time, ISO). For more than
|
|
788
|
+
* one page use `runsPage` (it returns the `next_cursor`). */
|
|
723
789
|
runs: async (q) => {
|
|
724
|
-
const s =
|
|
725
|
-
job_name: q?.job_name || undefined,
|
|
726
|
-
state: q?.state || undefined,
|
|
727
|
-
ids: q?.ids,
|
|
728
|
-
limit: q?.limit || undefined,
|
|
729
|
-
});
|
|
790
|
+
const s = jobRunsQs(q);
|
|
730
791
|
return (await this.call('GET', `/v1/jobs/runs${s}`)).data.runs;
|
|
731
792
|
},
|
|
793
|
+
/** One page of runs, newest first, plus `next_cursor` (null on the last
|
|
794
|
+
* page): pass it back as `cursor` for the next older page. The keyset
|
|
795
|
+
* keeps pages stable while new runs arrive. */
|
|
796
|
+
runsPage: async (q) => {
|
|
797
|
+
const s = jobRunsQs(q);
|
|
798
|
+
const d = (await this.call('GET', `/v1/jobs/runs${s}`)).data;
|
|
799
|
+
return { runs: d.runs, next_cursor: d.next_cursor ?? null };
|
|
800
|
+
},
|
|
732
801
|
run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
|
|
733
802
|
/** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
|
|
734
803
|
* the request platform-side (re-reading the run on one bounded connection,
|
|
@@ -748,6 +817,12 @@ export class Vxil {
|
|
|
748
817
|
/** The live queue: depth by state, the oldest waiting run's age (the
|
|
749
818
|
* number to alarm on), in-flight runs per lane, dead letters in 24 h. */
|
|
750
819
|
queue: async () => (await this.call('GET', '/v1/jobs/queue')).data,
|
|
820
|
+
/** Cancel a run that has not started its current attempt (`queued`,
|
|
821
|
+
* `delayed`, `retrying`) or a plain run handed off to its signed callback
|
|
822
|
+
* (`waiting` after its handler answered 202 — the callback URL is
|
|
823
|
+
* consumed). A `running` run (a delivery in flight, or a generation
|
|
824
|
+
* waiting on its provider), a run waiting on an event, or a terminal run
|
|
825
|
+
* answers `409 not_cancellable`. A generation's credit hold is released. */
|
|
751
826
|
cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
|
|
752
827
|
/** Clone a terminal run into a fresh queued run. A generation run answers
|
|
753
828
|
* `409 not_replayable` — submit the generation again instead. */
|
|
@@ -765,10 +840,20 @@ export class Vxil {
|
|
|
765
840
|
/** Secret for verifying X-Vxil-Jobs-Signature on your callback endpoints. */
|
|
766
841
|
signingSecret: async () => (await this.call('GET', '/v1/jobs/signing-secret')).data.signing_secret,
|
|
767
842
|
schedules: {
|
|
768
|
-
/** Recurring (5-field cron
|
|
843
|
+
/** Recurring (5-field cron) or one-shot (run_at). Exactly one of cron/run_at.
|
|
844
|
+
* `timezone` (IANA, default UTC) is the zone the cron is read in, DST-
|
|
845
|
+
* correct. `external_id` makes it an UPSERT: a live schedule with the
|
|
846
|
+
* same key is updated in place (`created: false`) — one schedule per
|
|
847
|
+
* key, safe to call on every sign-in / settings save (an identical
|
|
848
|
+
* repeat writes nothing). Server mode only: in end-user mode a create
|
|
849
|
+
* with `external_id` is `403 server_only` (the key is tenant-wide).
|
|
769
850
|
* `overlap: 'skip'` (cron only): no new run while the previous one is
|
|
770
851
|
* still open — a missed window is never caught up either way. */
|
|
771
852
|
create: async (input) => (await this.call('POST', '/v1/jobs/schedules', input)).data,
|
|
853
|
+
/** Change a live schedule in place (its id, state and counters kept). A
|
|
854
|
+
* cron / run_at / timezone change recomputes the next fire. Server mode
|
|
855
|
+
* only (`403 server_only` in end-user mode). */
|
|
856
|
+
update: async (scheduleId, patch) => (await this.call('PATCH', `/v1/jobs/schedules/${encodeURIComponent(scheduleId)}`, patch)).data,
|
|
772
857
|
list: async () => (await this.call('GET', '/v1/jobs/schedules')).data.schedules,
|
|
773
858
|
delete: async (scheduleId) => {
|
|
774
859
|
await this.call('DELETE', `/v1/jobs/schedules/${encodeURIComponent(scheduleId)}`);
|
|
@@ -2345,3 +2430,23 @@ export class Vxil {
|
|
|
2345
2430
|
// Failure reporting for tenant functions — the Sentry-envelope forwarder
|
|
2346
2431
|
// (guide ch. 8). Zero dependencies; see reporting.ts.
|
|
2347
2432
|
export { withReporting, report, buildEnvelope, parseDsn, exceptionEvent, reportServerErrors, REPORT_TIMEOUT_MS, } from './reporting.js';
|
|
2433
|
+
/** The query string of `vx.jobs.runs` / `runsPage`. */
|
|
2434
|
+
function jobRunsQs(q) {
|
|
2435
|
+
return qs({
|
|
2436
|
+
job_name: q?.job_name || undefined,
|
|
2437
|
+
state: q?.state || undefined,
|
|
2438
|
+
ids: q?.ids,
|
|
2439
|
+
batch_id: q?.batch_id || undefined,
|
|
2440
|
+
since: q?.since || undefined,
|
|
2441
|
+
until: q?.until || undefined,
|
|
2442
|
+
cursor: q?.cursor || undefined,
|
|
2443
|
+
limit: q?.limit || undefined,
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
/** A fresh idempotency key (crypto.randomUUID where the runtime has it). */
|
|
2447
|
+
function randomIdempotencyKey() {
|
|
2448
|
+
const c = globalThis.crypto;
|
|
2449
|
+
if (c?.randomUUID)
|
|
2450
|
+
return `sdk-${c.randomUUID()}`;
|
|
2451
|
+
return `sdk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
2452
|
+
}
|
package/dist/retry.d.ts
CHANGED
|
@@ -45,3 +45,27 @@ export interface Transport {
|
|
|
45
45
|
* `retry` / `timeoutMs` / `hooks` set this is one `fetch` + `text()` — the
|
|
46
46
|
* pre-seam behaviour, byte for byte. */
|
|
47
47
|
export declare function createTransport(opts: TransportOptions): Transport;
|
|
48
|
+
/** The 429 answers that mean "the project is at a capacity cap right now —
|
|
49
|
+
* try the SAME request again shortly" (the generation lane's in-flight cap
|
|
50
|
+
* and outstanding-holds ceiling, and the queue admission cap). Any other 429
|
|
51
|
+
* (a rate limit, a plan quota) is not a capacity answer and is rethrown. */
|
|
52
|
+
export declare const CAPACITY_ERROR_CODES: ReadonlySet<string>;
|
|
53
|
+
/** `retryOnCapacity` options. */
|
|
54
|
+
export interface RetryOnCapacityOptions {
|
|
55
|
+
/** Give up (rethrow the last 429) once waiting again would pass this many
|
|
56
|
+
* milliseconds since the first attempt. */
|
|
57
|
+
maxWaitMs: number;
|
|
58
|
+
sleep?: (ms: number) => Promise<void>;
|
|
59
|
+
random?: () => number;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Re-run `attempt` while it throws a CAPACITY 429 (`CAPACITY_ERROR_CODES`):
|
|
64
|
+
* wait the server's `Retry-After` (else 5 s) plus up to 20 % jitter (≤ 1 s,
|
|
65
|
+
* so many clients told the same second do not all return on it), and never
|
|
66
|
+
* past `maxWaitMs` since the first attempt — then the last 429 is rethrown.
|
|
67
|
+
* The caller makes `attempt` repeat the SAME request (same idempotency key),
|
|
68
|
+
* so a retry that races an earlier accepted one is deduplicated server-side.
|
|
69
|
+
* Duck-typed on `{ status, code, retryAfter }` (VxilError's fields).
|
|
70
|
+
*/
|
|
71
|
+
export declare function retryOnCapacity<T>(attempt: () => Promise<T>, opts: RetryOnCapacityOptions): Promise<T>;
|
package/dist/retry.js
CHANGED
|
@@ -154,3 +154,45 @@ export function createTransport(opts) {
|
|
|
154
154
|
},
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
+
// ─── capacity retry (`vx.jobs.generation(…, { retryOnCapacity })`) ────────────
|
|
158
|
+
/** The 429 answers that mean "the project is at a capacity cap right now —
|
|
159
|
+
* try the SAME request again shortly" (the generation lane's in-flight cap
|
|
160
|
+
* and outstanding-holds ceiling, and the queue admission cap). Any other 429
|
|
161
|
+
* (a rate limit, a plan quota) is not a capacity answer and is rethrown. */
|
|
162
|
+
export const CAPACITY_ERROR_CODES = new Set([
|
|
163
|
+
'generation_concurrency_exceeded', 'reserve_holds_exceeded', 'queue_full',
|
|
164
|
+
]);
|
|
165
|
+
/** Fallback wait (ms) when a capacity 429 carries no Retry-After. */
|
|
166
|
+
const CAPACITY_DEFAULT_WAIT_MS = 5_000;
|
|
167
|
+
/**
|
|
168
|
+
* Re-run `attempt` while it throws a CAPACITY 429 (`CAPACITY_ERROR_CODES`):
|
|
169
|
+
* wait the server's `Retry-After` (else 5 s) plus up to 20 % jitter (≤ 1 s,
|
|
170
|
+
* so many clients told the same second do not all return on it), and never
|
|
171
|
+
* past `maxWaitMs` since the first attempt — then the last 429 is rethrown.
|
|
172
|
+
* The caller makes `attempt` repeat the SAME request (same idempotency key),
|
|
173
|
+
* so a retry that races an earlier accepted one is deduplicated server-side.
|
|
174
|
+
* Duck-typed on `{ status, code, retryAfter }` (VxilError's fields).
|
|
175
|
+
*/
|
|
176
|
+
export async function retryOnCapacity(attempt, opts) {
|
|
177
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
178
|
+
const random = opts.random ?? Math.random;
|
|
179
|
+
const now = opts.now ?? Date.now;
|
|
180
|
+
const started = now();
|
|
181
|
+
for (;;) {
|
|
182
|
+
try {
|
|
183
|
+
return await attempt();
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
const err = e;
|
|
187
|
+
if (!err || err.status !== 429 || typeof err.code !== 'string' || !CAPACITY_ERROR_CODES.has(err.code))
|
|
188
|
+
throw e;
|
|
189
|
+
const baseMs = typeof err.retryAfter === 'number' && err.retryAfter >= 0
|
|
190
|
+
? Math.round(err.retryAfter * 1000)
|
|
191
|
+
: CAPACITY_DEFAULT_WAIT_MS;
|
|
192
|
+
const waitMs = baseMs + Math.round(Math.min(1_000, baseMs * 0.2) * random());
|
|
193
|
+
if (now() - started + waitMs > opts.maxWaitMs)
|
|
194
|
+
throw e;
|
|
195
|
+
await sleep(waitMs);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
package/package.json
CHANGED