@vxil/sdk 0.5.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +354 -4
- package/dist/index.js +189 -2
- package/dist/reporting.d.ts +62 -0
- package/dist/reporting.js +218 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -359,7 +359,41 @@ export interface JobRun {
|
|
|
359
359
|
/** earliest next pickup time; for a 'delayed' run this is deliver_after */
|
|
360
360
|
next_attempt_at?: string | null;
|
|
361
361
|
queued_at: string;
|
|
362
|
+
started_at?: string | null;
|
|
362
363
|
completed_at: string | null;
|
|
364
|
+
/** generation runs (POST /v1/jobs/generation) only: the provider-side status */
|
|
365
|
+
generation_status?: string | null;
|
|
366
|
+
generation_json?: Record<string, unknown> | null;
|
|
367
|
+
/** the single-run read (`vx.jobs.run` / `waitForRun`) only: your enqueue
|
|
368
|
+
* payload — `{ redacted: true }` for a platform-internal run; never on lists */
|
|
369
|
+
payload_json?: unknown;
|
|
370
|
+
}
|
|
371
|
+
/** The run states no later write can move — what `waitForRun` and an
|
|
372
|
+
* async+wait invoke resolve `done: true` on. */
|
|
373
|
+
export declare const JOB_TERMINAL_STATES: ReadonlySet<JobRun['state']>;
|
|
374
|
+
/** `GET /v1/jobs/queue` — the live queue, one aggregate over your project's
|
|
375
|
+
* runs. `oldest_queued_age_s` is the number to alarm on: the age of the
|
|
376
|
+
* oldest run still waiting for an executor (null when nothing waits). */
|
|
377
|
+
export interface JobsQueue {
|
|
378
|
+
depth: {
|
|
379
|
+
/** waiting for an executor right now: queued + retrying whose next attempt is due */
|
|
380
|
+
queued: number;
|
|
381
|
+
/** held until a time: a deliver_after, or a retry still in its backoff */
|
|
382
|
+
delayed: number;
|
|
383
|
+
/** an executor currently holds them */
|
|
384
|
+
running: number;
|
|
385
|
+
};
|
|
386
|
+
oldest_queued_age_s: number | null;
|
|
387
|
+
in_flight_by_lane: {
|
|
388
|
+
/** your own jobs (enqueue / enqueue-batch / generation / async fn invokes) */
|
|
389
|
+
runs: number;
|
|
390
|
+
/** the platform's outbound webhook delivery runs */
|
|
391
|
+
deliveries: number;
|
|
392
|
+
/** schedule-fired runs, incl. `fn-cron:*` function ticks */
|
|
393
|
+
cron: number;
|
|
394
|
+
};
|
|
395
|
+
/** runs that reached `dead` in the last 24 hours, on every path */
|
|
396
|
+
dead_last_24h: number;
|
|
363
397
|
}
|
|
364
398
|
export interface JobFlowRule {
|
|
365
399
|
rule_id: string;
|
|
@@ -768,6 +802,11 @@ export interface JobRunEventPayload {
|
|
|
768
802
|
attempt: number;
|
|
769
803
|
level?: 'error';
|
|
770
804
|
state?: 'broken';
|
|
805
|
+
/** `job.dead_lettered` only, and only when something other than the
|
|
806
|
+
* executor killed the run: `reaped` (the stuck-run reaper) or
|
|
807
|
+
* `queue_backstop` (the queue's own retries ran out). Absent when the run
|
|
808
|
+
* exhausted its attempts normally. */
|
|
809
|
+
reason?: 'reaped' | 'queue_backstop';
|
|
771
810
|
}
|
|
772
811
|
/** Event name → typed `data`, for the events that settle work you started.
|
|
773
812
|
* `VxilEventPayload<'job.generation.failed'>` names one; everything else on
|
|
@@ -780,6 +819,121 @@ export interface VxilEventPayloads {
|
|
|
780
819
|
'job.dead_lettered': JobRunEventPayload;
|
|
781
820
|
}
|
|
782
821
|
export type VxilEventPayload<E extends string> = E extends keyof VxilEventPayloads ? VxilEventPayloads[E] : Record<string, unknown>;
|
|
822
|
+
/** The `trigger` label on the envelope. The config spells two of them in
|
|
823
|
+
* camelCase (`cmsHook`, `authHook`); on the wire they are kebab-case. */
|
|
824
|
+
export type FunctionEnvelopeTrigger = 'http' | 'cms-hook' | 'auth-hook' | 'queue' | 'cron' | 'webhook';
|
|
825
|
+
/** The audiences a function's scoped callback tokens are minted for — one per
|
|
826
|
+
* feature its declared scopes reach. `control-plane` carries `users:*` /
|
|
827
|
+
* `usage:read` only. There is deliberately no `functions` audience: a function
|
|
828
|
+
* cannot call another function. */
|
|
829
|
+
export type FunctionCallbackAudience = 'cms' | 'payments' | 'notifications' | 'comments' | 'files' | 'ai' | 'rag' | 'vector-search' | 'activity-feed' | 'orgs' | 'auth' | 'jobs' | 'realtime' | 'rate-limits' | 'control-plane';
|
|
830
|
+
/** One short-lived scoped token per audience your scopes imply
|
|
831
|
+
* (`env.scoped_jwts.cms`, `env.scoped_jwts['control-plane']`); an audience your
|
|
832
|
+
* scopes do not reach is absent. Send it as `Authorization: Bearer …` to
|
|
833
|
+
* `vxil_base`. */
|
|
834
|
+
export type FunctionScopedJwts = {
|
|
835
|
+
[A in FunctionCallbackAudience]?: string;
|
|
836
|
+
};
|
|
837
|
+
/** The verified end-user an `http` invocation carries when the caller presented
|
|
838
|
+
* a vxil-auth session. */
|
|
839
|
+
export interface FunctionEndUser {
|
|
840
|
+
id: string;
|
|
841
|
+
sid: string;
|
|
842
|
+
/** the step-up epoch, when the session has one */
|
|
843
|
+
elv?: number;
|
|
844
|
+
}
|
|
845
|
+
/** The fields every invocation carries, whatever fired it. */
|
|
846
|
+
export interface FunctionEnvelopeBase {
|
|
847
|
+
tenant_id: string;
|
|
848
|
+
request_id: string;
|
|
849
|
+
/** Stable across redeliveries of the same event: delivery is at-least-once,
|
|
850
|
+
* so dedupe your writes on it. */
|
|
851
|
+
idempotency_key: string;
|
|
852
|
+
/** The edge to call vxil back through. */
|
|
853
|
+
vxil_base: string;
|
|
854
|
+
scoped_jwts: FunctionScopedJwts;
|
|
855
|
+
/** Your declared per-function secrets (`secrets: ['secret:<name>']`), keyed by
|
|
856
|
+
* bare name and resolved at invoke time. */
|
|
857
|
+
secrets: Record<string, string>;
|
|
858
|
+
}
|
|
859
|
+
/** `payload` of a `cms-hook` invocation. It carries no field values: re-read
|
|
860
|
+
* the item by `item_id`. */
|
|
861
|
+
export interface CmsHookTriggerPayload {
|
|
862
|
+
/** `cms.item.created` or `cms.item.updated` for a binding that names a
|
|
863
|
+
* collection; a binding without one also sees the other `cms.item.*` events */
|
|
864
|
+
event: string;
|
|
865
|
+
collection: string;
|
|
866
|
+
item_id: string;
|
|
867
|
+
}
|
|
868
|
+
/** `payload` of an `auth-hook` invocation: the event's own fields plus
|
|
869
|
+
* `event`. `auth.user.created` → `{ user_id, method, is_anonymous }`;
|
|
870
|
+
* `auth.session.created` → `{ user_id, session_id }`; `auth.session.revoked`
|
|
871
|
+
* adds `reason`; `auth.signin.failure` → `{ email_hash | user_id, reason }`. */
|
|
872
|
+
export interface AuthHookTriggerPayload {
|
|
873
|
+
event: string;
|
|
874
|
+
user_id?: string;
|
|
875
|
+
[field: string]: unknown;
|
|
876
|
+
}
|
|
877
|
+
/** `payload` of a `webhook` invocation: one platform event under the binding's
|
|
878
|
+
* `source` prefix. `D` is the event's own payload (see `VxilEventPayload`). */
|
|
879
|
+
export interface WebhookTriggerPayload<D = Record<string, unknown>> {
|
|
880
|
+
event: string;
|
|
881
|
+
audit_id: number | null;
|
|
882
|
+
occurred_at: string | null;
|
|
883
|
+
actor: string | null;
|
|
884
|
+
surface: string | null;
|
|
885
|
+
/** the event's payload; `{ truncated: true }` when it was larger than the
|
|
886
|
+
* function's webhook payload bound, null when the event carried none */
|
|
887
|
+
data: D | {
|
|
888
|
+
truncated: true;
|
|
889
|
+
} | null;
|
|
890
|
+
}
|
|
891
|
+
/** `POST /v1/fn/:name`: `payload` is the JSON request body (the query
|
|
892
|
+
* parameters on a GET). */
|
|
893
|
+
export interface HttpFunctionEnvelope<P = unknown> extends FunctionEnvelopeBase {
|
|
894
|
+
trigger: 'http';
|
|
895
|
+
/** present only when the caller presented a verified end-user session */
|
|
896
|
+
end_user?: FunctionEndUser;
|
|
897
|
+
payload: P;
|
|
898
|
+
}
|
|
899
|
+
export interface CmsHookFunctionEnvelope extends FunctionEnvelopeBase {
|
|
900
|
+
trigger: 'cms-hook';
|
|
901
|
+
payload: CmsHookTriggerPayload;
|
|
902
|
+
}
|
|
903
|
+
export interface AuthHookFunctionEnvelope extends FunctionEnvelopeBase {
|
|
904
|
+
trigger: 'auth-hook';
|
|
905
|
+
payload: AuthHookTriggerPayload;
|
|
906
|
+
}
|
|
907
|
+
/** `payload` is exactly what the job was enqueued with. */
|
|
908
|
+
export interface QueueFunctionEnvelope<P = unknown> extends FunctionEnvelopeBase {
|
|
909
|
+
trigger: 'queue';
|
|
910
|
+
payload: P;
|
|
911
|
+
}
|
|
912
|
+
/** A schedule tick carries no data: `payload` is `{}`. */
|
|
913
|
+
export interface CronFunctionEnvelope extends FunctionEnvelopeBase {
|
|
914
|
+
trigger: 'cron';
|
|
915
|
+
payload: Record<string, unknown>;
|
|
916
|
+
}
|
|
917
|
+
export interface WebhookFunctionEnvelope<D = Record<string, unknown>> extends FunctionEnvelopeBase {
|
|
918
|
+
trigger: 'webhook';
|
|
919
|
+
payload: WebhookTriggerPayload<D>;
|
|
920
|
+
}
|
|
921
|
+
/** The invocation envelope, discriminated on `trigger`. `P` types the `http`
|
|
922
|
+
* body and the `queue` payload. A function bound to one trigger can name its
|
|
923
|
+
* member directly (`CmsHookFunctionEnvelope`, `WebhookFunctionEnvelope<D>`). */
|
|
924
|
+
export type FunctionEnvelope<P = unknown> = HttpFunctionEnvelope<P> | CmsHookFunctionEnvelope | AuthHookFunctionEnvelope | QueueFunctionEnvelope<P> | CronFunctionEnvelope | WebhookFunctionEnvelope;
|
|
925
|
+
/** The body the jobs engine POSTs to a run's `target_url` (signed with
|
|
926
|
+
* `X-Vxil-Jobs-Signature`; verify it with the secret from
|
|
927
|
+
* `jobs.signingSecret()`). Redelivered attempts carry the same `run_id` and a
|
|
928
|
+
* higher `attempt`. */
|
|
929
|
+
export interface JobDelivery<P = unknown> {
|
|
930
|
+
run_id: string;
|
|
931
|
+
job_name: string;
|
|
932
|
+
/** 1-based */
|
|
933
|
+
attempt: number;
|
|
934
|
+
/** exactly what the run was enqueued with */
|
|
935
|
+
payload: P;
|
|
936
|
+
}
|
|
783
937
|
/** A re-minted mid-stream connect token (GET /v1/ai/generations/{id}/token). */
|
|
784
938
|
export interface AiStreamToken {
|
|
785
939
|
generation_id: string;
|
|
@@ -1231,6 +1385,15 @@ export interface FnInvokeOptions {
|
|
|
1231
1385
|
* function dedupes on it); on the async lane it is also the run's dedupe key,
|
|
1232
1386
|
* so a repeat with the same key returns the SAME `run_id`. */
|
|
1233
1387
|
idempotencyKey?: string;
|
|
1388
|
+
/** With `async: true` — "kick and know it finished": hold the call up to
|
|
1389
|
+
* `wait` seconds (integer 1..25, sent as `X-Vxil-Wait`) for the run to reach
|
|
1390
|
+
* a terminal state. Resolves to a `FnAsyncOutcome`: `done: true` + the
|
|
1391
|
+
* terminal `run` when it settled inside the bound, else `done: false` with
|
|
1392
|
+
* the run's current `status` (the run IS queued — `vx.jobs.waitForRun` /
|
|
1393
|
+
* `vx.jobs.run` continue from there). The function's OWN response never
|
|
1394
|
+
* rides this shape; the sync lane returns it. A client `timeoutMs` shorter
|
|
1395
|
+
* than `wait` × 1000 aborts the call first. Ignored without `async`. */
|
|
1396
|
+
wait?: number;
|
|
1234
1397
|
}
|
|
1235
1398
|
/** The async lane's 202 ack. */
|
|
1236
1399
|
export interface FnAsyncAck {
|
|
@@ -1239,13 +1402,34 @@ export interface FnAsyncAck {
|
|
|
1239
1402
|
/** present (true) when the Idempotency-Key matched an earlier run */
|
|
1240
1403
|
deduplicated?: boolean;
|
|
1241
1404
|
}
|
|
1242
|
-
/** `vx.fn.<name
|
|
1405
|
+
/** What `vx.fn.<name>(input, { async: true, wait })` resolves to. */
|
|
1406
|
+
export interface FnAsyncOutcome {
|
|
1407
|
+
run_id: string;
|
|
1408
|
+
/** the run's state: terminal when `done`, else its live state at the deadline */
|
|
1409
|
+
status: JobRun['state'];
|
|
1410
|
+
/** true ⇔ the run reached a terminal state inside the wait bound */
|
|
1411
|
+
done: boolean;
|
|
1412
|
+
/** the terminal run (the `GET /v1/jobs/runs/{run_id}` row); absent on a timeout */
|
|
1413
|
+
run?: JobRun;
|
|
1414
|
+
/** present (true) when the Idempotency-Key matched an earlier run */
|
|
1415
|
+
deduplicated?: boolean;
|
|
1416
|
+
/** `timeout` — the bound passed before the run was terminal; `unavailable` —
|
|
1417
|
+
* the run was queued but the platform could not hold the wait (poll it) */
|
|
1418
|
+
wait?: 'timeout' | 'unavailable';
|
|
1419
|
+
}
|
|
1420
|
+
/** `vx.fn.<name>`: the function's Output by default; the 202 ack with
|
|
1421
|
+
* `{ async: true }`; the run outcome with `{ async: true, wait }`. */
|
|
1243
1422
|
export interface FnInvoker<I, O> {
|
|
1244
1423
|
(input: I, opts?: FnInvokeOptions & {
|
|
1245
1424
|
async?: false;
|
|
1246
1425
|
}): Promise<O>;
|
|
1247
1426
|
(input: I, opts: FnInvokeOptions & {
|
|
1248
1427
|
async: true;
|
|
1428
|
+
wait: number;
|
|
1429
|
+
}): Promise<FnAsyncOutcome>;
|
|
1430
|
+
(input: I, opts: FnInvokeOptions & {
|
|
1431
|
+
async: true;
|
|
1432
|
+
wait?: undefined;
|
|
1249
1433
|
}): Promise<FnAsyncAck>;
|
|
1250
1434
|
}
|
|
1251
1435
|
/** feature client property → the feature key (in S['features']) that enables it.
|
|
@@ -1436,6 +1620,104 @@ export type CmsTxStep = {
|
|
|
1436
1620
|
item_id: string;
|
|
1437
1621
|
$where?: Record<string, unknown>;
|
|
1438
1622
|
};
|
|
1623
|
+
/** One op of `vx.cms.batch` (cms.md §21) — the single route's own body keys,
|
|
1624
|
+
* typed by op. `ref` is an opaque tag echoed on the matching result. */
|
|
1625
|
+
export type CmsBatchOp = {
|
|
1626
|
+
op: 'get';
|
|
1627
|
+
collection: string;
|
|
1628
|
+
id: string;
|
|
1629
|
+
expand?: readonly string[] | string;
|
|
1630
|
+
ref?: string;
|
|
1631
|
+
} | {
|
|
1632
|
+
op: 'query';
|
|
1633
|
+
collection: string;
|
|
1634
|
+
filter?: Record<string, unknown>;
|
|
1635
|
+
sort?: string;
|
|
1636
|
+
limit?: number;
|
|
1637
|
+
cursor?: string;
|
|
1638
|
+
expand?: readonly string[] | string;
|
|
1639
|
+
count?: boolean;
|
|
1640
|
+
ref?: string;
|
|
1641
|
+
} | {
|
|
1642
|
+
op: 'create';
|
|
1643
|
+
collection: string;
|
|
1644
|
+
data: Record<string, unknown>;
|
|
1645
|
+
status?: 'draft' | 'published';
|
|
1646
|
+
lock?: string;
|
|
1647
|
+
guard?: CmsGuard;
|
|
1648
|
+
guards?: CmsGuardTerm[];
|
|
1649
|
+
ref?: string;
|
|
1650
|
+
} | {
|
|
1651
|
+
op: 'patch';
|
|
1652
|
+
collection: string;
|
|
1653
|
+
id: string;
|
|
1654
|
+
data?: Record<string, unknown>;
|
|
1655
|
+
$inc?: Record<string, number>;
|
|
1656
|
+
if?: Record<string, unknown>;
|
|
1657
|
+
if_version?: number;
|
|
1658
|
+
lock?: string;
|
|
1659
|
+
guard?: CmsGuard;
|
|
1660
|
+
guards?: CmsGuardTerm[];
|
|
1661
|
+
ref?: string;
|
|
1662
|
+
} | {
|
|
1663
|
+
op: 'delete';
|
|
1664
|
+
collection: string;
|
|
1665
|
+
id: string;
|
|
1666
|
+
if_version?: number;
|
|
1667
|
+
ref?: string;
|
|
1668
|
+
};
|
|
1669
|
+
/** What a 2xx batch result's `data` is, per op: the item record (get / create /
|
|
1670
|
+
* patch), a page or `{ count }` (query), the delete tally (delete). */
|
|
1671
|
+
export type CmsBatchData<O extends CmsBatchOp> = O extends {
|
|
1672
|
+
op: 'query';
|
|
1673
|
+
} ? (O extends {
|
|
1674
|
+
count: true;
|
|
1675
|
+
} ? {
|
|
1676
|
+
count: number;
|
|
1677
|
+
} : {
|
|
1678
|
+
items: Array<Record<string, unknown>>;
|
|
1679
|
+
next_cursor: string | null;
|
|
1680
|
+
}) : O extends {
|
|
1681
|
+
op: 'delete';
|
|
1682
|
+
} ? {
|
|
1683
|
+
item_id: string;
|
|
1684
|
+
deleted: boolean;
|
|
1685
|
+
cascaded: number;
|
|
1686
|
+
set_null: number;
|
|
1687
|
+
} : Record<string, unknown>;
|
|
1688
|
+
/** One batch result: the single route's `status` and its `data` (2xx) or
|
|
1689
|
+
* `error`. In an aborted atomic batch the undone ops carry `424 rolled_back`
|
|
1690
|
+
* and the ops after the failure `424 not_run`. The op whose result crossed
|
|
1691
|
+
* the 8 MiB per-batch budget is `413 batch_result_too_large` (the rest
|
|
1692
|
+
* `424 not_run`); non-atomic, an op that failed unexpectedly is
|
|
1693
|
+
* `500 internal_error` in-line — the ops before it committed and keep their
|
|
1694
|
+
* results, so never retry the whole batch on one 500. At most 5 ops may be
|
|
1695
|
+
* `query` (a whole-batch 422 otherwise). */
|
|
1696
|
+
export type CmsBatchResult<O extends CmsBatchOp = CmsBatchOp> = {
|
|
1697
|
+
op: O['op'];
|
|
1698
|
+
ref?: string;
|
|
1699
|
+
status: number;
|
|
1700
|
+
data: CmsBatchData<O>;
|
|
1701
|
+
error?: undefined;
|
|
1702
|
+
} | {
|
|
1703
|
+
op: O['op'];
|
|
1704
|
+
ref?: string;
|
|
1705
|
+
status: number;
|
|
1706
|
+
data?: undefined;
|
|
1707
|
+
error: {
|
|
1708
|
+
code: string;
|
|
1709
|
+
message: string;
|
|
1710
|
+
hint?: string;
|
|
1711
|
+
};
|
|
1712
|
+
};
|
|
1713
|
+
export interface CmsBatchResponse<T extends readonly CmsBatchOp[]> {
|
|
1714
|
+
/** true iff every op is 2xx — for an atomic batch, iff the transaction committed. */
|
|
1715
|
+
ok: boolean;
|
|
1716
|
+
atomic: boolean;
|
|
1717
|
+
results: {
|
|
1718
|
+
[K in keyof T]: T[K] extends CmsBatchOp ? CmsBatchResult<T[K]> : never;
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1439
1721
|
/** One CMS item as the API returns it — the envelope `$expand` inlines in place
|
|
1440
1722
|
* of a stored relation id (cms.md §6.2). `R` is the target collection's Row. */
|
|
1441
1723
|
export interface CmsItemEnvelope<R> {
|
|
@@ -1640,7 +1922,8 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
1640
1922
|
* `vx.fn.<name>(payload, { async: true })` takes the ASYNC lane
|
|
1641
1923
|
* (`x-vxil-async: 1`): it resolves to the `202 { run_id, status:'queued' }`
|
|
1642
1924
|
* ack at once and the run is delivered through the jobs lane — poll
|
|
1643
|
-
* `vx.jobs.run(run_id)` or subscribe to `job.*` events.
|
|
1925
|
+
* `vx.jobs.run(run_id)` or subscribe to `job.*` events. With `wait` (1..25 s)
|
|
1926
|
+
* the call holds for the run to settle and resolves to a `FnAsyncOutcome`. */
|
|
1644
1927
|
readonly fn: { [K in keyof S["functions"] & string]: FnInvoker<S["functions"][K]["Input"], S["functions"][K]["Output"]>; };
|
|
1645
1928
|
private call;
|
|
1646
1929
|
readonly users: {
|
|
@@ -2127,6 +2410,26 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2127
2410
|
limit?: number;
|
|
2128
2411
|
}) => Promise<JobRun[]>;
|
|
2129
2412
|
run: (runId: string) => Promise<JobRun>;
|
|
2413
|
+
/** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
|
|
2414
|
+
* the request platform-side (re-reading the run on one bounded connection,
|
|
2415
|
+
* 500 ms at first then backing off to 2 s) until the run is terminal —
|
|
2416
|
+
* succeeded / failed / dead / cancelled — or `seconds` (integer 1..25)
|
|
2417
|
+
* pass; then the CURRENT run comes back with `done: false` (also when the
|
|
2418
|
+
* per-tenant budget of 60 held reads a minute declined to hold the wait —
|
|
2419
|
+
* the response header `x-vxil-wait` says `timeout` or `unavailable`). `run` is the plain `run(runId)` row either
|
|
2420
|
+
* way (same `jobs:read` scope; 404 at once for an unknown id). Loop it for a
|
|
2421
|
+
* longer wait. A client `timeoutMs` shorter than `seconds` × 1000 aborts the
|
|
2422
|
+
* call first. NOT `wait(runId, …)` — that is the durable in-handler
|
|
2423
|
+
* wait-for-event. */
|
|
2424
|
+
waitForRun: (runId: string, opts: {
|
|
2425
|
+
seconds: number;
|
|
2426
|
+
}) => Promise<{
|
|
2427
|
+
run: JobRun;
|
|
2428
|
+
done: boolean;
|
|
2429
|
+
}>;
|
|
2430
|
+
/** The live queue: depth by state, the oldest waiting run's age (the
|
|
2431
|
+
* number to alarm on), in-flight runs per lane, dead letters in 24 h. */
|
|
2432
|
+
queue: () => Promise<JobsQueue>;
|
|
2130
2433
|
cancel: (runId: string) => Promise<{
|
|
2131
2434
|
run_id: string;
|
|
2132
2435
|
state: string;
|
|
@@ -2433,6 +2736,34 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2433
2736
|
user_id: string;
|
|
2434
2737
|
session: AuthSession;
|
|
2435
2738
|
}>;
|
|
2739
|
+
/** Keep a session pair usable: when it expires within `skewSeconds`
|
|
2740
|
+
* (default 300) — or `expires_at` is missing/unparseable, or already past
|
|
2741
|
+
* — rotate it through `refresh` and return the NEW pair with
|
|
2742
|
+
* `refreshed: true`; otherwise return it unchanged, with no request.
|
|
2743
|
+
* Store the pair again whenever `refreshed` is true (the old refresh
|
|
2744
|
+
* token is revoked by the rotation).
|
|
2745
|
+
*
|
|
2746
|
+
* The window is capped at half the token's own lifetime (iat→exp, read
|
|
2747
|
+
* from the session JWT; 60 s for an opaque token), so a short session
|
|
2748
|
+
* TTL never makes every call rotate. Concurrent calls for the same
|
|
2749
|
+
* refresh token in one process share ONE rotation, and for 30 s after it
|
|
2750
|
+
* settles a call still carrying the OLD pair gets the new pair (no second
|
|
2751
|
+
* request) while a call with the just-minted pair gets it back unchanged.
|
|
2752
|
+
* A refresh that fails (revoked, expired, or already rotated by another
|
|
2753
|
+
* process) throws the `VxilError` — treat it as "sign in again".
|
|
2754
|
+
*
|
|
2755
|
+
* Call it on a client whose key carries `auth:signin` — normally the
|
|
2756
|
+
* sign-in key, in server mode. An end-user-mode client sends its
|
|
2757
|
+
* X-Vxil-End-User token with the rotation only while that token is the
|
|
2758
|
+
* pair's own and still has 10 s left; otherwise the rotation goes out
|
|
2759
|
+
* without it, which a thin-client (`end_user_required`) key cannot do.
|
|
2760
|
+
* See https://vxil.com/docs/guide/09-security-and-multitenancy. */
|
|
2761
|
+
ensureFresh: (pair: AuthSession, opts?: {
|
|
2762
|
+
skewSeconds?: number;
|
|
2763
|
+
}) => Promise<{
|
|
2764
|
+
session: AuthSession;
|
|
2765
|
+
refreshed: boolean;
|
|
2766
|
+
}>;
|
|
2436
2767
|
revoke: (token: string) => Promise<void>;
|
|
2437
2768
|
/** "Sign out everywhere": revoke EVERY live session of a user in one call
|
|
2438
2769
|
* (one statement, one batched edge-cache write; each session's
|
|
@@ -2888,6 +3219,20 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2888
3219
|
committed: boolean;
|
|
2889
3220
|
tx_id: string;
|
|
2890
3221
|
}>;
|
|
3222
|
+
/** P1-1 (cms.md §21): several get / query / create / patch / delete ops in
|
|
3223
|
+
* ONE round trip — the function-chain shape ("read 3 rows → patch 2 →
|
|
3224
|
+
* create 1" is one call, not six). ≤25 ops, each run through the SAME path
|
|
3225
|
+
* its single route uses (scopes, owner-scoping, hooks, guards, `if`,
|
|
3226
|
+
* `if_version`, `$inc`), each reporting its own status in `results` — a
|
|
3227
|
+
* batch never throws for a per-op failure; check `ok` / each `status`.
|
|
3228
|
+
* `atomic: true` runs everything in ONE tenant transaction: reads see
|
|
3229
|
+
* earlier writes and the first non-2xx op rolls every write back (the
|
|
3230
|
+
* undone ops answer `424 rolled_back`, the rest `424 not_run`). Default:
|
|
3231
|
+
* sequential, per-op status, no cross-op rollback. `ref` tags an op so
|
|
3232
|
+
* its result is easy to find. */
|
|
3233
|
+
batch: <T extends readonly CmsBatchOp[]>(ops: readonly [...T], opts?: {
|
|
3234
|
+
atomic?: boolean;
|
|
3235
|
+
}) => Promise<CmsBatchResponse<T>>;
|
|
2891
3236
|
/** cms-rel B5 (cms.md §12.4): declared read-models (config `readModels`
|
|
2892
3237
|
* bag) — list with last-run status, and "run now" materialization into
|
|
2893
3238
|
* the rollup collection. */
|
|
@@ -3852,11 +4197,16 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
3852
4197
|
template: string;
|
|
3853
4198
|
version: number;
|
|
3854
4199
|
}>;
|
|
3855
|
-
/** List stored templates (each name + its latest version).
|
|
4200
|
+
/** List stored templates (each name + its latest version). `content_sha256`
|
|
4201
|
+
* is the canonical hash of the latest version's {system, user, schema},
|
|
4202
|
+
* stored when the version was written — what `vxil push` compares a
|
|
4203
|
+
* declared `ai.templates[]` entry against; absent on a version stored
|
|
4204
|
+
* before 2026-09-23 (the next push re-puts that template once). */
|
|
3856
4205
|
list: () => Promise<Array<{
|
|
3857
4206
|
name: string;
|
|
3858
4207
|
latest_version: number;
|
|
3859
4208
|
created_at: string;
|
|
4209
|
+
content_sha256?: string;
|
|
3860
4210
|
}>>;
|
|
3861
4211
|
};
|
|
3862
4212
|
/** Provider × capability matrix + the tenant's default provider (the set an
|
|
@@ -4595,4 +4945,4 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
4595
4945
|
};
|
|
4596
4946
|
};
|
|
4597
4947
|
}
|
|
4598
|
-
export {};
|
|
4948
|
+
export { withReporting, report, buildEnvelope, parseDsn, exceptionEvent, reportServerErrors, REPORT_TIMEOUT_MS, type ReportingOptions, type ReportEvent, type FetchHandler, } from './reporting.js';
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,72 @@ export class VxilError extends Error {
|
|
|
47
47
|
this.name = 'VxilError';
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/** The run states no later write can move — what `waitForRun` and an
|
|
51
|
+
* async+wait invoke resolve `done: true` on. */
|
|
52
|
+
export const JOB_TERMINAL_STATES = new Set(['succeeded', 'failed', 'dead', 'cancelled']);
|
|
53
|
+
/** `vx.auth.sessions.ensureFresh` rotates a pair this many seconds before it
|
|
54
|
+
* expires unless the call names its own `skewSeconds`. */
|
|
55
|
+
const ENSURE_FRESH_SKEW_SECONDS = 300;
|
|
56
|
+
/** The window never exceeds this fraction of the token's own lifetime (iat→exp
|
|
57
|
+
* from the session JWT), so a short session TTL cannot make every call rotate. */
|
|
58
|
+
const ENSURE_FRESH_MAX_WINDOW_FRACTION = 0.5;
|
|
59
|
+
/** Cap for a token whose lifetime cannot be read (an opaque, non-JWT token). */
|
|
60
|
+
const ENSURE_FRESH_OPAQUE_MAX_SKEW_SECONDS = 60;
|
|
61
|
+
/** An end-user-mode client keeps its X-Vxil-End-User token on the rotation
|
|
62
|
+
* while that token has at least this long left (a thin-client key has no
|
|
63
|
+
* server-mode fallback at the edge); closer to expiry it rotates in server mode. */
|
|
64
|
+
const ENSURE_FRESH_HEADER_MARGIN_MS = 10_000;
|
|
65
|
+
/** A settled rotation stays readable this long: a request that arrives just
|
|
66
|
+
* after the winner still carrying the OLD pair adopts the winner's pair
|
|
67
|
+
* instead of re-sending a revoked refresh token, and a pair this process just
|
|
68
|
+
* minted is never rotated again inside the window (bounds rotations to one
|
|
69
|
+
* per window per session per process whatever the clocks say). */
|
|
70
|
+
const ENSURE_FRESH_GRACE_MS = 30_000;
|
|
71
|
+
/** Rotations keyed by (edge base, OLD refresh token), shared by every client in
|
|
72
|
+
* the process. `until` is Infinity while in flight and settle-time + grace
|
|
73
|
+
* after a success; a failure is dropped at once. */
|
|
74
|
+
const refreshRotations = new Map();
|
|
75
|
+
/** (edge base, NEW refresh token) → the time until which that just-minted pair
|
|
76
|
+
* is returned unchanged even if it already looks due. */
|
|
77
|
+
const refreshJustMinted = new Map();
|
|
78
|
+
function sweepRefreshCaches(now) {
|
|
79
|
+
for (const [k, r] of refreshRotations)
|
|
80
|
+
if (r.until <= now)
|
|
81
|
+
refreshRotations.delete(k);
|
|
82
|
+
for (const [k, until] of refreshJustMinted)
|
|
83
|
+
if (until <= now)
|
|
84
|
+
refreshJustMinted.delete(k);
|
|
85
|
+
}
|
|
86
|
+
const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
87
|
+
/** iat→exp of a session JWT in seconds, read WITHOUT verification (it only
|
|
88
|
+
* schedules the refresh); undefined for an opaque or malformed token. No
|
|
89
|
+
* atob: the SDK also runs on React Native builds that lack it. */
|
|
90
|
+
function sessionLifetimeSeconds(token) {
|
|
91
|
+
const parts = token.split('.');
|
|
92
|
+
if (parts.length !== 3 || !parts[1] || parts[1].length > 8192)
|
|
93
|
+
return undefined;
|
|
94
|
+
let acc = 0;
|
|
95
|
+
let bits = 0;
|
|
96
|
+
let json = '';
|
|
97
|
+
for (const ch of parts[1].replace(/=+$/, '')) {
|
|
98
|
+
const v = B64URL.indexOf(ch);
|
|
99
|
+
if (v < 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
acc = ((acc << 6) | v) & 0xffff;
|
|
102
|
+
bits += 6;
|
|
103
|
+
if (bits >= 8) {
|
|
104
|
+
bits -= 8;
|
|
105
|
+
json += String.fromCharCode((acc >> bits) & 0xff);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const p = JSON.parse(json);
|
|
110
|
+
if (typeof p.iat === 'number' && typeof p.exp === 'number' && p.exp > p.iat)
|
|
111
|
+
return p.exp - p.iat;
|
|
112
|
+
}
|
|
113
|
+
catch { /* not a JWT payload */ }
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
50
116
|
const DEFAULT_BASE = 'https://api.vxil.com';
|
|
51
117
|
/** Normalize the `expand` option to the `$expand` query value: a comma-joined
|
|
52
118
|
* list (the server's spelling), or `undefined` when nothing is expanded so the
|
|
@@ -232,7 +298,8 @@ export class Vxil {
|
|
|
232
298
|
* `vx.fn.<name>(payload, { async: true })` takes the ASYNC lane
|
|
233
299
|
* (`x-vxil-async: 1`): it resolves to the `202 { run_id, status:'queued' }`
|
|
234
300
|
* ack at once and the run is delivered through the jobs lane — poll
|
|
235
|
-
* `vx.jobs.run(run_id)` or subscribe to `job.*` events.
|
|
301
|
+
* `vx.jobs.run(run_id)` or subscribe to `job.*` events. With `wait` (1..25 s)
|
|
302
|
+
* the call holds for the run to settle and resolves to a `FnAsyncOutcome`. */
|
|
236
303
|
fn = new Proxy({}, {
|
|
237
304
|
get: (_t, prop) => {
|
|
238
305
|
if (typeof prop !== 'string')
|
|
@@ -246,6 +313,7 @@ export class Vxil {
|
|
|
246
313
|
headers: {
|
|
247
314
|
authorization: `Bearer ${this.key}`, ...this.authHeaders(), 'content-type': 'application/json',
|
|
248
315
|
...(opts?.async ? { 'x-vxil-async': '1' } : {}),
|
|
316
|
+
...(opts?.async && opts.wait !== undefined ? { 'x-vxil-wait': String(opts.wait) } : {}),
|
|
249
317
|
...(opts?.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {}),
|
|
250
318
|
},
|
|
251
319
|
body: JSON.stringify(payload ?? {}),
|
|
@@ -258,6 +326,22 @@ export class Vxil {
|
|
|
258
326
|
catch { /* non-JSON error body */ }
|
|
259
327
|
throw new VxilError(res.status, e.code ?? `http_${res.status}`, e.message ?? text.slice(0, 200), undefined, undefined, undefined, parseRetryAfter(res.headers.get('retry-after')));
|
|
260
328
|
}
|
|
329
|
+
if (opts?.async && opts.wait !== undefined) {
|
|
330
|
+
// kick-and-wait: 200 = the terminal run's { data, meta } envelope
|
|
331
|
+
// (the jobs read shape); 202 = the ack with the run's current state
|
|
332
|
+
// (+ x-vxil-wait: timeout | unavailable). Normalised to ONE shape.
|
|
333
|
+
const parsed = (text.length ? JSON.parse(text) : {});
|
|
334
|
+
if (res.status === 200 && parsed.data) {
|
|
335
|
+
const run = parsed.data;
|
|
336
|
+
return { run_id: run.run_id, status: run.state, done: JOB_TERMINAL_STATES.has(run.state), run };
|
|
337
|
+
}
|
|
338
|
+
const w = res.headers.get('x-vxil-wait');
|
|
339
|
+
return {
|
|
340
|
+
run_id: parsed.run_id ?? '', status: parsed.status ?? 'queued', done: false,
|
|
341
|
+
...(parsed.deduplicated ? { deduplicated: true } : {}),
|
|
342
|
+
...(w === 'timeout' || w === 'unavailable' ? { wait: w } : {}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
261
345
|
return text.length ? JSON.parse(text) : undefined;
|
|
262
346
|
};
|
|
263
347
|
},
|
|
@@ -581,6 +665,24 @@ export class Vxil {
|
|
|
581
665
|
return (await this.call('GET', `/v1/jobs/runs${s}`)).data.runs;
|
|
582
666
|
},
|
|
583
667
|
run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
|
|
668
|
+
/** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
|
|
669
|
+
* the request platform-side (re-reading the run on one bounded connection,
|
|
670
|
+
* 500 ms at first then backing off to 2 s) until the run is terminal —
|
|
671
|
+
* succeeded / failed / dead / cancelled — or `seconds` (integer 1..25)
|
|
672
|
+
* pass; then the CURRENT run comes back with `done: false` (also when the
|
|
673
|
+
* per-tenant budget of 60 held reads a minute declined to hold the wait —
|
|
674
|
+
* the response header `x-vxil-wait` says `timeout` or `unavailable`). `run` is the plain `run(runId)` row either
|
|
675
|
+
* way (same `jobs:read` scope; 404 at once for an unknown id). Loop it for a
|
|
676
|
+
* longer wait. A client `timeoutMs` shorter than `seconds` × 1000 aborts the
|
|
677
|
+
* call first. NOT `wait(runId, …)` — that is the durable in-handler
|
|
678
|
+
* wait-for-event. */
|
|
679
|
+
waitForRun: async (runId, opts) => {
|
|
680
|
+
const { data } = await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}?wait=${encodeURIComponent(String(opts.seconds))}`);
|
|
681
|
+
return { run: data, done: JOB_TERMINAL_STATES.has(data.state) };
|
|
682
|
+
},
|
|
683
|
+
/** The live queue: depth by state, the oldest waiting run's age (the
|
|
684
|
+
* number to alarm on), in-flight runs per lane, dead letters in 24 h. */
|
|
685
|
+
queue: async () => (await this.call('GET', '/v1/jobs/queue')).data,
|
|
584
686
|
cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
|
|
585
687
|
/** Clone a terminal run into a fresh queued run. */
|
|
586
688
|
replay: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/replay`)).data,
|
|
@@ -749,6 +851,69 @@ export class Vxil {
|
|
|
749
851
|
verify: async (token) => (await this.call('POST', '/v1/auth/sessions/verify', { token })).data,
|
|
750
852
|
/** Rotates the refresh token: store the returned pair, discard the old one. */
|
|
751
853
|
refresh: async (refreshToken) => (await this.call('POST', '/v1/auth/sessions/refresh', { refresh_token: refreshToken })).data,
|
|
854
|
+
/** Keep a session pair usable: when it expires within `skewSeconds`
|
|
855
|
+
* (default 300) — or `expires_at` is missing/unparseable, or already past
|
|
856
|
+
* — rotate it through `refresh` and return the NEW pair with
|
|
857
|
+
* `refreshed: true`; otherwise return it unchanged, with no request.
|
|
858
|
+
* Store the pair again whenever `refreshed` is true (the old refresh
|
|
859
|
+
* token is revoked by the rotation).
|
|
860
|
+
*
|
|
861
|
+
* The window is capped at half the token's own lifetime (iat→exp, read
|
|
862
|
+
* from the session JWT; 60 s for an opaque token), so a short session
|
|
863
|
+
* TTL never makes every call rotate. Concurrent calls for the same
|
|
864
|
+
* refresh token in one process share ONE rotation, and for 30 s after it
|
|
865
|
+
* settles a call still carrying the OLD pair gets the new pair (no second
|
|
866
|
+
* request) while a call with the just-minted pair gets it back unchanged.
|
|
867
|
+
* A refresh that fails (revoked, expired, or already rotated by another
|
|
868
|
+
* process) throws the `VxilError` — treat it as "sign in again".
|
|
869
|
+
*
|
|
870
|
+
* Call it on a client whose key carries `auth:signin` — normally the
|
|
871
|
+
* sign-in key, in server mode. An end-user-mode client sends its
|
|
872
|
+
* X-Vxil-End-User token with the rotation only while that token is the
|
|
873
|
+
* pair's own and still has 10 s left; otherwise the rotation goes out
|
|
874
|
+
* without it, which a thin-client (`end_user_required`) key cannot do.
|
|
875
|
+
* See https://vxil.com/docs/guide/09-security-and-multitenancy. */
|
|
876
|
+
ensureFresh: async (pair, opts = {}) => {
|
|
877
|
+
const skew = opts.skewSeconds ?? ENSURE_FRESH_SKEW_SECONDS;
|
|
878
|
+
if (typeof skew !== 'number' || !Number.isFinite(skew) || skew < 0) {
|
|
879
|
+
throw new VxilError(0, 'invalid_argument', 'ensureFresh: skewSeconds must be a finite number ≥ 0.');
|
|
880
|
+
}
|
|
881
|
+
const now = Date.now();
|
|
882
|
+
const key = `${this.base}\n${pair.refresh_token}`;
|
|
883
|
+
// A pair this process already rotated (or is rotating): its tokens are
|
|
884
|
+
// revoked, so the answer is the winner's pair whatever expires_at says.
|
|
885
|
+
const prior = pair.refresh_token ? refreshRotations.get(key) : undefined;
|
|
886
|
+
if (prior && prior.until > now)
|
|
887
|
+
return { session: await prior.session, refreshed: true };
|
|
888
|
+
const lifetime = sessionLifetimeSeconds(pair.token);
|
|
889
|
+
const window = lifetime !== undefined
|
|
890
|
+
? Math.min(skew, lifetime * ENSURE_FRESH_MAX_WINDOW_FRACTION)
|
|
891
|
+
: Math.min(skew, ENSURE_FRESH_OPAQUE_MAX_SKEW_SECONDS);
|
|
892
|
+
const expMs = Date.parse(pair.expires_at);
|
|
893
|
+
if (Number.isFinite(expMs) && expMs - now > window * 1000)
|
|
894
|
+
return { session: pair, refreshed: false };
|
|
895
|
+
if (pair.refresh_token && (refreshJustMinted.get(key) ?? 0) > now)
|
|
896
|
+
return { session: pair, refreshed: false };
|
|
897
|
+
if (!pair.refresh_token) {
|
|
898
|
+
throw new VxilError(0, 'refresh_token_missing', 'ensureFresh: the session is due for refresh but the pair carries no refresh_token.', 'Store both halves of the pair a sign-in returns (token + refresh_token), or sign in again.');
|
|
899
|
+
}
|
|
900
|
+
const keepEndUser = this.endUserToken !== undefined && this.endUserToken === pair.token
|
|
901
|
+
&& Number.isFinite(expMs) && expMs - now >= ENSURE_FRESH_HEADER_MARGIN_MS;
|
|
902
|
+
const client = this.endUserToken && !keepEndUser ? this.asEndUser(undefined) : this;
|
|
903
|
+
sweepRefreshCaches(now);
|
|
904
|
+
const entry = {
|
|
905
|
+
session: client.call('POST', '/v1/auth/sessions/refresh', { refresh_token: pair.refresh_token }).then((r) => r.data.session),
|
|
906
|
+
until: Number.POSITIVE_INFINITY,
|
|
907
|
+
};
|
|
908
|
+
entry.session.then((fresh) => {
|
|
909
|
+
entry.until = Date.now() + ENSURE_FRESH_GRACE_MS;
|
|
910
|
+
if (fresh.refresh_token)
|
|
911
|
+
refreshJustMinted.set(`${this.base}\n${fresh.refresh_token}`, entry.until);
|
|
912
|
+
}, () => { if (refreshRotations.get(key) === entry)
|
|
913
|
+
refreshRotations.delete(key); });
|
|
914
|
+
refreshRotations.set(key, entry);
|
|
915
|
+
return { session: await entry.session, refreshed: true };
|
|
916
|
+
},
|
|
752
917
|
revoke: async (token) => {
|
|
753
918
|
await this.call('POST', '/v1/auth/sessions/revoke', { token });
|
|
754
919
|
},
|
|
@@ -1058,6 +1223,21 @@ export class Vxil {
|
|
|
1058
1223
|
* rolls the WHOLE transaction back (409 precondition_failed names the
|
|
1059
1224
|
* step). cms-internal only — no cross-feature effects inside the tx. */
|
|
1060
1225
|
transaction: async (steps) => (await this.call('POST', '/v1/cms/transactions', { steps })).data,
|
|
1226
|
+
/** P1-1 (cms.md §21): several get / query / create / patch / delete ops in
|
|
1227
|
+
* ONE round trip — the function-chain shape ("read 3 rows → patch 2 →
|
|
1228
|
+
* create 1" is one call, not six). ≤25 ops, each run through the SAME path
|
|
1229
|
+
* its single route uses (scopes, owner-scoping, hooks, guards, `if`,
|
|
1230
|
+
* `if_version`, `$inc`), each reporting its own status in `results` — a
|
|
1231
|
+
* batch never throws for a per-op failure; check `ok` / each `status`.
|
|
1232
|
+
* `atomic: true` runs everything in ONE tenant transaction: reads see
|
|
1233
|
+
* earlier writes and the first non-2xx op rolls every write back (the
|
|
1234
|
+
* undone ops answer `424 rolled_back`, the rest `424 not_run`). Default:
|
|
1235
|
+
* sequential, per-op status, no cross-op rollback. `ref` tags an op so
|
|
1236
|
+
* its result is easy to find. */
|
|
1237
|
+
batch: async (ops, opts) => (await this.call('POST', '/v1/cms/batch', {
|
|
1238
|
+
ops: ops.map((o) => ('expand' in o && o.expand !== undefined ? { ...o, expand: expandParam(o.expand) } : o)),
|
|
1239
|
+
...(opts?.atomic !== undefined ? { atomic: opts.atomic } : {}),
|
|
1240
|
+
})).data,
|
|
1061
1241
|
/** cms-rel B5 (cms.md §12.4): declared read-models (config `readModels`
|
|
1062
1242
|
* bag) — list with last-run status, and "run now" materialization into
|
|
1063
1243
|
* the rollup collection. */
|
|
@@ -1521,7 +1701,11 @@ export class Vxil {
|
|
|
1521
1701
|
* requests — the output is validated (with one repair pass) against it;
|
|
1522
1702
|
* a per-request `response_schema` overrides it. Ignored on streams. */
|
|
1523
1703
|
put: async (input) => (await this.call('POST', '/v1/ai/templates', input)).data,
|
|
1524
|
-
/** List stored templates (each name + its latest version).
|
|
1704
|
+
/** List stored templates (each name + its latest version). `content_sha256`
|
|
1705
|
+
* is the canonical hash of the latest version's {system, user, schema},
|
|
1706
|
+
* stored when the version was written — what `vxil push` compares a
|
|
1707
|
+
* declared `ai.templates[]` entry against; absent on a version stored
|
|
1708
|
+
* before 2026-09-23 (the next push re-puts that template once). */
|
|
1525
1709
|
list: async () => (await this.call('GET', '/v1/ai/templates')).data.templates,
|
|
1526
1710
|
},
|
|
1527
1711
|
/** Provider × capability matrix + the tenant's default provider (the set an
|
|
@@ -1881,3 +2065,6 @@ export class Vxil {
|
|
|
1881
2065
|
},
|
|
1882
2066
|
};
|
|
1883
2067
|
}
|
|
2068
|
+
// Failure reporting for tenant functions — the Sentry-envelope forwarder
|
|
2069
|
+
// (roadmap §4.11 P0-4e). Zero dependencies; see reporting.ts.
|
|
2070
|
+
export { withReporting, report, buildEnvelope, parseDsn, exceptionEvent, reportServerErrors, REPORT_TIMEOUT_MS, } from './reporting.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** The report POST is bounded to this many milliseconds. */
|
|
2
|
+
export declare const REPORT_TIMEOUT_MS = 3000;
|
|
3
|
+
export interface ReportingOptions {
|
|
4
|
+
/** The reporter's DSN: `https://<key>@<host>/<projectId>`. */
|
|
5
|
+
dsn: string;
|
|
6
|
+
/** Free-form tags stamped on every event (string values only). */
|
|
7
|
+
tags?: Record<string, string>;
|
|
8
|
+
/** `environment` / `release` as your reporter shows them. */
|
|
9
|
+
environment?: string;
|
|
10
|
+
release?: string;
|
|
11
|
+
/** Override the fetch used to post the envelope (tests; a custom egress). */
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
/** `false` never reports a Response, whatever its status (throws are always
|
|
14
|
+
* reported). */
|
|
15
|
+
reportNon2xx?: boolean;
|
|
16
|
+
/** Which Response statuses are reported. Default: 5xx only — a 4xx is your
|
|
17
|
+
* caller's problem, and reporting it would let an unauthenticated caller of
|
|
18
|
+
* an HTTP-triggered function post one envelope per bad request. Opt 4xx in
|
|
19
|
+
* deliberately: `reportStatuses: (s) => s >= 400`. */
|
|
20
|
+
reportStatuses?: (status: number) => boolean;
|
|
21
|
+
/** Bound on the report POST, in milliseconds (default REPORT_TIMEOUT_MS). */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface FetchHandler {
|
|
25
|
+
fetch(request: Request, ...rest: unknown[]): Promise<Response> | Response;
|
|
26
|
+
}
|
|
27
|
+
/** Parsed DSN parts, or null when the DSN is not usable. PURE. */
|
|
28
|
+
export declare function parseDsn(dsn: string): {
|
|
29
|
+
endpoint: string;
|
|
30
|
+
key: string;
|
|
31
|
+
} | null;
|
|
32
|
+
export interface ReportEvent {
|
|
33
|
+
/** `exception` for a throw; `message` for a non-2xx response. */
|
|
34
|
+
kind: 'exception' | 'message';
|
|
35
|
+
type?: string;
|
|
36
|
+
value: string;
|
|
37
|
+
stack?: string;
|
|
38
|
+
status?: number;
|
|
39
|
+
tags?: Record<string, string>;
|
|
40
|
+
environment?: string;
|
|
41
|
+
release?: string;
|
|
42
|
+
}
|
|
43
|
+
/** The envelope body for one event (three NDJSON lines). PURE. */
|
|
44
|
+
export declare function buildEnvelope(dsn: string, ev: ReportEvent, now?: Date): string;
|
|
45
|
+
/** POST one event to the DSN. Never throws, never takes longer than
|
|
46
|
+
* `timeoutMs` (REPORT_TIMEOUT_MS by default); resolves true when the reporter
|
|
47
|
+
* accepted it. Exported so a handler can report on its own terms too. */
|
|
48
|
+
export declare function report(opts: ReportingOptions, ev: ReportEvent): Promise<boolean>;
|
|
49
|
+
/** The event for a thrown value. PURE. */
|
|
50
|
+
export declare function exceptionEvent(e: unknown): ReportEvent;
|
|
51
|
+
/** The default status filter: 5xx only. */
|
|
52
|
+
export declare const reportServerErrors: (status: number) => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Wrap a function's `{ fetch }` handler (or a bare `(request) => Response`).
|
|
55
|
+
* A throw is reported and re-thrown; a 5xx Response (or whatever
|
|
56
|
+
* `reportStatuses` selects) is reported and returned. The wrapper adds nothing
|
|
57
|
+
* else — the platform still sees exactly what your handler produced, and never
|
|
58
|
+
* later than REPORT_TIMEOUT_MS after it produced it (not later at all when the
|
|
59
|
+
* runtime passes an ExecutionContext: the report rides `ctx.waitUntil`).
|
|
60
|
+
*/
|
|
61
|
+
export declare function withReporting<H extends FetchHandler>(handler: H, opts: ReportingOptions): H;
|
|
62
|
+
export declare function withReporting<F extends (request: Request, ...rest: never[]) => Promise<Response> | Response>(handler: F, opts: ReportingOptions): F;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// withReporting — forward a tenant function's failure to the tenant's OWN
|
|
2
|
+
// error reporter over the Sentry envelope HTTP format (roadmap §4.11 P0-4e,
|
|
3
|
+
// techmaker evaluation P0-5's "SDK half", 2026-09-23).
|
|
4
|
+
//
|
|
5
|
+
// NOT a Sentry SDK and NOT a dependency on one: the function sandbox is
|
|
6
|
+
// Web-standard fetch/crypto only, so this is the plain ingestion envelope
|
|
7
|
+
// (https://develop.sentry.dev/sdk/data-model/envelopes/) posted to
|
|
8
|
+
// `<scheme>://<host>/api/<project>/envelope/` with the `X-Sentry-Auth` header
|
|
9
|
+
// derived from the DSN. Any reporter that speaks that endpoint — Sentry,
|
|
10
|
+
// GlitchTip, Bugsink, a self-hosted relay — receives it.
|
|
11
|
+
//
|
|
12
|
+
// CONTRACT
|
|
13
|
+
// • a handler that THROWS is reported, then the throw is re-thrown unchanged —
|
|
14
|
+
// the platform's own retry / dead-letter / functions.run.failed semantics
|
|
15
|
+
// are never altered by observing them;
|
|
16
|
+
// • a handler that returns a 5xx Response is reported as a message event
|
|
17
|
+
// and the Response is returned unchanged. A 4xx is the CALLER's problem
|
|
18
|
+
// and is NOT reported by default: an HTTP-triggered function answering
|
|
19
|
+
// 401/404 to bad requests must not let any internet caller drive one
|
|
20
|
+
// envelope per request out of the tenant's egress and reporter quota
|
|
21
|
+
// (`reportStatuses` widens it deliberately);
|
|
22
|
+
// • the report NEVER extends the handler's wall time beyond REPORT_TIMEOUT_MS:
|
|
23
|
+
// the POST is bounded (`AbortSignal.timeout` on the fetch AND a race, for
|
|
24
|
+
// a fetch that ignores its signal), and when the runtime hands the handler
|
|
25
|
+
// an ExecutionContext (`fetch(request, env, ctx)`) the report rides
|
|
26
|
+
// `ctx.waitUntil` and delays the result by nothing at all. Without the
|
|
27
|
+
// bound, a reporter host that accepted the TCP connection and never
|
|
28
|
+
// answered turned a 200 ms failure into the function's own deadline
|
|
29
|
+
// expiry, the run was recorded as a timeout instead of the real error, and
|
|
30
|
+
// each retry burned the whole budget on the reporter;
|
|
31
|
+
// • the report itself can NEVER surface into the handler's result: a
|
|
32
|
+
// malformed DSN, a blocked egress (403 egress_blocked — put the DSN host in
|
|
33
|
+
// the function's `egressAllow`), a network fault, a timeout or a 5xx from
|
|
34
|
+
// the reporter are swallowed (and printed with `console.warn` so `vxil
|
|
35
|
+
// functions logs` shows them);
|
|
36
|
+
// • no body of the request or response is ever sent — only the error's
|
|
37
|
+
// class/message/stack, the status, and the tags you pass.
|
|
38
|
+
//
|
|
39
|
+
// Usage inside a function (the DSN in a per-function secret, never the bundle):
|
|
40
|
+
//
|
|
41
|
+
// import { withReporting } from '@vxil/sdk';
|
|
42
|
+
// export default withReporting({
|
|
43
|
+
// async fetch(req, env, ctx) { … },
|
|
44
|
+
// }, { dsn: SENTRY_DSN, tags: { fn: 'generation-sweeper' }, environment: 'production' });
|
|
45
|
+
/** The report POST is bounded to this many milliseconds. */
|
|
46
|
+
export const REPORT_TIMEOUT_MS = 3_000;
|
|
47
|
+
/** Parsed DSN parts, or null when the DSN is not usable. PURE. */
|
|
48
|
+
export function parseDsn(dsn) {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(dsn);
|
|
51
|
+
const key = u.username;
|
|
52
|
+
const project = u.pathname.replace(/\/+$/, '').split('/').pop() ?? '';
|
|
53
|
+
if (!key || !project || !/^https?:$/.test(u.protocol))
|
|
54
|
+
return null;
|
|
55
|
+
const base = u.pathname.slice(0, u.pathname.lastIndexOf(`/${project}`));
|
|
56
|
+
return { endpoint: `${u.protocol}//${u.host}${base}/api/${project}/envelope/`, key };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** A 32-hex event id (the envelope requires one). */
|
|
63
|
+
function eventId() {
|
|
64
|
+
const b = new Uint8Array(16);
|
|
65
|
+
crypto.getRandomValues(b);
|
|
66
|
+
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
67
|
+
}
|
|
68
|
+
/** The envelope body for one event (three NDJSON lines). PURE. */
|
|
69
|
+
export function buildEnvelope(dsn, ev, now = new Date()) {
|
|
70
|
+
const id = eventId();
|
|
71
|
+
const header = { event_id: id, sent_at: now.toISOString(), dsn };
|
|
72
|
+
const item = { type: 'event', content_type: 'application/json' };
|
|
73
|
+
const event = {
|
|
74
|
+
event_id: id,
|
|
75
|
+
timestamp: now.toISOString(),
|
|
76
|
+
platform: 'javascript',
|
|
77
|
+
level: 'error',
|
|
78
|
+
sdk: { name: 'vxil.withReporting', version: '1' },
|
|
79
|
+
tags: { ...(ev.tags ?? {}), ...(ev.status !== undefined ? { http_status: String(ev.status) } : {}) },
|
|
80
|
+
...(ev.environment ? { environment: ev.environment } : {}),
|
|
81
|
+
...(ev.release ? { release: ev.release } : {}),
|
|
82
|
+
};
|
|
83
|
+
if (ev.kind === 'exception') {
|
|
84
|
+
event.exception = {
|
|
85
|
+
values: [{
|
|
86
|
+
type: ev.type ?? 'Error',
|
|
87
|
+
value: ev.value,
|
|
88
|
+
...(ev.stack ? { stacktrace: { frames: framesOf(ev.stack) } } : {}),
|
|
89
|
+
}],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
event.message = { formatted: ev.value };
|
|
94
|
+
event.logger = 'vxil.function';
|
|
95
|
+
}
|
|
96
|
+
return `${JSON.stringify(header)}\n${JSON.stringify(item)}\n${JSON.stringify(event)}\n`;
|
|
97
|
+
}
|
|
98
|
+
/** Best-effort V8 stack → Sentry frames (innermost last, as Sentry expects). */
|
|
99
|
+
function framesOf(stack) {
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const line of stack.split('\n')) {
|
|
102
|
+
const m = /^\s*at\s+(?:(.*?)\s+\()?(.*?):(\d+):(\d+)\)?\s*$/.exec(line);
|
|
103
|
+
if (!m)
|
|
104
|
+
continue;
|
|
105
|
+
out.push({ function: m[1] ?? '<anonymous>', filename: m[2], lineno: Number(m[3]), colno: Number(m[4]) });
|
|
106
|
+
}
|
|
107
|
+
return out.reverse().slice(-50);
|
|
108
|
+
}
|
|
109
|
+
/** POST one event to the DSN. Never throws, never takes longer than
|
|
110
|
+
* `timeoutMs` (REPORT_TIMEOUT_MS by default); resolves true when the reporter
|
|
111
|
+
* accepted it. Exported so a handler can report on its own terms too. */
|
|
112
|
+
export async function report(opts, ev) {
|
|
113
|
+
const parsed = parseDsn(opts.dsn);
|
|
114
|
+
if (!parsed) {
|
|
115
|
+
console.warn('vxil.withReporting: unusable DSN (expected https://<key>@<host>/<project>)');
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
119
|
+
const timeoutMs = Math.max(1, opts.timeoutMs ?? REPORT_TIMEOUT_MS);
|
|
120
|
+
// Two bounds on purpose: the signal ends the real request early (and frees
|
|
121
|
+
// the connection); the race ends the WAIT even when a fetch ignores its
|
|
122
|
+
// signal, so the handler's wall time is bounded regardless.
|
|
123
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
124
|
+
let timer;
|
|
125
|
+
const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), timeoutMs); });
|
|
126
|
+
try {
|
|
127
|
+
const res = await Promise.race([
|
|
128
|
+
doFetch(parsed.endpoint, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
'content-type': 'application/x-sentry-envelope',
|
|
132
|
+
'x-sentry-auth': `Sentry sentry_version=7, sentry_client=vxil.withReporting/1, sentry_key=${parsed.key}`,
|
|
133
|
+
},
|
|
134
|
+
body: buildEnvelope(opts.dsn, {
|
|
135
|
+
...ev,
|
|
136
|
+
tags: { ...(opts.tags ?? {}), ...(ev.tags ?? {}) },
|
|
137
|
+
...(opts.environment ? { environment: opts.environment } : {}),
|
|
138
|
+
...(opts.release ? { release: opts.release } : {}),
|
|
139
|
+
}),
|
|
140
|
+
...(signal ? { signal } : {}),
|
|
141
|
+
}),
|
|
142
|
+
deadline,
|
|
143
|
+
]);
|
|
144
|
+
if (res === 'timeout') {
|
|
145
|
+
console.warn(`vxil.withReporting: reporter did not answer within ${timeoutMs} ms`);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
console.warn(`vxil.withReporting: reporter answered ${res.status}` + (res.status === 403 ? ' (is the DSN host in egressAllow?)' : ''));
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
console.warn(`vxil.withReporting: report failed: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
if (timer !== undefined)
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** The event for a thrown value. PURE. */
|
|
164
|
+
export function exceptionEvent(e) {
|
|
165
|
+
if (e instanceof Error) {
|
|
166
|
+
return { kind: 'exception', type: e.name || 'Error', value: e.message || String(e), ...(e.stack ? { stack: e.stack } : {}) };
|
|
167
|
+
}
|
|
168
|
+
return { kind: 'exception', type: 'Error', value: typeof e === 'string' ? e : JSON.stringify(e)?.slice(0, 500) ?? String(e) };
|
|
169
|
+
}
|
|
170
|
+
/** The first `ExecutionContext`-shaped argument after the request (Workers
|
|
171
|
+
* hand `fetch(request, env, ctx)`), or null. */
|
|
172
|
+
function waitUntilOf(rest) {
|
|
173
|
+
for (const x of rest) {
|
|
174
|
+
if (x && typeof x === 'object' && typeof x.waitUntil === 'function')
|
|
175
|
+
return x;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** The default status filter: 5xx only. */
|
|
180
|
+
export const reportServerErrors = (status) => status >= 500;
|
|
181
|
+
export function withReporting(handler, opts) {
|
|
182
|
+
const inner = typeof handler === 'function'
|
|
183
|
+
? (req, ...rest) => handler(req, ...rest)
|
|
184
|
+
: (req, ...rest) => handler.fetch(req, ...rest);
|
|
185
|
+
const shouldReport = opts.reportStatuses ?? reportServerErrors;
|
|
186
|
+
const wrapped = async (req, ...rest) => {
|
|
187
|
+
const ctx = waitUntilOf(rest);
|
|
188
|
+
// With a ctx the report is handed off and the result is not delayed at
|
|
189
|
+
// all; without one it is awaited, but report() is itself bounded.
|
|
190
|
+
const dispatch = (ev) => {
|
|
191
|
+
const p = report(opts, ev);
|
|
192
|
+
if (ctx) {
|
|
193
|
+
ctx.waitUntil(p);
|
|
194
|
+
return Promise.resolve();
|
|
195
|
+
}
|
|
196
|
+
return p;
|
|
197
|
+
};
|
|
198
|
+
let res;
|
|
199
|
+
try {
|
|
200
|
+
res = await inner(req, ...rest);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
await dispatch(exceptionEvent(e));
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
206
|
+
if (opts.reportNon2xx !== false && shouldReport(res.status)) {
|
|
207
|
+
await dispatch({
|
|
208
|
+
kind: 'message',
|
|
209
|
+
value: `function returned ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`,
|
|
210
|
+
status: res.status,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return res;
|
|
214
|
+
};
|
|
215
|
+
if (typeof handler === 'function')
|
|
216
|
+
return wrapped;
|
|
217
|
+
return { ...handler, fetch: wrapped };
|
|
218
|
+
}
|
package/package.json
CHANGED