@vxil/sdk 0.17.0 → 0.19.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.js CHANGED
@@ -429,9 +429,23 @@ export class Vxil {
429
429
  return { data: parsed.data, meta: parsed.meta ?? { request_id: '' }, response: res };
430
430
  }
431
431
  users = {
432
+ /** Create or replace a tenant user by id. Upserting a deleted id brings it
433
+ * back; upserting an ERASED id re-creates it as a new, empty user
434
+ * (`erased_at` cleared — the erase already removed the old data). While
435
+ * the erased user's files are still being erased the call fails with 409
436
+ * `user_erase_pending`; retry later. `bulk` is all-or-nothing. */
432
437
  upsert: async (user) => (await this.call('POST', '/v1/users', user)).data,
433
438
  bulk: async (users) => (await this.call('POST', '/v1/users/bulk', { users })).data.upserted,
434
439
  get: async (id, opts) => (await this.call('GET', `/v1/users/${encodeURIComponent(id)}${opts?.includeDeleted ? '?include_deleted=true' : ''}`)).data,
440
+ /**
441
+ * Update a user. `email` / `display_name` / `avatar_url` are replaced
442
+ * (null clears them). `attributes` is DEEP-MERGED into the stored
443
+ * attributes (RFC 7396 merge patch): objects merge key by key, arrays and
444
+ * scalars replace, and `null` deletes a key AT ANY DEPTH — including inside
445
+ * a subtree the row does not have yet, where the null is simply dropped
446
+ * (`{ attributes: { prefs: { phone: null } } }` never stores a null).
447
+ * Scope users:write.
448
+ */
435
449
  patch: async (id, patch) => (await this.call('PATCH', `/v1/users/${encodeURIComponent(id)}`, patch)).data,
436
450
  /**
437
451
  * Delete a user. By default this is a SOFT delete (sets deleted_at; the PII
@@ -449,7 +463,7 @@ export class Vxil {
449
463
  * Merge a REGISTRY-ONLY user (a row you created with upsert that never
450
464
  * signed in) INTO another user of the tenant: its attributes fill the
451
465
  * 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
466
+ * soft-deleted, its owner-scoped cms / files / payments / jobs rows move to the
453
467
  * survivor, and `auth.user.merged { from, into, method: 'registry_merge' }`
454
468
  * is audited. `rekeyed: false` means a re-key could not finish inside the
455
469
  * request — the event is the backstop (call the feature's re-key route
@@ -567,7 +581,8 @@ export class Vxil {
567
581
  await this.call('POST', `/v1/notifications/dead-letters/${encodeURIComponent(deliveryId)}/replay`);
568
582
  },
569
583
  },
570
- /** A single delivery by id (the list is `deliveries()`). */
584
+ /** A single delivery by id (the list is `deliveries()`), with its render —
585
+ * the full one for a `test_sink` row (see `DeliveryDetail`). */
571
586
  delivery: async (deliveryId) => (await this.call('GET', `/v1/notifications/deliveries/${encodeURIComponent(deliveryId)}`)).data,
572
587
  /** Email broadcast campaigns (guide ch. 6, notifications): audience-ref fan-out
573
588
  * with quiet-hours + frequency-cap policy. `schedule_cron` sets a recurring
@@ -628,12 +643,24 @@ export class Vxil {
628
643
  ...(opts?.brief === false ? { brief: false } : {}),
629
644
  })).data,
630
645
  };
646
+ /** The project audit log. Needs `audit:read` (the narrow read-only scope —
647
+ * declare it on a function to read the log from its callback token) or
648
+ * `features:read`. Server-only: a public / thin-client key cannot read it. */
631
649
  audit = {
650
+ /**
651
+ * The audit trail, newest first. `subject` narrows to the events about ONE
652
+ * thing: a user id (`payload.user_id` / `payload.id`), a cms record
653
+ * (`payload.item_id` — the record's history), a verified end user
654
+ * (`payload.end_user_id` — what that person changed through a thin-client
655
+ * key or a function acting for them, and their payments events), or a
656
+ * principal (`actor`). Server keys only.
657
+ */
632
658
  list: async (q) => {
633
659
  const s = qs({
634
660
  since: q?.since || undefined,
635
661
  cursor: q?.cursor || undefined,
636
662
  limit: q?.limit || undefined,
663
+ subject: q?.subject || undefined,
637
664
  });
638
665
  return (await this.call('GET', `/v1/audit${s}`)).data.events;
639
666
  },
@@ -647,6 +674,7 @@ export class Vxil {
647
674
  until: q?.until || undefined,
648
675
  after_id: q?.after_id || undefined,
649
676
  limit: q?.limit || undefined,
677
+ subject: q?.subject || undefined,
650
678
  });
651
679
  const { response: res, text } = await this.transport.send(`${this.base}${this.path(`/v1/audit/export${s}`)}`, {
652
680
  method: 'GET',
@@ -756,7 +784,16 @@ export class Vxil {
756
784
  *
757
785
  * The answer's `generation_status` is `pending` on a fresh enqueue; a replay
758
786
  * of the same `idempotency_key` (`deduplicated: true`) carries the run's
759
- * status NOW (`processing`, `completed` or `failed` too).
787
+ * status NOW (`processing`, `completed` or `failed` too). The key is bound
788
+ * to the request BODY: re-sending it with a different body (another
789
+ * payload — a rebuilt `deadline_at` included —, provider, completion,
790
+ * timeout, reserve…) will answer 422 `idempotency_key_reused` naming the
791
+ * original run (the ai-v1 rule). That check is being rolled out: today a
792
+ * mismatch is only logged, on every project, and the send is still
793
+ * deduplicated to the first run; a changelog entry will announce
794
+ * enforcement — so keep the body deterministic now. A 409 `generation_in_progress`
795
+ * (Retry-After 5) means the run that key names is still placing its
796
+ * credit hold.
760
797
  *
761
798
  * `reserve_credits` takes a PROVISIONAL held credit debit at enqueue
762
799
  * (linked to the run), committed on `completed` and reversed on
@@ -764,7 +801,8 @@ export class Vxil {
764
801
  * `config.generation.maxReserveCredits` cap; in end-user mode `user_id` is
765
802
  * FORCED to the verified end-user (a mismatched user_id → 400). A 402 aborts
766
803
  * the enqueue (insufficient balance; the run ends with job.generation.failed
767
- * `ReserveInsufficient` and the 402 names its `run_id`); a runaway over the
804
+ * `ReserveInsufficient` and the 402 names its `run_id`; the key is released,
805
+ * so the same request after a top-up starts a new run); a runaway over the
768
806
  * per-tenant outstanding-holds ceiling → 429 (Retry-After 5). Payments
769
807
  * unreachable at the hold → 503 `payments_unavailable` (Retry-After 15;
770
808
  * nothing started, nothing held — re-send the same request), unless
@@ -785,8 +823,9 @@ export class Vxil {
785
823
  *
786
824
  * `opts.retryOnCapacity: { maxWaitMs }` retries a CAPACITY 429
787
825
  * (`generation_concurrency_exceeded`, `reserve_holds_exceeded`,
788
- * `queue_full`) and a 503 `payments_unavailable`, honouring Retry-After
789
- * with jitter, with the SAME
826
+ * `queue_full`), a 503 `payments_unavailable` / `enqueue_interrupted` and a
827
+ * 409 `generation_in_progress` (the key's run is still placing its credit
828
+ * hold), honouring Retry-After with jitter, with the SAME
790
829
  * idempotency_key (one is generated when the input has none), until
791
830
  * maxWaitMs has passed — then the last 429 is thrown. */
792
831
  generation: async (input, opts) => {
@@ -835,13 +874,45 @@ export class Vxil {
835
874
  /** Cancel a run that has not started its current attempt (`queued`,
836
875
  * `delayed`, `retrying`) or a plain run handed off to its signed callback
837
876
  * (`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. */
877
+ * consumed). A generation's credit hold is released — a STARTED
878
+ * generation's too (its provider accepted it): it ends `failed`
879
+ * (`Cancelled`) and a late provider callback is a no-op, but the provider
880
+ * is NOT told, so its work and its charge may still complete. A plain
881
+ * `running` run (a delivery in flight), a run waiting on an event, or a
882
+ * terminal run answers `409 not_cancellable`. */
841
883
  cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
842
884
  /** Clone a terminal run into a fresh queued run. A generation run answers
843
- * `409 not_replayable` — submit the generation again instead. */
885
+ * `409 not_replayable` — submit the generation again instead; a redacted
886
+ * or erased run answers `409 run_redacted` (its payload is gone). */
844
887
  replay: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/replay`)).data,
888
+ /** SERVER-ONLY (403 server_only in end-user mode). Account merge: move the
889
+ * credit-hold owner (`reserve_credits.user_id`) of every run of
890
+ * `from_user_id`, in any state, onto `into_user_id`, so the surviving
891
+ * account reads, cancels and replays the generation runs it started as a
892
+ * guest. ONE bounded page (≤500 runs) per call — call again until `done`
893
+ * (idempotent: a second call moves 0). The jobs consumer of
894
+ * `auth.user.merged { from, into }`; auth and `users.merge` call it after
895
+ * a merge, the event is the backstop. Emits one `jobs.runs.rekeyed` when
896
+ * a call moved at least one run (none on a zero-move call). Unlike the
897
+ * files re-key it does not check that `into_user_id` exists.
898
+ * Scope jobs:write. */
899
+ reKey: async (input) => (await this.call('POST', '/v1/jobs/runs/re-key', input)).data,
900
+ /** SERVER-ONLY (403 server_only in end-user mode). Redact FINISHED runs
901
+ * that hold personal data: the payload becomes `{ erased: true }`, the
902
+ * result, progress, callback body and error text are cleared, a
903
+ * generation's provider request body/headers are dropped and its credit
904
+ * hold's user id is pseudonymized (unless its settle is still owed). The
905
+ * row itself stays (state, timings, job_name). The same fields a GDPR
906
+ * erase clears on the runs it can link to a user — use this for runs
907
+ * nothing links to one (a plain queue run, an async function call).
908
+ * `{ run_ids }` (≤500, one call; open runs are left alone and counted in
909
+ * `skipped_open`) or `{ user_id }` (the generation runs whose credit hold
910
+ * names that user — ONE page of ≤500 per call, call again until `done`).
911
+ * Idempotent. Audited as `jobs.runs.redacted` (counts only; none when a
912
+ * call redacted nothing). NOT cleared: concurrency_key, debounce_key,
913
+ * idempotency_key and target_url — keep user ids out of those (hash them).
914
+ * A redacted run does not replay. Scope jobs:write. */
915
+ redact: async (input) => (await this.call('POST', '/v1/jobs/runs/redact', input)).data,
845
916
  /**
846
917
  * Suspend the RUNNING run until an event (call from the executing
847
918
  * handler, then return 200 — the suspension wins). `state: 'resumed'` =
@@ -926,9 +997,10 @@ export class Vxil {
926
997
  * existing account; swap tokens). Needs `anonymous.enabled`; a non-guest
927
998
  * bearer is `409 not_anonymous`. */
928
999
  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
1000
+ /** An unknown, expired or already-used link answers `401 invalid_token`
1001
+ * (request a new one with `request`). `opts.anonymous_token` is REQUIRED
1002
+ * for a link that was requested with one (the guest claim above): the
1003
+ * same guest session must present it, else `401 invalid_session`. `merged` is true only when the guest was
932
1004
  * folded into an existing account (then `user_id` is that account), and
933
1005
  * `rekeyed` rides beside it: true ⇒ the guest's payments / cms / files
934
1006
  * rows were already moved onto `user_id` when this returned; false ⇒
@@ -958,8 +1030,10 @@ export class Vxil {
958
1030
  /** On success the guest keeps its user id (`user_id` unchanged).
959
1031
  * If the claimed email ALREADY has an account, the guest is MERGED
960
1032
  * into it: `user_id` is the existing account, `merged: true`, and a
961
- * fresh `session.token` (same session, re-signed for the merged
962
- * identity — swap it client-side; other guest sessions are revoked).
1033
+ * fresh `session.token` (the same session carried over to the merged
1034
+ * identity under a new session id — swap it client-side: the old
1035
+ * guest token is revoked, the refresh token keeps working; other
1036
+ * guest sessions are revoked).
963
1037
  * `rekeyed` (present on a merge): true ⇒ the guest's payments / cms /
964
1038
  * files rows were already moved onto `user_id` when this returned, so
965
1039
  * the merged user's next request finds them; false ⇒ the move ran
@@ -1026,7 +1100,7 @@ export class Vxil {
1026
1100
  import: async (input) => (await this.call('POST', '/v1/auth/users/import', input)).data,
1027
1101
  /** END-USER MODE ONLY (`endUserToken` / `asEndUser`): the signed-in user
1028
1102
  * 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
1103
+ * row (null deletes a key at any depth — RFC 7396; ≤8 KB, ≤64 top-level keys, depth ≤8). Profile
1030
1104
  * fields are not patchable here. In server mode the call throws 422
1031
1105
  * `end_user_mode_required` — use `users.patch(id, …)` instead. */
1032
1106
  patchMe: async (attributes) => (await this.call('PATCH', '/v1/auth/users/me', { attributes })).data,
@@ -1145,15 +1219,33 @@ export class Vxil {
1145
1219
  },
1146
1220
  confirm: async (token) => (await this.call('POST', '/v1/auth/email/verify/confirm', { token })).data,
1147
1221
  },
1148
- /** Social sign-in. The web `start`/`callback` flows are browser redirects
1149
- * (not JSON calls): send the browser to
1150
- * `GET /v1/auth/oauth/{provider}/start?redirect_uri=…` (server-side, with
1151
- * your key) and hand the returned `code`+`state` to `…/callback`; `native`
1222
+ /** Social sign-in. The web flow: `startUrl` fetches the provider authorize
1223
+ * URL with your key (a browser cannot follow the header-carrying 302 of
1224
+ * `GET /v1/auth/oauth/{provider}/start` itself), you navigate the browser
1225
+ * there, and hand the returned `code`+`state` to `…/callback`; `native`
1152
1226
  * is the mobile / broker token-exchange the SDK wraps. `oidc` is the
1153
1227
  * tenant's generic OIDC / SSO issuer (auth config `providers.oidc` —
1154
1228
  * Okta / Entra / Auth0 / any OpenID Connect IdP, or a SAML broker that
1155
1229
  * speaks OIDC); it rides the same three routes. */
1156
1230
  oauth: {
1231
+ /** Start the web sign-in from a browser SPA: answers the provider
1232
+ * `authorize_url` (PKCE challenge, one-shot `state`, and for `oidc` a
1233
+ * nonce bound to the flow — all held server-side) for you to navigate
1234
+ * to: `location.assign((await vx.auth.oauth.startUrl('oidc',
1235
+ * { redirect_uri })).authorize_url)`. The provider returns to
1236
+ * `redirect_uri` with `?code&state`, which your page sends to
1237
+ * `GET /v1/auth/oauth/{provider}/callback` for the session. The same
1238
+ * rules as the redirect form apply: `redirect_uri` must match the auth
1239
+ * config `security.allowedRedirectOrigins` when set (else
1240
+ * `422 redirect_not_allowed`), and the state expires after
1241
+ * `expires_in` seconds (600). `anonymous_token` (a guest bearer)
1242
+ * promotes or merges that guest at the callback. */
1243
+ startUrl: async (provider, input) => {
1244
+ // plain encodeURIComponent (no URLSearchParams — React Native ≤ 0.79)
1245
+ const q = `redirect_uri=${encodeURIComponent(input.redirect_uri)}`
1246
+ + (input.anonymous_token ? `&anonymous_token=${encodeURIComponent(input.anonymous_token)}` : '');
1247
+ return (await this.call('GET', `/v1/auth/oauth/${encodeURIComponent(provider)}/start?${q}`, undefined, { accept: 'application/json' })).data;
1248
+ },
1157
1249
  /** Native social sign-in: exchange a provider `id_token`/`access_token`
1158
1250
  * for a vxil session (`linked` marks whether the user was created or
1159
1251
  * matched to an existing identity). */
@@ -1164,6 +1256,9 @@ export class Vxil {
1164
1256
  * (`linked: 'merged'`, `user_id` = that account, `rekeyed` says whether
1165
1257
  * the guest's owner-scoped rows already moved — see
1166
1258
  * `anonymous.link.verify`). */
1259
+ /** `nonce`: the value your app put on the IdP authorize request. For
1260
+ * When sent, the id_token's `nonce` claim must equal it (401
1261
+ * `oauth_token_invalid` otherwise); omitted, it is not checked. */
1167
1262
  input) => (await this.call('POST', `/v1/auth/oauth/${encodeURIComponent(provider)}/native`, input)).data,
1168
1263
  },
1169
1264
  };
@@ -1274,6 +1369,19 @@ export class Vxil {
1274
1369
  * (default) restores today's behaviour. Server keys are never affected.
1275
1370
  * Audited; takes effect on the next request. */
1276
1371
  setEndUserAccess: async (collection, access) => (await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { end_user_access: access })).data,
1372
+ /** Set (or clear, with `[]` / `null`) the collection's END-USER WRITE
1373
+ * ROLES (guide ch. 9): a verified end user must hold one of `roles` (the
1374
+ * session's verified role claims — orgs roles) to write the collection,
1375
+ * otherwise every write door answers `403 role_required`. Reads are
1376
+ * unaffected; server keys are never affected. Audited; takes effect on
1377
+ * the next write. */
1378
+ setWriteRoles: async (collection, roles) => (await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { write_roles: roles ?? [] })).data,
1379
+ /** Set (1..3650 days) or clear (`null`) the collection's RETENTION (guide
1380
+ * ch. 4): the nightly platform sweep soft-deletes live items created more
1381
+ * than `days` ago — bounded per project per night, no restore, purged at
1382
+ * least 30 days later like any delete, one `cms.collection.retention_swept` audit event
1383
+ * per collection per sweep. Audited (`cms.collection.retain_days.set`). */
1384
+ setRetention: async (collection, days) => (await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { retain_days: days })).data,
1277
1385
  /** Re-project the collection's index slots after an `index_slot` move
1278
1386
  * (guide ch. 4). Slots are projected on WRITE only, so until this runs,
1279
1387
  * stored rows keep their OLD projection: the new slot is NULL and the
@@ -1342,6 +1450,16 @@ export class Vxil {
1342
1450
  /** `lock` serializes same-key writers (a per-key lock); `guard`
1343
1451
  * is the declarative capacity/overlap invariant (requires lock) — guide ch. 4. */
1344
1452
  create: async (collection, input) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}`, input)).data,
1453
+ /** Upsert by a declared `unique` field (guide ch. 4): creates the item, or
1454
+ * — when a live item already holds `data[field]` — merges `patch` into
1455
+ * THAT item instead (one transaction; update hooks, guards, version bump
1456
+ * and a `cms.item.updated` audit, exactly like `patch`; null clears a
1457
+ * field). `created` tells which happened (201 vs 200). Concurrent upserts
1458
+ * of one key serialize, so they never create a duplicate. */
1459
+ upsert: async (collection, input) => {
1460
+ const r = await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}`, input);
1461
+ return { ...r.data, created: r.response.status === 201 };
1462
+ },
1345
1463
  /** `expand` inlines relation/file fields into `data` (guide ch. 4): the
1346
1464
  * full item envelope, a `{ object_id, $ref: 'files' }` stub for a file,
1347
1465
  * the bare id on a cycle/depth cut, `null` for an invisible target. */
@@ -1351,7 +1469,9 @@ export class Vxil {
1351
1469
  },
1352
1470
  /**
1353
1471
  * The bounded query DSL. filter ops: $eq $ne $gt $gte $lt $lte $in
1354
- * $contains $startsWith (LIKE-escaped; substring-searchable on s*-slotted
1472
+ * ($ne is NULL-safe: a row whose field is absent or null counts as not
1473
+ * equal; on a dotted join term the row still needs a readable target,
1474
+ * so a null or dangling relation is excluded) $contains $startsWith (LIKE-escaped; substring-searchable on s*-slotted
1355
1475
  * fields) $arrayContains/$anyOf (json/relation array containment, indexed);
1356
1476
  * range/sort needs slot-indexed fields. This is the
1357
1477
  * DELIBERATELY-UNTYPED escape hatch: it accepts everything the server
@@ -1582,6 +1702,17 @@ export class Vxil {
1582
1702
  * `features:write`. (DELETE /v1/functions/:name)
1583
1703
  */
1584
1704
  delete: async (name, opts) => (await this.call('DELETE', `/v1/functions/${encodeURIComponent(name)}`, undefined, opts?.ifMatch !== undefined ? { 'if-match': String(opts.ifMatch) } : {})).data,
1705
+ /**
1706
+ * Log records of ONE function, one per invocation (outcome, wall/cpu ms,
1707
+ * console lines, exceptions), oldest first. Pass the returned `next_since`
1708
+ * back as `since` to read only newer records (a tail); without it, the most
1709
+ * recent records of the last 24 hours. Kept 14 days. Needs
1710
+ * `functions:read`. (GET /v1/functions/:name/logs)
1711
+ */
1712
+ logs: async (name, opts) => {
1713
+ const s = qs({ since: opts?.since || undefined });
1714
+ return (await this.call('GET', `/v1/functions/${encodeURIComponent(name)}/logs${s}`)).data;
1715
+ },
1585
1716
  };
1586
1717
  /** The MCP aggregation surface (the `mcp` feature). */
1587
1718
  mcp = {
@@ -1720,7 +1851,19 @@ export class Vxil {
1720
1851
  */
1721
1852
  test: async (subId) => (await this.call('POST', `/v1/webhooks/subscriptions/${encodeURIComponent(subId)}/test`)).data,
1722
1853
  sources: {
1723
- /** Register an inbound source; the receiver URL is returned ONCE. */
1854
+ /** Register an inbound source; the receiver URL is returned ONCE.
1855
+ *
1856
+ * Destination: `forward_url` (your endpoint) OR `target_function` (a
1857
+ * deployed function that declares a bare `trigger: { kind: 'webhook' }`
1858
+ * binding — it receives `payload.event === 'inbound_webhook.received'`
1859
+ * with the inbound envelope as `payload.data`), never both; neither =
1860
+ * store only (read with `events.list`).
1861
+ *
1862
+ * `provider: 'hmac'` is the parameterized preset for any provider that
1863
+ * signs an HMAC of the raw body (Shopify, Intercom, Linear, Zendesk, …):
1864
+ * `verify` says which header, sha256|sha1, hex|base64, an optional prefix
1865
+ * and an optional timestamp header + tolerance. Like stripe/paddle/slack it
1866
+ * fails closed (401) until the source's signing secret is set. */
1724
1867
  create: async (input) => (await this.call('POST', '/v1/webhooks/sources', input)).data,
1725
1868
  list: async () => (await this.call('GET', '/v1/webhooks/sources')).data.sources,
1726
1869
  delete: async (sourceId) => {
@@ -1731,11 +1874,21 @@ export class Vxil {
1731
1874
  test: async (sourceId) => (await this.call('POST', `/v1/webhooks/sources/${encodeURIComponent(sourceId)}/test`)).data,
1732
1875
  },
1733
1876
  events: {
1877
+ /** Received events. Newest first, paged with `cursor`; or — with `after`
1878
+ * (your watermark, an event id) — OLDEST first from just after it, the
1879
+ * drain shape: store `next_after` and pass it back, `has_more` says
1880
+ * another page waits. The `after` drain answers only events received
1881
+ * at least 10 s ago (ids are not strictly arrival-ordered, so a fresher
1882
+ * event could otherwise commit below a stored watermark and be
1883
+ * skipped); `cursor` and `event_id` reads are not lagged. `event_id`
1884
+ * reads exactly one event. `cursor` and `after` are exclusive. */
1734
1885
  list: async (q) => {
1735
1886
  const s = qs({
1736
1887
  source_id: q?.source_id || undefined,
1737
1888
  status: q?.status || undefined,
1738
1889
  cursor: q?.cursor || undefined,
1890
+ after: q?.after || undefined,
1891
+ event_id: q?.event_id || undefined,
1739
1892
  limit: q?.limit || undefined,
1740
1893
  });
1741
1894
  return (await this.call('GET', `/v1/webhooks/events${s}`)).data;
@@ -1838,8 +1991,14 @@ export class Vxil {
1838
1991
  * matching its TokenProvider type *structurally* (no import — a static
1839
1992
  * import would break the single-file served sdk.mjs) that re-mints via
1840
1993
  * POST /v1/realtime/tokens with `defaults` merged over the channel the
1841
- * client asks for. Server-side (Node) use only — it needs the API key;
1842
- * browsers must fetch tokens from YOUR backend instead. */
1994
+ * client asks for. Two safe ways to use it:
1995
+ * - on a server, with a server key (`user_id` names the subject); or
1996
+ * - in a browser / mobile app, with a PUBLIC `end_user_required` key and
1997
+ * the signed-in user's session (`endUserToken` on the client): the mint
1998
+ * runs in end-user mode and the token's subject is FORCED to the
1999
+ * verified user (any `user_id` you pass is overridden), so no
2000
+ * backend of your own is needed.
2001
+ * Never ship a server key to a browser. */
1843
2002
  tokenProvider: (defaults) => (ctx) => this.realtime.mintToken({ channel: ctx.channel, ...defaults }),
1844
2003
  /** One-shot: mint a token AND open the WebSocket. Uses the global
1845
2004
  * `WebSocket` (browser / Node ≥22). On older Node, pass a constructor —
@@ -1864,10 +2023,20 @@ export class Vxil {
1864
2023
  * long after the mint, whether or not `files.ttl` is enabled; `null` =
1865
2024
  * never. The answer's `expires_in` is the UPLOAD URL's life in seconds;
1866
2025
  * `expires_at` is when the object expires (null = never). `user_id` is
1867
- * required with a server key and omitted in end-user mode. */
2026
+ * required with a server key and omitted in end-user mode.
2027
+ * `checksum_sha256` (the SHA-256 of the bytes, 64 hex or 44 base64
2028
+ * characters) is signed into the URL (send the answer's `upload_headers`
2029
+ * on the PUT) and kept with the upload: `complete` compares it with the
2030
+ * SHA-256 the store reports for the bytes — 409 `checksum_mismatch` when
2031
+ * they differ, 409 `checksum_unverified` when the store reports none —
2032
+ * and on success answers the verified `checksum_sha256`. */
1868
2033
  createUploadUrl: async (input) => (await this.call('POST', '/v1/files/upload-url', input)).data,
1869
2034
  /** Confirm the PUT (bytes verified, object → available). Answers the
1870
- * object's `expires_at` (null = never). */
2035
+ * object's `expires_at` (null = never) and `checksum_sha256` (hex; null
2036
+ * when the store reported none) — a repeated complete answers the same.
2037
+ * When the upload declared `checksum_sha256`: 409 `checksum_mismatch` if
2038
+ * the bytes hash differently, 409 `checksum_unverified` if the store
2039
+ * reports no checksum (the object stays pending either way). */
1871
2040
  complete: async (objectId) => (await this.call('POST', `/v1/files/${encodeURIComponent(objectId)}/complete`)).data,
1872
2041
  downloadUrl: async (objectId) => (await this.call('GET', `/v1/files/${encodeURIComponent(objectId)}/download-url`)).data.download_url,
1873
2042
  /** Mint up to 100 presigned GETs in ONE call (`GET /v1/files/download-urls`)
@@ -2123,8 +2292,10 @@ export class Vxil {
2123
2292
  * would ground on, with rerank + metadata boosts applied — no generation,
2124
2293
  * no token spend. `boosts`/`rerank`/`min_score` override the rag config.
2125
2294
  * `min_score` floors the EFFECTIVE `score`, which is a RANK value (about
2126
- * 0.016–0.033 at the top): a floor above ~0.033 drops every hit. Judge
2127
- * relevance by each hit's `similarity` (cosine, -1..1) instead. */
2295
+ * 0.016–0.033 at the top): a floor above ~0.033 drops every hit. For a
2296
+ * relevance threshold use `min_similarity`, which floors each hit's cosine
2297
+ * `similarity` (-1..1; overrides config `retrieval.minSimilarity`; a hit
2298
+ * with no measured similarity, e.g. keyword mode, is kept). */
2128
2299
  search: async (input) => (await this.call('POST', '/v1/rag/search', input)).data,
2129
2300
  /** Ingest into the backing vector-search collection (chunk → embed → index). */
2130
2301
  ingest: async (collection, input) => (await this.call('POST', `/v1/rag/ingest/${encodeURIComponent(collection)}`, input)).data,
@@ -2262,7 +2433,9 @@ export class Vxil {
2262
2433
  * (no oversell). Pass `job_id` to make the debit PROVISIONAL (held, not yet
2263
2434
  * committed): a linked jobs run that terminally fails auto-refunds the hold,
2264
2435
  * a success settles it. Throws VxilError(402, 'INSUFFICIENT_CREDITS') when the
2265
- * available balance can't cover `amount`.
2436
+ * available balance can't cover `amount`. Throws VxilError(409,
2437
+ * 'job_released') when `job_id` names a generation run that already ended
2438
+ * (cancelled or failed) before this hold landed: nothing is held.
2266
2439
  *
2267
2440
  * Ordered credits: pass `credit_types` (1–8, in spend order — e.g.
2268
2441
  * `['free', 'subscription', 'topup']`) instead of `credit_type`; the WHOLE
@@ -2383,6 +2556,30 @@ export class Vxil {
2383
2556
  * past-due-grace, cross-platform-unlock, transfer) through the mock
2384
2557
  * webhook path and judge it with the conformance oracle. */
2385
2558
  simulate: async (input) => (await this.call('POST', '/v1/payments/simulate', input)).data,
2559
+ /** SERVER-ONLY TEST CLOCK — move the end (`until`, past or future, within
2560
+ * 5 years of now) of a MANUAL grant or a purchase pass, to test expired /
2561
+ * export-window / renew-soon states on demand. Allowed only where no live
2562
+ * money can be involved: a `mock`-provider project, a project whose
2563
+ * provider runs in its sandbox (`paddle.sandbox`, `paypal.sandbox`, a
2564
+ * Stripe `sk_test_` key), or a dev/preview project — elsewhere 403
2565
+ * `test_clock_forbidden` (also for a pass linked to a non-sandbox charge).
2566
+ * 409 `provider_managed` for a provider subscription (use the provider's
2567
+ * own test clock); 409 `subscription_ended` for a revoked/refunded row.
2568
+ * The effect is a natural lapse's: the entitlements refold
2569
+ * (`payments.entitlement.changed`), a move into the past turns an active
2570
+ * row `lapsed` and emits `payments.subscription.lapsed` (`event_type:
2571
+ * 'test_clock'`, `environment: 'sandbox'`) once; a move back into the
2572
+ * future revives it (as `active` — a `trialing` row does not return to
2573
+ * `trialing`; a `past_due` row's status is left as it is). Only the end
2574
+ * moves: `since` (the period start) is never rewritten, so after a move
2575
+ * before it `since` > `until`. Every move emits
2576
+ * `payments.test_clock.moved`. Both events are emitted after the move
2577
+ * commits, best-effort (as the period-end sweep's are): if one fails to
2578
+ * record, the move still stands and is not re-announced on a retry, so
2579
+ * assert on the returned `status` / `lapsed` rather than only on the
2580
+ * event. Same-tier passes stacked behind the moved one are not
2581
+ * re-chained. No credits are granted or reversed. */
2582
+ moveSubscriptionUntil: async (subscriptionId, until) => (await this.call('POST', `/v1/payments/subscriptions/${encodeURIComponent(subscriptionId)}/until`, { until: typeof until === 'string' ? until : until.toISOString() })).data,
2386
2583
  /** The last report-only reconciliation sweep result for this project
2387
2584
  * (`run: null` before the first daily tick). Finding kinds:
2388
2585
  * `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.19.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).",