@vxil/sdk 0.16.1 → 0.18.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
@@ -160,7 +160,11 @@ export interface Delivery {
160
160
  template_id: string;
161
161
  locale: string;
162
162
  to_email: string;
163
- status: 'queued' | 'sent' | 'failed' | 'suppressed';
163
+ /** `test_sink` (2026-10-04): the recipient matched the project's
164
+ * `notifications.suppression.testRecipients` — rendered in full and recorded,
165
+ * but no provider was called and the send is not billed. Read what would
166
+ * have gone out with `notifications.delivery(id)` (`rendered`). */
167
+ status: 'queued' | 'sent' | 'failed' | 'suppressed' | 'test_sink';
164
168
  attempts: number;
165
169
  provider: string;
166
170
  provider_message_id: string | null;
@@ -185,6 +189,27 @@ export interface Delivery {
185
189
  * `delivery_id` on the 202.) */
186
190
  deliver_after?: string | null;
187
191
  }
192
+ /** One delivery by id (`notifications.delivery(id)`): the row plus a render.
193
+ * For a `test_sink` row `rendered` is the FULL render the send produced (its
194
+ * data, its locale — an `ar` template carries `dir="rtl"`) and `rendered_from`
195
+ * is `'send'` — EXCEPT auth mail (the `magic-link` / `otp-code` templates and
196
+ * the auth feature's password-reset / e-mail-verification mail) and a row
197
+ * sunk at delivery time (an address added to `testRecipients` after the send
198
+ * was queued): those are rendered WITHOUT the send's data (`rendered_from:
199
+ * 'send_redacted'`), so a sign-in link, code or reset link is never stored or
200
+ * readable. For every other row the template variables were never stored, so
201
+ * `rendered` is the template re-rendered with no data (`rendered_from:
202
+ * 'template'`), or null when it cannot be rendered. */
203
+ export interface DeliveryDetail extends Delivery {
204
+ last_error_msg?: string | null;
205
+ rendered: {
206
+ subject: string;
207
+ html: string;
208
+ text: string;
209
+ locale: string;
210
+ } | null;
211
+ rendered_from?: 'send' | 'send_redacted' | 'template';
212
+ }
188
213
  /** An ADDITIVE, non-fatal note on a 202 send — today only `mock_provider`
189
214
  * (the send is recorded but no email leaves vxil). */
190
215
  export interface NotificationWarning {
@@ -403,9 +428,20 @@ export interface JobRun {
403
428
  * problem (400 / 413 / 422 — e.g. a callback key the collection does not
404
429
  * declare) is retried with only the status column (a progress update tries
405
430
  * 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`). */
431
+ * strands the row; this says what was dropped (`status_written`: whether
432
+ * the record holds the status word). Never flips the run. A later TERMINAL
433
+ * mirror that lands cleanly moves it to `last_mirror_error` (null here
434
+ * again); a non-terminal success leaves it. A write the record refuses
435
+ * because it already holds a terminal word, or because its
436
+ * `status_mirror.fence_field` names another run, is skipped quietly —
437
+ * never a mirror_error. */
408
438
  mirror_error?: JobRunMirrorError | null;
439
+ /** the single-run read, generation runs only: the `mirror_error` a later
440
+ * clean terminal mirror healed; null otherwise */
441
+ last_mirror_error?: JobRunMirrorError | null;
442
+ /** the single-run read, generation runs only: when that healing terminal
443
+ * mirror landed (ISO); null otherwise */
444
+ mirror_ok_at?: string | null;
409
445
  /** the single-run read only: the enqueue `concurrency_key` this run holds
410
446
  * while running or waiting; null for an unkeyed run */
411
447
  concurrency_key?: string | null;
@@ -458,6 +494,7 @@ export interface JobEnqueueResult {
458
494
  export interface JobRunsQuery {
459
495
  job_name?: string;
460
496
  state?: JobRun['state'] | string;
497
+ /** exact run_ids, at most 50 distinct (more answers 422 — page in chunks of 50) */
461
498
  ids?: string[];
462
499
  batch_id?: string;
463
500
  /** ISO: runs created at or after */
@@ -501,13 +538,25 @@ export interface JobRunMirrorError {
501
538
  code: string | null;
502
539
  /** the target's message (≤ 200 chars), e.g. "unknown field 'video_url'" */
503
540
  message: string | null;
541
+ /** the target's hint (≤ 200 chars) when it sent one — e.g. the engine
542
+ * detail of a cms validate hook that could not evaluate */
543
+ hint?: string;
504
544
  /** when it was refused (ISO) */
505
545
  at: string;
546
+ /** whether the record holds `generation_status` after this write: true
547
+ * when a narrower retry carrying the status word landed (or the write left
548
+ * the word out because the record already held it); false when no write
549
+ * carrying it landed — the full write was already the status column alone,
550
+ * the refusal was not a body problem (401 / 403 / 404 / 409 / 429 / 5xx,
551
+ * a timeout), or every narrower retry was refused. Absent on errors
552
+ * recorded before it existed. */
553
+ status_written?: boolean;
506
554
  /** present when a narrower retry LANDED: the keys that were not written */
507
555
  fields_dropped?: string[];
508
- /** present when every narrower retry was refused too (the last one's HTTP
509
- * status, 0 = no answer): the status word itself was refused and the
510
- * record still holds the previous status */
556
+ /** present when narrower retries ran and none landed: the LAST one's HTTP
557
+ * status (0 = no answer). The ladder stops at the first refusal that is not
558
+ * a body problem, so read `status_written`, not this, for "did the status
559
+ * word land" */
511
560
  retry_status?: number;
512
561
  }
513
562
  /** A run's latest progress report (`JobRun.progress`): only these keys pass. */
@@ -748,6 +797,28 @@ export interface PaymentsSimulationResult {
748
797
  pass: boolean;
749
798
  };
750
799
  }
800
+ /** `payments.moveSubscriptionUntil` (the test clock) — the moved row and the
801
+ * user's entitlement snapshot after the refold. */
802
+ export interface PaymentsTestClockResult {
803
+ subscription_id: string;
804
+ user_id: string;
805
+ tier: string | null;
806
+ /** 'lapsed' after a move into the past, 'active' after one into the future */
807
+ status: string;
808
+ since: string | null;
809
+ until: string;
810
+ previous_until: string | null;
811
+ /** true when THIS move ended access (payments.subscription.lapsed emitted) */
812
+ lapsed: boolean;
813
+ /** why the project may use the test clock */
814
+ lane: 'mock' | 'sandbox' | 'dev';
815
+ entitlement: {
816
+ tier: string;
817
+ entitlements: string[];
818
+ quotas: Record<string, number>;
819
+ source_sub_id: string | null;
820
+ };
821
+ }
751
822
  /** The ONE complete entitlement read (GET /v1/payments/entitlements; guide ch. 6,
752
823
  * payments). `until` is the winning subscription's current_period_end (+ the
753
824
  * configured grace window when it is past_due); null on the free baseline. */
@@ -880,6 +951,10 @@ export interface FileUploadUrl {
880
951
  expires_in: number;
881
952
  /** When the OBJECT expires (null = never). */
882
953
  expires_at: string | null;
954
+ /** The headers the PUT to `upload_url` must send, exactly — each one is
955
+ * signed into the URL: `content-type`, plus `x-amz-checksum-sha256` when
956
+ * the request declared `checksum_sha256`. */
957
+ upload_headers?: Record<string, string>;
883
958
  }
884
959
  /** `files.usage` answer. */
885
960
  export interface FilesUsage {
@@ -893,7 +968,15 @@ export interface FilesUsage {
893
968
  maxObjectBytes?: number;
894
969
  maxTotalBytes?: number;
895
970
  };
896
- available: Record<string, number | null>;
971
+ /** Headroom left under the quotas. Never negative: when usage sits ABOVE a
972
+ * lowered quota (or a plan downgrade) both read 0 and `over_quota` is true
973
+ * — new uploads answer 422 quota_exceeded until usage drops back under. */
974
+ available: {
975
+ object_slots: number;
976
+ bytes_remaining: number;
977
+ over_quota?: boolean;
978
+ [key: string]: number | boolean | null | undefined;
979
+ };
897
980
  /** Your plan's storage ceiling, and whether `quotas.maxTotalBytes` is that
898
981
  * ceiling (`plan`) or your own lower value (`config`). */
899
982
  storage_plan?: {
@@ -1361,7 +1444,8 @@ export interface PaymentsSubscriptionStatusEventPayload {
1361
1444
  current_period_end: string | null;
1362
1445
  environment: PaymentsEventEnvironment;
1363
1446
  /** what wrote it: the provider event type, `subscription.synced` /
1364
- * `subscription.restored`, or `period_end_enforced` */
1447
+ * `subscription.restored`, `period_end_enforced`, or `test_clock` (a
1448
+ * `moveSubscriptionUntil` on a non-production project ended the row) */
1365
1449
  event_type: string;
1366
1450
  slack_hours: number | null;
1367
1451
  level: 'info';
@@ -1502,6 +1586,24 @@ export interface PaymentsConformanceEventPayload {
1502
1586
  level: 'info' | 'error';
1503
1587
  state: 'ok' | 'broken';
1504
1588
  }
1589
+ /** `payments.test_clock.moved` — the test clock moved a manual grant's or a
1590
+ * purchase pass's end (`moveSubscriptionUntil`; non-production projects only).
1591
+ * `lapsed` is true when this move ended access (a `payments.subscription.lapsed`
1592
+ * with `event_type: 'test_clock'` follows); `lane` names why the project was
1593
+ * allowed: `mock` provider, a `sandbox` provider environment, or a `dev`
1594
+ * (preview / `vxil dev up`) project. */
1595
+ export interface PaymentsTestClockMovedEventPayload {
1596
+ end_user_id: string;
1597
+ subscription_id: string;
1598
+ provider_sub_id: string;
1599
+ tier: string | null;
1600
+ previous_until: string | null;
1601
+ until: string;
1602
+ lapsed: boolean;
1603
+ lane: 'mock' | 'sandbox' | 'dev';
1604
+ level: 'info';
1605
+ state: 'ok';
1606
+ }
1505
1607
  /** Event name → typed `data`, for the events that settle work you started and
1506
1608
  * (since 2026-10-01) every `payments.*` event. `VxilEventPayload<'job.generation.failed'>`
1507
1609
  * names one; everything else on the catalog is `Record<string, unknown>` until
@@ -1536,6 +1638,7 @@ export interface VxilEventPayloads {
1536
1638
  'payments.reconcile.discrepancy': PaymentsReconcileDiscrepancyEventPayload;
1537
1639
  'payments.reconcile.ok': PaymentsReconcileOkEventPayload;
1538
1640
  'payments.conformance': PaymentsConformanceEventPayload;
1641
+ 'payments.test_clock.moved': PaymentsTestClockMovedEventPayload;
1539
1642
  }
1540
1643
  export type VxilEventPayload<E extends string> = E extends keyof VxilEventPayloads ? VxilEventPayloads[E] : Record<string, unknown>;
1541
1644
  /** The `trigger` label on the envelope. The config spells two of them in
@@ -2266,6 +2369,11 @@ type DisabledFeatures<S extends VxilSchemaShape> = {
2266
2369
  export type EnabledVxil<S extends VxilSchemaShape> = Omit<Vxil<S>, DisabledFeatures<S>> & {
2267
2370
  [P in DisabledFeatures<S>]: DisabledFeature<FeatureMap[P] & string>;
2268
2371
  };
2372
+ /** A cms collection's END-USER ACCESS mode (guide ch. 9): what a verified end
2373
+ * user (thin-client key + session) may do on it — `'readwrite'` (default),
2374
+ * `'read'` (writes are `403 server_only`) or `'none'` (reads and writes are
2375
+ * `403 server_only`). Server keys are never affected. */
2376
+ export type CmsEndUserAccess = 'readwrite' | 'read' | 'none';
2269
2377
  /** One config-declared per-record ACTION (guide ch. 4): a button on a record row
2270
2378
  * that invokes the deployed tenant function `fn` ONCE with `{ collection,
2271
2379
  * item_id, action, actor, item }`. Exactly one human-initiated step — no
@@ -2753,6 +2861,15 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2753
2861
  get: (id: string, opts?: {
2754
2862
  includeDeleted?: boolean;
2755
2863
  }) => Promise<VxilUser>;
2864
+ /**
2865
+ * Update a user. `email` / `display_name` / `avatar_url` are replaced
2866
+ * (null clears them). `attributes` is DEEP-MERGED into the stored
2867
+ * attributes (RFC 7396 merge patch): objects merge key by key, arrays and
2868
+ * scalars replace, and `null` deletes a key AT ANY DEPTH — including inside
2869
+ * a subtree the row does not have yet, where the null is simply dropped
2870
+ * (`{ attributes: { prefs: { phone: null } } }` never stores a null).
2871
+ * Scope users:write.
2872
+ */
2756
2873
  patch: (id: string, patch: {
2757
2874
  email?: string | null;
2758
2875
  display_name?: string | null;
@@ -2775,7 +2892,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2775
2892
  * Merge a REGISTRY-ONLY user (a row you created with upsert that never
2776
2893
  * signed in) INTO another user of the tenant: its attributes fill the
2777
2894
  * survivor's gaps (the survivor wins on conflicts), the merged id is
2778
- * soft-deleted, its owner-scoped cms / files / payments rows move to the
2895
+ * soft-deleted, its owner-scoped cms / files / payments / jobs rows move to the
2779
2896
  * survivor, and `auth.user.merged { from, into, method: 'registry_merge' }`
2780
2897
  * is audited. `rekeyed: false` means a re-key could not finish inside the
2781
2898
  * request — the event is the backstop (call the feature's re-key route
@@ -2994,8 +3111,9 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2994
3111
  /** Reset the row and re-enqueue the original message. */
2995
3112
  replay: (deliveryId: string) => Promise<void>;
2996
3113
  };
2997
- /** A single delivery by id (the list is `deliveries()`). */
2998
- delivery: (deliveryId: string) => Promise<Delivery>;
3114
+ /** A single delivery by id (the list is `deliveries()`), with its render —
3115
+ * the full one for a `test_sink` row (see `DeliveryDetail`). */
3116
+ delivery: (deliveryId: string) => Promise<DeliveryDetail>;
2999
3117
  /** Email broadcast campaigns (guide ch. 6, notifications): audience-ref fan-out
3000
3118
  * with quiet-hours + frequency-cap policy. `schedule_cron` sets a recurring
3001
3119
  * send (status `scheduled`); omit it for a `draft`. */
@@ -3048,6 +3166,9 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3048
3166
  skipped_freq_cap: number;
3049
3167
  skipped_no_email: number;
3050
3168
  already_sent: number;
3169
+ /** recipients in `suppression.testRecipients` on this page: recorded
3170
+ * `test_sink`, never sent, never billed */
3171
+ test_sink?: number;
3051
3172
  }>;
3052
3173
  };
3053
3174
  };
@@ -3220,7 +3341,16 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3220
3341
  *
3221
3342
  * The answer's `generation_status` is `pending` on a fresh enqueue; a replay
3222
3343
  * of the same `idempotency_key` (`deduplicated: true`) carries the run's
3223
- * status NOW (`processing`, `completed` or `failed` too).
3344
+ * status NOW (`processing`, `completed` or `failed` too). The key is bound
3345
+ * to the request BODY: re-sending it with a different body (another
3346
+ * payload — a rebuilt `deadline_at` included —, provider, completion,
3347
+ * timeout, reserve…) will answer 422 `idempotency_key_reused` naming the
3348
+ * original run (the ai-v1 rule). That check is being rolled out: today a
3349
+ * mismatch is only logged, on every project, and the send is still
3350
+ * deduplicated to the first run; a changelog entry will announce
3351
+ * enforcement — so keep the body deterministic now. A 409 `generation_in_progress`
3352
+ * (Retry-After 5) means the run that key names is still placing its
3353
+ * credit hold.
3224
3354
  *
3225
3355
  * `reserve_credits` takes a PROVISIONAL held credit debit at enqueue
3226
3356
  * (linked to the run), committed on `completed` and reversed on
@@ -3228,7 +3358,8 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3228
3358
  * `config.generation.maxReserveCredits` cap; in end-user mode `user_id` is
3229
3359
  * FORCED to the verified end-user (a mismatched user_id → 400). A 402 aborts
3230
3360
  * the enqueue (insufficient balance; the run ends with job.generation.failed
3231
- * `ReserveInsufficient` and the 402 names its `run_id`); a runaway over the
3361
+ * `ReserveInsufficient` and the 402 names its `run_id`; the key is released,
3362
+ * so the same request after a top-up starts a new run); a runaway over the
3232
3363
  * per-tenant outstanding-holds ceiling → 429 (Retry-After 5). Payments
3233
3364
  * unreachable at the hold → 503 `payments_unavailable` (Retry-After 15;
3234
3365
  * nothing started, nothing held — re-send the same request), unless
@@ -3249,8 +3380,9 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3249
3380
  *
3250
3381
  * `opts.retryOnCapacity: { maxWaitMs }` retries a CAPACITY 429
3251
3382
  * (`generation_concurrency_exceeded`, `reserve_holds_exceeded`,
3252
- * `queue_full`) and a 503 `payments_unavailable`, honouring Retry-After
3253
- * with jitter, with the SAME
3383
+ * `queue_full`), a 503 `payments_unavailable` / `enqueue_interrupted` and a
3384
+ * 409 `generation_in_progress` (the key's run is still placing its credit
3385
+ * hold), honouring Retry-After with jitter, with the SAME
3254
3386
  * idempotency_key (one is generated when the input has none), until
3255
3387
  * maxWaitMs has passed — then the last 429 is thrown. */
3256
3388
  generation: (input: {
@@ -3281,13 +3413,26 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3281
3413
  };
3282
3414
  /** `progress_fields`: the progress keys (`progress` / `stage` /
3283
3415
  * `message`) a webhook-mode `processing` callback also writes onto the
3284
- * mirrored record — realtime clients of that record see them live */
3416
+ * mirrored record — realtime clients of that record see them live.
3417
+ * In END-USER mode (`asEndUser`, or a function invoked for a signed-in
3418
+ * user) the target must be a cms record that user can read: another
3419
+ * user's row, a server-only collection or a non-cms feature answers
3420
+ * 403 `mirror_target_forbidden` and nothing starts (503
3421
+ * `mirror_check_unavailable` if the check could not run — retry).
3422
+ * Mirror onto a server-only row from server-mode code instead. */
3285
3423
  status_mirror?: {
3286
3424
  feature: string;
3287
3425
  collection: string;
3288
3426
  record_id: string;
3289
3427
  column?: string;
3290
3428
  progress_fields?: Array<"progress" | "stage" | "message">;
3429
+ /** cms only: a declared string field the mirror stamps with this
3430
+ * run's `run_id` on its first write; every later write of the run
3431
+ * requires it (a cms `if` precondition), so an older run's late
3432
+ * writes never land on a row a newer run has claimed. A write the
3433
+ * record refuses that way — or because it already holds
3434
+ * `completed` / `failed` — is skipped, not a `mirror_error`. */
3435
+ fence_field?: string;
3291
3436
  };
3292
3437
  timeout?: {
3293
3438
  after_ms: number;
@@ -3365,9 +3510,12 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3365
3510
  /** Cancel a run that has not started its current attempt (`queued`,
3366
3511
  * `delayed`, `retrying`) or a plain run handed off to its signed callback
3367
3512
  * (`waiting` after its handler answered 202 — the callback URL is
3368
- * consumed). A `running` run (a delivery in flight, or a generation
3369
- * waiting on its provider), a run waiting on an event, or a terminal run
3370
- * answers `409 not_cancellable`. A generation's credit hold is released. */
3513
+ * consumed). A generation's credit hold is released — a STARTED
3514
+ * generation's too (its provider accepted it): it ends `failed`
3515
+ * (`Cancelled`) and a late provider callback is a no-op, but the provider
3516
+ * is NOT told, so its work and its charge may still complete. A plain
3517
+ * `running` run (a delivery in flight), a run waiting on an event, or a
3518
+ * terminal run answers `409 not_cancellable`. */
3371
3519
  cancel: (runId: string) => Promise<{
3372
3520
  run_id: string;
3373
3521
  state: string;
@@ -3378,6 +3526,24 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3378
3526
  run_id: string;
3379
3527
  replayed_from: string;
3380
3528
  }>;
3529
+ /** SERVER-ONLY (403 server_only in end-user mode). Account merge: move the
3530
+ * credit-hold owner (`reserve_credits.user_id`) of every run of
3531
+ * `from_user_id`, in any state, onto `into_user_id`, so the surviving
3532
+ * account reads, cancels and replays the generation runs it started as a
3533
+ * guest. ONE bounded page (≤500 runs) per call — call again until `done`
3534
+ * (idempotent: a second call moves 0). The jobs consumer of
3535
+ * `auth.user.merged { from, into }`; auth and `users.merge` call it after
3536
+ * a merge, the event is the backstop. Emits one `jobs.runs.rekeyed` when
3537
+ * a call moved at least one run (none on a zero-move call). Unlike the
3538
+ * files re-key it does not check that `into_user_id` exists.
3539
+ * Scope jobs:write. */
3540
+ reKey: (input: {
3541
+ from_user_id: string;
3542
+ into_user_id: string;
3543
+ }) => Promise<{
3544
+ moved: number;
3545
+ done: boolean;
3546
+ }>;
3381
3547
  /**
3382
3548
  * Suspend the RUNNING run until an event (call from the executing
3383
3549
  * handler, then return 200 — the suspension wins). `state: 'resumed'` =
@@ -3502,9 +3668,10 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3502
3668
  sent: true;
3503
3669
  test_link?: string;
3504
3670
  }>;
3505
- /** `opts.anonymous_token` is REQUIRED for a link that was requested with
3506
- * one (the guest claim above): the same guest session must present it,
3507
- * else `401 invalid_session`. `merged` is true only when the guest was
3671
+ /** An unknown, expired or already-used link answers `401 invalid_token`
3672
+ * (request a new one with `request`). `opts.anonymous_token` is REQUIRED
3673
+ * for a link that was requested with one (the guest claim above): the
3674
+ * same guest session must present it, else `401 invalid_session`. `merged` is true only when the guest was
3508
3675
  * folded into an existing account (then `user_id` is that account), and
3509
3676
  * `rekeyed` rides beside it: true ⇒ the guest's payments / cms / files
3510
3677
  * rows were already moved onto `user_id` when this returned; false ⇒
@@ -3689,7 +3856,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3689
3856
  }) => Promise<AuthImportResult>;
3690
3857
  /** END-USER MODE ONLY (`endUserToken` / `asEndUser`): the signed-in user
3691
3858
  * deep-merges a bounded `attributes` object into its OWN shared identity
3692
- * row (null deletes a key; ≤8 KB, ≤64 top-level keys, depth ≤8). Profile
3859
+ * row (null deletes a key at any depth — RFC 7396; ≤8 KB, ≤64 top-level keys, depth ≤8). Profile
3693
3860
  * fields are not patchable here. In server mode the call throws 422
3694
3861
  * `end_user_mode_required` — use `users.patch(id, …)` instead. */
3695
3862
  patchMe: (attributes: Record<string, unknown>) => Promise<{
@@ -3933,7 +4100,11 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3933
4100
  };
3934
4101
  readonly cms: {
3935
4102
  collections: {
3936
- /** Define a content type. The model is data — not config. */
4103
+ /** Define a content type. The model is data — not config.
4104
+ * Model management (`create`, `addField` and every fields-route meta-op
4105
+ * in this namespace) is SERVER-ONLY: a client acting as a signed-in end
4106
+ * user (`asEndUser`, a thin-client key) gets `403 server_only`. Call
4107
+ * these with a server key — your backend, `vxil push`, or the dashboard. */
3937
4108
  create: (input: {
3938
4109
  collection: string;
3939
4110
  singular?: string;
@@ -3949,6 +4120,12 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3949
4120
  * and the owner_field are never exposed. Optional; defaults false. Use the
3950
4121
  * top-level `cmsPublicUrl` / `listCmsPublic` helpers for the reader side. */
3951
4122
  public?: boolean;
4123
+ /** What a VERIFIED end user may do here (guide ch. 9, "Read-only and
4124
+ * server-only collections"): `'readwrite'` (default), `'read'` (every
4125
+ * end-user write is `403 server_only`; reads unchanged) or `'none'`
4126
+ * (end-user reads and writes are `403 server_only`, even with an
4127
+ * owner_field). Server keys are never affected. */
4128
+ end_user_access?: CmsEndUserAccess;
3952
4129
  fields?: Array<{
3953
4130
  field: string;
3954
4131
  type: "string" | "text" | "int" | "float" | "bool" | "datetime" | "json" | "relation" | "file";
@@ -3996,12 +4173,16 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3996
4173
  owner_field?: string;
3997
4174
  public?: boolean;
3998
4175
  actions?: CmsActionDef[];
4176
+ /** present when not the default 'readwrite' */
4177
+ end_user_access?: CmsEndUserAccess;
3999
4178
  }>;
4000
4179
  list: () => Promise<Array<{
4001
4180
  collection: string;
4002
4181
  singular: string;
4003
4182
  fields: unknown[];
4004
4183
  owner_field?: string | null;
4184
+ /** the collection's end-user access mode (absent from an older server = 'readwrite') */
4185
+ end_user_access?: CmsEndUserAccess;
4005
4186
  }>>;
4006
4187
  addField: (collection: string, field: Record<string, unknown>) => Promise<void>;
4007
4188
  /** Set (or clear, with `null`/`[]`) a field's READ-ROLE gate (guide ch. 4) —
@@ -4033,6 +4214,17 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
4033
4214
  * the owner_field are never exposed. `false` closes the lane (and purges the
4034
4215
  * edge cache). Read the public side with `cmsPublicUrl` / `listCmsPublic`. */
4035
4216
  setPublic: (collection: string, isPublic: boolean) => Promise<void>;
4217
+ /** Set the collection's END-USER ACCESS mode (guide ch. 9, "Read-only and
4218
+ * server-only collections"). `'read'`: a verified end user (thin-client
4219
+ * key + session) may still read — owner-scoped when the collection has an
4220
+ * owner_field — but every end-user write answers `403 server_only`;
4221
+ * `'none'`: end-user reads and writes are both refused; `'readwrite'`
4222
+ * (default) restores today's behaviour. Server keys are never affected.
4223
+ * Audited; takes effect on the next request. */
4224
+ setEndUserAccess: (collection: string, access: CmsEndUserAccess) => Promise<{
4225
+ collection: string;
4226
+ end_user_access: CmsEndUserAccess;
4227
+ }>;
4036
4228
  /** Re-project the collection's index slots after an `index_slot` move
4037
4229
  * (guide ch. 4). Slots are projected on WRITE only, so until this runs,
4038
4230
  * stored rows keep their OLD projection: the new slot is NULL and the
@@ -5071,16 +5263,27 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
5071
5263
  * long after the mint, whether or not `files.ttl` is enabled; `null` =
5072
5264
  * never. The answer's `expires_in` is the UPLOAD URL's life in seconds;
5073
5265
  * `expires_at` is when the object expires (null = never). `user_id` is
5074
- * required with a server key and omitted in end-user mode. */
5266
+ * required with a server key and omitted in end-user mode.
5267
+ * `checksum_sha256` (the SHA-256 of the bytes, 64 hex or 44 base64
5268
+ * characters) is signed into the URL (send the answer's `upload_headers`
5269
+ * on the PUT) and kept with the upload: `complete` compares it with the
5270
+ * SHA-256 the store reports for the bytes — 409 `checksum_mismatch` when
5271
+ * they differ, 409 `checksum_unverified` when the store reports none —
5272
+ * and on success answers the verified `checksum_sha256`. */
5075
5273
  createUploadUrl: (input: {
5076
5274
  user_id?: string;
5077
5275
  filename: string;
5078
5276
  content_type: string;
5079
5277
  size_bytes: number;
5080
5278
  expiresInSeconds?: number | null;
5279
+ checksum_sha256?: string;
5081
5280
  }) => Promise<FileUploadUrl>;
5082
5281
  /** Confirm the PUT (bytes verified, object → available). Answers the
5083
- * object's `expires_at` (null = never). */
5282
+ * object's `expires_at` (null = never) and `checksum_sha256` (hex; null
5283
+ * when the store reported none) — a repeated complete answers the same.
5284
+ * When the upload declared `checksum_sha256`: 409 `checksum_mismatch` if
5285
+ * the bytes hash differently, 409 `checksum_unverified` if the store
5286
+ * reports no checksum (the object stays pending either way). */
5084
5287
  complete: (objectId: string) => Promise<{
5085
5288
  object_id: string;
5086
5289
  status: string;
@@ -6047,6 +6250,30 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
6047
6250
  user_id: string;
6048
6251
  tier?: string;
6049
6252
  }) => Promise<PaymentsSimulationResult>;
6253
+ /** SERVER-ONLY TEST CLOCK — move the end (`until`, past or future, within
6254
+ * 5 years of now) of a MANUAL grant or a purchase pass, to test expired /
6255
+ * export-window / renew-soon states on demand. Allowed only where no live
6256
+ * money can be involved: a `mock`-provider project, a project whose
6257
+ * provider runs in its sandbox (`paddle.sandbox`, `paypal.sandbox`, a
6258
+ * Stripe `sk_test_` key), or a dev/preview project — elsewhere 403
6259
+ * `test_clock_forbidden` (also for a pass linked to a non-sandbox charge).
6260
+ * 409 `provider_managed` for a provider subscription (use the provider's
6261
+ * own test clock); 409 `subscription_ended` for a revoked/refunded row.
6262
+ * The effect is a natural lapse's: the entitlements refold
6263
+ * (`payments.entitlement.changed`), a move into the past turns an active
6264
+ * row `lapsed` and emits `payments.subscription.lapsed` (`event_type:
6265
+ * 'test_clock'`, `environment: 'sandbox'`) once; a move back into the
6266
+ * future revives it (as `active` — a `trialing` row does not return to
6267
+ * `trialing`; a `past_due` row's status is left as it is). Only the end
6268
+ * moves: `since` (the period start) is never rewritten, so after a move
6269
+ * before it `since` > `until`. Every move emits
6270
+ * `payments.test_clock.moved`. Both events are emitted after the move
6271
+ * commits, best-effort (as the period-end sweep's are): if one fails to
6272
+ * record, the move still stands and is not re-announced on a retry, so
6273
+ * assert on the returned `status` / `lapsed` rather than only on the
6274
+ * event. Same-tier passes stacked behind the moved one are not
6275
+ * re-chained. No credits are granted or reversed. */
6276
+ moveSubscriptionUntil: (subscriptionId: string, until: string | Date) => Promise<PaymentsTestClockResult>;
6050
6277
  /** The last report-only reconciliation sweep result for this project
6051
6278
  * (`run: null` before the first daily tick). Finding kinds:
6052
6279
  * `balance_drift`, `entitlement_lag`, `null_tier`, `refund_pending`,
package/dist/index.js CHANGED
@@ -432,6 +432,15 @@ export class Vxil {
432
432
  upsert: async (user) => (await this.call('POST', '/v1/users', user)).data,
433
433
  bulk: async (users) => (await this.call('POST', '/v1/users/bulk', { users })).data.upserted,
434
434
  get: async (id, opts) => (await this.call('GET', `/v1/users/${encodeURIComponent(id)}${opts?.includeDeleted ? '?include_deleted=true' : ''}`)).data,
435
+ /**
436
+ * Update a user. `email` / `display_name` / `avatar_url` are replaced
437
+ * (null clears them). `attributes` is DEEP-MERGED into the stored
438
+ * attributes (RFC 7396 merge patch): objects merge key by key, arrays and
439
+ * scalars replace, and `null` deletes a key AT ANY DEPTH — including inside
440
+ * a subtree the row does not have yet, where the null is simply dropped
441
+ * (`{ attributes: { prefs: { phone: null } } }` never stores a null).
442
+ * Scope users:write.
443
+ */
435
444
  patch: async (id, patch) => (await this.call('PATCH', `/v1/users/${encodeURIComponent(id)}`, patch)).data,
436
445
  /**
437
446
  * Delete a user. By default this is a SOFT delete (sets deleted_at; the PII
@@ -449,7 +458,7 @@ export class Vxil {
449
458
  * Merge a REGISTRY-ONLY user (a row you created with upsert that never
450
459
  * signed in) INTO another user of the tenant: its attributes fill the
451
460
  * survivor's gaps (the survivor wins on conflicts), the merged id is
452
- * soft-deleted, its owner-scoped cms / files / payments rows move to the
461
+ * soft-deleted, its owner-scoped cms / files / payments / jobs rows move to the
453
462
  * survivor, and `auth.user.merged { from, into, method: 'registry_merge' }`
454
463
  * is audited. `rekeyed: false` means a re-key could not finish inside the
455
464
  * request — the event is the backstop (call the feature's re-key route
@@ -567,7 +576,8 @@ export class Vxil {
567
576
  await this.call('POST', `/v1/notifications/dead-letters/${encodeURIComponent(deliveryId)}/replay`);
568
577
  },
569
578
  },
570
- /** A single delivery by id (the list is `deliveries()`). */
579
+ /** A single delivery by id (the list is `deliveries()`), with its render —
580
+ * the full one for a `test_sink` row (see `DeliveryDetail`). */
571
581
  delivery: async (deliveryId) => (await this.call('GET', `/v1/notifications/deliveries/${encodeURIComponent(deliveryId)}`)).data,
572
582
  /** Email broadcast campaigns (guide ch. 6, notifications): audience-ref fan-out
573
583
  * with quiet-hours + frequency-cap policy. `schedule_cron` sets a recurring
@@ -756,7 +766,16 @@ export class Vxil {
756
766
  *
757
767
  * The answer's `generation_status` is `pending` on a fresh enqueue; a replay
758
768
  * of the same `idempotency_key` (`deduplicated: true`) carries the run's
759
- * status NOW (`processing`, `completed` or `failed` too).
769
+ * status NOW (`processing`, `completed` or `failed` too). The key is bound
770
+ * to the request BODY: re-sending it with a different body (another
771
+ * payload — a rebuilt `deadline_at` included —, provider, completion,
772
+ * timeout, reserve…) will answer 422 `idempotency_key_reused` naming the
773
+ * original run (the ai-v1 rule). That check is being rolled out: today a
774
+ * mismatch is only logged, on every project, and the send is still
775
+ * deduplicated to the first run; a changelog entry will announce
776
+ * enforcement — so keep the body deterministic now. A 409 `generation_in_progress`
777
+ * (Retry-After 5) means the run that key names is still placing its
778
+ * credit hold.
760
779
  *
761
780
  * `reserve_credits` takes a PROVISIONAL held credit debit at enqueue
762
781
  * (linked to the run), committed on `completed` and reversed on
@@ -764,7 +783,8 @@ export class Vxil {
764
783
  * `config.generation.maxReserveCredits` cap; in end-user mode `user_id` is
765
784
  * FORCED to the verified end-user (a mismatched user_id → 400). A 402 aborts
766
785
  * the enqueue (insufficient balance; the run ends with job.generation.failed
767
- * `ReserveInsufficient` and the 402 names its `run_id`); a runaway over the
786
+ * `ReserveInsufficient` and the 402 names its `run_id`; the key is released,
787
+ * so the same request after a top-up starts a new run); a runaway over the
768
788
  * per-tenant outstanding-holds ceiling → 429 (Retry-After 5). Payments
769
789
  * unreachable at the hold → 503 `payments_unavailable` (Retry-After 15;
770
790
  * nothing started, nothing held — re-send the same request), unless
@@ -785,8 +805,9 @@ export class Vxil {
785
805
  *
786
806
  * `opts.retryOnCapacity: { maxWaitMs }` retries a CAPACITY 429
787
807
  * (`generation_concurrency_exceeded`, `reserve_holds_exceeded`,
788
- * `queue_full`) and a 503 `payments_unavailable`, honouring Retry-After
789
- * with jitter, with the SAME
808
+ * `queue_full`), a 503 `payments_unavailable` / `enqueue_interrupted` and a
809
+ * 409 `generation_in_progress` (the key's run is still placing its credit
810
+ * hold), honouring Retry-After with jitter, with the SAME
790
811
  * idempotency_key (one is generated when the input has none), until
791
812
  * maxWaitMs has passed — then the last 429 is thrown. */
792
813
  generation: async (input, opts) => {
@@ -835,13 +856,28 @@ export class Vxil {
835
856
  /** Cancel a run that has not started its current attempt (`queued`,
836
857
  * `delayed`, `retrying`) or a plain run handed off to its signed callback
837
858
  * (`waiting` after its handler answered 202 — the callback URL is
838
- * consumed). A `running` run (a delivery in flight, or a generation
839
- * waiting on its provider), a run waiting on an event, or a terminal run
840
- * answers `409 not_cancellable`. A generation's credit hold is released. */
859
+ * consumed). A generation's credit hold is released — a STARTED
860
+ * generation's too (its provider accepted it): it ends `failed`
861
+ * (`Cancelled`) and a late provider callback is a no-op, but the provider
862
+ * is NOT told, so its work and its charge may still complete. A plain
863
+ * `running` run (a delivery in flight), a run waiting on an event, or a
864
+ * terminal run answers `409 not_cancellable`. */
841
865
  cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
842
866
  /** Clone a terminal run into a fresh queued run. A generation run answers
843
867
  * `409 not_replayable` — submit the generation again instead. */
844
868
  replay: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/replay`)).data,
869
+ /** SERVER-ONLY (403 server_only in end-user mode). Account merge: move the
870
+ * credit-hold owner (`reserve_credits.user_id`) of every run of
871
+ * `from_user_id`, in any state, onto `into_user_id`, so the surviving
872
+ * account reads, cancels and replays the generation runs it started as a
873
+ * guest. ONE bounded page (≤500 runs) per call — call again until `done`
874
+ * (idempotent: a second call moves 0). The jobs consumer of
875
+ * `auth.user.merged { from, into }`; auth and `users.merge` call it after
876
+ * a merge, the event is the backstop. Emits one `jobs.runs.rekeyed` when
877
+ * a call moved at least one run (none on a zero-move call). Unlike the
878
+ * files re-key it does not check that `into_user_id` exists.
879
+ * Scope jobs:write. */
880
+ reKey: async (input) => (await this.call('POST', '/v1/jobs/runs/re-key', input)).data,
845
881
  /**
846
882
  * Suspend the RUNNING run until an event (call from the executing
847
883
  * handler, then return 200 — the suspension wins). `state: 'resumed'` =
@@ -926,9 +962,10 @@ export class Vxil {
926
962
  * existing account; swap tokens). Needs `anonymous.enabled`; a non-guest
927
963
  * bearer is `409 not_anonymous`. */
928
964
  request: async (input) => (await this.call('POST', '/v1/auth/magic-link/request', input)).data,
929
- /** `opts.anonymous_token` is REQUIRED for a link that was requested with
930
- * one (the guest claim above): the same guest session must present it,
931
- * else `401 invalid_session`. `merged` is true only when the guest was
965
+ /** An unknown, expired or already-used link answers `401 invalid_token`
966
+ * (request a new one with `request`). `opts.anonymous_token` is REQUIRED
967
+ * for a link that was requested with one (the guest claim above): the
968
+ * same guest session must present it, else `401 invalid_session`. `merged` is true only when the guest was
932
969
  * folded into an existing account (then `user_id` is that account), and
933
970
  * `rekeyed` rides beside it: true ⇒ the guest's payments / cms / files
934
971
  * rows were already moved onto `user_id` when this returned; false ⇒
@@ -1026,7 +1063,7 @@ export class Vxil {
1026
1063
  import: async (input) => (await this.call('POST', '/v1/auth/users/import', input)).data,
1027
1064
  /** END-USER MODE ONLY (`endUserToken` / `asEndUser`): the signed-in user
1028
1065
  * deep-merges a bounded `attributes` object into its OWN shared identity
1029
- * row (null deletes a key; ≤8 KB, ≤64 top-level keys, depth ≤8). Profile
1066
+ * row (null deletes a key at any depth — RFC 7396; ≤8 KB, ≤64 top-level keys, depth ≤8). Profile
1030
1067
  * fields are not patchable here. In server mode the call throws 422
1031
1068
  * `end_user_mode_required` — use `users.patch(id, …)` instead. */
1032
1069
  patchMe: async (attributes) => (await this.call('PATCH', '/v1/auth/users/me', { attributes })).data,
@@ -1210,7 +1247,11 @@ export class Vxil {
1210
1247
  };
1211
1248
  cms = {
1212
1249
  collections: {
1213
- /** Define a content type. The model is data — not config. */
1250
+ /** Define a content type. The model is data — not config.
1251
+ * Model management (`create`, `addField` and every fields-route meta-op
1252
+ * in this namespace) is SERVER-ONLY: a client acting as a signed-in end
1253
+ * user (`asEndUser`, a thin-client key) gets `403 server_only`. Call
1254
+ * these with a server key — your backend, `vxil push`, or the dashboard. */
1214
1255
  create: async (input) => (await this.call('POST', '/v1/cms/collections', input)).data,
1215
1256
  list: async () => (await this.call('GET', '/v1/cms/collections')).data.collections,
1216
1257
  addField: async (collection, field) => {
@@ -1262,6 +1303,14 @@ export class Vxil {
1262
1303
  setPublic: async (collection, isPublic) => {
1263
1304
  await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { public: isPublic });
1264
1305
  },
1306
+ /** Set the collection's END-USER ACCESS mode (guide ch. 9, "Read-only and
1307
+ * server-only collections"). `'read'`: a verified end user (thin-client
1308
+ * key + session) may still read — owner-scoped when the collection has an
1309
+ * owner_field — but every end-user write answers `403 server_only`;
1310
+ * `'none'`: end-user reads and writes are both refused; `'readwrite'`
1311
+ * (default) restores today's behaviour. Server keys are never affected.
1312
+ * Audited; takes effect on the next request. */
1313
+ setEndUserAccess: async (collection, access) => (await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { end_user_access: access })).data,
1265
1314
  /** Re-project the collection's index slots after an `index_slot` move
1266
1315
  * (guide ch. 4). Slots are projected on WRITE only, so until this runs,
1267
1316
  * stored rows keep their OLD projection: the new slot is NULL and the
@@ -1852,10 +1901,20 @@ export class Vxil {
1852
1901
  * long after the mint, whether or not `files.ttl` is enabled; `null` =
1853
1902
  * never. The answer's `expires_in` is the UPLOAD URL's life in seconds;
1854
1903
  * `expires_at` is when the object expires (null = never). `user_id` is
1855
- * required with a server key and omitted in end-user mode. */
1904
+ * required with a server key and omitted in end-user mode.
1905
+ * `checksum_sha256` (the SHA-256 of the bytes, 64 hex or 44 base64
1906
+ * characters) is signed into the URL (send the answer's `upload_headers`
1907
+ * on the PUT) and kept with the upload: `complete` compares it with the
1908
+ * SHA-256 the store reports for the bytes — 409 `checksum_mismatch` when
1909
+ * they differ, 409 `checksum_unverified` when the store reports none —
1910
+ * and on success answers the verified `checksum_sha256`. */
1856
1911
  createUploadUrl: async (input) => (await this.call('POST', '/v1/files/upload-url', input)).data,
1857
1912
  /** Confirm the PUT (bytes verified, object → available). Answers the
1858
- * object's `expires_at` (null = never). */
1913
+ * object's `expires_at` (null = never) and `checksum_sha256` (hex; null
1914
+ * when the store reported none) — a repeated complete answers the same.
1915
+ * When the upload declared `checksum_sha256`: 409 `checksum_mismatch` if
1916
+ * the bytes hash differently, 409 `checksum_unverified` if the store
1917
+ * reports no checksum (the object stays pending either way). */
1859
1918
  complete: async (objectId) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/complete`)).data,
1860
1919
  downloadUrl: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/download-url`)).data.download_url,
1861
1920
  /** Mint up to 100 presigned GETs in ONE call (`GET /v1/files/download-urls`)
@@ -2371,6 +2430,30 @@ export class Vxil {
2371
2430
  * past-due-grace, cross-platform-unlock, transfer) through the mock
2372
2431
  * webhook path and judge it with the conformance oracle. */
2373
2432
  simulate: async (input) => (await this.call('POST', '/v1/payments/simulate', input)).data,
2433
+ /** SERVER-ONLY TEST CLOCK — move the end (`until`, past or future, within
2434
+ * 5 years of now) of a MANUAL grant or a purchase pass, to test expired /
2435
+ * export-window / renew-soon states on demand. Allowed only where no live
2436
+ * money can be involved: a `mock`-provider project, a project whose
2437
+ * provider runs in its sandbox (`paddle.sandbox`, `paypal.sandbox`, a
2438
+ * Stripe `sk_test_` key), or a dev/preview project — elsewhere 403
2439
+ * `test_clock_forbidden` (also for a pass linked to a non-sandbox charge).
2440
+ * 409 `provider_managed` for a provider subscription (use the provider's
2441
+ * own test clock); 409 `subscription_ended` for a revoked/refunded row.
2442
+ * The effect is a natural lapse's: the entitlements refold
2443
+ * (`payments.entitlement.changed`), a move into the past turns an active
2444
+ * row `lapsed` and emits `payments.subscription.lapsed` (`event_type:
2445
+ * 'test_clock'`, `environment: 'sandbox'`) once; a move back into the
2446
+ * future revives it (as `active` — a `trialing` row does not return to
2447
+ * `trialing`; a `past_due` row's status is left as it is). Only the end
2448
+ * moves: `since` (the period start) is never rewritten, so after a move
2449
+ * before it `since` > `until`. Every move emits
2450
+ * `payments.test_clock.moved`. Both events are emitted after the move
2451
+ * commits, best-effort (as the period-end sweep's are): if one fails to
2452
+ * record, the move still stands and is not re-announced on a retry, so
2453
+ * assert on the returned `status` / `lapsed` rather than only on the
2454
+ * event. Same-tier passes stacked behind the moved one are not
2455
+ * re-chained. No credits are granted or reversed. */
2456
+ moveSubscriptionUntil: async (subscriptionId, until) => (await this.call('POST', `/v1/payments/subscriptions/${encodeURIComponent(subscriptionId)}/until`, { until: typeof until === 'string' ? until : until.toISOString() })).data,
2374
2457
  /** The last report-only reconciliation sweep result for this project
2375
2458
  * (`run: null` before the first daily tick). Finding kinds:
2376
2459
  * `balance_drift`, `entitlement_lag`, `null_tier`, `refund_pending`,
package/dist/retry.d.ts CHANGED
@@ -55,6 +55,11 @@ export declare const CAPACITY_ERROR_CODES: ReadonlySet<string>;
55
55
  * started and nothing held, and the SAME request re-sent after Retry-After
56
56
  * starts the run (the refused run released its idempotency key). */
57
57
  export declare const UNAVAILABLE_ERROR_CODES: ReadonlySet<string>;
58
+ /** The 409 answers retried the same way: the run this idempotency_key names
59
+ * is still placing its credit hold (`generation_in_progress`, Retry-After 5)
60
+ * — the SAME request re-sent then answers with that run (or, when the
61
+ * earlier enqueue was cut off, starts a fresh one). */
62
+ export declare const IN_PROGRESS_ERROR_CODES: ReadonlySet<string>;
58
63
  /** Is this thrown error one `retryOnCapacity` waits out? Duck-typed on
59
64
  * `{ status, code }` (VxilError's fields). */
60
65
  export declare function isCapacityRetryable(e: unknown): boolean;
@@ -68,8 +73,9 @@ export interface RetryOnCapacityOptions {
68
73
  now?: () => number;
69
74
  }
70
75
  /**
71
- * Re-run `attempt` while it throws a CAPACITY 429 (`CAPACITY_ERROR_CODES`) or
72
- * a 503 `payments_unavailable` (`UNAVAILABLE_ERROR_CODES`):
76
+ * Re-run `attempt` while it throws a CAPACITY 429 (`CAPACITY_ERROR_CODES`),
77
+ * a 503 `payments_unavailable` / `enqueue_interrupted` (`UNAVAILABLE_ERROR_CODES`)
78
+ * or a 409 `generation_in_progress` (`IN_PROGRESS_ERROR_CODES`):
73
79
  * wait the server's `Retry-After` (else 5 s) plus up to 20 % jitter (≤ 1 s,
74
80
  * so many clients told the same second do not all return on it), and never
75
81
  * past `maxWaitMs` since the first attempt — then the last 429 is rethrown.
package/dist/retry.js CHANGED
@@ -166,7 +166,12 @@ export const CAPACITY_ERROR_CODES = new Set([
166
166
  * lane could not reach payments to place its credit hold — nothing was
167
167
  * started and nothing held, and the SAME request re-sent after Retry-After
168
168
  * starts the run (the refused run released its idempotency key). */
169
- export const UNAVAILABLE_ERROR_CODES = new Set(['payments_unavailable']);
169
+ export const UNAVAILABLE_ERROR_CODES = new Set(['payments_unavailable', 'enqueue_interrupted']);
170
+ /** The 409 answers retried the same way: the run this idempotency_key names
171
+ * is still placing its credit hold (`generation_in_progress`, Retry-After 5)
172
+ * — the SAME request re-sent then answers with that run (or, when the
173
+ * earlier enqueue was cut off, starts a fresh one). */
174
+ export const IN_PROGRESS_ERROR_CODES = new Set(['generation_in_progress']);
170
175
  /** Is this thrown error one `retryOnCapacity` waits out? Duck-typed on
171
176
  * `{ status, code }` (VxilError's fields). */
172
177
  export function isCapacityRetryable(e) {
@@ -174,13 +179,15 @@ export function isCapacityRetryable(e) {
174
179
  if (!err || typeof err.code !== 'string')
175
180
  return false;
176
181
  return (err.status === 429 && CAPACITY_ERROR_CODES.has(err.code))
177
- || (err.status === 503 && UNAVAILABLE_ERROR_CODES.has(err.code));
182
+ || (err.status === 503 && UNAVAILABLE_ERROR_CODES.has(err.code))
183
+ || (err.status === 409 && IN_PROGRESS_ERROR_CODES.has(err.code));
178
184
  }
179
185
  /** Fallback wait (ms) when a capacity 429 carries no Retry-After. */
180
186
  const CAPACITY_DEFAULT_WAIT_MS = 5_000;
181
187
  /**
182
- * Re-run `attempt` while it throws a CAPACITY 429 (`CAPACITY_ERROR_CODES`) or
183
- * a 503 `payments_unavailable` (`UNAVAILABLE_ERROR_CODES`):
188
+ * Re-run `attempt` while it throws a CAPACITY 429 (`CAPACITY_ERROR_CODES`),
189
+ * a 503 `payments_unavailable` / `enqueue_interrupted` (`UNAVAILABLE_ERROR_CODES`)
190
+ * or a 409 `generation_in_progress` (`IN_PROGRESS_ERROR_CODES`):
184
191
  * wait the server's `Retry-After` (else 5 s) plus up to 20 % jitter (≤ 1 s,
185
192
  * so many clients told the same second do not all return on it), and never
186
193
  * past `maxWaitMs` since the first attempt — then the last 429 is rethrown.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/sdk",
3
- "version": "0.16.1",
3
+ "version": "0.18.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).",