@vxil/sdk 0.17.0 → 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
@@ -2758,6 +2861,15 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2758
2861
  get: (id: string, opts?: {
2759
2862
  includeDeleted?: boolean;
2760
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
+ */
2761
2873
  patch: (id: string, patch: {
2762
2874
  email?: string | null;
2763
2875
  display_name?: string | null;
@@ -2780,7 +2892,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2780
2892
  * Merge a REGISTRY-ONLY user (a row you created with upsert that never
2781
2893
  * signed in) INTO another user of the tenant: its attributes fill the
2782
2894
  * survivor's gaps (the survivor wins on conflicts), the merged id is
2783
- * 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
2784
2896
  * survivor, and `auth.user.merged { from, into, method: 'registry_merge' }`
2785
2897
  * is audited. `rekeyed: false` means a re-key could not finish inside the
2786
2898
  * request — the event is the backstop (call the feature's re-key route
@@ -2999,8 +3111,9 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2999
3111
  /** Reset the row and re-enqueue the original message. */
3000
3112
  replay: (deliveryId: string) => Promise<void>;
3001
3113
  };
3002
- /** A single delivery by id (the list is `deliveries()`). */
3003
- 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>;
3004
3117
  /** Email broadcast campaigns (guide ch. 6, notifications): audience-ref fan-out
3005
3118
  * with quiet-hours + frequency-cap policy. `schedule_cron` sets a recurring
3006
3119
  * send (status `scheduled`); omit it for a `draft`. */
@@ -3053,6 +3166,9 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3053
3166
  skipped_freq_cap: number;
3054
3167
  skipped_no_email: number;
3055
3168
  already_sent: number;
3169
+ /** recipients in `suppression.testRecipients` on this page: recorded
3170
+ * `test_sink`, never sent, never billed */
3171
+ test_sink?: number;
3056
3172
  }>;
3057
3173
  };
3058
3174
  };
@@ -3225,7 +3341,16 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3225
3341
  *
3226
3342
  * The answer's `generation_status` is `pending` on a fresh enqueue; a replay
3227
3343
  * of the same `idempotency_key` (`deduplicated: true`) carries the run's
3228
- * 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.
3229
3354
  *
3230
3355
  * `reserve_credits` takes a PROVISIONAL held credit debit at enqueue
3231
3356
  * (linked to the run), committed on `completed` and reversed on
@@ -3233,7 +3358,8 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3233
3358
  * `config.generation.maxReserveCredits` cap; in end-user mode `user_id` is
3234
3359
  * FORCED to the verified end-user (a mismatched user_id → 400). A 402 aborts
3235
3360
  * the enqueue (insufficient balance; the run ends with job.generation.failed
3236
- * `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
3237
3363
  * per-tenant outstanding-holds ceiling → 429 (Retry-After 5). Payments
3238
3364
  * unreachable at the hold → 503 `payments_unavailable` (Retry-After 15;
3239
3365
  * nothing started, nothing held — re-send the same request), unless
@@ -3254,8 +3380,9 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3254
3380
  *
3255
3381
  * `opts.retryOnCapacity: { maxWaitMs }` retries a CAPACITY 429
3256
3382
  * (`generation_concurrency_exceeded`, `reserve_holds_exceeded`,
3257
- * `queue_full`) and a 503 `payments_unavailable`, honouring Retry-After
3258
- * 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
3259
3386
  * idempotency_key (one is generated when the input has none), until
3260
3387
  * maxWaitMs has passed — then the last 429 is thrown. */
3261
3388
  generation: (input: {
@@ -3299,6 +3426,13 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3299
3426
  record_id: string;
3300
3427
  column?: string;
3301
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;
3302
3436
  };
3303
3437
  timeout?: {
3304
3438
  after_ms: number;
@@ -3376,9 +3510,12 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3376
3510
  /** Cancel a run that has not started its current attempt (`queued`,
3377
3511
  * `delayed`, `retrying`) or a plain run handed off to its signed callback
3378
3512
  * (`waiting` after its handler answered 202 — the callback URL is
3379
- * consumed). A `running` run (a delivery in flight, or a generation
3380
- * waiting on its provider), a run waiting on an event, or a terminal run
3381
- * 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`. */
3382
3519
  cancel: (runId: string) => Promise<{
3383
3520
  run_id: string;
3384
3521
  state: string;
@@ -3389,6 +3526,24 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3389
3526
  run_id: string;
3390
3527
  replayed_from: string;
3391
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
+ }>;
3392
3547
  /**
3393
3548
  * Suspend the RUNNING run until an event (call from the executing
3394
3549
  * handler, then return 200 — the suspension wins). `state: 'resumed'` =
@@ -3513,9 +3668,10 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3513
3668
  sent: true;
3514
3669
  test_link?: string;
3515
3670
  }>;
3516
- /** `opts.anonymous_token` is REQUIRED for a link that was requested with
3517
- * one (the guest claim above): the same guest session must present it,
3518
- * 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
3519
3675
  * folded into an existing account (then `user_id` is that account), and
3520
3676
  * `rekeyed` rides beside it: true ⇒ the guest's payments / cms / files
3521
3677
  * rows were already moved onto `user_id` when this returned; false ⇒
@@ -3700,7 +3856,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3700
3856
  }) => Promise<AuthImportResult>;
3701
3857
  /** END-USER MODE ONLY (`endUserToken` / `asEndUser`): the signed-in user
3702
3858
  * deep-merges a bounded `attributes` object into its OWN shared identity
3703
- * 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
3704
3860
  * fields are not patchable here. In server mode the call throws 422
3705
3861
  * `end_user_mode_required` — use `users.patch(id, …)` instead. */
3706
3862
  patchMe: (attributes: Record<string, unknown>) => Promise<{
@@ -5107,16 +5263,27 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
5107
5263
  * long after the mint, whether or not `files.ttl` is enabled; `null` =
5108
5264
  * never. The answer's `expires_in` is the UPLOAD URL's life in seconds;
5109
5265
  * `expires_at` is when the object expires (null = never). `user_id` is
5110
- * 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`. */
5111
5273
  createUploadUrl: (input: {
5112
5274
  user_id?: string;
5113
5275
  filename: string;
5114
5276
  content_type: string;
5115
5277
  size_bytes: number;
5116
5278
  expiresInSeconds?: number | null;
5279
+ checksum_sha256?: string;
5117
5280
  }) => Promise<FileUploadUrl>;
5118
5281
  /** Confirm the PUT (bytes verified, object → available). Answers the
5119
- * 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). */
5120
5287
  complete: (objectId: string) => Promise<{
5121
5288
  object_id: string;
5122
5289
  status: string;
@@ -6083,6 +6250,30 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
6083
6250
  user_id: string;
6084
6251
  tier?: string;
6085
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>;
6086
6277
  /** The last report-only reconciliation sweep result for this project
6087
6278
  * (`run: null` before the first daily tick). Finding kinds:
6088
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,
@@ -1864,10 +1901,20 @@ export class Vxil {
1864
1901
  * long after the mint, whether or not `files.ttl` is enabled; `null` =
1865
1902
  * never. The answer's `expires_in` is the UPLOAD URL's life in seconds;
1866
1903
  * `expires_at` is when the object expires (null = never). `user_id` is
1867
- * 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`. */
1868
1911
  createUploadUrl: async (input) => (await this.call('POST', '/v1/files/upload-url', input)).data,
1869
1912
  /** Confirm the PUT (bytes verified, object → available). Answers the
1870
- * 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). */
1871
1918
  complete: async (objectId) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/complete`)).data,
1872
1919
  downloadUrl: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/download-url`)).data.download_url,
1873
1920
  /** Mint up to 100 presigned GETs in ONE call (`GET /v1/files/download-urls`)
@@ -2383,6 +2430,30 @@ export class Vxil {
2383
2430
  * past-due-grace, cross-platform-unlock, transfer) through the mock
2384
2431
  * webhook path and judge it with the conformance oracle. */
2385
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,
2386
2457
  /** The last report-only reconciliation sweep result for this project
2387
2458
  * (`run: null` before the first daily tick). Finding kinds:
2388
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.17.0",
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).",