@vxil/sdk 0.4.0 → 0.5.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/README.md CHANGED
@@ -88,6 +88,10 @@ try {
88
88
 
89
89
  ## Changelog
90
90
 
91
+ ### 0.4.1 — 2026-09-13
92
+
93
+ - Documentation only: JSDoc no longer cites internal repository paths; points at the public guide instead. No runtime change.
94
+
91
95
  ### 0.4.0 — 2026-09-11
92
96
 
93
97
  - React-Native-clean: every query string is built without `URLSearchParams` (React Native's polyfill throws on `.set` before 0.81); wire bytes unchanged.
package/dist/index.d.ts CHANGED
@@ -84,11 +84,11 @@ export interface VxilOptions {
84
84
  * a mobile/SPA holds a public/thin-client key + the signed-in user's session
85
85
  * token and calls the edge directly, safely scoped to that user. Absent ⇒
86
86
  * **no header** ⇒ server-caller mode, exactly as today (backward compatible).
87
- * See docs/end-user-principals-design.md §4.1, §10. For a per-call override on
87
+ * See https://vxil.com/docs/guide/09-security-and-multitenancy. For a per-call override on
88
88
  * an otherwise server-mode client, use `vx.asEndUser(token)`. */
89
89
  endUserToken?: string;
90
90
  /** Global API major every request pins (default `'v1'`). Path-major, per
91
- * docs/feature-versioning.md — a breaking change ships as a new major on a
91
+ * the versioning section of https://vxil.com/docs/guide/12-going-to-production — a breaking change ships as a new major on a
92
92
  * new path, never in place. */
93
93
  apiVersion?: ApiVersion;
94
94
  /** Per-feature API-major overrides (`{ cms: 'v2' }`); each takes precedence
@@ -208,6 +208,41 @@ export interface FeatureConfig {
208
208
  version: number;
209
209
  manifest: Record<string, unknown>;
210
210
  }
211
+ /** One rule in the advisory architecture brief. `title`, `fact`, `replaces` and
212
+ * `docs` are platform copy rendered by the server from a gate-checked rule
213
+ * catalog — they are never model prose. `why` explains why the rule applies to
214
+ * the app you described. */
215
+ export interface VxilBriefItem {
216
+ /** stable rule id, e.g. 'anonymous-session-for-guests'. */
217
+ rule_id: string;
218
+ title: string;
219
+ /** the platform rule itself, numbers inline. */
220
+ fact: string;
221
+ /** why it applies to this app. */
222
+ why: string;
223
+ /** the older habit this rule supersedes, when there is one. */
224
+ replaces?: string;
225
+ /** site path to the chapter section, e.g. '/docs/guide/16-…#…'. */
226
+ docs: string;
227
+ }
228
+ /** One section of the brief: the eight subject sections plus 'old-patterns',
229
+ * a derived view of every cited rule that supersedes an older habit. */
230
+ export interface VxilBriefSection {
231
+ /** 'identity' | 'data' | 'money' | 'background' | 'notifications' |
232
+ * 'rate-limits' | 'environments' | 'limits' | 'old-patterns' */
233
+ key: string;
234
+ title: string;
235
+ items: VxilBriefItem[];
236
+ }
237
+ /** The ADVISORY architecture brief attached to a plan: the platform rules the
238
+ * plan's shape implies. `source` is 'ai' when a model chose the rules and
239
+ * 'deterministic' when they were derived from the feature set alone. */
240
+ export interface VxilPlanBrief {
241
+ source: 'ai' | 'deterministic';
242
+ sections: VxilBriefSection[];
243
+ /** decisions the description does not settle (empty on the deterministic path). */
244
+ open_questions: string[];
245
+ }
211
246
  /** An advisory plan from the planner (POST /v1/plan) — a proposal, not applied. */
212
247
  export interface VxilPlan {
213
248
  app: {
@@ -254,6 +289,8 @@ export interface VxilPlan {
254
289
  rationale: string;
255
290
  /** which brain produced the plan. */
256
291
  source: 'ai' | 'deterministic';
292
+ /** the advisory architecture brief. Absent when you pass `{ brief: false }`. */
293
+ brief?: VxilPlanBrief;
257
294
  }
258
295
  /** Current-month usage projection (GET /v1/usage) — the same meter the
259
296
  * platform bills and enforces request quotas from. */
@@ -281,6 +318,15 @@ export interface UsageCurrent {
281
318
  metric: string;
282
319
  quantity: number;
283
320
  }>;
321
+ /** month-to-date split of `requests.used` by the SURFACE that served it —
322
+ * the edge's resolved upstream (a bounded set), with /v1/fn/* reported as
323
+ * 'fn-invoke' and /v1/functions/* as its management surface,
324
+ * 'control-plane'. Busiest first. Observational only: never a billing
325
+ * basis, and [] wherever request metering is not switched on. */
326
+ routes: Array<{
327
+ route_class: string;
328
+ requests: number;
329
+ }>;
284
330
  }
285
331
  export interface DeadLetter {
286
332
  delivery_id: string;
@@ -567,6 +613,12 @@ export interface SearchSyncSource {
567
613
  export interface AiUsage {
568
614
  input_tokens: number;
569
615
  output_tokens: number;
616
+ /** PROMPT-CACHE accounting, present only when the provider reports it
617
+ * (anthropic, when the request asked for a cache breakpoint). A cache read
618
+ * is already excluded from `input_tokens`, so metering and the daily budget
619
+ * need no adjustment — these are for your own visibility. */
620
+ cache_creation_input_tokens?: number;
621
+ cache_read_input_tokens?: number;
570
622
  }
571
623
  /** One tool invocation the model asked for (tool-use passthrough — vxil relays,
572
624
  * YOU execute). Echo `id` back as `tool_call_id` on the `role:'tool'` turn. */
@@ -1074,6 +1126,12 @@ export interface VxilSchemaShape {
1074
1126
  Patch: unknown;
1075
1127
  Sortable: string;
1076
1128
  Filterable: Record<string, unknown>;
1129
+ /** `vxil gen` emits this per collection: every `$expand`-able field (relation
1130
+ * + file) → its target collection slug (`'files'` for a file field, `null`
1131
+ * for a relation with no declared target). OPTIONAL on the shape so a client
1132
+ * generated BEFORE typed expand still satisfies the constraint — the expand
1133
+ * names then stay `string` and an expanded member resolves to an opaque bag. */
1134
+ Relations?: Record<string, string | null>;
1077
1135
  }>;
1078
1136
  functions: Record<string, {
1079
1137
  Input: unknown;
@@ -1144,6 +1202,33 @@ export interface CmsBacklinksPage {
1144
1202
  has_more: boolean;
1145
1203
  limit: number;
1146
1204
  }
1205
+ /** ONE page of the slot re-index (cms.md §19). Loop while `complete` is false,
1206
+ * feeding `next_cursor` back in. `skipped` counts rows a concurrent write moved
1207
+ * under the page — the re-index never overwrites them (their own writer
1208
+ * re-projected them), but a `$inc` or a cascade `set_null` only re-projects its
1209
+ * OWN field's slot, so re-run the page while `skipped > 0`. */
1210
+ export interface CmsReindexPage {
1211
+ collection: string;
1212
+ field: string | null;
1213
+ scanned: number;
1214
+ updated: number;
1215
+ skipped: number;
1216
+ next_cursor: string | null;
1217
+ complete: boolean;
1218
+ }
1219
+ /** The result of a bounded filtered delete (cms.md §20). `matched` counts the
1220
+ * rows this page selected (≤ `limit`); `deleted` counts the ones actually
1221
+ * removed (a row that vanished between the match and the delete is skipped).
1222
+ * Loop while `deleted > 0` — the filter re-evaluates against live rows. */
1223
+ export interface CmsBulkDeleteResult {
1224
+ collection: string;
1225
+ matched: number;
1226
+ deleted: number;
1227
+ cascaded: number;
1228
+ set_null: number;
1229
+ dry_run: boolean;
1230
+ next_cursor: string | null;
1231
+ }
1147
1232
  /** A write guard (cms.md §10): "after this write, at most `max` live items
1148
1233
  * match `filter`". Requires `lock` — an unlocked guard is racy by construction. */
1149
1234
  export interface CmsGuard {
@@ -1241,8 +1326,56 @@ export type CmsTxStep = {
1241
1326
  item_id: string;
1242
1327
  $where?: Record<string, unknown>;
1243
1328
  };
1244
- /** A per-collection typed handle returned by `vx.from(collection)`. */
1245
- export interface CollectionClient<C extends VxilSchemaShape['cms'][string]> {
1329
+ /** One CMS item as the API returns it — the envelope `$expand` inlines in place
1330
+ * of a stored relation id (cms.md §6.2). `R` is the target collection's Row. */
1331
+ export interface CmsItemEnvelope<R> {
1332
+ item_id: string;
1333
+ collection: string;
1334
+ status: string;
1335
+ version: number;
1336
+ data: R;
1337
+ created_at: string;
1338
+ updated_at: string;
1339
+ published_at: string | null;
1340
+ }
1341
+ /** What an expanded FILE field resolves to — a stub, never a cms row. */
1342
+ export interface CmsFileRef {
1343
+ object_id: string;
1344
+ $ref: 'files';
1345
+ }
1346
+ /** Every shape an expanded member can take (cms.md §6.2, all four verified in
1347
+ * cms-v1 core.ts): the full envelope; a `{ object_id, $ref: 'files' }` stub for
1348
+ * a file field; the BARE id when the expansion hit a cycle or the depth budget;
1349
+ * `null` when the target is soft-deleted, not visible to this caller, or owned
1350
+ * by another end-user. A
1351
+ * relation field may hold an ARRAY of ids, in which case the expansion is an
1352
+ * array of those same shapes — `vxil gen` types a relation as `string` and does
1353
+ * not carry arity, so the union includes the array form rather than inventing
1354
+ * a precision the model does not have. */
1355
+ export type CmsExpanded<R> = CmsItemEnvelope<R> | CmsFileRef | string | null | Array<CmsItemEnvelope<R> | CmsFileRef | string | null>;
1356
+ /** The expand-able field names of a collection: the keys of the generated
1357
+ * `Relations` map, or plain `string` for a client generated before it existed. */
1358
+ type ExpandableOf<C> = C extends {
1359
+ Relations: infer R;
1360
+ } ? (R extends Record<string, unknown> ? keyof R & string : string) : string;
1361
+ /** Resolve one expanded member's row type: the target collection's `Row` when
1362
+ * `Relations` names a cms collection in THIS schema, else an opaque bag (a file
1363
+ * stub, an untargeted relation, or an older generated client). */
1364
+ type TargetRow<C, S extends VxilSchemaShape, K extends string> = C extends {
1365
+ Relations: infer R;
1366
+ } ? (K extends keyof R ? (R[K] extends keyof S['cms'] ? S['cms'][R[K]]['Row'] : Record<string, unknown>) : Record<string, unknown>) : Record<string, unknown>;
1367
+ /** The Row with the expanded members re-typed. Non-expanded fields are untouched,
1368
+ * so a query WITHOUT `expand` keeps exactly today's type. */
1369
+ export type ExpandedRow<C extends {
1370
+ Row: unknown;
1371
+ }, S extends VxilSchemaShape, E extends readonly string[]> = Omit<C['Row'], E[number]> & {
1372
+ [K in E[number]]: CmsExpanded<TargetRow<C, S, K>>;
1373
+ };
1374
+ /** A per-collection typed handle returned by `vx.from(collection)`. `S` (the
1375
+ * whole generated schema) is DEFAULTED so `CollectionClient<X>` — the only
1376
+ * spelling that existed before typed expand — still compiles; it is what lets
1377
+ * an expanded relation resolve to the TARGET collection's Row. */
1378
+ export interface CollectionClient<C extends VxilSchemaShape['cms'][string], S extends VxilSchemaShape = VxilSchemaShape> {
1246
1379
  create(data: C['Insert'], opts?: {
1247
1380
  status?: 'draft' | 'published';
1248
1381
  lock?: string;
@@ -1254,7 +1387,26 @@ export interface CollectionClient<C extends VxilSchemaShape['cms'][string]> {
1254
1387
  version: number;
1255
1388
  data: C['Row'];
1256
1389
  }>;
1390
+ /** `expand` inlines the named relation/file fields (cms.md §6.2) — the names
1391
+ * come from the generated `Relations`, so a typo is a compile error. Bounds
1392
+ * (worker-clamped regardless of config): depth 1 (ceiling 2), ≤5 fields
1393
+ * (ceiling 25). Omit it and the result type is exactly today's `Row`. */
1394
+ get<E extends readonly ExpandableOf<C>[]>(itemId: string, opts: {
1395
+ expand: E;
1396
+ }): Promise<ExpandedRow<C, S, E>>;
1257
1397
  get(itemId: string): Promise<C['Row']>;
1398
+ query<E extends readonly ExpandableOf<C>[]>(q: {
1399
+ filter?: Partial<C['Filterable']>;
1400
+ sort?: C['Sortable'] | `-${C['Sortable'] & string}`;
1401
+ limit?: number;
1402
+ cursor?: string;
1403
+ /** relation/file fields to inline — see `get`. Cannot be combined with
1404
+ * `count` (a separate method here; the raw API 422s when both are sent). */
1405
+ expand: E;
1406
+ }): Promise<{
1407
+ items: ExpandedRow<C, S, E>[];
1408
+ next_cursor: string | null;
1409
+ }>;
1258
1410
  query(q?: {
1259
1411
  /** Keys AND values come from the generated `Filterable`: each field's value
1260
1412
  * is its operator union (`VxilFilterRange*` on slotted fields, equality/
@@ -1283,6 +1435,15 @@ export interface CollectionClient<C extends VxilSchemaShape['cms'][string]> {
1283
1435
  cascaded: number;
1284
1436
  set_null: number;
1285
1437
  }>;
1438
+ /** cms.md §20: bounded filtered delete over the generated `Filterable` — a
1439
+ * filter is required, ≤100 rows per call, `dryRun` previews. Loop while
1440
+ * `deleted > 0`. */
1441
+ deleteMany(q: {
1442
+ filter: Partial<C['Filterable']>;
1443
+ limit?: number;
1444
+ cursor?: string;
1445
+ dryRun?: boolean;
1446
+ }): Promise<CmsBulkDeleteResult>;
1286
1447
  publish(itemId: string): Promise<C['Row']>;
1287
1448
  /** cms-rel B2: bounded group-by aggregate (spec §7 `groupBy()` surface).
1288
1449
  * groupBy/field names are typed over the Row's keys; slot-existence stays a
@@ -1345,7 +1506,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1345
1506
  * client, mirroring how the edge threads the verified principal. The base
1346
1507
  * URL, api key, fetch impl, version pins and the retry/timeout/hooks options
1347
1508
  * are inherited unchanged; only the end-user token is (re)set. Pass a falsy token to get back a server-mode
1348
- * client (drops the header). See docs/end-user-principals-design.md §4.1. */
1509
+ * client (drops the header). See https://vxil.com/docs/guide/09-security-and-multitenancy. */
1349
1510
  asEndUser(endUserToken: string | undefined): Vxil<S>;
1350
1511
  /** Build the versioned request path — the ONE place a wire path is finalized.
1351
1512
  * Every request (call(), audit.export, the fn proxy) routes through here, so
@@ -1361,7 +1522,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1361
1522
  * generic `cms.items.*` methods — the wire calls are identical; the generated
1362
1523
  * `VxilSchema` supplies the field types. `vx.cms.items.*` stays as the
1363
1524
  * always-available un-generic fallback. */
1364
- from<C extends keyof S['cms'] & string>(collection: C): CollectionClient<S['cms'][C]>;
1525
+ from<C extends keyof S['cms'] & string>(collection: C): CollectionClient<S['cms'][C], S>;
1365
1526
  /** Typed function invoke (design §4.5). `vx.fn.<name>(payload)` POSTs to
1366
1527
  * /v1/fn/<name>; the generated `VxilSchema` types Input/Output (Level-0
1367
1528
  * opaque until a function declares a signature). A Proxy gives the
@@ -1415,7 +1576,8 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1415
1576
  readonly notifications: {
1416
1577
  send: (input: {
1417
1578
  user_id: string;
1418
- template: "magic-link" | "welcome" | "transactional";
1579
+ /** The four shipped template ids (notifications.md §7). */
1580
+ template: "magic-link" | "otp-code" | "welcome" | "transactional";
1419
1581
  data: Record<string, unknown>;
1420
1582
  /** Explicit wins; otherwise the recipient's stored `locale` attribute
1421
1583
  * (set it via `users.upsert({ attributes: { locale } })`), then
@@ -1434,7 +1596,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1434
1596
  * + enqueued, or neither) and `results` is index-aligned with `items`. */
1435
1597
  sendBatch: (items: Array<{
1436
1598
  user_id: string;
1437
- template: "magic-link" | "welcome" | "transactional";
1599
+ template: "magic-link" | "otp-code" | "welcome" | "transactional";
1438
1600
  data: Record<string, unknown>;
1439
1601
  locale?: string;
1440
1602
  channel?: "email" | "inbox" | "both";
@@ -1521,6 +1683,80 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1521
1683
  }>>;
1522
1684
  add: (email: string) => Promise<void>;
1523
1685
  remove: (email: string) => Promise<void>;
1686
+ /** Bounce/complaint AGGREGATE over a 1–90 day window (default 30; an
1687
+ * out-of-range or non-numeric `days` clamps rather than erroring).
1688
+ * `counts` always carries all four reasons with explicit zeros; `rate` is
1689
+ * `counts.total / sent` to 4 dp, or null when nothing was sent. `sent`
1690
+ * counts deliveries the provider ACCEPTED, not inbox-delivered mail —
1691
+ * this is a suppressions-per-accepted-send ratio, not an RFC bounce rate.
1692
+ * Operator surface: a verified end-user token gets 403. */
1693
+ stats: (q?: {
1694
+ days?: number;
1695
+ }) => Promise<{
1696
+ window_days: number;
1697
+ since: string;
1698
+ counts: {
1699
+ hard_bounce: number;
1700
+ spam_complaint: number;
1701
+ soft_bounce: number;
1702
+ manual: number;
1703
+ total: number;
1704
+ };
1705
+ sent: number;
1706
+ rate: number | null;
1707
+ }>;
1708
+ };
1709
+ /** Per-end-user MUTES — the opt-out table every send channel checks before
1710
+ * it delivers. Absence of a row means "not muted". A row with no
1711
+ * `template_id` is the ALL-TEMPLATES row.
1712
+ *
1713
+ * MUTE WINS. Enforcement is `(template_id = <template> OR template_id IS
1714
+ * NULL) AND muted`, so the all-templates row SHADOWS a per-template
1715
+ * `muted: false` — to re-enable one template after a master mute, clear the
1716
+ * all-templates row (`{ muted: false }`, no `template_id`) and mute the
1717
+ * others individually. `get` returns a computed `effective` per row, which
1718
+ * is the answer enforcement gives; render THAT, not the stored `muted`.
1719
+ *
1720
+ * AUTH MAIL IS NEVER MUTEABLE. `magic-link` and `otp-code` carry sign-in
1721
+ * links and codes: `set` rejects them with a 422 and the all-templates row
1722
+ * does not cover them, so an "unsubscribe from everything" click can never
1723
+ * lock a user out of its own account. Muteable ids: `welcome`,
1724
+ * `transactional`. (Password-reset mail rides `transactional`, which stays
1725
+ * muteable — see the notifications feature doc §6c.)
1726
+ *
1727
+ * SCOPES: with an end-user token both calls are confined to the verified
1728
+ * principal and take `notifications:read` (a browser key that can mute its
1729
+ * own mail must not also be able to send mail). With a server key
1730
+ * `user_id` is required and `set` takes `notifications:send`. */
1731
+ preferences: {
1732
+ get: (q: {
1733
+ user_id?: string;
1734
+ }) => Promise<{
1735
+ preferences: Array<{
1736
+ user_id: string;
1737
+ template_id: string | null;
1738
+ muted: boolean;
1739
+ /** what enforcement actually does with this row: the stored `muted`
1740
+ * OR the all-templates row's mute (which shadows it). */
1741
+ effective: boolean;
1742
+ updated_at: string;
1743
+ }>;
1744
+ count: number;
1745
+ }>;
1746
+ /** Idempotent upsert. OMIT `template_id` for the all-templates row; pass
1747
+ * a MUTEABLE id to mute just that template. Auth mail (`magic-link`,
1748
+ * `otp-code`) is a 422 — it always sends. There is no delete —
1749
+ * `muted: false` is the un-mute. */
1750
+ set: (input: {
1751
+ user_id?: string;
1752
+ template_id?: "welcome" | "transactional";
1753
+ muted: boolean;
1754
+ }) => Promise<{
1755
+ user_id: string;
1756
+ template_id: string | null;
1757
+ muted: boolean;
1758
+ updated_at: string;
1759
+ }>;
1524
1760
  };
1525
1761
  /** Deliveries the retry budget could not save (the dashboard "Resend" surface). */
1526
1762
  deadLetters: {
@@ -1618,11 +1854,15 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1618
1854
  };
1619
1855
  /** The planner (config-architect): describe an app in plain English and get an
1620
1856
  * ADVISORY vxil plan — which features to enable, a materialized `config_draft`,
1621
- * and which truly-unique logic needs a tenant function. Deterministic + pure;
1622
- * it PROPOSES a plan, it never applies anything (review it, then push the
1623
- * config). Needs `features:read`. (POST /v1/plan; docs/planner-feature-design.md) */
1857
+ * which truly-unique logic needs a tenant function, and an architecture brief
1858
+ * (identity, data, money path, background work, limits, and the older patterns
1859
+ * vxil replaces). It PROPOSES a plan, it never applies anything (review it,
1860
+ * then push the config). Pass `{ brief: false }` to skip the brief.
1861
+ * Needs `features:read`. (POST /v1/plan; https://vxil.com/docs/guide/10-agents-and-mcp) */
1624
1862
  readonly planner: {
1625
- create: (description: string, name?: string) => Promise<VxilPlan>;
1863
+ create: (description: string, name?: string, opts?: {
1864
+ brief?: boolean;
1865
+ }) => Promise<VxilPlan>;
1626
1866
  };
1627
1867
  readonly audit: {
1628
1868
  list: (q?: {
@@ -1832,7 +2072,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1832
2072
  delete: (ruleId: string) => Promise<void>;
1833
2073
  };
1834
2074
  };
1835
- /** vxil-auth. SCOPES (docs/features/auth.md §7.6): every flow here that
2075
+ /** vxil-auth. SCOPES (vxil.com/docs/guide/09-security-and-multitenancy): every flow here that
1836
2076
  * obtains, renews, verifies or ends the caller's OWN session (signUp/signIn,
1837
2077
  * magicLink, otp, anonymous, stepUp, oauth, sessions.refresh/revoke/verify,
1838
2078
  * password reset) accepts the narrow `auth:signin` — the ONLY auth scope a
@@ -1859,11 +2099,27 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1859
2099
  session: AuthSession;
1860
2100
  verified: boolean;
1861
2101
  }>;
2102
+ /** Magic-link sign-in: vxil mails a single-use link to the address and the
2103
+ * click returns here with `?token=`. The request is always a 202 —
2104
+ * identical for a known and an unknown address (anti-enumeration). A
2105
+ * second request for the SAME address inside the per-identifier cooldown
2106
+ * (auth config `magicLink.resendCooldownSec`, default 60s, 0 disables) is
2107
+ * `429 magic_link_rate_limited` with a `Retry-After` header. */
1862
2108
  magicLink: {
2109
+ /** `test_link` is present ONLY when the address matches auth config
2110
+ * `otp.testRecipients` (store-review / CI accounts): no mail is sent and
2111
+ * the full sign-in link comes back instead. `captcha_token` is required
2112
+ * when the tenant configured `security.captchaSecretRef`; `locale`
2113
+ * chooses the mail's language. */
1863
2114
  request: (input: {
1864
2115
  email: string;
1865
2116
  redirect_url: string;
1866
- }) => Promise<void>;
2117
+ locale?: string;
2118
+ captcha_token?: string;
2119
+ }) => Promise<{
2120
+ sent: true;
2121
+ test_link?: string;
2122
+ }>;
1867
2123
  verify: (token: string) => Promise<{
1868
2124
  user_id: string;
1869
2125
  session: AuthSession;
@@ -2174,7 +2430,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2174
2430
  create: (input: {
2175
2431
  collection: string;
2176
2432
  singular?: string;
2177
- /** End-user owner-scoping (docs/end-user-principals-design.md §5.1):
2433
+ /** End-user owner-scoping (https://vxil.com/docs/guide/09-security-and-multitenancy):
2178
2434
  * names an existing `string` field that holds the owner id. When set,
2179
2435
  * the cms worker auto-scopes owned reads/writes to the VERIFIED
2180
2436
  * end-user principal (default-deny) in end-user mode; a no-op in
@@ -2252,6 +2508,35 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2252
2508
  * the owner_field are never exposed. `false` closes the lane (and purges the
2253
2509
  * edge cache). Read the public side with `cmsPublicUrl` / `listCmsPublic`. */
2254
2510
  setPublic: (collection: string, isPublic: boolean) => Promise<void>;
2511
+ /** Re-project the collection's index slots after an `index_slot` move
2512
+ * (cms.md §19). Slots are projected on WRITE only, so until this runs,
2513
+ * stored rows keep their OLD projection: the new slot is NULL and the
2514
+ * VACATED slot still holds the old field's values — range/sort on the
2515
+ * moved field returns the WRONG rows, not merely missing ones. ONE page
2516
+ * per call (≤2000 rows, default 500), resumable via `next_cursor`,
2517
+ * idempotent (a second full pass reports `updated: 0`), and never a
2518
+ * version bump / CDC event (slots are derived columns). Server-key only.
2519
+ * `field` narrows the write to that field's slot plus any unowned slot. */
2520
+ reindex: (collection: string, opts?: {
2521
+ field?: string;
2522
+ cursor?: string;
2523
+ limit?: number;
2524
+ }) => Promise<CmsReindexPage>;
2525
+ /** Drive `reindex` to completion, page by page. Returns the totals.
2526
+ * A page that reports `skipped > 0` lost rows to a concurrent writer, so
2527
+ * it is re-run ONCE before the loop advances (the write is idempotent and
2528
+ * the retry is bounded — a permanently hot collection never spins). The
2529
+ * residual `skipped` is returned, not swallowed. */
2530
+ reindexAll: (collection: string, opts?: {
2531
+ field?: string;
2532
+ limit?: number;
2533
+ onPage?: (p: CmsReindexPage) => void;
2534
+ }) => Promise<{
2535
+ scanned: number;
2536
+ updated: number;
2537
+ skipped: number;
2538
+ pages: number;
2539
+ }>;
2255
2540
  /** Replace the collection's per-record ACTION list (cms.md §17) — the
2256
2541
  * `{ actions }` fields-route meta-op; `[]` clears. Each action is ONE
2257
2542
  * human-initiated step: the dashboard renders it as a button per record,
@@ -2273,7 +2558,12 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2273
2558
  version: number;
2274
2559
  data: Record<string, unknown>;
2275
2560
  }>;
2276
- get: (collection: string, itemId: string) => Promise<Record<string, unknown>>;
2561
+ /** `expand` inlines relation/file fields into `data` (cms.md §6.2): the
2562
+ * full item envelope, a `{ object_id, $ref: 'files' }` stub for a file,
2563
+ * the bare id on a cycle/depth cut, `null` for an invisible target. */
2564
+ get: (collection: string, itemId: string, opts?: {
2565
+ expand?: readonly string[] | string;
2566
+ }) => Promise<Record<string, unknown>>;
2277
2567
  /**
2278
2568
  * The bounded query DSL. filter ops: $eq $ne $gt $gte $lt $lte $in
2279
2569
  * $contains $startsWith (LIKE-escaped; substring-searchable on s*-slotted
@@ -2289,6 +2579,10 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2289
2579
  sort?: string;
2290
2580
  limit?: number;
2291
2581
  cursor?: string;
2582
+ /** relation/file fields to inline (`$expand`) — see `get`. The server
2583
+ * refuses `$expand` together with `count=true` (422 invalid_query), so
2584
+ * use `count()` for the aggregate. */
2585
+ expand?: readonly string[] | string;
2292
2586
  }) => Promise<{
2293
2587
  items: Array<Record<string, unknown>>;
2294
2588
  next_cursor: string | null;
@@ -2313,6 +2607,22 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2313
2607
  cascaded: number;
2314
2608
  set_null: number;
2315
2609
  }>;
2610
+ /** N-22 (cms.md §20): BOUNDED FILTERED delete — soft-delete up to `limit`
2611
+ * items (1–100, default 25) matching `filter`, newest id first. A filter
2612
+ * is REQUIRED (an unselected sweep is refused 422). Each matched row runs
2613
+ * the SAME single-item delete path (restrict refusal, cascade budget,
2614
+ * unique-claim freeing, CDC), so this is N deletes in one round trip, not
2615
+ * a set-delete. `dryRun` reports `matched` and mutates nothing. Loop
2616
+ * while `deleted > 0`. A refusal mid-sweep (409 referenced / 422
2617
+ * cascade_too_large) stops the call — rows already deleted STAY deleted,
2618
+ * and the error message names how many. Soft-deleted rows are hard-purged
2619
+ * by the platform 30 days later (there is no per-tenant retention knob). */
2620
+ deleteMany: (collection: string, q: {
2621
+ filter: Record<string, unknown>;
2622
+ limit?: number;
2623
+ cursor?: string;
2624
+ dryRun?: boolean;
2625
+ }) => Promise<CmsBulkDeleteResult>;
2316
2626
  publish: (collection: string, itemId: string) => Promise<Record<string, unknown>>;
2317
2627
  /** P1-12 (cms.md §11.1): the BOUNDED reverse read — which live items
2318
2628
  * reference this one, through which relation field. Owner-scoped in
@@ -2474,7 +2784,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2474
2784
  * only the envelope: membership, read-cursors, mute, and a directed block
2475
2785
  * list; the messages themselves are `comments` rows on the
2476
2786
  * `dm:<conversation_id>` topic. Gated by its own `dm` config, independent of
2477
- * comments. See docs/features/dm.md.
2787
+ * comments. See vxil.com/docs/guide/06-feature-catalog.
2478
2788
  */
2479
2789
  readonly dm: {
2480
2790
  /** Read the resolved DM config (defaults merged with the stored partial). */
@@ -3362,6 +3672,27 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3362
3672
  * over the template's stored schema. */
3363
3673
  response_schema?: Record<string, unknown>;
3364
3674
  images?: string[];
3675
+ /** DOCUMENT inputs (pdf / plain text), up to 8 refs, each a public https
3676
+ * URL, a `data:application/pdf;base64,…` (or `text/plain`) URL, or
3677
+ * `file:<object_id>`. Every ref is fetched + inlined server-side, so the
3678
+ * provider only ever sees base64. Needs a provider with the `documents`
3679
+ * capability (anthropic) — others answer 501 with a fixHint. */
3680
+ documents?: string[];
3681
+ /** PROMPT-CACHE breakpoints (anthropic; ignored elsewhere). `system`
3682
+ * caches the system prompt (tool definitions render before it and cache
3683
+ * with it); `messages` asks the provider to cache the transcript prefix;
3684
+ * `ttl` picks the 5-minute default or the 1-hour window. A cache READ
3685
+ * simply lowers the billed `input_tokens` — reported back as
3686
+ * `usage.cache_read_input_tokens` / `usage.cache_creation_input_tokens`
3687
+ * when the provider sends them. The minimum cacheable prefix is
3688
+ * model-dependent (512-4096 tokens); a shorter prompt silently does not
3689
+ * cache. The response cache key ignores this field (it changes billing,
3690
+ * not output). */
3691
+ cache?: {
3692
+ system?: boolean;
3693
+ messages?: boolean;
3694
+ ttl?: "5m" | "1h";
3695
+ };
3365
3696
  user_id?: string;
3366
3697
  }) => Promise<AiGeneration>;
3367
3698
  /** Streamed generation: returns the channel + connect token immediately; open
@@ -3418,6 +3749,27 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3418
3749
  * usage frame). */
3419
3750
  response_schema?: Record<string, unknown>;
3420
3751
  images?: string[];
3752
+ /** DOCUMENT inputs (pdf / plain text), up to 8 refs, each a public https
3753
+ * URL, a `data:application/pdf;base64,…` (or `text/plain`) URL, or
3754
+ * `file:<object_id>`. Every ref is fetched + inlined server-side, so the
3755
+ * provider only ever sees base64. Needs a provider with the `documents`
3756
+ * capability (anthropic) — others answer 501 with a fixHint. */
3757
+ documents?: string[];
3758
+ /** PROMPT-CACHE breakpoints (anthropic; ignored elsewhere). `system`
3759
+ * caches the system prompt (tool definitions render before it and cache
3760
+ * with it); `messages` asks the provider to cache the transcript prefix;
3761
+ * `ttl` picks the 5-minute default or the 1-hour window. A cache READ
3762
+ * simply lowers the billed `input_tokens` — reported back as
3763
+ * `usage.cache_read_input_tokens` / `usage.cache_creation_input_tokens`
3764
+ * when the provider sends them. The minimum cacheable prefix is
3765
+ * model-dependent (512-4096 tokens); a shorter prompt silently does not
3766
+ * cache. The response cache key ignores this field (it changes billing,
3767
+ * not output). */
3768
+ cache?: {
3769
+ system?: boolean;
3770
+ messages?: boolean;
3771
+ ttl?: "5m" | "1h";
3772
+ };
3421
3773
  user_id?: string;
3422
3774
  }) => Promise<AiJobHandle>;
3423
3775
  /** Re-mint a fresh connect token for a LIVE stream (a generation that
@@ -3648,7 +4000,23 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3648
4000
  * `quota` inlines one quota, `creditType` inlines the same owner-bound
3649
4001
  * balance `getBalance` returns — ONE call for a thin client's paywall.
3650
4002
  * A non-2xx answer means UNKNOWN: render the last cached answer, never
3651
- * free (payments.md §3a). */
4003
+ * free (payments.md §3a).
4004
+ *
4005
+ * OVERLAPPING SUBSCRIPTIONS — what `until` means. The WINNER is the
4006
+ * entitled subscription with the highest `tierMap[tier].rank` (default 0);
4007
+ * within a rank, the one with the latest period end (an open-ended manual
4008
+ * grant counts as unbounded), ties broken by subscription id, so the answer
4009
+ * is stable on every read. `entitlements` are the UNION and `quotas` the
4010
+ * MAX across ALL entitled rows, so a cancelled add-on never strips an
4011
+ * entitlement another live row still grants. `until` is the WINNER's OWN
4012
+ * boundary — not `max()` across rows, and not "when all access ends": it
4013
+ * answers *when does the CURRENT tier stop being good*. It is `null` for an
4014
+ * open-ended manual grant and on the `free` baseline. Across ranks a higher
4015
+ * tier wins even when a lower one runs longer, so `until` can report the
4016
+ * higher tier's earlier end; access does not dip (a lapsed winner re-folds
4017
+ * on read and the lower tier takes over), but a client that CACHES `until`
4018
+ * will show an early expiry — re-read instead of trusting a cached value
4019
+ * past it. */
3652
4020
  getEntitlements: (userId: string, opts?: {
3653
4021
  quota?: string;
3654
4022
  creditType?: string;
@@ -3800,6 +4168,21 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3800
4168
  * refund the full un-refunded remainder. Idempotency-Key REQUIRED — a retry
3801
4169
  * replays the recorded refund (never a second provider refund); a refund can
3802
4170
  * NEVER exceed the charge (422 refund_exceeds_charge).
4171
+ *
4172
+ * PADDLE: a PARTIAL refund is issued as a per-line-item adjustment on the
4173
+ * Paddle transaction and typically settles ASYNCHRONOUSLY — it comes back
4174
+ * `pending` until your Paddle account approves the adjustment, and the
4175
+ * approving webhook flips it to `succeeded`. A rejected adjustment flips it
4176
+ * to `failed`, releases the reserved amount back onto the charge and emits
4177
+ * `payments.refund.failed`. While a refund is `pending`, the credits the
4178
+ * charge bought are NOT yet reversed. Stripe and the mock settle inline.
4179
+ *
4180
+ * If the provider has nothing left to refund on the charge — e.g. a refund
4181
+ * was already issued in the provider's own dashboard, which vxil's running
4182
+ * total cannot see — the call returns 422 `refund_not_allocatable` with the
4183
+ * precise reason, and nothing is sent to the provider. It is deterministic:
4184
+ * retrying the same refund fails the same way. A provider call that actually
4185
+ * fails is still 502 `provider_error`.
3803
4186
  */
3804
4187
  createRefund: (input: {
3805
4188
  charge_id: string;
@@ -3896,7 +4279,11 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3896
4279
  tier?: string;
3897
4280
  }) => Promise<PaymentsSimulationResult>;
3898
4281
  /** The last report-only reconciliation sweep result for this project
3899
- * (`run: null` before the first daily tick). */
4282
+ * (`run: null` before the first daily tick). Finding kinds:
4283
+ * `balance_drift`, `entitlement_lag`, `null_tier`, `refund_pending`,
4284
+ * `stranded_webhook`, `terminal_entitled`, `webhook_secret_missing`.
4285
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
4286
+ * 403 `server_only`; the sweep is a tenant-wide operator surface. */
3900
4287
  reconcile: () => Promise<{
3901
4288
  run: {
3902
4289
  run_id: string;
@@ -3935,7 +4322,11 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3935
4322
  /** Provider webhook event log (payments.md §7 "Event log & replay"):
3936
4323
  * operator visibility over every delivery — incl. persisted signature
3937
4324
  * failures — plus an idempotent reprocess verb. Needs payments:read
3938
- * (reprocess: payments:write). */
4325
+ * (reprocess: payments:write).
4326
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
4327
+ * 403 `server_only` on list, detail and reprocess: the event log is a
4328
+ * tenant-wide operator surface and the detail carries the provider
4329
+ * payload (other customers' emails, provider ids and amounts). */
3939
4330
  webhookEvents: {
3940
4331
  /** List deliveries newest-first (keyset-paginated; pass `cursor` from a
3941
4332
  * prior page's next_cursor). List rows omit payload/raw_body. */
package/dist/index.js CHANGED
@@ -48,6 +48,16 @@ export class VxilError extends Error {
48
48
  }
49
49
  }
50
50
  const DEFAULT_BASE = 'https://api.vxil.com';
51
+ /** Normalize the `expand` option to the `$expand` query value: a comma-joined
52
+ * list (the server's spelling), or `undefined` when nothing is expanded so the
53
+ * emitted URL is BYTE-IDENTICAL to a call without it. `qs()` percent-encodes
54
+ * the `$` in the key, which `url.searchParams.get('$expand')` decodes. */
55
+ function expandParam(e) {
56
+ if (e === undefined)
57
+ return undefined;
58
+ const list = (typeof e === 'string' ? [e] : e).filter((x) => typeof x === 'string' && x.length > 0);
59
+ return list.length ? list.join(',') : undefined;
60
+ }
51
61
  /** Resolve the API base. Precedence: explicit `baseUrl` option > the
52
62
  * `VXIL_BASE_URL` env var (Node/server only — guarded via `globalThis`, so it's
53
63
  * inert in browsers & Workers) > the production default. This lets an internal /
@@ -150,7 +160,7 @@ export class Vxil {
150
160
  * client, mirroring how the edge threads the verified principal. The base
151
161
  * URL, api key, fetch impl, version pins and the retry/timeout/hooks options
152
162
  * are inherited unchanged; only the end-user token is (re)set. Pass a falsy token to get back a server-mode
153
- * client (drops the header). See docs/end-user-principals-design.md §4.1. */
163
+ * client (drops the header). See https://vxil.com/docs/guide/09-security-and-multitenancy. */
154
164
  asEndUser(endUserToken) {
155
165
  return new Vxil({
156
166
  apiKey: this.key,
@@ -195,13 +205,21 @@ export class Vxil {
195
205
  ...(opts?.guard ? { guard: opts.guard } : {}),
196
206
  ...(opts?.guards ? { guards: opts.guards } : {}),
197
207
  }),
198
- get: (itemId) => this.cms.items.get(c, itemId).then(unwrap),
199
- query: (q) => this.cms.items.query(c, q)
200
- .then((r) => ({ items: r.items.map(unwrap), next_cursor: r.next_cursor })),
208
+ // `expand` rides through as `$expand` on the wire; the envelope's `.data`
209
+ // already CARRIES the expanded members (cms-v1 replaces the stored id
210
+ // inside `data`), so the unwrap is unchanged and the overloads above are
211
+ // purely a type-level narrowing.
212
+ // (the two `as` casts pick the OVERLOADED member type: one runtime
213
+ // implementation serves both the plain and the `expand` overload, which
214
+ // differ only in the result TYPE — the wire call is the same.)
215
+ get: ((itemId, opts) => this.cms.items.get(c, itemId, opts).then(unwrap)),
216
+ query: ((q) => this.cms.items.query(c, q)
217
+ .then((r) => ({ items: r.items.map(unwrap), next_cursor: r.next_cursor }))),
201
218
  count: (filter) => this.cms.items.count(c, filter),
202
219
  patch: (itemId, data, opts) => this.cms.items.patch(c, itemId, data, opts).then(unwrap),
203
220
  inc: (itemId, incs, opts) => this.cms.items.inc(c, itemId, incs, opts).then(unwrap),
204
221
  delete: (itemId, opts) => this.cms.items.delete(c, itemId, opts),
222
+ deleteMany: (q) => this.cms.items.deleteMany(c, q),
205
223
  publish: (itemId) => this.cms.items.publish(c, itemId).then(unwrap),
206
224
  aggregate: (q) => this.cms.items.aggregate(c, q),
207
225
  rank: (q) => this.cms.items.rank(c, q),
@@ -344,6 +362,50 @@ export class Vxil {
344
362
  remove: async (email) => {
345
363
  await this.call('DELETE', `/v1/notifications/suppressions/${encodeURIComponent(email)}`);
346
364
  },
365
+ /** Bounce/complaint AGGREGATE over a 1–90 day window (default 30; an
366
+ * out-of-range or non-numeric `days` clamps rather than erroring).
367
+ * `counts` always carries all four reasons with explicit zeros; `rate` is
368
+ * `counts.total / sent` to 4 dp, or null when nothing was sent. `sent`
369
+ * counts deliveries the provider ACCEPTED, not inbox-delivered mail —
370
+ * this is a suppressions-per-accepted-send ratio, not an RFC bounce rate.
371
+ * Operator surface: a verified end-user token gets 403. */
372
+ stats: async (q) => {
373
+ const s = qs({ days: q?.days || undefined });
374
+ return (await this.call('GET', `/v1/notifications/suppressions/stats${s}`)).data;
375
+ },
376
+ },
377
+ /** Per-end-user MUTES — the opt-out table every send channel checks before
378
+ * it delivers. Absence of a row means "not muted". A row with no
379
+ * `template_id` is the ALL-TEMPLATES row.
380
+ *
381
+ * MUTE WINS. Enforcement is `(template_id = <template> OR template_id IS
382
+ * NULL) AND muted`, so the all-templates row SHADOWS a per-template
383
+ * `muted: false` — to re-enable one template after a master mute, clear the
384
+ * all-templates row (`{ muted: false }`, no `template_id`) and mute the
385
+ * others individually. `get` returns a computed `effective` per row, which
386
+ * is the answer enforcement gives; render THAT, not the stored `muted`.
387
+ *
388
+ * AUTH MAIL IS NEVER MUTEABLE. `magic-link` and `otp-code` carry sign-in
389
+ * links and codes: `set` rejects them with a 422 and the all-templates row
390
+ * does not cover them, so an "unsubscribe from everything" click can never
391
+ * lock a user out of its own account. Muteable ids: `welcome`,
392
+ * `transactional`. (Password-reset mail rides `transactional`, which stays
393
+ * muteable — see the notifications feature doc §6c.)
394
+ *
395
+ * SCOPES: with an end-user token both calls are confined to the verified
396
+ * principal and take `notifications:read` (a browser key that can mute its
397
+ * own mail must not also be able to send mail). With a server key
398
+ * `user_id` is required and `set` takes `notifications:send`. */
399
+ preferences: {
400
+ get: async (q) => {
401
+ const s = qs({ user_id: q.user_id || undefined });
402
+ return (await this.call('GET', `/v1/notifications/preferences${s}`)).data;
403
+ },
404
+ /** Idempotent upsert. OMIT `template_id` for the all-templates row; pass
405
+ * a MUTEABLE id to mute just that template. Auth mail (`magic-link`,
406
+ * `otp-code`) is a 422 — it always sends. There is no delete —
407
+ * `muted: false` is the un-mute. */
408
+ set: async (input) => (await this.call('PUT', '/v1/notifications/preferences', input)).data,
347
409
  },
348
410
  /** Deliveries the retry budget could not save (the dashboard "Resend" surface). */
349
411
  deadLetters: {
@@ -400,13 +462,18 @@ export class Vxil {
400
462
  };
401
463
  /** The planner (config-architect): describe an app in plain English and get an
402
464
  * ADVISORY vxil plan — which features to enable, a materialized `config_draft`,
403
- * and which truly-unique logic needs a tenant function. Deterministic + pure;
404
- * it PROPOSES a plan, it never applies anything (review it, then push the
405
- * config). Needs `features:read`. (POST /v1/plan; docs/planner-feature-design.md) */
465
+ * which truly-unique logic needs a tenant function, and an architecture brief
466
+ * (identity, data, money path, background work, limits, and the older patterns
467
+ * vxil replaces). It PROPOSES a plan, it never applies anything (review it,
468
+ * then push the config). Pass `{ brief: false }` to skip the brief.
469
+ * Needs `features:read`. (POST /v1/plan; https://vxil.com/docs/guide/10-agents-and-mcp) */
406
470
  planner = {
407
- create: async (description, name) => (await this.call('POST', '/v1/plan', {
471
+ create: async (description, name, opts) => (await this.call('POST', '/v1/plan', {
408
472
  description,
409
473
  ...(name ? { name } : {}),
474
+ // sent ONLY when opting out, so every shipped call site's request body
475
+ // stays byte-identical
476
+ ...(opts?.brief === false ? { brief: false } : {}),
410
477
  })).data,
411
478
  };
412
479
  audit = {
@@ -544,7 +611,7 @@ export class Vxil {
544
611
  },
545
612
  },
546
613
  };
547
- /** vxil-auth. SCOPES (docs/features/auth.md §7.6): every flow here that
614
+ /** vxil-auth. SCOPES (vxil.com/docs/guide/09-security-and-multitenancy): every flow here that
548
615
  * obtains, renews, verifies or ends the caller's OWN session (signUp/signIn,
549
616
  * magicLink, otp, anonymous, stepUp, oauth, sessions.refresh/revoke/verify,
550
617
  * password reset) accepts the narrow `auth:signin` — the ONLY auth scope a
@@ -557,10 +624,19 @@ export class Vxil {
557
624
  auth = {
558
625
  signUp: async (input) => (await this.call('POST', '/v1/auth/sign-up', input)).data,
559
626
  signIn: async (input) => (await this.call('POST', '/v1/auth/sign-in', input)).data,
627
+ /** Magic-link sign-in: vxil mails a single-use link to the address and the
628
+ * click returns here with `?token=`. The request is always a 202 —
629
+ * identical for a known and an unknown address (anti-enumeration). A
630
+ * second request for the SAME address inside the per-identifier cooldown
631
+ * (auth config `magicLink.resendCooldownSec`, default 60s, 0 disables) is
632
+ * `429 magic_link_rate_limited` with a `Retry-After` header. */
560
633
  magicLink: {
561
- request: async (input) => {
562
- await this.call('POST', '/v1/auth/magic-link/request', input);
563
- },
634
+ /** `test_link` is present ONLY when the address matches auth config
635
+ * `otp.testRecipients` (store-review / CI accounts): no mail is sent and
636
+ * the full sign-in link comes back instead. `captcha_token` is required
637
+ * when the tenant configured `security.captchaSecretRef`; `locale`
638
+ * chooses the mail's language. */
639
+ request: async (input) => (await this.call('POST', '/v1/auth/magic-link/request', input)).data,
564
640
  verify: async (token) => (await this.call('POST', '/v1/auth/magic-link/verify', { token })).data,
565
641
  },
566
642
  /** Email OTP sign-in: a 6-digit single-use code (distinct from magic-link).
@@ -748,6 +824,59 @@ export class Vxil {
748
824
  setPublic: async (collection, isPublic) => {
749
825
  await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { public: isPublic });
750
826
  },
827
+ /** Re-project the collection's index slots after an `index_slot` move
828
+ * (cms.md §19). Slots are projected on WRITE only, so until this runs,
829
+ * stored rows keep their OLD projection: the new slot is NULL and the
830
+ * VACATED slot still holds the old field's values — range/sort on the
831
+ * moved field returns the WRONG rows, not merely missing ones. ONE page
832
+ * per call (≤2000 rows, default 500), resumable via `next_cursor`,
833
+ * idempotent (a second full pass reports `updated: 0`), and never a
834
+ * version bump / CDC event (slots are derived columns). Server-key only.
835
+ * `field` narrows the write to that field's slot plus any unowned slot. */
836
+ reindex: async (collection, opts) => (await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/reindex`, {
837
+ ...(opts?.field ? { field: opts.field } : {}),
838
+ ...(opts?.cursor ? { cursor: opts.cursor } : {}),
839
+ ...(opts?.limit ? { limit: opts.limit } : {}),
840
+ })).data,
841
+ /** Drive `reindex` to completion, page by page. Returns the totals.
842
+ * A page that reports `skipped > 0` lost rows to a concurrent writer, so
843
+ * it is re-run ONCE before the loop advances (the write is idempotent and
844
+ * the retry is bounded — a permanently hot collection never spins). The
845
+ * residual `skipped` is returned, not swallowed. */
846
+ reindexAll: async (collection, opts) => {
847
+ let cursor;
848
+ let scanned = 0;
849
+ let updated = 0;
850
+ let skipped = 0;
851
+ let pages = 0;
852
+ for (;;) {
853
+ let page = await this.cms.collections.reindex(collection, {
854
+ ...(opts?.field ? { field: opts.field } : {}),
855
+ ...(opts?.limit ? { limit: opts.limit } : {}),
856
+ ...(cursor ? { cursor } : {}),
857
+ });
858
+ scanned += page.scanned;
859
+ updated += page.updated;
860
+ pages += 1;
861
+ opts?.onPage?.(page);
862
+ if ((page.skipped ?? 0) > 0) {
863
+ page = await this.cms.collections.reindex(collection, {
864
+ ...(opts?.field ? { field: opts.field } : {}),
865
+ ...(opts?.limit ? { limit: opts.limit } : {}),
866
+ ...(cursor ? { cursor } : {}),
867
+ });
868
+ scanned += page.scanned;
869
+ updated += page.updated;
870
+ pages += 1;
871
+ opts?.onPage?.(page);
872
+ }
873
+ skipped += page.skipped ?? 0;
874
+ if (page.complete || !page.next_cursor)
875
+ break;
876
+ cursor = page.next_cursor;
877
+ }
878
+ return { scanned, updated, skipped, pages };
879
+ },
751
880
  /** Replace the collection's per-record ACTION list (cms.md §17) — the
752
881
  * `{ actions }` fields-route meta-op; `[]` clears. Each action is ONE
753
882
  * human-initiated step: the dashboard renders it as a button per record,
@@ -760,7 +889,13 @@ export class Vxil {
760
889
  /** `lock` serializes same-key writers (per-tenant advisory lock); `guard`
761
890
  * is the declarative capacity/overlap invariant (requires lock) — cms.md §10. */
762
891
  create: async (collection, input) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}`, input)).data,
763
- get: async (collection, itemId) => (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}`)).data,
892
+ /** `expand` inlines relation/file fields into `data` (cms.md §6.2): the
893
+ * full item envelope, a `{ object_id, $ref: 'files' }` stub for a file,
894
+ * the bare id on a cycle/depth cut, `null` for an invisible target. */
895
+ get: async (collection, itemId, opts) => {
896
+ const s = qs({ $expand: expandParam(opts?.expand) });
897
+ return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}${s}`)).data;
898
+ },
764
899
  /**
765
900
  * The bounded query DSL. filter ops: $eq $ne $gt $gte $lt $lte $in
766
901
  * $contains $startsWith (LIKE-escaped; substring-searchable on s*-slotted
@@ -777,6 +912,7 @@ export class Vxil {
777
912
  sort: q?.sort || undefined,
778
913
  limit: q?.limit || undefined,
779
914
  cursor: q?.cursor || undefined,
915
+ $expand: expandParam(q?.expand),
780
916
  });
781
917
  return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}${s}`)).data;
782
918
  },
@@ -804,6 +940,22 @@ export class Vxil {
804
940
  /** Returns the cascade tally (cms.md §11); `ifVersion` rides If-Match and
805
941
  * a conflict aborts BEFORE any cascade side-effect. */
806
942
  delete: async (collection, itemId, opts) => (await this.call('DELETE', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}`, undefined, opts?.ifVersion !== undefined ? { 'if-match': String(opts.ifVersion) } : {})).data,
943
+ /** N-22 (cms.md §20): BOUNDED FILTERED delete — soft-delete up to `limit`
944
+ * items (1–100, default 25) matching `filter`, newest id first. A filter
945
+ * is REQUIRED (an unselected sweep is refused 422). Each matched row runs
946
+ * the SAME single-item delete path (restrict refusal, cascade budget,
947
+ * unique-claim freeing, CDC), so this is N deletes in one round trip, not
948
+ * a set-delete. `dryRun` reports `matched` and mutates nothing. Loop
949
+ * while `deleted > 0`. A refusal mid-sweep (409 referenced / 422
950
+ * cascade_too_large) stops the call — rows already deleted STAY deleted,
951
+ * and the error message names how many. Soft-deleted rows are hard-purged
952
+ * by the platform 30 days later (there is no per-tenant retention knob). */
953
+ deleteMany: async (collection, q) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/delete`, {
954
+ filter: q.filter,
955
+ ...(q.limit !== undefined ? { limit: q.limit } : {}),
956
+ ...(q.cursor ? { cursor: q.cursor } : {}),
957
+ ...(q.dryRun !== undefined ? { dry_run: q.dryRun } : {}),
958
+ })).data,
807
959
  publish: async (collection, itemId) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}/publish`)).data,
808
960
  /** P1-12 (cms.md §11.1): the BOUNDED reverse read — which live items
809
961
  * reference this one, through which relation field. Owner-scoped in
@@ -880,7 +1032,7 @@ export class Vxil {
880
1032
  * only the envelope: membership, read-cursors, mute, and a directed block
881
1033
  * list; the messages themselves are `comments` rows on the
882
1034
  * `dm:<conversation_id>` topic. Gated by its own `dm` config, independent of
883
- * comments. See docs/features/dm.md.
1035
+ * comments. See vxil.com/docs/guide/06-feature-catalog.
884
1036
  */
885
1037
  dm = {
886
1038
  /** Read the resolved DM config (defaults merged with the stored partial). */
@@ -1455,7 +1607,23 @@ export class Vxil {
1455
1607
  * `quota` inlines one quota, `creditType` inlines the same owner-bound
1456
1608
  * balance `getBalance` returns — ONE call for a thin client's paywall.
1457
1609
  * A non-2xx answer means UNKNOWN: render the last cached answer, never
1458
- * free (payments.md §3a). */
1610
+ * free (payments.md §3a).
1611
+ *
1612
+ * OVERLAPPING SUBSCRIPTIONS — what `until` means. The WINNER is the
1613
+ * entitled subscription with the highest `tierMap[tier].rank` (default 0);
1614
+ * within a rank, the one with the latest period end (an open-ended manual
1615
+ * grant counts as unbounded), ties broken by subscription id, so the answer
1616
+ * is stable on every read. `entitlements` are the UNION and `quotas` the
1617
+ * MAX across ALL entitled rows, so a cancelled add-on never strips an
1618
+ * entitlement another live row still grants. `until` is the WINNER's OWN
1619
+ * boundary — not `max()` across rows, and not "when all access ends": it
1620
+ * answers *when does the CURRENT tier stop being good*. It is `null` for an
1621
+ * open-ended manual grant and on the `free` baseline. Across ranks a higher
1622
+ * tier wins even when a lower one runs longer, so `until` can report the
1623
+ * higher tier's earlier end; access does not dip (a lapsed winner re-folds
1624
+ * on read and the lower tier takes over), but a client that CACHES `until`
1625
+ * will show an early expiry — re-read instead of trusting a cached value
1626
+ * past it. */
1459
1627
  getEntitlements: async (userId, opts) => {
1460
1628
  const s = qs({
1461
1629
  user_id: userId,
@@ -1527,6 +1695,21 @@ export class Vxil {
1527
1695
  * refund the full un-refunded remainder. Idempotency-Key REQUIRED — a retry
1528
1696
  * replays the recorded refund (never a second provider refund); a refund can
1529
1697
  * NEVER exceed the charge (422 refund_exceeds_charge).
1698
+ *
1699
+ * PADDLE: a PARTIAL refund is issued as a per-line-item adjustment on the
1700
+ * Paddle transaction and typically settles ASYNCHRONOUSLY — it comes back
1701
+ * `pending` until your Paddle account approves the adjustment, and the
1702
+ * approving webhook flips it to `succeeded`. A rejected adjustment flips it
1703
+ * to `failed`, releases the reserved amount back onto the charge and emits
1704
+ * `payments.refund.failed`. While a refund is `pending`, the credits the
1705
+ * charge bought are NOT yet reversed. Stripe and the mock settle inline.
1706
+ *
1707
+ * If the provider has nothing left to refund on the charge — e.g. a refund
1708
+ * was already issued in the provider's own dashboard, which vxil's running
1709
+ * total cannot see — the call returns 422 `refund_not_allocatable` with the
1710
+ * precise reason, and nothing is sent to the provider. It is deterministic:
1711
+ * retrying the same refund fails the same way. A provider call that actually
1712
+ * fails is still 502 `provider_error`.
1530
1713
  */
1531
1714
  createRefund: async (input, opts) => (await this.call('POST', '/v1/payments/refunds', input, { 'idempotency-key': opts.idempotencyKey })).data,
1532
1715
  /** Where the user MANAGES their subscription, resolved by FUNDING SOURCE
@@ -1569,7 +1752,11 @@ export class Vxil {
1569
1752
  * webhook path and judge it with the conformance oracle. */
1570
1753
  simulate: async (input) => (await this.call('POST', '/v1/payments/simulate', input)).data,
1571
1754
  /** The last report-only reconciliation sweep result for this project
1572
- * (`run: null` before the first daily tick). */
1755
+ * (`run: null` before the first daily tick). Finding kinds:
1756
+ * `balance_drift`, `entitlement_lag`, `null_tier`, `refund_pending`,
1757
+ * `stranded_webhook`, `terminal_entitled`, `webhook_secret_missing`.
1758
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
1759
+ * 403 `server_only`; the sweep is a tenant-wide operator surface. */
1573
1760
  reconcile: async () => (await this.call('GET', '/v1/payments/reconcile')).data,
1574
1761
  /** List charges newest-first (the refund enabler — discover the charge_id).
1575
1762
  * Filters: user_id, status, limit (clamped 1..100, default 50). */
@@ -1584,7 +1771,11 @@ export class Vxil {
1584
1771
  /** Provider webhook event log (payments.md §7 "Event log & replay"):
1585
1772
  * operator visibility over every delivery — incl. persisted signature
1586
1773
  * failures — plus an idempotent reprocess verb. Needs payments:read
1587
- * (reprocess: payments:write). */
1774
+ * (reprocess: payments:write).
1775
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
1776
+ * 403 `server_only` on list, detail and reprocess: the event log is a
1777
+ * tenant-wide operator surface and the detail carries the provider
1778
+ * payload (other customers' emails, provider ids and amounts). */
1588
1779
  webhookEvents: {
1589
1780
  /** List deliveries newest-first (keyset-paginated; pass `cursor` from a
1590
1781
  * prior page's next_cursor). List rows omit payload/raw_body. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.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).",
@@ -10,8 +10,7 @@
10
10
  "backend",
11
11
  "baas",
12
12
  "sdk",
13
- "typed-client",
14
- "cloudflare-workers"
13
+ "typed-client"
15
14
  ],
16
15
  "engines": {
17
16
  "node": ">=18"