@vxil/sdk 0.4.1 → 0.5.1

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +469 -15
  2. package/dist/index.js +242 -15
  3. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -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
@@ -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; https://vxil.com/docs/guide/10-agents-and-mcp) */
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?: {
@@ -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;
@@ -1933,6 +2189,37 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
1933
2189
  }>;
1934
2190
  };
1935
2191
  };
2192
+ /** E-mail CHANGE for a signed-in (non-anonymous) user: a 6-digit code is
2193
+ * mailed to the NEW address; verify swaps the account's address (both the
2194
+ * auth row and the shared identity row, in one transaction), marks it
2195
+ * verified, and revokes every OTHER session of the user (reason
2196
+ * `email_changed`) — the presenting `token` keeps working. `request` is
2197
+ * always 202 whether or not the address is taken (no enumeration);
2198
+ * a taken address surfaces at `verify` as 409 `email_in_use`. A guest
2199
+ * session is 409 `anonymous_user` — guests use `anonymous.link`. Shares
2200
+ * the OTP knobs (cooldown → 429 `otp_rate_limited`, attempts, TTL,
2201
+ * `test_code` for `otp.testRecipients`) but does NOT need `otp.enabled`. */
2202
+ email: {
2203
+ change: {
2204
+ request: (input: {
2205
+ token: string;
2206
+ new_email: string;
2207
+ locale?: string;
2208
+ }) => Promise<{
2209
+ sent: true;
2210
+ test_code?: string;
2211
+ }>;
2212
+ verify: (input: {
2213
+ token: string;
2214
+ new_email: string;
2215
+ code: string;
2216
+ }) => Promise<{
2217
+ user_id: string;
2218
+ email: string;
2219
+ verified: true;
2220
+ }>;
2221
+ };
2222
+ };
1936
2223
  /** Step-up re-auth: an OTP challenge on the CURRENT session. On verify the
1937
2224
  * bearer token ROTATES (the returned session.token replaces the old one —
1938
2225
  * swap it client-side) and the JWT gains an `elv` claim. */
@@ -2252,6 +2539,35 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2252
2539
  * the owner_field are never exposed. `false` closes the lane (and purges the
2253
2540
  * edge cache). Read the public side with `cmsPublicUrl` / `listCmsPublic`. */
2254
2541
  setPublic: (collection: string, isPublic: boolean) => Promise<void>;
2542
+ /** Re-project the collection's index slots after an `index_slot` move
2543
+ * (cms.md §19). Slots are projected on WRITE only, so until this runs,
2544
+ * stored rows keep their OLD projection: the new slot is NULL and the
2545
+ * VACATED slot still holds the old field's values — range/sort on the
2546
+ * moved field returns the WRONG rows, not merely missing ones. ONE page
2547
+ * per call (≤2000 rows, default 500), resumable via `next_cursor`,
2548
+ * idempotent (a second full pass reports `updated: 0`), and never a
2549
+ * version bump / CDC event (slots are derived columns). Server-key only.
2550
+ * `field` narrows the write to that field's slot plus any unowned slot. */
2551
+ reindex: (collection: string, opts?: {
2552
+ field?: string;
2553
+ cursor?: string;
2554
+ limit?: number;
2555
+ }) => Promise<CmsReindexPage>;
2556
+ /** Drive `reindex` to completion, page by page. Returns the totals.
2557
+ * A page that reports `skipped > 0` lost rows to a concurrent writer, so
2558
+ * it is re-run ONCE before the loop advances (the write is idempotent and
2559
+ * the retry is bounded — a permanently hot collection never spins). The
2560
+ * residual `skipped` is returned, not swallowed. */
2561
+ reindexAll: (collection: string, opts?: {
2562
+ field?: string;
2563
+ limit?: number;
2564
+ onPage?: (p: CmsReindexPage) => void;
2565
+ }) => Promise<{
2566
+ scanned: number;
2567
+ updated: number;
2568
+ skipped: number;
2569
+ pages: number;
2570
+ }>;
2255
2571
  /** Replace the collection's per-record ACTION list (cms.md §17) — the
2256
2572
  * `{ actions }` fields-route meta-op; `[]` clears. Each action is ONE
2257
2573
  * human-initiated step: the dashboard renders it as a button per record,
@@ -2273,7 +2589,12 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2273
2589
  version: number;
2274
2590
  data: Record<string, unknown>;
2275
2591
  }>;
2276
- get: (collection: string, itemId: string) => Promise<Record<string, unknown>>;
2592
+ /** `expand` inlines relation/file fields into `data` (cms.md §6.2): the
2593
+ * full item envelope, a `{ object_id, $ref: 'files' }` stub for a file,
2594
+ * the bare id on a cycle/depth cut, `null` for an invisible target. */
2595
+ get: (collection: string, itemId: string, opts?: {
2596
+ expand?: readonly string[] | string;
2597
+ }) => Promise<Record<string, unknown>>;
2277
2598
  /**
2278
2599
  * The bounded query DSL. filter ops: $eq $ne $gt $gte $lt $lte $in
2279
2600
  * $contains $startsWith (LIKE-escaped; substring-searchable on s*-slotted
@@ -2289,6 +2610,10 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2289
2610
  sort?: string;
2290
2611
  limit?: number;
2291
2612
  cursor?: string;
2613
+ /** relation/file fields to inline (`$expand`) — see `get`. The server
2614
+ * refuses `$expand` together with `count=true` (422 invalid_query), so
2615
+ * use `count()` for the aggregate. */
2616
+ expand?: readonly string[] | string;
2292
2617
  }) => Promise<{
2293
2618
  items: Array<Record<string, unknown>>;
2294
2619
  next_cursor: string | null;
@@ -2313,6 +2638,22 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2313
2638
  cascaded: number;
2314
2639
  set_null: number;
2315
2640
  }>;
2641
+ /** N-22 (cms.md §20): BOUNDED FILTERED delete — soft-delete up to `limit`
2642
+ * items (1–100, default 25) matching `filter`, newest id first. A filter
2643
+ * is REQUIRED (an unselected sweep is refused 422). Each matched row runs
2644
+ * the SAME single-item delete path (restrict refusal, cascade budget,
2645
+ * unique-claim freeing, CDC), so this is N deletes in one round trip, not
2646
+ * a set-delete. `dryRun` reports `matched` and mutates nothing. Loop
2647
+ * while `deleted > 0`. A refusal mid-sweep (409 referenced / 422
2648
+ * cascade_too_large) stops the call — rows already deleted STAY deleted,
2649
+ * and the error message names how many. Soft-deleted rows are hard-purged
2650
+ * by the platform 30 days later (there is no per-tenant retention knob). */
2651
+ deleteMany: (collection: string, q: {
2652
+ filter: Record<string, unknown>;
2653
+ limit?: number;
2654
+ cursor?: string;
2655
+ dryRun?: boolean;
2656
+ }) => Promise<CmsBulkDeleteResult>;
2316
2657
  publish: (collection: string, itemId: string) => Promise<Record<string, unknown>>;
2317
2658
  /** P1-12 (cms.md §11.1): the BOUNDED reverse read — which live items
2318
2659
  * reference this one, through which relation field. Owner-scoped in
@@ -2358,6 +2699,21 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
2358
2699
  }>;
2359
2700
  scanned: number;
2360
2701
  }>;
2702
+ /** SERVER-ONLY (403 server_only in end-user mode). Account merge: move
2703
+ * every row `from_user_id` owns — across every collection that declares
2704
+ * an `owner_field`, soft-deleted rows included, the stored owner value
2705
+ * AND its indexed projection — onto `into_user_id`. ONE bounded page (≤500
2706
+ * rows) per call: loop while `done` is false. Idempotent (a second pass
2707
+ * moves 0). Bumps each moved row's `version`. The cms consumer of the
2708
+ * `auth.user.merged { from, into }` event; auth-v1 calls it after a
2709
+ * merge, the event is the backstop. Emits one `cms.items.rekeyed`. */
2710
+ reKey: (input: {
2711
+ from_user_id: string;
2712
+ into_user_id: string;
2713
+ }) => Promise<{
2714
+ moved: number;
2715
+ done: boolean;
2716
+ }>;
2361
2717
  };
2362
2718
  /** cms-rel B4 (cms.md §13): atomic multi-collection transaction — 1–5
2363
2719
  * steps over ≤3 collections, per-step `$where` CAS preconditions (the
@@ -3195,6 +3551,22 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3195
3551
  object_id: string;
3196
3552
  expires_at: string | null;
3197
3553
  }>;
3554
+ /** SERVER-ONLY (403 server_only in end-user mode). Account merge: move
3555
+ * every object `from_user_id` owns (every status, tombstones included) and
3556
+ * the shared links minted on them onto `into_user_id`, which must be a
3557
+ * live tenant user (404 user_not_found otherwise). ONE bounded page (≤500
3558
+ * objects) per call: loop while `done` is false. Idempotent. An erased
3559
+ * `from` user's objects are held back for the GDPR byte sweep (moved 0).
3560
+ * The files consumer of the `auth.user.merged { from, into }` event;
3561
+ * auth-v1 calls it after a merge, the event is the backstop. Emits one
3562
+ * `files.objects.rekeyed`. */
3563
+ reKey: (input: {
3564
+ from_user_id: string;
3565
+ into_user_id: string;
3566
+ }) => Promise<{
3567
+ moved: number;
3568
+ done: boolean;
3569
+ }>;
3198
3570
  sharedLinks: {
3199
3571
  /** Public (unauthenticated) URL for the object; revocable. Optional
3200
3572
  * `max_downloads` (1 = one-time link) and an absolute `expires_at`; the
@@ -3304,7 +3676,8 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3304
3676
  * openrouter) route via BYO keys in tenant_secrets. `images` on the generate
3305
3677
  * inputs takes up to 8 vision refs: a public https:// URL, a
3306
3678
  * data:image/...;base64 URL, or file:<object_id> (a files-feature object) —
3307
- * fetched images are capped at 4 MiB each / 16 MiB total.
3679
+ * fetched images are capped at 4 MiB each; `documents` (pdf/text, ≤10 MiB
3680
+ * each) share one 20 MiB inline ceiling per request with them.
3308
3681
  */
3309
3682
  readonly ai: {
3310
3683
  templates: {
@@ -3362,6 +3735,27 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3362
3735
  * over the template's stored schema. */
3363
3736
  response_schema?: Record<string, unknown>;
3364
3737
  images?: string[];
3738
+ /** DOCUMENT inputs (pdf / plain text), up to 8 refs, each a public https
3739
+ * URL, a `data:application/pdf;base64,…` (or `text/plain`) URL, or
3740
+ * `file:<object_id>`. Every ref is fetched + inlined server-side, so the
3741
+ * provider only ever sees base64. Needs a provider with the `documents`
3742
+ * capability (anthropic) — others answer 501 with a fixHint. */
3743
+ documents?: string[];
3744
+ /** PROMPT-CACHE breakpoints (anthropic; ignored elsewhere). `system`
3745
+ * caches the system prompt (tool definitions render before it and cache
3746
+ * with it); `messages` asks the provider to cache the transcript prefix;
3747
+ * `ttl` picks the 5-minute default or the 1-hour window. A cache READ
3748
+ * simply lowers the billed `input_tokens` — reported back as
3749
+ * `usage.cache_read_input_tokens` / `usage.cache_creation_input_tokens`
3750
+ * when the provider sends them. The minimum cacheable prefix is
3751
+ * model-dependent (512-4096 tokens); a shorter prompt silently does not
3752
+ * cache. The response cache key ignores this field (it changes billing,
3753
+ * not output). */
3754
+ cache?: {
3755
+ system?: boolean;
3756
+ messages?: boolean;
3757
+ ttl?: "5m" | "1h";
3758
+ };
3365
3759
  user_id?: string;
3366
3760
  }) => Promise<AiGeneration>;
3367
3761
  /** Streamed generation: returns the channel + connect token immediately; open
@@ -3418,6 +3812,27 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3418
3812
  * usage frame). */
3419
3813
  response_schema?: Record<string, unknown>;
3420
3814
  images?: string[];
3815
+ /** DOCUMENT inputs (pdf / plain text), up to 8 refs, each a public https
3816
+ * URL, a `data:application/pdf;base64,…` (or `text/plain`) URL, or
3817
+ * `file:<object_id>`. Every ref is fetched + inlined server-side, so the
3818
+ * provider only ever sees base64. Needs a provider with the `documents`
3819
+ * capability (anthropic) — others answer 501 with a fixHint. */
3820
+ documents?: string[];
3821
+ /** PROMPT-CACHE breakpoints (anthropic; ignored elsewhere). `system`
3822
+ * caches the system prompt (tool definitions render before it and cache
3823
+ * with it); `messages` asks the provider to cache the transcript prefix;
3824
+ * `ttl` picks the 5-minute default or the 1-hour window. A cache READ
3825
+ * simply lowers the billed `input_tokens` — reported back as
3826
+ * `usage.cache_read_input_tokens` / `usage.cache_creation_input_tokens`
3827
+ * when the provider sends them. The minimum cacheable prefix is
3828
+ * model-dependent (512-4096 tokens); a shorter prompt silently does not
3829
+ * cache. The response cache key ignores this field (it changes billing,
3830
+ * not output). */
3831
+ cache?: {
3832
+ system?: boolean;
3833
+ messages?: boolean;
3834
+ ttl?: "5m" | "1h";
3835
+ };
3421
3836
  user_id?: string;
3422
3837
  }) => Promise<AiJobHandle>;
3423
3838
  /** Re-mint a fresh connect token for a LIVE stream (a generation that
@@ -3648,7 +4063,23 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3648
4063
  * `quota` inlines one quota, `creditType` inlines the same owner-bound
3649
4064
  * balance `getBalance` returns — ONE call for a thin client's paywall.
3650
4065
  * A non-2xx answer means UNKNOWN: render the last cached answer, never
3651
- * free (payments.md §3a). */
4066
+ * free (payments.md §3a).
4067
+ *
4068
+ * OVERLAPPING SUBSCRIPTIONS — what `until` means. The WINNER is the
4069
+ * entitled subscription with the highest `tierMap[tier].rank` (default 0);
4070
+ * within a rank, the one with the latest period end (an open-ended manual
4071
+ * grant counts as unbounded), ties broken by subscription id, so the answer
4072
+ * is stable on every read. `entitlements` are the UNION and `quotas` the
4073
+ * MAX across ALL entitled rows, so a cancelled add-on never strips an
4074
+ * entitlement another live row still grants. `until` is the WINNER's OWN
4075
+ * boundary — not `max()` across rows, and not "when all access ends": it
4076
+ * answers *when does the CURRENT tier stop being good*. It is `null` for an
4077
+ * open-ended manual grant and on the `free` baseline. Across ranks a higher
4078
+ * tier wins even when a lower one runs longer, so `until` can report the
4079
+ * higher tier's earlier end; access does not dip (a lapsed winner re-folds
4080
+ * on read and the lower tier takes over), but a client that CACHES `until`
4081
+ * will show an early expiry — re-read instead of trusting a cached value
4082
+ * past it. */
3652
4083
  getEntitlements: (userId: string, opts?: {
3653
4084
  quota?: string;
3654
4085
  creditType?: string;
@@ -3800,6 +4231,21 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3800
4231
  * refund the full un-refunded remainder. Idempotency-Key REQUIRED — a retry
3801
4232
  * replays the recorded refund (never a second provider refund); a refund can
3802
4233
  * NEVER exceed the charge (422 refund_exceeds_charge).
4234
+ *
4235
+ * PADDLE: a PARTIAL refund is issued as a per-line-item adjustment on the
4236
+ * Paddle transaction and typically settles ASYNCHRONOUSLY — it comes back
4237
+ * `pending` until your Paddle account approves the adjustment, and the
4238
+ * approving webhook flips it to `succeeded`. A rejected adjustment flips it
4239
+ * to `failed`, releases the reserved amount back onto the charge and emits
4240
+ * `payments.refund.failed`. While a refund is `pending`, the credits the
4241
+ * charge bought are NOT yet reversed. Stripe and the mock settle inline.
4242
+ *
4243
+ * If the provider has nothing left to refund on the charge — e.g. a refund
4244
+ * was already issued in the provider's own dashboard, which vxil's running
4245
+ * total cannot see — the call returns 422 `refund_not_allocatable` with the
4246
+ * precise reason, and nothing is sent to the provider. It is deterministic:
4247
+ * retrying the same refund fails the same way. A provider call that actually
4248
+ * fails is still 502 `provider_error`.
3803
4249
  */
3804
4250
  createRefund: (input: {
3805
4251
  charge_id: string;
@@ -3896,7 +4342,11 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3896
4342
  tier?: string;
3897
4343
  }) => Promise<PaymentsSimulationResult>;
3898
4344
  /** The last report-only reconciliation sweep result for this project
3899
- * (`run: null` before the first daily tick). */
4345
+ * (`run: null` before the first daily tick). Finding kinds:
4346
+ * `balance_drift`, `entitlement_lag`, `null_tier`, `refund_pending`,
4347
+ * `stranded_webhook`, `terminal_entitled`, `webhook_secret_missing`.
4348
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
4349
+ * 403 `server_only`; the sweep is a tenant-wide operator surface. */
3900
4350
  reconcile: () => Promise<{
3901
4351
  run: {
3902
4352
  run_id: string;
@@ -3935,7 +4385,11 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
3935
4385
  /** Provider webhook event log (payments.md §7 "Event log & replay"):
3936
4386
  * operator visibility over every delivery — incl. persisted signature
3937
4387
  * failures — plus an idempotent reprocess verb. Needs payments:read
3938
- * (reprocess: payments:write). */
4388
+ * (reprocess: payments:write).
4389
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
4390
+ * 403 `server_only` on list, detail and reprocess: the event log is a
4391
+ * tenant-wide operator surface and the detail carries the provider
4392
+ * payload (other customers' emails, provider ids and amounts). */
3939
4393
  webhookEvents: {
3940
4394
  /** List deliveries newest-first (keyset-paginated; pass `cursor` from a
3941
4395
  * 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 /
@@ -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; https://vxil.com/docs/guide/10-agents-and-mcp) */
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 = {
@@ -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).
@@ -588,6 +664,22 @@ export class Vxil {
588
664
  verify: async (input) => (await this.call('POST', '/v1/auth/anonymous/link/verify', input)).data,
589
665
  },
590
666
  },
667
+ /** E-mail CHANGE for a signed-in (non-anonymous) user: a 6-digit code is
668
+ * mailed to the NEW address; verify swaps the account's address (both the
669
+ * auth row and the shared identity row, in one transaction), marks it
670
+ * verified, and revokes every OTHER session of the user (reason
671
+ * `email_changed`) — the presenting `token` keeps working. `request` is
672
+ * always 202 whether or not the address is taken (no enumeration);
673
+ * a taken address surfaces at `verify` as 409 `email_in_use`. A guest
674
+ * session is 409 `anonymous_user` — guests use `anonymous.link`. Shares
675
+ * the OTP knobs (cooldown → 429 `otp_rate_limited`, attempts, TTL,
676
+ * `test_code` for `otp.testRecipients`) but does NOT need `otp.enabled`. */
677
+ email: {
678
+ change: {
679
+ request: async (input) => (await this.call('POST', '/v1/auth/email/change/request', input)).data,
680
+ verify: async (input) => (await this.call('POST', '/v1/auth/email/change/verify', input)).data,
681
+ },
682
+ },
591
683
  /** Step-up re-auth: an OTP challenge on the CURRENT session. On verify the
592
684
  * bearer token ROTATES (the returned session.token replaces the old one —
593
685
  * swap it client-side) and the JWT gains an `elv` claim. */
@@ -748,6 +840,59 @@ export class Vxil {
748
840
  setPublic: async (collection, isPublic) => {
749
841
  await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/fields`, { public: isPublic });
750
842
  },
843
+ /** Re-project the collection's index slots after an `index_slot` move
844
+ * (cms.md §19). Slots are projected on WRITE only, so until this runs,
845
+ * stored rows keep their OLD projection: the new slot is NULL and the
846
+ * VACATED slot still holds the old field's values — range/sort on the
847
+ * moved field returns the WRONG rows, not merely missing ones. ONE page
848
+ * per call (≤2000 rows, default 500), resumable via `next_cursor`,
849
+ * idempotent (a second full pass reports `updated: 0`), and never a
850
+ * version bump / CDC event (slots are derived columns). Server-key only.
851
+ * `field` narrows the write to that field's slot plus any unowned slot. */
852
+ reindex: async (collection, opts) => (await this.call('POST', `/v1/cms/collections/${encodeURIComponent(collection)}/reindex`, {
853
+ ...(opts?.field ? { field: opts.field } : {}),
854
+ ...(opts?.cursor ? { cursor: opts.cursor } : {}),
855
+ ...(opts?.limit ? { limit: opts.limit } : {}),
856
+ })).data,
857
+ /** Drive `reindex` to completion, page by page. Returns the totals.
858
+ * A page that reports `skipped > 0` lost rows to a concurrent writer, so
859
+ * it is re-run ONCE before the loop advances (the write is idempotent and
860
+ * the retry is bounded — a permanently hot collection never spins). The
861
+ * residual `skipped` is returned, not swallowed. */
862
+ reindexAll: async (collection, opts) => {
863
+ let cursor;
864
+ let scanned = 0;
865
+ let updated = 0;
866
+ let skipped = 0;
867
+ let pages = 0;
868
+ for (;;) {
869
+ let page = await this.cms.collections.reindex(collection, {
870
+ ...(opts?.field ? { field: opts.field } : {}),
871
+ ...(opts?.limit ? { limit: opts.limit } : {}),
872
+ ...(cursor ? { cursor } : {}),
873
+ });
874
+ scanned += page.scanned;
875
+ updated += page.updated;
876
+ pages += 1;
877
+ opts?.onPage?.(page);
878
+ if ((page.skipped ?? 0) > 0) {
879
+ page = await this.cms.collections.reindex(collection, {
880
+ ...(opts?.field ? { field: opts.field } : {}),
881
+ ...(opts?.limit ? { limit: opts.limit } : {}),
882
+ ...(cursor ? { cursor } : {}),
883
+ });
884
+ scanned += page.scanned;
885
+ updated += page.updated;
886
+ pages += 1;
887
+ opts?.onPage?.(page);
888
+ }
889
+ skipped += page.skipped ?? 0;
890
+ if (page.complete || !page.next_cursor)
891
+ break;
892
+ cursor = page.next_cursor;
893
+ }
894
+ return { scanned, updated, skipped, pages };
895
+ },
751
896
  /** Replace the collection's per-record ACTION list (cms.md §17) — the
752
897
  * `{ actions }` fields-route meta-op; `[]` clears. Each action is ONE
753
898
  * human-initiated step: the dashboard renders it as a button per record,
@@ -760,7 +905,13 @@ export class Vxil {
760
905
  /** `lock` serializes same-key writers (per-tenant advisory lock); `guard`
761
906
  * is the declarative capacity/overlap invariant (requires lock) — cms.md §10. */
762
907
  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,
908
+ /** `expand` inlines relation/file fields into `data` (cms.md §6.2): the
909
+ * full item envelope, a `{ object_id, $ref: 'files' }` stub for a file,
910
+ * the bare id on a cycle/depth cut, `null` for an invisible target. */
911
+ get: async (collection, itemId, opts) => {
912
+ const s = qs({ $expand: expandParam(opts?.expand) });
913
+ return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}${s}`)).data;
914
+ },
764
915
  /**
765
916
  * The bounded query DSL. filter ops: $eq $ne $gt $gte $lt $lte $in
766
917
  * $contains $startsWith (LIKE-escaped; substring-searchable on s*-slotted
@@ -777,6 +928,7 @@ export class Vxil {
777
928
  sort: q?.sort || undefined,
778
929
  limit: q?.limit || undefined,
779
930
  cursor: q?.cursor || undefined,
931
+ $expand: expandParam(q?.expand),
780
932
  });
781
933
  return (await this.call('GET', `/v1/cms/items/${encodeURIComponent(collection)}${s}`)).data;
782
934
  },
@@ -804,6 +956,22 @@ export class Vxil {
804
956
  /** Returns the cascade tally (cms.md §11); `ifVersion` rides If-Match and
805
957
  * a conflict aborts BEFORE any cascade side-effect. */
806
958
  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,
959
+ /** N-22 (cms.md §20): BOUNDED FILTERED delete — soft-delete up to `limit`
960
+ * items (1–100, default 25) matching `filter`, newest id first. A filter
961
+ * is REQUIRED (an unselected sweep is refused 422). Each matched row runs
962
+ * the SAME single-item delete path (restrict refusal, cascade budget,
963
+ * unique-claim freeing, CDC), so this is N deletes in one round trip, not
964
+ * a set-delete. `dryRun` reports `matched` and mutates nothing. Loop
965
+ * while `deleted > 0`. A refusal mid-sweep (409 referenced / 422
966
+ * cascade_too_large) stops the call — rows already deleted STAY deleted,
967
+ * and the error message names how many. Soft-deleted rows are hard-purged
968
+ * by the platform 30 days later (there is no per-tenant retention knob). */
969
+ deleteMany: async (collection, q) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/delete`, {
970
+ filter: q.filter,
971
+ ...(q.limit !== undefined ? { limit: q.limit } : {}),
972
+ ...(q.cursor ? { cursor: q.cursor } : {}),
973
+ ...(q.dryRun !== undefined ? { dry_run: q.dryRun } : {}),
974
+ })).data,
807
975
  publish: async (collection, itemId) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/${encodeURIComponent(itemId)}/publish`)).data,
808
976
  /** P1-12 (cms.md §11.1): the BOUNDED reverse read — which live items
809
977
  * reference this one, through which relation field. Owner-scoped in
@@ -831,6 +999,15 @@ export class Vxil {
831
999
  * row_number|rank|percent_rank, computed over ≤500 aggregated groups
832
1000
  * (never raw rows), optional partitionBy. Same scan cap as aggregate. */
833
1001
  rank: async (collection, body) => (await this.call('POST', `/v1/cms/items/${encodeURIComponent(collection)}/rank`, body)).data,
1002
+ /** SERVER-ONLY (403 server_only in end-user mode). Account merge: move
1003
+ * every row `from_user_id` owns — across every collection that declares
1004
+ * an `owner_field`, soft-deleted rows included, the stored owner value
1005
+ * AND its indexed projection — onto `into_user_id`. ONE bounded page (≤500
1006
+ * rows) per call: loop while `done` is false. Idempotent (a second pass
1007
+ * moves 0). Bumps each moved row's `version`. The cms consumer of the
1008
+ * `auth.user.merged { from, into }` event; auth-v1 calls it after a
1009
+ * merge, the event is the backstop. Emits one `cms.items.rekeyed`. */
1010
+ reKey: async (input) => (await this.call('POST', '/v1/cms/items/re-key', input)).data,
834
1011
  },
835
1012
  /** cms-rel B4 (cms.md §13): atomic multi-collection transaction — 1–5
836
1013
  * steps over ≤3 collections, per-step `$where` CAS preconditions (the
@@ -1213,6 +1390,16 @@ export class Vxil {
1213
1390
  /** Set / extend / clear an object's TTL. `expiresInSeconds: null` clears it
1214
1391
  * (else a future auto-delete after the given seconds; minimum 60). */
1215
1392
  setTtl: async (objectId, expiresInSeconds) => (await this.call('PUT', `/v1/files/${encodeURIComponent(objectId)}/ttl`, { expiresInSeconds })).data,
1393
+ /** SERVER-ONLY (403 server_only in end-user mode). Account merge: move
1394
+ * every object `from_user_id` owns (every status, tombstones included) and
1395
+ * the shared links minted on them onto `into_user_id`, which must be a
1396
+ * live tenant user (404 user_not_found otherwise). ONE bounded page (≤500
1397
+ * objects) per call: loop while `done` is false. Idempotent. An erased
1398
+ * `from` user's objects are held back for the GDPR byte sweep (moved 0).
1399
+ * The files consumer of the `auth.user.merged { from, into }` event;
1400
+ * auth-v1 calls it after a merge, the event is the backstop. Emits one
1401
+ * `files.objects.rekeyed`. */
1402
+ reKey: async (input) => (await this.call('POST', '/v1/files/objects/re-key', input)).data,
1216
1403
  sharedLinks: {
1217
1404
  /** Public (unauthenticated) URL for the object; revocable. Optional
1218
1405
  * `max_downloads` (1 = one-time link) and an absolute `expires_at`; the
@@ -1280,7 +1467,8 @@ export class Vxil {
1280
1467
  * openrouter) route via BYO keys in tenant_secrets. `images` on the generate
1281
1468
  * inputs takes up to 8 vision refs: a public https:// URL, a
1282
1469
  * data:image/...;base64 URL, or file:<object_id> (a files-feature object) —
1283
- * fetched images are capped at 4 MiB each / 16 MiB total.
1470
+ * fetched images are capped at 4 MiB each; `documents` (pdf/text, ≤10 MiB
1471
+ * each) share one 20 MiB inline ceiling per request with them.
1284
1472
  */
1285
1473
  ai = {
1286
1474
  templates: {
@@ -1455,7 +1643,23 @@ export class Vxil {
1455
1643
  * `quota` inlines one quota, `creditType` inlines the same owner-bound
1456
1644
  * balance `getBalance` returns — ONE call for a thin client's paywall.
1457
1645
  * A non-2xx answer means UNKNOWN: render the last cached answer, never
1458
- * free (payments.md §3a). */
1646
+ * free (payments.md §3a).
1647
+ *
1648
+ * OVERLAPPING SUBSCRIPTIONS — what `until` means. The WINNER is the
1649
+ * entitled subscription with the highest `tierMap[tier].rank` (default 0);
1650
+ * within a rank, the one with the latest period end (an open-ended manual
1651
+ * grant counts as unbounded), ties broken by subscription id, so the answer
1652
+ * is stable on every read. `entitlements` are the UNION and `quotas` the
1653
+ * MAX across ALL entitled rows, so a cancelled add-on never strips an
1654
+ * entitlement another live row still grants. `until` is the WINNER's OWN
1655
+ * boundary — not `max()` across rows, and not "when all access ends": it
1656
+ * answers *when does the CURRENT tier stop being good*. It is `null` for an
1657
+ * open-ended manual grant and on the `free` baseline. Across ranks a higher
1658
+ * tier wins even when a lower one runs longer, so `until` can report the
1659
+ * higher tier's earlier end; access does not dip (a lapsed winner re-folds
1660
+ * on read and the lower tier takes over), but a client that CACHES `until`
1661
+ * will show an early expiry — re-read instead of trusting a cached value
1662
+ * past it. */
1459
1663
  getEntitlements: async (userId, opts) => {
1460
1664
  const s = qs({
1461
1665
  user_id: userId,
@@ -1527,6 +1731,21 @@ export class Vxil {
1527
1731
  * refund the full un-refunded remainder. Idempotency-Key REQUIRED — a retry
1528
1732
  * replays the recorded refund (never a second provider refund); a refund can
1529
1733
  * NEVER exceed the charge (422 refund_exceeds_charge).
1734
+ *
1735
+ * PADDLE: a PARTIAL refund is issued as a per-line-item adjustment on the
1736
+ * Paddle transaction and typically settles ASYNCHRONOUSLY — it comes back
1737
+ * `pending` until your Paddle account approves the adjustment, and the
1738
+ * approving webhook flips it to `succeeded`. A rejected adjustment flips it
1739
+ * to `failed`, releases the reserved amount back onto the charge and emits
1740
+ * `payments.refund.failed`. While a refund is `pending`, the credits the
1741
+ * charge bought are NOT yet reversed. Stripe and the mock settle inline.
1742
+ *
1743
+ * If the provider has nothing left to refund on the charge — e.g. a refund
1744
+ * was already issued in the provider's own dashboard, which vxil's running
1745
+ * total cannot see — the call returns 422 `refund_not_allocatable` with the
1746
+ * precise reason, and nothing is sent to the provider. It is deterministic:
1747
+ * retrying the same refund fails the same way. A provider call that actually
1748
+ * fails is still 502 `provider_error`.
1530
1749
  */
1531
1750
  createRefund: async (input, opts) => (await this.call('POST', '/v1/payments/refunds', input, { 'idempotency-key': opts.idempotencyKey })).data,
1532
1751
  /** Where the user MANAGES their subscription, resolved by FUNDING SOURCE
@@ -1569,7 +1788,11 @@ export class Vxil {
1569
1788
  * webhook path and judge it with the conformance oracle. */
1570
1789
  simulate: async (input) => (await this.call('POST', '/v1/payments/simulate', input)).data,
1571
1790
  /** The last report-only reconciliation sweep result for this project
1572
- * (`run: null` before the first daily tick). */
1791
+ * (`run: null` before the first daily tick). Finding kinds:
1792
+ * `balance_drift`, `entitlement_lag`, `null_tier`, `refund_pending`,
1793
+ * `stranded_webhook`, `terminal_entitled`, `webhook_secret_missing`.
1794
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
1795
+ * 403 `server_only`; the sweep is a tenant-wide operator surface. */
1573
1796
  reconcile: async () => (await this.call('GET', '/v1/payments/reconcile')).data,
1574
1797
  /** List charges newest-first (the refund enabler — discover the charge_id).
1575
1798
  * Filters: user_id, status, limit (clamped 1..100, default 50). */
@@ -1584,7 +1807,11 @@ export class Vxil {
1584
1807
  /** Provider webhook event log (payments.md §7 "Event log & replay"):
1585
1808
  * operator visibility over every delivery — incl. persisted signature
1586
1809
  * failures — plus an idempotent reprocess verb. Needs payments:read
1587
- * (reprocess: payments:write). */
1810
+ * (reprocess: payments:write).
1811
+ * SERVER-SIDE KEYS ONLY — a thin-client (`end_user_required`) key gets
1812
+ * 403 `server_only` on list, detail and reprocess: the event log is a
1813
+ * tenant-wide operator surface and the detail carries the provider
1814
+ * payload (other customers' emails, provider ids and amounts). */
1588
1815
  webhookEvents: {
1589
1816
  /** List deliveries newest-first (keyset-paginated; pass `cursor` from a
1590
1817
  * 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.1",
3
+ "version": "0.5.1",
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).",