@vxil/sdk 0.5.2 → 0.6.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 +333 -6
- package/dist/index.js +97 -7
- package/dist/reporting.d.ts +62 -0
- package/dist/reporting.js +218 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -169,6 +169,17 @@ export interface Delivery {
|
|
|
169
169
|
delivered_at?: string | null;
|
|
170
170
|
opened_at?: string | null;
|
|
171
171
|
clicked_at?: string | null;
|
|
172
|
+
/** The instant the first delivery attempt may fire (a `send_at` /
|
|
173
|
+
* `delay_seconds` schedule, a campaign quiet-hours deferral, a dead-letter
|
|
174
|
+
* replay); null = at `queued_at`. A `queued` row with no attempt 30 min past
|
|
175
|
+
* this is checked against its jobs run by a platform sweep and becomes
|
|
176
|
+
* `failed` / `last_error_code: 'never_attempted'` only when that run is gone
|
|
177
|
+
* or terminal (a run still waiting — e.g. behind the jobs concurrency cap —
|
|
178
|
+
* moves this instant forward instead) — send it again with the same
|
|
179
|
+
* Idempotency-Key. (The key itself is never on the row on the wire; a
|
|
180
|
+
* replay is the `x-vxil-idempotent-replay: true` header plus the identical
|
|
181
|
+
* `delivery_id` on the 202.) */
|
|
182
|
+
deliver_after?: string | null;
|
|
172
183
|
}
|
|
173
184
|
/** An ADDITIVE, non-fatal note on a 202 send — today only `mock_provider`
|
|
174
185
|
* (the send is recorded but no email leaves vxil). */
|
|
@@ -348,7 +359,41 @@ export interface JobRun {
|
|
|
348
359
|
/** earliest next pickup time; for a 'delayed' run this is deliver_after */
|
|
349
360
|
next_attempt_at?: string | null;
|
|
350
361
|
queued_at: string;
|
|
362
|
+
started_at?: string | null;
|
|
351
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;
|
|
352
397
|
}
|
|
353
398
|
export interface JobFlowRule {
|
|
354
399
|
rule_id: string;
|
|
@@ -713,6 +758,62 @@ export interface AiJobHandle {
|
|
|
713
758
|
status: string;
|
|
714
759
|
resume_path: string;
|
|
715
760
|
}
|
|
761
|
+
/** The `data` of `job.generation.queued` (jobs.md §11): the run was accepted. */
|
|
762
|
+
export interface JobGenerationQueuedEventPayload {
|
|
763
|
+
run_id: string;
|
|
764
|
+
job_name: string;
|
|
765
|
+
/** the `202` handle's id from `POST /v1/ai/generate` (a tenant-authored
|
|
766
|
+
* `POST /v1/jobs/generation` run echoes its own `payload.generation_id`) */
|
|
767
|
+
generation_id: string | null;
|
|
768
|
+
/** your own `correlation_id` from that request (≤128 chars), or null */
|
|
769
|
+
correlation_id: string | null;
|
|
770
|
+
/** the completion mode the run was enqueued with (the ai lane enqueues `poll`) */
|
|
771
|
+
mode: 'poll' | 'webhook';
|
|
772
|
+
/** when the run is failed as expired if the provider never settles it */
|
|
773
|
+
expires_at: string;
|
|
774
|
+
}
|
|
775
|
+
/** The `data` of `job.generation.completed` / `job.generation.failed`. */
|
|
776
|
+
export interface JobGenerationSettledEventPayload {
|
|
777
|
+
run_id: string;
|
|
778
|
+
generation_id: string | null;
|
|
779
|
+
correlation_id: string | null;
|
|
780
|
+
/** `completed` on the healthy terminal, `failed` on the broken one */
|
|
781
|
+
status: 'completed' | 'failed';
|
|
782
|
+
/** the run's SETTLED error class (`provider_error`, `PollExhausted`, …);
|
|
783
|
+
* always null on `completed` */
|
|
784
|
+
error_class: string | null;
|
|
785
|
+
/** the provider hint alone (`Upstream said: gemini 400: …`); null on
|
|
786
|
+
* `completed` and whenever the failure carried none */
|
|
787
|
+
error_hint: string | null;
|
|
788
|
+
/** the failure-event vocabulary every `*.failed` carries (guide 12) */
|
|
789
|
+
level: 'info' | 'error';
|
|
790
|
+
state: 'ok' | 'broken';
|
|
791
|
+
}
|
|
792
|
+
/** The union a handler subscribed to `job.generation.` receives as `data`:
|
|
793
|
+
* narrow on `'status' in data` (queued carries none). */
|
|
794
|
+
export type JobGenerationEventPayload = JobGenerationQueuedEventPayload | JobGenerationSettledEventPayload;
|
|
795
|
+
/** The `data` of `job.succeeded` / `job.dead_lettered` (every run kind). The
|
|
796
|
+
* dead letter is the one that carries `level: 'error'` / `state: 'broken'`;
|
|
797
|
+
* `job.succeeded` carries neither. */
|
|
798
|
+
export interface JobRunEventPayload {
|
|
799
|
+
run_id: string;
|
|
800
|
+
job_name: string;
|
|
801
|
+
/** the attempt that settled the run (1-based) */
|
|
802
|
+
attempt: number;
|
|
803
|
+
level?: 'error';
|
|
804
|
+
state?: 'broken';
|
|
805
|
+
}
|
|
806
|
+
/** Event name → typed `data`, for the events that settle work you started.
|
|
807
|
+
* `VxilEventPayload<'job.generation.failed'>` names one; everything else on
|
|
808
|
+
* the catalog is `Record<string, unknown>` until typed here. */
|
|
809
|
+
export interface VxilEventPayloads {
|
|
810
|
+
'job.generation.queued': JobGenerationQueuedEventPayload;
|
|
811
|
+
'job.generation.completed': JobGenerationSettledEventPayload;
|
|
812
|
+
'job.generation.failed': JobGenerationSettledEventPayload;
|
|
813
|
+
'job.succeeded': JobRunEventPayload;
|
|
814
|
+
'job.dead_lettered': JobRunEventPayload;
|
|
815
|
+
}
|
|
816
|
+
export type VxilEventPayload<E extends string> = E extends keyof VxilEventPayloads ? VxilEventPayloads[E] : Record<string, unknown>;
|
|
716
817
|
/** A re-minted mid-stream connect token (GET /v1/ai/generations/{id}/token). */
|
|
717
818
|
export interface AiStreamToken {
|
|
718
819
|
generation_id: string;
|
|
@@ -1152,6 +1253,65 @@ export interface VxilSchemaShape {
|
|
|
1152
1253
|
Output: unknown;
|
|
1153
1254
|
}>;
|
|
1154
1255
|
}
|
|
1256
|
+
/** Per-call options of `vx.fn.<name>(input, opts)`. */
|
|
1257
|
+
export interface FnInvokeOptions {
|
|
1258
|
+
/** `true` ⇒ the ASYNC http lane (`x-vxil-async: 1`): the call resolves to the
|
|
1259
|
+
* `202 { run_id, status:'queued' }` ack at once; the run is delivered through
|
|
1260
|
+
* the jobs lane with exactly one attempt and records the function's real
|
|
1261
|
+
* status — poll `vx.jobs.run(run_id)` or listen for `job.*` events.
|
|
1262
|
+
* Server-mode only (an end-user client gets `403 server_only`). */
|
|
1263
|
+
async?: boolean;
|
|
1264
|
+
/** Sent as `Idempotency-Key`: rides the envelope as `idempotency_key` (the
|
|
1265
|
+
* function dedupes on it); on the async lane it is also the run's dedupe key,
|
|
1266
|
+
* so a repeat with the same key returns the SAME `run_id`. */
|
|
1267
|
+
idempotencyKey?: string;
|
|
1268
|
+
/** With `async: true` — "kick and know it finished": hold the call up to
|
|
1269
|
+
* `wait` seconds (integer 1..25, sent as `X-Vxil-Wait`) for the run to reach
|
|
1270
|
+
* a terminal state. Resolves to a `FnAsyncOutcome`: `done: true` + the
|
|
1271
|
+
* terminal `run` when it settled inside the bound, else `done: false` with
|
|
1272
|
+
* the run's current `status` (the run IS queued — `vx.jobs.waitForRun` /
|
|
1273
|
+
* `vx.jobs.run` continue from there). The function's OWN response never
|
|
1274
|
+
* rides this shape; the sync lane returns it. A client `timeoutMs` shorter
|
|
1275
|
+
* than `wait` × 1000 aborts the call first. Ignored without `async`. */
|
|
1276
|
+
wait?: number;
|
|
1277
|
+
}
|
|
1278
|
+
/** The async lane's 202 ack. */
|
|
1279
|
+
export interface FnAsyncAck {
|
|
1280
|
+
run_id: string;
|
|
1281
|
+
status: 'queued';
|
|
1282
|
+
/** present (true) when the Idempotency-Key matched an earlier run */
|
|
1283
|
+
deduplicated?: boolean;
|
|
1284
|
+
}
|
|
1285
|
+
/** What `vx.fn.<name>(input, { async: true, wait })` resolves to. */
|
|
1286
|
+
export interface FnAsyncOutcome {
|
|
1287
|
+
run_id: string;
|
|
1288
|
+
/** the run's state: terminal when `done`, else its live state at the deadline */
|
|
1289
|
+
status: JobRun['state'];
|
|
1290
|
+
/** true ⇔ the run reached a terminal state inside the wait bound */
|
|
1291
|
+
done: boolean;
|
|
1292
|
+
/** the terminal run (the `GET /v1/jobs/runs/{run_id}` row); absent on a timeout */
|
|
1293
|
+
run?: JobRun;
|
|
1294
|
+
/** present (true) when the Idempotency-Key matched an earlier run */
|
|
1295
|
+
deduplicated?: boolean;
|
|
1296
|
+
/** `timeout` — the bound passed before the run was terminal; `unavailable` —
|
|
1297
|
+
* the run was queued but the platform could not hold the wait (poll it) */
|
|
1298
|
+
wait?: 'timeout' | 'unavailable';
|
|
1299
|
+
}
|
|
1300
|
+
/** `vx.fn.<name>`: the function's Output by default; the 202 ack with
|
|
1301
|
+
* `{ async: true }`; the run outcome with `{ async: true, wait }`. */
|
|
1302
|
+
export interface FnInvoker<I, O> {
|
|
1303
|
+
(input: I, opts?: FnInvokeOptions & {
|
|
1304
|
+
async?: false;
|
|
1305
|
+
}): Promise<O>;
|
|
1306
|
+
(input: I, opts: FnInvokeOptions & {
|
|
1307
|
+
async: true;
|
|
1308
|
+
wait: number;
|
|
1309
|
+
}): Promise<FnAsyncOutcome>;
|
|
1310
|
+
(input: I, opts: FnInvokeOptions & {
|
|
1311
|
+
async: true;
|
|
1312
|
+
wait?: undefined;
|
|
1313
|
+
}): Promise<FnAsyncAck>;
|
|
1314
|
+
}
|
|
1155
1315
|
/** feature client property → the feature key (in S['features']) that enables it.
|
|
1156
1316
|
* The renamed namespaces map to their real feature ids. Used by EnabledVxil to
|
|
1157
1317
|
* disable namespaces a tenant hasn't enabled. */
|
|
@@ -1340,6 +1500,104 @@ export type CmsTxStep = {
|
|
|
1340
1500
|
item_id: string;
|
|
1341
1501
|
$where?: Record<string, unknown>;
|
|
1342
1502
|
};
|
|
1503
|
+
/** One op of `vx.cms.batch` (cms.md §21) — the single route's own body keys,
|
|
1504
|
+
* typed by op. `ref` is an opaque tag echoed on the matching result. */
|
|
1505
|
+
export type CmsBatchOp = {
|
|
1506
|
+
op: 'get';
|
|
1507
|
+
collection: string;
|
|
1508
|
+
id: string;
|
|
1509
|
+
expand?: readonly string[] | string;
|
|
1510
|
+
ref?: string;
|
|
1511
|
+
} | {
|
|
1512
|
+
op: 'query';
|
|
1513
|
+
collection: string;
|
|
1514
|
+
filter?: Record<string, unknown>;
|
|
1515
|
+
sort?: string;
|
|
1516
|
+
limit?: number;
|
|
1517
|
+
cursor?: string;
|
|
1518
|
+
expand?: readonly string[] | string;
|
|
1519
|
+
count?: boolean;
|
|
1520
|
+
ref?: string;
|
|
1521
|
+
} | {
|
|
1522
|
+
op: 'create';
|
|
1523
|
+
collection: string;
|
|
1524
|
+
data: Record<string, unknown>;
|
|
1525
|
+
status?: 'draft' | 'published';
|
|
1526
|
+
lock?: string;
|
|
1527
|
+
guard?: CmsGuard;
|
|
1528
|
+
guards?: CmsGuardTerm[];
|
|
1529
|
+
ref?: string;
|
|
1530
|
+
} | {
|
|
1531
|
+
op: 'patch';
|
|
1532
|
+
collection: string;
|
|
1533
|
+
id: string;
|
|
1534
|
+
data?: Record<string, unknown>;
|
|
1535
|
+
$inc?: Record<string, number>;
|
|
1536
|
+
if?: Record<string, unknown>;
|
|
1537
|
+
if_version?: number;
|
|
1538
|
+
lock?: string;
|
|
1539
|
+
guard?: CmsGuard;
|
|
1540
|
+
guards?: CmsGuardTerm[];
|
|
1541
|
+
ref?: string;
|
|
1542
|
+
} | {
|
|
1543
|
+
op: 'delete';
|
|
1544
|
+
collection: string;
|
|
1545
|
+
id: string;
|
|
1546
|
+
if_version?: number;
|
|
1547
|
+
ref?: string;
|
|
1548
|
+
};
|
|
1549
|
+
/** What a 2xx batch result's `data` is, per op: the item record (get / create /
|
|
1550
|
+
* patch), a page or `{ count }` (query), the delete tally (delete). */
|
|
1551
|
+
export type CmsBatchData<O extends CmsBatchOp> = O extends {
|
|
1552
|
+
op: 'query';
|
|
1553
|
+
} ? (O extends {
|
|
1554
|
+
count: true;
|
|
1555
|
+
} ? {
|
|
1556
|
+
count: number;
|
|
1557
|
+
} : {
|
|
1558
|
+
items: Array<Record<string, unknown>>;
|
|
1559
|
+
next_cursor: string | null;
|
|
1560
|
+
}) : O extends {
|
|
1561
|
+
op: 'delete';
|
|
1562
|
+
} ? {
|
|
1563
|
+
item_id: string;
|
|
1564
|
+
deleted: boolean;
|
|
1565
|
+
cascaded: number;
|
|
1566
|
+
set_null: number;
|
|
1567
|
+
} : Record<string, unknown>;
|
|
1568
|
+
/** One batch result: the single route's `status` and its `data` (2xx) or
|
|
1569
|
+
* `error`. In an aborted atomic batch the undone ops carry `424 rolled_back`
|
|
1570
|
+
* and the ops after the failure `424 not_run`. The op whose result crossed
|
|
1571
|
+
* the 8 MiB per-batch budget is `413 batch_result_too_large` (the rest
|
|
1572
|
+
* `424 not_run`); non-atomic, an op that failed unexpectedly is
|
|
1573
|
+
* `500 internal_error` in-line — the ops before it committed and keep their
|
|
1574
|
+
* results, so never retry the whole batch on one 500. At most 5 ops may be
|
|
1575
|
+
* `query` (a whole-batch 422 otherwise). */
|
|
1576
|
+
export type CmsBatchResult<O extends CmsBatchOp = CmsBatchOp> = {
|
|
1577
|
+
op: O['op'];
|
|
1578
|
+
ref?: string;
|
|
1579
|
+
status: number;
|
|
1580
|
+
data: CmsBatchData<O>;
|
|
1581
|
+
error?: undefined;
|
|
1582
|
+
} | {
|
|
1583
|
+
op: O['op'];
|
|
1584
|
+
ref?: string;
|
|
1585
|
+
status: number;
|
|
1586
|
+
data?: undefined;
|
|
1587
|
+
error: {
|
|
1588
|
+
code: string;
|
|
1589
|
+
message: string;
|
|
1590
|
+
hint?: string;
|
|
1591
|
+
};
|
|
1592
|
+
};
|
|
1593
|
+
export interface CmsBatchResponse<T extends readonly CmsBatchOp[]> {
|
|
1594
|
+
/** true iff every op is 2xx — for an atomic batch, iff the transaction committed. */
|
|
1595
|
+
ok: boolean;
|
|
1596
|
+
atomic: boolean;
|
|
1597
|
+
results: {
|
|
1598
|
+
[K in keyof T]: T[K] extends CmsBatchOp ? CmsBatchResult<T[K]> : never;
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1343
1601
|
/** One CMS item as the API returns it — the envelope `$expand` inlines in place
|
|
1344
1602
|
* of a stored relation id (cms.md §6.2). `R` is the target collection's Row. */
|
|
1345
1603
|
export interface CmsItemEnvelope<R> {
|
|
@@ -1540,8 +1798,13 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
1540
1798
|
/** Typed function invoke (design §4.5). `vx.fn.<name>(payload)` POSTs to
|
|
1541
1799
|
* /v1/fn/<name>; the generated `VxilSchema` types Input/Output (Level-0
|
|
1542
1800
|
* opaque until a function declares a signature). A Proxy gives the
|
|
1543
|
-
* `vx.fn.<name>` accessor shape without enumerating names at runtime.
|
|
1544
|
-
|
|
1801
|
+
* `vx.fn.<name>` accessor shape without enumerating names at runtime.
|
|
1802
|
+
* `vx.fn.<name>(payload, { async: true })` takes the ASYNC lane
|
|
1803
|
+
* (`x-vxil-async: 1`): it resolves to the `202 { run_id, status:'queued' }`
|
|
1804
|
+
* ack at once and the run is delivered through the jobs lane — poll
|
|
1805
|
+
* `vx.jobs.run(run_id)` or subscribe to `job.*` events. With `wait` (1..25 s)
|
|
1806
|
+
* the call holds for the run to settle and resolves to a `FnAsyncOutcome`. */
|
|
1807
|
+
readonly fn: { [K in keyof S["functions"] & string]: FnInvoker<S["functions"][K]["Input"], S["functions"][K]["Output"]>; };
|
|
1545
1808
|
private call;
|
|
1546
1809
|
readonly users: {
|
|
1547
1810
|
upsert: (user: {
|
|
@@ -2027,6 +2290,26 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2027
2290
|
limit?: number;
|
|
2028
2291
|
}) => Promise<JobRun[]>;
|
|
2029
2292
|
run: (runId: string) => Promise<JobRun>;
|
|
2293
|
+
/** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
|
|
2294
|
+
* the request platform-side (re-reading the run on one bounded connection,
|
|
2295
|
+
* 500 ms at first then backing off to 2 s) until the run is terminal —
|
|
2296
|
+
* succeeded / failed / dead / cancelled — or `seconds` (integer 1..25)
|
|
2297
|
+
* pass; then the CURRENT run comes back with `done: false` (also when the
|
|
2298
|
+
* per-tenant budget of 60 held reads a minute declined to hold the wait —
|
|
2299
|
+
* the response header `x-vxil-wait` says `timeout` or `unavailable`). `run` is the plain `run(runId)` row either
|
|
2300
|
+
* way (same `jobs:read` scope; 404 at once for an unknown id). Loop it for a
|
|
2301
|
+
* longer wait. A client `timeoutMs` shorter than `seconds` × 1000 aborts the
|
|
2302
|
+
* call first. NOT `wait(runId, …)` — that is the durable in-handler
|
|
2303
|
+
* wait-for-event. */
|
|
2304
|
+
waitForRun: (runId: string, opts: {
|
|
2305
|
+
seconds: number;
|
|
2306
|
+
}) => Promise<{
|
|
2307
|
+
run: JobRun;
|
|
2308
|
+
done: boolean;
|
|
2309
|
+
}>;
|
|
2310
|
+
/** The live queue: depth by state, the oldest waiting run's age (the
|
|
2311
|
+
* number to alarm on), in-flight runs per lane, dead letters in 24 h. */
|
|
2312
|
+
queue: () => Promise<JobsQueue>;
|
|
2030
2313
|
cancel: (runId: string) => Promise<{
|
|
2031
2314
|
run_id: string;
|
|
2032
2315
|
state: string;
|
|
@@ -2148,7 +2431,13 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2148
2431
|
/** `opts.anonymous_token` is REQUIRED for a link that was requested with
|
|
2149
2432
|
* one (the guest claim above): the same guest session must present it,
|
|
2150
2433
|
* else `401 invalid_session`. `merged` is true only when the guest was
|
|
2151
|
-
* folded into an existing account (then `user_id` is that account)
|
|
2434
|
+
* folded into an existing account (then `user_id` is that account), and
|
|
2435
|
+
* `rekeyed` rides beside it: true ⇒ the guest's payments / cms / files
|
|
2436
|
+
* rows were already moved onto `user_id` when this returned; false ⇒
|
|
2437
|
+
* the move continues in the background — wait for the
|
|
2438
|
+
* `auth.user.rekeyed` event before reading owner-scoped rows as the
|
|
2439
|
+
* merged user. The event is written when the pass finishes and is
|
|
2440
|
+
* best-effort past this response (see `anonymous.link.verify`). */
|
|
2152
2441
|
verify: (token: string, opts?: {
|
|
2153
2442
|
anonymous_token?: string;
|
|
2154
2443
|
}) => Promise<{
|
|
@@ -2156,6 +2445,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2156
2445
|
session: AuthSession;
|
|
2157
2446
|
verified: boolean;
|
|
2158
2447
|
merged?: boolean;
|
|
2448
|
+
rekeyed?: boolean;
|
|
2159
2449
|
}>;
|
|
2160
2450
|
};
|
|
2161
2451
|
/** Email OTP sign-in: a 6-digit single-use code (distinct from magic-link).
|
|
@@ -2204,7 +2494,20 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2204
2494
|
* If the claimed email ALREADY has an account, the guest is MERGED
|
|
2205
2495
|
* into it: `user_id` is the existing account, `merged: true`, and a
|
|
2206
2496
|
* fresh `session.token` (same session, re-signed for the merged
|
|
2207
|
-
* identity — swap it client-side; other guest sessions are revoked).
|
|
2497
|
+
* identity — swap it client-side; other guest sessions are revoked).
|
|
2498
|
+
* `rekeyed` (present on a merge): true ⇒ the guest's payments / cms /
|
|
2499
|
+
* files rows were already moved onto `user_id` when this returned, so
|
|
2500
|
+
* the merged user's next request finds them; false ⇒ the move ran
|
|
2501
|
+
* past its in-request budget and continues in the background — wait
|
|
2502
|
+
* for the `auth.user.rekeyed { from_user_id, into_user_id, moved,
|
|
2503
|
+
* done, failed }` event before reading owner-scoped rows as `user_id`.
|
|
2504
|
+
* That event is written when the pass finishes and is BEST-EFFORT past
|
|
2505
|
+
* this response: the continuation is not re-run if the instance
|
|
2506
|
+
* serving it is evicted, and a failed event write is logged, not
|
|
2507
|
+
* retried — treat `rekeyed: false` with no event within a minute or
|
|
2508
|
+
* so as "poll your own rows", or re-run the idempotent
|
|
2509
|
+
* `cms.items.reKey` / `files.reKey` / `payments.reKeySubscriptions`
|
|
2510
|
+
* routes from the `auth.user.merged` backstop. */
|
|
2208
2511
|
verify: (input: {
|
|
2209
2512
|
token: string;
|
|
2210
2513
|
email: string;
|
|
@@ -2214,6 +2517,7 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2214
2517
|
email: string;
|
|
2215
2518
|
verified: boolean;
|
|
2216
2519
|
merged?: true;
|
|
2520
|
+
rekeyed?: boolean;
|
|
2217
2521
|
session?: {
|
|
2218
2522
|
token: string;
|
|
2219
2523
|
expires_at: string;
|
|
@@ -2391,6 +2695,10 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2391
2695
|
session: AuthSession;
|
|
2392
2696
|
verified: boolean;
|
|
2393
2697
|
linked: "created" | "existing" | "promoted" | "merged";
|
|
2698
|
+
/** present exactly when `linked === 'merged'`: true ⇒ the guest's rows
|
|
2699
|
+
* are already under `user_id`; false ⇒ wait for `auth.user.rekeyed`
|
|
2700
|
+
* (best-effort past this response — see `anonymous.link.verify`) */
|
|
2701
|
+
rekeyed?: boolean;
|
|
2394
2702
|
/** sessions taken over by auth config session.maxConcurrent (present once opted in) */
|
|
2395
2703
|
took_over?: string[];
|
|
2396
2704
|
}>;
|
|
@@ -2763,6 +3071,20 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
2763
3071
|
committed: boolean;
|
|
2764
3072
|
tx_id: string;
|
|
2765
3073
|
}>;
|
|
3074
|
+
/** P1-1 (cms.md §21): several get / query / create / patch / delete ops in
|
|
3075
|
+
* ONE round trip — the function-chain shape ("read 3 rows → patch 2 →
|
|
3076
|
+
* create 1" is one call, not six). ≤25 ops, each run through the SAME path
|
|
3077
|
+
* its single route uses (scopes, owner-scoping, hooks, guards, `if`,
|
|
3078
|
+
* `if_version`, `$inc`), each reporting its own status in `results` — a
|
|
3079
|
+
* batch never throws for a per-op failure; check `ok` / each `status`.
|
|
3080
|
+
* `atomic: true` runs everything in ONE tenant transaction: reads see
|
|
3081
|
+
* earlier writes and the first non-2xx op rolls every write back (the
|
|
3082
|
+
* undone ops answer `424 rolled_back`, the rest `424 not_run`). Default:
|
|
3083
|
+
* sequential, per-op status, no cross-op rollback. `ref` tags an op so
|
|
3084
|
+
* its result is easy to find. */
|
|
3085
|
+
batch: <T extends readonly CmsBatchOp[]>(ops: readonly [...T], opts?: {
|
|
3086
|
+
atomic?: boolean;
|
|
3087
|
+
}) => Promise<CmsBatchResponse<T>>;
|
|
2766
3088
|
/** cms-rel B5 (cms.md §12.4): declared read-models (config `readModels`
|
|
2767
3089
|
* bag) — list with last-run status, and "run now" materialization into
|
|
2768
3090
|
* the rollup collection. */
|
|
@@ -3727,11 +4049,16 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
3727
4049
|
template: string;
|
|
3728
4050
|
version: number;
|
|
3729
4051
|
}>;
|
|
3730
|
-
/** List stored templates (each name + its latest version).
|
|
4052
|
+
/** List stored templates (each name + its latest version). `content_sha256`
|
|
4053
|
+
* is the canonical hash of the latest version's {system, user, schema},
|
|
4054
|
+
* stored when the version was written — what `vxil push` compares a
|
|
4055
|
+
* declared `ai.templates[]` entry against; absent on a version stored
|
|
4056
|
+
* before 2026-09-23 (the next push re-puts that template once). */
|
|
3731
4057
|
list: () => Promise<Array<{
|
|
3732
4058
|
name: string;
|
|
3733
4059
|
latest_version: number;
|
|
3734
4060
|
created_at: string;
|
|
4061
|
+
content_sha256?: string;
|
|
3735
4062
|
}>>;
|
|
3736
4063
|
};
|
|
3737
4064
|
/** Provider × capability matrix + the tenant's default provider (the set an
|
|
@@ -4470,4 +4797,4 @@ export declare class Vxil<S extends VxilSchemaShape = VxilSchemaShape> {
|
|
|
4470
4797
|
};
|
|
4471
4798
|
};
|
|
4472
4799
|
}
|
|
4473
|
-
export {};
|
|
4800
|
+
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,9 @@ 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']);
|
|
50
53
|
const DEFAULT_BASE = 'https://api.vxil.com';
|
|
51
54
|
/** Normalize the `expand` option to the `$expand` query value: a comma-joined
|
|
52
55
|
* list (the server's spelling), or `undefined` when nothing is expanded so the
|
|
@@ -228,7 +231,12 @@ export class Vxil {
|
|
|
228
231
|
/** Typed function invoke (design §4.5). `vx.fn.<name>(payload)` POSTs to
|
|
229
232
|
* /v1/fn/<name>; the generated `VxilSchema` types Input/Output (Level-0
|
|
230
233
|
* opaque until a function declares a signature). A Proxy gives the
|
|
231
|
-
* `vx.fn.<name>` accessor shape without enumerating names at runtime.
|
|
234
|
+
* `vx.fn.<name>` accessor shape without enumerating names at runtime.
|
|
235
|
+
* `vx.fn.<name>(payload, { async: true })` takes the ASYNC lane
|
|
236
|
+
* (`x-vxil-async: 1`): it resolves to the `202 { run_id, status:'queued' }`
|
|
237
|
+
* ack at once and the run is delivered through the jobs lane — poll
|
|
238
|
+
* `vx.jobs.run(run_id)` or subscribe to `job.*` events. With `wait` (1..25 s)
|
|
239
|
+
* the call holds for the run to settle and resolves to a `FnAsyncOutcome`. */
|
|
232
240
|
fn = new Proxy({}, {
|
|
233
241
|
get: (_t, prop) => {
|
|
234
242
|
if (typeof prop !== 'string')
|
|
@@ -236,10 +244,15 @@ export class Vxil {
|
|
|
236
244
|
// /v1/fn/<name> returns the function's RAW Response (not a {data,meta}
|
|
237
245
|
// envelope), so bypass call()'s unwrap — read the body directly, like
|
|
238
246
|
// audit.export. Errors still surface as VxilError.
|
|
239
|
-
return async (payload) => {
|
|
247
|
+
return async (payload, opts) => {
|
|
240
248
|
const { response: res, text } = await this.transport.send(`${this.base}${this.path(`/v1/fn/${encodeURIComponent(prop)}`)}`, {
|
|
241
249
|
method: 'POST',
|
|
242
|
-
headers: {
|
|
250
|
+
headers: {
|
|
251
|
+
authorization: `Bearer ${this.key}`, ...this.authHeaders(), 'content-type': 'application/json',
|
|
252
|
+
...(opts?.async ? { 'x-vxil-async': '1' } : {}),
|
|
253
|
+
...(opts?.async && opts.wait !== undefined ? { 'x-vxil-wait': String(opts.wait) } : {}),
|
|
254
|
+
...(opts?.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {}),
|
|
255
|
+
},
|
|
243
256
|
body: JSON.stringify(payload ?? {}),
|
|
244
257
|
});
|
|
245
258
|
if (!res.ok) {
|
|
@@ -250,6 +263,22 @@ export class Vxil {
|
|
|
250
263
|
catch { /* non-JSON error body */ }
|
|
251
264
|
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')));
|
|
252
265
|
}
|
|
266
|
+
if (opts?.async && opts.wait !== undefined) {
|
|
267
|
+
// kick-and-wait: 200 = the terminal run's { data, meta } envelope
|
|
268
|
+
// (the jobs read shape); 202 = the ack with the run's current state
|
|
269
|
+
// (+ x-vxil-wait: timeout | unavailable). Normalised to ONE shape.
|
|
270
|
+
const parsed = (text.length ? JSON.parse(text) : {});
|
|
271
|
+
if (res.status === 200 && parsed.data) {
|
|
272
|
+
const run = parsed.data;
|
|
273
|
+
return { run_id: run.run_id, status: run.state, done: JOB_TERMINAL_STATES.has(run.state), run };
|
|
274
|
+
}
|
|
275
|
+
const w = res.headers.get('x-vxil-wait');
|
|
276
|
+
return {
|
|
277
|
+
run_id: parsed.run_id ?? '', status: parsed.status ?? 'queued', done: false,
|
|
278
|
+
...(parsed.deduplicated ? { deduplicated: true } : {}),
|
|
279
|
+
...(w === 'timeout' || w === 'unavailable' ? { wait: w } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
253
282
|
return text.length ? JSON.parse(text) : undefined;
|
|
254
283
|
};
|
|
255
284
|
},
|
|
@@ -573,6 +602,24 @@ export class Vxil {
|
|
|
573
602
|
return (await this.call('GET', `/v1/jobs/runs${s}`)).data.runs;
|
|
574
603
|
},
|
|
575
604
|
run: async (runId) => (await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}`)).data,
|
|
605
|
+
/** "Wait for this run": `GET /v1/jobs/runs/{run_id}?wait=<seconds>` holds
|
|
606
|
+
* the request platform-side (re-reading the run on one bounded connection,
|
|
607
|
+
* 500 ms at first then backing off to 2 s) until the run is terminal —
|
|
608
|
+
* succeeded / failed / dead / cancelled — or `seconds` (integer 1..25)
|
|
609
|
+
* pass; then the CURRENT run comes back with `done: false` (also when the
|
|
610
|
+
* per-tenant budget of 60 held reads a minute declined to hold the wait —
|
|
611
|
+
* the response header `x-vxil-wait` says `timeout` or `unavailable`). `run` is the plain `run(runId)` row either
|
|
612
|
+
* way (same `jobs:read` scope; 404 at once for an unknown id). Loop it for a
|
|
613
|
+
* longer wait. A client `timeoutMs` shorter than `seconds` × 1000 aborts the
|
|
614
|
+
* call first. NOT `wait(runId, …)` — that is the durable in-handler
|
|
615
|
+
* wait-for-event. */
|
|
616
|
+
waitForRun: async (runId, opts) => {
|
|
617
|
+
const { data } = await this.call('GET', `/v1/jobs/runs/${encodeURIComponent(runId)}?wait=${encodeURIComponent(String(opts.seconds))}`);
|
|
618
|
+
return { run: data, done: JOB_TERMINAL_STATES.has(data.state) };
|
|
619
|
+
},
|
|
620
|
+
/** The live queue: depth by state, the oldest waiting run's age (the
|
|
621
|
+
* number to alarm on), in-flight runs per lane, dead letters in 24 h. */
|
|
622
|
+
queue: async () => (await this.call('GET', '/v1/jobs/queue')).data,
|
|
576
623
|
cancel: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/cancel`)).data,
|
|
577
624
|
/** Clone a terminal run into a fresh queued run. */
|
|
578
625
|
replay: async (runId) => (await this.call('POST', `/v1/jobs/runs/${encodeURIComponent(runId)}/replay`)).data,
|
|
@@ -650,7 +697,13 @@ export class Vxil {
|
|
|
650
697
|
/** `opts.anonymous_token` is REQUIRED for a link that was requested with
|
|
651
698
|
* one (the guest claim above): the same guest session must present it,
|
|
652
699
|
* else `401 invalid_session`. `merged` is true only when the guest was
|
|
653
|
-
* folded into an existing account (then `user_id` is that account)
|
|
700
|
+
* folded into an existing account (then `user_id` is that account), and
|
|
701
|
+
* `rekeyed` rides beside it: true ⇒ the guest's payments / cms / files
|
|
702
|
+
* rows were already moved onto `user_id` when this returned; false ⇒
|
|
703
|
+
* the move continues in the background — wait for the
|
|
704
|
+
* `auth.user.rekeyed` event before reading owner-scoped rows as the
|
|
705
|
+
* merged user. The event is written when the pass finishes and is
|
|
706
|
+
* best-effort past this response (see `anonymous.link.verify`). */
|
|
654
707
|
verify: async (token, opts = {}) => (await this.call('POST', '/v1/auth/magic-link/verify', { token, ...(opts.anonymous_token !== undefined ? { anonymous_token: opts.anonymous_token } : {}) })).data,
|
|
655
708
|
},
|
|
656
709
|
/** Email OTP sign-in: a 6-digit single-use code (distinct from magic-link).
|
|
@@ -674,7 +727,20 @@ export class Vxil {
|
|
|
674
727
|
* If the claimed email ALREADY has an account, the guest is MERGED
|
|
675
728
|
* into it: `user_id` is the existing account, `merged: true`, and a
|
|
676
729
|
* fresh `session.token` (same session, re-signed for the merged
|
|
677
|
-
* identity — swap it client-side; other guest sessions are revoked).
|
|
730
|
+
* identity — swap it client-side; other guest sessions are revoked).
|
|
731
|
+
* `rekeyed` (present on a merge): true ⇒ the guest's payments / cms /
|
|
732
|
+
* files rows were already moved onto `user_id` when this returned, so
|
|
733
|
+
* the merged user's next request finds them; false ⇒ the move ran
|
|
734
|
+
* past its in-request budget and continues in the background — wait
|
|
735
|
+
* for the `auth.user.rekeyed { from_user_id, into_user_id, moved,
|
|
736
|
+
* done, failed }` event before reading owner-scoped rows as `user_id`.
|
|
737
|
+
* That event is written when the pass finishes and is BEST-EFFORT past
|
|
738
|
+
* this response: the continuation is not re-run if the instance
|
|
739
|
+
* serving it is evicted, and a failed event write is logged, not
|
|
740
|
+
* retried — treat `rekeyed: false` with no event within a minute or
|
|
741
|
+
* so as "poll your own rows", or re-run the idempotent
|
|
742
|
+
* `cms.items.reKey` / `files.reKey` / `payments.reKeySubscriptions`
|
|
743
|
+
* routes from the `auth.user.merged` backstop. */
|
|
678
744
|
verify: async (input) => (await this.call('POST', '/v1/auth/anonymous/link/verify', input)).data,
|
|
679
745
|
},
|
|
680
746
|
},
|
|
@@ -782,7 +848,9 @@ export class Vxil {
|
|
|
782
848
|
/** `anonymous_token`: the caller's CURRENT guest session bearer — the
|
|
783
849
|
* guest is PROMOTED to this identity (same user id, `linked: 'promoted'`)
|
|
784
850
|
* or, if the identity already has an account, MERGED into it
|
|
785
|
-
* (`linked: 'merged'`, `user_id` = that account
|
|
851
|
+
* (`linked: 'merged'`, `user_id` = that account, `rekeyed` says whether
|
|
852
|
+
* the guest's owner-scoped rows already moved — see
|
|
853
|
+
* `anonymous.link.verify`). */
|
|
786
854
|
input) => (await this.call('POST', `/v1/auth/oauth/${encodeURIComponent(provider)}/native`, input)).data,
|
|
787
855
|
},
|
|
788
856
|
};
|
|
@@ -1029,6 +1097,21 @@ export class Vxil {
|
|
|
1029
1097
|
* rolls the WHOLE transaction back (409 precondition_failed names the
|
|
1030
1098
|
* step). cms-internal only — no cross-feature effects inside the tx. */
|
|
1031
1099
|
transaction: async (steps) => (await this.call('POST', '/v1/cms/transactions', { steps })).data,
|
|
1100
|
+
/** P1-1 (cms.md §21): several get / query / create / patch / delete ops in
|
|
1101
|
+
* ONE round trip — the function-chain shape ("read 3 rows → patch 2 →
|
|
1102
|
+
* create 1" is one call, not six). ≤25 ops, each run through the SAME path
|
|
1103
|
+
* its single route uses (scopes, owner-scoping, hooks, guards, `if`,
|
|
1104
|
+
* `if_version`, `$inc`), each reporting its own status in `results` — a
|
|
1105
|
+
* batch never throws for a per-op failure; check `ok` / each `status`.
|
|
1106
|
+
* `atomic: true` runs everything in ONE tenant transaction: reads see
|
|
1107
|
+
* earlier writes and the first non-2xx op rolls every write back (the
|
|
1108
|
+
* undone ops answer `424 rolled_back`, the rest `424 not_run`). Default:
|
|
1109
|
+
* sequential, per-op status, no cross-op rollback. `ref` tags an op so
|
|
1110
|
+
* its result is easy to find. */
|
|
1111
|
+
batch: async (ops, opts) => (await this.call('POST', '/v1/cms/batch', {
|
|
1112
|
+
ops: ops.map((o) => ('expand' in o && o.expand !== undefined ? { ...o, expand: expandParam(o.expand) } : o)),
|
|
1113
|
+
...(opts?.atomic !== undefined ? { atomic: opts.atomic } : {}),
|
|
1114
|
+
})).data,
|
|
1032
1115
|
/** cms-rel B5 (cms.md §12.4): declared read-models (config `readModels`
|
|
1033
1116
|
* bag) — list with last-run status, and "run now" materialization into
|
|
1034
1117
|
* the rollup collection. */
|
|
@@ -1492,7 +1575,11 @@ export class Vxil {
|
|
|
1492
1575
|
* requests — the output is validated (with one repair pass) against it;
|
|
1493
1576
|
* a per-request `response_schema` overrides it. Ignored on streams. */
|
|
1494
1577
|
put: async (input) => (await this.call('POST', '/v1/ai/templates', input)).data,
|
|
1495
|
-
/** List stored templates (each name + its latest version).
|
|
1578
|
+
/** List stored templates (each name + its latest version). `content_sha256`
|
|
1579
|
+
* is the canonical hash of the latest version's {system, user, schema},
|
|
1580
|
+
* stored when the version was written — what `vxil push` compares a
|
|
1581
|
+
* declared `ai.templates[]` entry against; absent on a version stored
|
|
1582
|
+
* before 2026-09-23 (the next push re-puts that template once). */
|
|
1496
1583
|
list: async () => (await this.call('GET', '/v1/ai/templates')).data.templates,
|
|
1497
1584
|
},
|
|
1498
1585
|
/** Provider × capability matrix + the tenant's default provider (the set an
|
|
@@ -1852,3 +1939,6 @@ export class Vxil {
|
|
|
1852
1939
|
},
|
|
1853
1940
|
};
|
|
1854
1941
|
}
|
|
1942
|
+
// Failure reporting for tenant functions — the Sentry-envelope forwarder
|
|
1943
|
+
// (roadmap §4.11 P0-4e). Zero dependencies; see reporting.ts.
|
|
1944
|
+
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