@vxil/sdk 0.14.1 → 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 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
@@ -400,6 +406,88 @@ export interface JobRun {
400
406
  * strands the row; this says what was dropped. Never flips the run; not cleared by a later
401
407
  * successful mirror (compare `generation_status` / `at`). */
402
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;
403
491
  }
404
492
  /** A refused status-mirror write (`JobRun.mirror_error`). */
405
493
  export interface JobRunMirrorError {
@@ -508,8 +596,11 @@ export interface JobScheduleHeld {
508
596
  export interface JobSchedule {
509
597
  schedule_id: string;
510
598
  job_name: string;
511
- /** the create echo carries no cron; the list names it `cron_expr` */
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`.) */
512
602
  cron?: string | null;
603
+ /** the same value under the stored column's name */
513
604
  cron_expr?: string | null;
514
605
  run_at?: string | null;
515
606
  next_run_at: string | null;
@@ -524,7 +615,41 @@ export interface JobSchedule {
524
615
  last_skipped_at?: string | null;
525
616
  /** non-null while the schedule is HELD (list only) */
526
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;
527
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
+ };
528
653
  export interface AuthSession {
529
654
  token: string;
530
655
  refresh_token: string;
@@ -1069,9 +1194,25 @@ export interface JobRunEventPayload {
1069
1194
  * `queue_backstop` (the queue's own retries ran out), `callback_failed`
1070
1195
  * (the run's signed callback reported `status: 'failed'`) or
1071
1196
  * `callback_timeout` (the handler handed the run off with a 202 and no
1072
- * callback completed it within its lifetime). Absent when the run
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
1073
1200
  * exhausted its attempts normally. */
1074
- 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;
1075
1216
  }
1076
1217
  /** The provider-reported environment of the money (`production` | `sandbox`). */
1077
1218
  export type PaymentsEventEnvironment = 'production' | 'sandbox';
@@ -1318,6 +1459,7 @@ export interface VxilEventPayloads {
1318
1459
  'job.generation.failed': JobGenerationSettledEventPayload;
1319
1460
  'job.succeeded': JobRunEventPayload;
1320
1461
  'job.dead_lettered': JobRunEventPayload;
1462
+ 'job.batch.completed': JobBatchCompletedEventPayload;
1321
1463
  'payments.charge.succeeded': PaymentsChargeEventPayload;
1322
1464
  'payments.charge.completed': PaymentsChargeEventPayload;
1323
1465
  'payments.charge.refunded': PaymentsChargeRefundedEventPayload;
@@ -2952,44 +3094,72 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2952
3094
  * delivery): an external worker POSTs it to complete / fail the run, report
2953
3095
  * progress, or wake a `wait`. A handler that answers 202 HANDS the run off
2954
3096
  * — it waits for that callback (dead-lettered as `CallbackTimeout` when the
2955
- * lifetime passes). See `postRunCallback`. */
2956
- enqueue: (input: {
2957
- job_name: string;
2958
- target_url: string;
2959
- payload?: Record<string, unknown>;
2960
- idempotency_key?: string;
2961
- max_attempts?: number;
2962
- deliver_after?: string;
2963
- delay_seconds?: number;
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 & {
2964
3123
  callback?: boolean | {
2965
3124
  ttl_seconds?: number;
2966
3125
  };
2967
- }) => Promise<{
2968
- run_id: string;
2969
- state: string;
2970
- deduplicated?: boolean;
2971
- deliver_after?: string;
2972
- callback_url?: string;
2973
- }>;
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>;
2974
3136
  /** Atomic multi-enqueue (≤100 items; any invalid item rejects the whole
2975
- * batch). Each item = the enqueue input, incl. per-item idempotency_key
2976
- * and deliver_after/delay_seconds. Results align with the input order. */
2977
- enqueueBatch: (items: Array<{
2978
- job_name: string;
2979
- target_url: string;
2980
- payload?: Record<string, unknown>;
2981
- idempotency_key?: string;
2982
- max_attempts?: number;
2983
- deliver_after?: string;
2984
- delay_seconds?: number;
2985
- }>) => Promise<{
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<{
2986
3151
  runs: Array<{
2987
3152
  run_id: string;
2988
3153
  state: string;
2989
3154
  deduplicated?: boolean;
2990
3155
  }>;
2991
3156
  count: number;
3157
+ batch_id?: string;
2992
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>;
2993
3163
  /** Enqueue a long-running EXTERNAL generation run (guide ch. 6, jobs): Vxil calls
2994
3164
  * the provider (BYO key), tracks completion via poll/webhook, mirrors a typed
2995
3165
  * generation_status onto a tenant record, enforces a built-in timeout, and
@@ -3019,7 +3189,13 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3019
3189
  * A mirror write the record refuses never fails the run: a body refusal
3020
3190
  * (e.g. a callback key the collection does not declare) is retried with
3021
3191
  * only the status column, so the row still reaches `completed`; the run then reports `mirror_error`
3022
- * (`JobRun.mirror_error` on `vx.jobs.run(run_id)`). */
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. */
3023
3199
  generation: (input: {
3024
3200
  job_name: string;
3025
3201
  provider: {
@@ -3077,18 +3253,26 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3077
3253
  payload?: Record<string, unknown>;
3078
3254
  idempotency_key?: string;
3079
3255
  max_attempts?: number;
3256
+ }, opts?: {
3257
+ retryOnCapacity?: {
3258
+ maxWaitMs: number;
3259
+ };
3080
3260
  }) => Promise<{
3081
3261
  run_id: string;
3082
3262
  generation_status: string;
3083
3263
  state?: string;
3084
3264
  deduplicated?: boolean;
3085
3265
  }>;
3086
- runs: (q?: {
3087
- job_name?: string;
3088
- state?: string;
3089
- ids?: string[];
3090
- limit?: number;
3091
- }) => Promise<JobRun[]>;
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>;
3092
3276
  run: (runId: string) => Promise<JobRun>;
3093
3277
  /** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
3094
3278
  * the request platform-side (re-reading the run on one bounded connection,
@@ -3110,6 +3294,12 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3110
3294
  /** The live queue: depth by state, the oldest waiting run's age (the
3111
3295
  * number to alarm on), in-flight runs per lane, dead letters in 24 h. */
3112
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. */
3113
3303
  cancel: (runId: string) => Promise<{
3114
3304
  run_id: string;
3115
3305
  state: string;
@@ -3142,17 +3332,20 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3142
3332
  /** Secret for verifying X-Vxil-Jobs-Signature on your callback endpoints. */
3143
3333
  signingSecret: () => Promise<string>;
3144
3334
  schedules: {
3145
- /** Recurring (5-field cron, UTC) or one-shot (run_at). Exactly one of cron/run_at.
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).
3146
3342
  * `overlap: 'skip'` (cron only): no new run while the previous one is
3147
3343
  * still open — a missed window is never caught up either way. */
3148
- create: (input: {
3149
- job_name: string;
3150
- target_url: string;
3151
- payload?: Record<string, unknown>;
3152
- cron?: string;
3153
- run_at?: string;
3154
- overlap?: "allow" | "skip";
3155
- }) => 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>;
3156
3349
  list: () => Promise<JobSchedule[]>;
3157
3350
  delete: (scheduleId: string) => Promise<void>;
3158
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 deliver_after/delay_seconds. Results align with the input order. */
696
- enqueueBatch: async (items) => (await this.call('POST', '/v1/jobs/enqueue-batch', { jobs: items })).data,
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
@@ -723,17 +767,37 @@ export class Vxil {
723
767
  * A mirror write the record refuses never fails the run: a body refusal
724
768
  * (e.g. a callback key the collection does not declare) is retried with
725
769
  * only the status column, so the row still reaches `completed`; the run then reports `mirror_error`
726
- * (`JobRun.mirror_error` on `vx.jobs.run(run_id)`). */
727
- generation: async (input) => (await this.call('POST', '/v1/jobs/generation', input)).data,
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`). */
728
789
  runs: async (q) => {
729
- const s = qs({
730
- job_name: q?.job_name || undefined,
731
- state: q?.state || undefined,
732
- ids: q?.ids,
733
- limit: q?.limit || undefined,
734
- });
790
+ const s = jobRunsQs(q);
735
791
  return (await this.call('GET', `/v1/jobs/runs${s}`)).data.runs;
736
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
+ },
737
801
  run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
738
802
  /** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
739
803
  * the request platform-side (re-reading the run on one bounded connection,
@@ -753,6 +817,12 @@ export class Vxil {
753
817
  /** The live queue: depth by state, the oldest waiting run's age (the
754
818
  * number to alarm on), in-flight runs per lane, dead letters in 24 h. */
755
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. */
756
826
  cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
757
827
  /** Clone a terminal run into a fresh queued run. A generation run answers
758
828
  * `409 not_replayable` — submit the generation again instead. */
@@ -770,10 +840,20 @@ export class Vxil {
770
840
  /** Secret for verifying X-Vxil-Jobs-Signature on your callback endpoints. */
771
841
  signingSecret: async () => (await this.call('GET', '/v1/jobs/signing-secret')).data.signing_secret,
772
842
  schedules: {
773
- /** Recurring (5-field cron, UTC) or one-shot (run_at). Exactly one of cron/run_at.
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).
774
850
  * `overlap: 'skip'` (cron only): no new run while the previous one is
775
851
  * still open — a missed window is never caught up either way. */
776
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,
777
857
  list: async () => (await this.call('GET', '/v1/jobs/schedules')).data.schedules,
778
858
  delete: async (scheduleId) => {
779
859
  await this.call('DELETE', `/v1/jobs/schedules/${encodeURIComponent(scheduleId)}`);
@@ -2350,3 +2430,23 @@ export class Vxil {
2350
2430
  // Failure reporting for tenant functions — the Sentry-envelope forwarder
2351
2431
  // (guide ch. 8). Zero dependencies; see reporting.ts.
2352
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/sdk",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Typed client for the Vxil REST API (notifications, auth, jobs, files, cms, comments, webhooks, realtime, orgs, rate-limits).",