@voltro/plugin-webhooks 0.29.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +448 -0
- package/dist/index.d.ts +170 -16
- package/dist/index.js +148 -109
- package/dist/mixin.d.ts +79 -2
- package/dist/mixin.js +11 -3
- package/dist/providers/index.d.ts +2 -6
- package/package.json +7 -7
package/dist/index.d.ts
CHANGED
|
@@ -7,11 +7,13 @@ import { Effect } from 'effect';
|
|
|
7
7
|
import { EventDescriptor } from '@voltro/protocol';
|
|
8
8
|
import { EventWebhookSpec } from '@voltro/protocol';
|
|
9
9
|
import { FieldDefinitions } from '@voltro/database';
|
|
10
|
+
import { OutboxHandlerDefinition } from '@voltro/runtime';
|
|
10
11
|
import { Schema } from 'effect';
|
|
11
12
|
import { Table } from '@voltro/database';
|
|
12
13
|
import { Workflow } from '@effect/workflow';
|
|
13
14
|
import { WorkflowEngine } from '@effect/workflow/WorkflowEngine';
|
|
14
15
|
import { WorkflowInstance } from '@effect/workflow/WorkflowEngine';
|
|
16
|
+
import { WorkflowRunHandle } from '@voltro/protocol';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Acquire one slot in EVERY scope for the window containing `now`
|
|
@@ -63,14 +65,6 @@ export declare const buildDeliverWebhookExecute: (ctx: AppContext, options?: Del
|
|
|
63
65
|
attempts: number;
|
|
64
66
|
}, never, WorkflowEngine | WorkflowInstance>;
|
|
65
67
|
|
|
66
|
-
/**
|
|
67
|
-
* Build a `WebhooksServiceShape` that uses the in-process store
|
|
68
|
-
* directly + an injected `trigger` for delivery. The `trigger` is
|
|
69
|
-
* the seam where the framework wires the actual @effect/workflow
|
|
70
|
-
* runner (stage 4). For tests and the very first dev-mode loop we
|
|
71
|
-
* accept a simple async function that resolves when the workflow
|
|
72
|
-
* has been kicked off (NOT when it completes).
|
|
73
|
-
*/
|
|
74
68
|
export declare const buildWebhooksService: (ctx: AppContext, trigger: (input: {
|
|
75
69
|
readonly deliveryId: string;
|
|
76
70
|
readonly targetId: string;
|
|
@@ -131,6 +125,13 @@ export declare const defaultOutgoingSignature: () => HmacSignatureScheme;
|
|
|
131
125
|
* 640s. Total ~17 minutes before giving up. */
|
|
132
126
|
export declare const defaultRetryPolicy: () => RetryPolicy;
|
|
133
127
|
|
|
128
|
+
/** The row payload. Deliberately flat and JSON-only — see the header. */
|
|
129
|
+
export declare interface DeferredEmitPayload {
|
|
130
|
+
readonly event: string;
|
|
131
|
+
readonly payload: unknown;
|
|
132
|
+
readonly tenantId: string | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
134
135
|
export declare const defineIncomingWebhook: <Body>(spec: Omit<IncomingWebhookDescriptor<Body>, "_tag">) => IncomingWebhookDescriptor<Body>;
|
|
135
136
|
|
|
136
137
|
export declare const defineWebhookProvider: (spec: Omit<WebhookProviderDescriptor, "_tag">) => WebhookProviderDescriptor;
|
|
@@ -219,6 +220,30 @@ export declare type DurationLiteral = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
|
|
|
219
220
|
export declare interface EmitOptions {
|
|
220
221
|
/** Only deliver to targets of this tenant. Absent ⇒ every target. */
|
|
221
222
|
readonly tenantId?: string | null;
|
|
223
|
+
/**
|
|
224
|
+
* Dispatch NOW instead of after the enclosing transaction commits.
|
|
225
|
+
*
|
|
226
|
+
* Inside a mutation, `emit` defaults to a transactional enqueue: the intent
|
|
227
|
+
* commits with your rows or rolls back with them, and the POST goes out
|
|
228
|
+
* afterwards. That is the right default and it is not the right answer
|
|
229
|
+
* everywhere — a diagnostic ping, or an emit whose receiver the caller is
|
|
230
|
+
* about to poll, wants the request on the wire immediately.
|
|
231
|
+
*
|
|
232
|
+
* It does NOT make the emit safe. A rollback after an immediate emit still
|
|
233
|
+
* tells a subscriber about a change that did not happen. The point of the flag
|
|
234
|
+
* is that the trade is written at the call site: the old behaviour was
|
|
235
|
+
* un-transactional by accident, and nothing said so.
|
|
236
|
+
*
|
|
237
|
+
* Outside a mutation this is a no-op — there is no transaction to skip.
|
|
238
|
+
*
|
|
239
|
+
* **Read by `ctx.webhooks`, not by this service.** The deferral lives in the
|
|
240
|
+
* request-scoped proxy (`cli/src/requestWebhooks.ts`), because only the
|
|
241
|
+
* request knows whether there is a transaction; the boot-level service is
|
|
242
|
+
* always immediate by construction. The option is declared here because this
|
|
243
|
+
* is the type a caller sees — but calling the boot service directly with it
|
|
244
|
+
* changes nothing, and that is the truthful behaviour rather than a gap.
|
|
245
|
+
*/
|
|
246
|
+
readonly immediate?: boolean;
|
|
222
247
|
}
|
|
223
248
|
|
|
224
249
|
export declare interface EmitResult {
|
|
@@ -410,12 +435,7 @@ export declare interface IncomingWebhookDescriptor<Body> {
|
|
|
410
435
|
}
|
|
411
436
|
|
|
412
437
|
export declare interface IncomingWorkflowFacade {
|
|
413
|
-
start<Payload = unknown>(workflowName: string, payload: Payload): Promise<
|
|
414
|
-
readonly id: string;
|
|
415
|
-
readonly workflowName: string;
|
|
416
|
-
readonly executionId: string;
|
|
417
|
-
readonly status: 'running';
|
|
418
|
-
}>;
|
|
438
|
+
start<Payload = unknown>(workflowName: string, payload: Payload): Promise<WorkflowRunHandle>;
|
|
419
439
|
signal(target: {
|
|
420
440
|
readonly id?: string;
|
|
421
441
|
readonly executionId?: string;
|
|
@@ -442,6 +462,33 @@ export declare interface IncomingWorkflowFacade {
|
|
|
442
462
|
* must default-export one of these. */
|
|
443
463
|
export declare const isWebhookDescriptor: (value: unknown) => value is WebhookDescriptor;
|
|
444
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Build the handler that performs a deferred emit after commit.
|
|
467
|
+
*
|
|
468
|
+
* `emitter` is the BOOT-level service — the same object `ctx.webhooks` proxies.
|
|
469
|
+
* By the time this runs the transaction is gone, so the tenant cannot be read
|
|
470
|
+
* from a request subject and travels in the row instead.
|
|
471
|
+
*
|
|
472
|
+
* `maxAttempts` is 1 on purpose and it is not a shortcut. The work here is
|
|
473
|
+
* "resolve targets and start a delivery workflow"; the DELIVERY itself already
|
|
474
|
+
* has its own retry policy per target, with backoff, a delivery-history table
|
|
475
|
+
* and a dashboard replay button. Retrying the fan-out on top would multiply the
|
|
476
|
+
* two schedules together and produce duplicate delivery rows for one emit —
|
|
477
|
+
* retrying a retry is how a webhook storm starts. What the outbox adds here is
|
|
478
|
+
* the guarantee that the fan-out HAPPENS at all, which is precisely the crash
|
|
479
|
+
* window the after-commit callback left open.
|
|
480
|
+
*/
|
|
481
|
+
export declare const makeDeferredEmitHandler: (
|
|
482
|
+
/**
|
|
483
|
+
* Resolved LAZILY, and it has to be. `voltro dev` starts the outbox runner
|
|
484
|
+
* before it builds the webhooks service (the service needs a trigger context
|
|
485
|
+
* that needs the workflow engine), while `voltro serve` builds them the other
|
|
486
|
+
* way round. A thunk is the one shape both orders can register, so the two
|
|
487
|
+
* boot paths cannot disagree about whether this handler exists — which is the
|
|
488
|
+
* drift class this repo keeps re-learning.
|
|
489
|
+
*/
|
|
490
|
+
emitter: () => Pick<WebhooksServiceShape, "emit"> | undefined) => OutboxHandlerDefinition;
|
|
491
|
+
|
|
445
492
|
/** Evaluate a target's filter against a payload. Returns `true`
|
|
446
493
|
* when the target should receive this delivery. Supports the
|
|
447
494
|
* simple key-path form `'payload.field': value` for v1; future
|
|
@@ -592,6 +639,7 @@ forEvent?: string, sharedSecret?: string) => {
|
|
|
592
639
|
readonly autoDisableReason: string | null;
|
|
593
640
|
readonly payloadVersion: number;
|
|
594
641
|
readonly description: string | null;
|
|
642
|
+
readonly tenantId: string | null;
|
|
595
643
|
};
|
|
596
644
|
|
|
597
645
|
export declare interface RetryDecision {
|
|
@@ -654,6 +702,30 @@ export declare const stripeSignature: (header?: string) => HmacSignatureScheme;
|
|
|
654
702
|
export declare interface SubscribeInput {
|
|
655
703
|
/** A single event. Use `events` for a multi-event subscription. */
|
|
656
704
|
readonly event?: string;
|
|
705
|
+
/**
|
|
706
|
+
* The tenant this subscription belongs to.
|
|
707
|
+
*
|
|
708
|
+
* **You should not normally pass this.** `ctx.webhooks.subscribe(...)` binds it
|
|
709
|
+
* from the acting subject, exactly as `emit` binds `EmitOptions.tenantId` — it
|
|
710
|
+
* is here for the same reason the read side has it, and for the admin tooling
|
|
711
|
+
* the error message names.
|
|
712
|
+
*
|
|
713
|
+
* It exists because the write path had NO binding at all and no way to supply
|
|
714
|
+
* one. `_voltro_webhook_targets` carries `.with(tenant())`, the mixin scopes by
|
|
715
|
+
* the REQUEST subject, and this service is built once at boot with the app-level
|
|
716
|
+
* store and no subject. So `subscribe` from an authenticated executor died with
|
|
717
|
+
* `TenantScopeViolation: cannot insert into tenant-scoped table without an
|
|
718
|
+
* authenticated tenant` — and both escapes that message offered were
|
|
719
|
+
* unreachable: you cannot "authenticate first" (the service is subject-less by
|
|
720
|
+
* construction) and you could not "pass tenantId explicitly" (this field did not
|
|
721
|
+
* exist).
|
|
722
|
+
*
|
|
723
|
+
* A consumer hit it on every subscribe for the life of the feature. The
|
|
724
|
+
* consequence worth recording: their `count(*) FROM _voltro_webhook_targets = 0`
|
|
725
|
+
* did not mean "unused", it meant "never worked" — and both sides read that zero
|
|
726
|
+
* as reassurance.
|
|
727
|
+
*/
|
|
728
|
+
readonly tenantId?: string | null;
|
|
657
729
|
readonly url: string;
|
|
658
730
|
/** Secret used to sign deliveries to this target. Auto-generated
|
|
659
731
|
* when omitted (32-byte hex). The caller receives it ONCE in the
|
|
@@ -857,7 +929,7 @@ export declare const _voltroWebhookDeliveriesTable: Table<"_voltro_webhook_deliv
|
|
|
857
929
|
readonly eventId: ColumnBuilder<string | null, "text", boolean>;
|
|
858
930
|
/** Attempt counter (1-indexed). */
|
|
859
931
|
readonly attempt: ColumnBuilder<number, "integer", boolean>;
|
|
860
|
-
readonly status: ColumnBuilder<"failed" | "succeeded" | "
|
|
932
|
+
readonly status: ColumnBuilder<"failed" | "succeeded" | "inFlight" | "pending" | "retryScheduled", "text", boolean>;
|
|
861
933
|
/** Payload as sent over the wire. Stored verbatim — re-rendering
|
|
862
934
|
* from a referenced event row would lose the snapshot if the
|
|
863
935
|
* source event was deleted. */
|
|
@@ -892,6 +964,60 @@ export declare const _voltroWebhookDeliveriesTable: Table<"_voltro_webhook_deliv
|
|
|
892
964
|
readonly updatedBy: ColumnDefinition<string | null, "reference", boolean>;
|
|
893
965
|
}, true, never>;
|
|
894
966
|
|
|
967
|
+
/**
|
|
968
|
+
* ONE row per declared event, stamped every time `emit` runs.
|
|
969
|
+
*
|
|
970
|
+
* **Why this is not derivable from `_voltro_webhook_deliveries`, which is the
|
|
971
|
+
* whole reason the table exists.** A delivery row is written when an emit MATCHES
|
|
972
|
+
* a target. So "no delivery rows" conflates three different facts:
|
|
973
|
+
*
|
|
974
|
+
* 1. no `emit(...)` call site exists, or none ever ran ← the defect
|
|
975
|
+
* 2. it ran, but nobody was subscribed yet
|
|
976
|
+
* 3. it ran, but every target's `filter` excluded the payload, or every
|
|
977
|
+
* target was paused
|
|
978
|
+
*
|
|
979
|
+
* Only (1) is a bug, and it is the one a consumer spent a week finding by hand:
|
|
980
|
+
* seven of eleven advertised events had no emit call site anywhere. Reading (2)
|
|
981
|
+
* or (3) as (1) turns a working integration into a false alarm; reading (1) as
|
|
982
|
+
* (2) hides it. Delivery history also ages out — `_voltro_webhook_deliveries`
|
|
983
|
+
* carries a 90-day retention — so an event emitted correctly and quietly can
|
|
984
|
+
* decay into looking dead.
|
|
985
|
+
*
|
|
986
|
+
* This row is written REGARDLESS of whether any target matched, which is
|
|
987
|
+
* precisely the axis history cannot see. The dashboard shows both, labelled,
|
|
988
|
+
* and their disagreement is itself the useful signal: emitted but never
|
|
989
|
+
* delivered means every target is paused, filtered out, or failing.
|
|
990
|
+
*
|
|
991
|
+
* Deliberately NOT tenant-scoped. The question is "does this event have a live
|
|
992
|
+
* call site in this deployment", which is a property of the CODE, not of a
|
|
993
|
+
* tenant's data — and scoping it per tenant would make an event look dead for
|
|
994
|
+
* every tenant that has not happened to trigger it yet.
|
|
995
|
+
*/
|
|
996
|
+
export declare const _voltroWebhookEventStatsTable: Table<"_voltro_webhook_event_stats", FieldDefinitions<{
|
|
997
|
+
/**
|
|
998
|
+
* A GENERATED id. The event name is the natural key and it is deliberately
|
|
999
|
+
* NOT reused here.
|
|
1000
|
+
*
|
|
1001
|
+
* An `id()` column is `VARCHAR(64)` on MySQL/MariaDB (`applier.ts`), while an
|
|
1002
|
+
* event name is allowed 191. Using the name as the id would make a namespaced
|
|
1003
|
+
* event longer than 64 characters fail its insert — and because the stats
|
|
1004
|
+
* write is best-effort and swallows every error, it would fail SILENTLY and
|
|
1005
|
+
* the dashboard would report "never emitted" for a live event. That is
|
|
1006
|
+
* precisely the false positive this table exists to remove, reintroduced by
|
|
1007
|
+
* its own primary key, on exactly the dialect that reported the original
|
|
1008
|
+
* defect.
|
|
1009
|
+
*/
|
|
1010
|
+
readonly id: ColumnBuilder<string, "id", boolean>;
|
|
1011
|
+
/** The natural key. Unique, so a concurrent double-insert loses rather than
|
|
1012
|
+
* duplicating the row. */
|
|
1013
|
+
readonly event: ColumnBuilder<string, "text", boolean>;
|
|
1014
|
+
/** Total emits seen, including those that matched no target. */
|
|
1015
|
+
readonly emitCount: ColumnBuilder<number, "integer", true>;
|
|
1016
|
+
readonly lastEmitAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1017
|
+
readonly createdAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1018
|
+
readonly updatedAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1019
|
+
}>, true, never>;
|
|
1020
|
+
|
|
895
1021
|
/**
|
|
896
1022
|
* Fixed-window rate-limit counters — one row per (scope, minute
|
|
897
1023
|
* bucket), where scope is `target:<targetId>` (per-target
|
|
@@ -1029,6 +1155,11 @@ export declare const _voltroWebhookTargetsTable: Table<"_voltro_webhook_targets"
|
|
|
1029
1155
|
readonly updatedBy: ColumnDefinition<string | null, "reference", boolean>;
|
|
1030
1156
|
}, true, never>;
|
|
1031
1157
|
|
|
1158
|
+
/** The effect name. Namespaced under `voltro.` because it is framework-owned:
|
|
1159
|
+
* an app's own `*.outbox.ts` may not claim it, and the loader's
|
|
1160
|
+
* duplicate-effect check is what enforces that. */
|
|
1161
|
+
export declare const WEBHOOK_EMIT_EFFECT = "voltro.webhook.emit";
|
|
1162
|
+
|
|
1032
1163
|
/**
|
|
1033
1164
|
* Thrown by `WebhooksService.replay` when no delivery row exists for the
|
|
1034
1165
|
* given `deliveryId` — the row can't be re-triggered because the
|
|
@@ -1409,7 +1540,7 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
|
|
|
1409
1540
|
readonly eventId: ColumnBuilder<string | null, "text", boolean>;
|
|
1410
1541
|
/** Attempt counter (1-indexed). */
|
|
1411
1542
|
readonly attempt: ColumnBuilder<number, "integer", boolean>;
|
|
1412
|
-
readonly status: ColumnBuilder<"failed" | "succeeded" | "
|
|
1543
|
+
readonly status: ColumnBuilder<"failed" | "succeeded" | "inFlight" | "pending" | "retryScheduled", "text", boolean>;
|
|
1413
1544
|
/** Payload as sent over the wire. Stored verbatim — re-rendering
|
|
1414
1545
|
* from a referenced event row would lose the snapshot if the
|
|
1415
1546
|
* source event was deleted. */
|
|
@@ -1455,6 +1586,29 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
|
|
|
1455
1586
|
readonly count: ColumnBuilder<number, "integer", true>;
|
|
1456
1587
|
readonly createdAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1457
1588
|
readonly updatedAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1589
|
+
}>, true, never>, Table<"_voltro_webhook_event_stats", FieldDefinitions<{
|
|
1590
|
+
/**
|
|
1591
|
+
* A GENERATED id. The event name is the natural key and it is deliberately
|
|
1592
|
+
* NOT reused here.
|
|
1593
|
+
*
|
|
1594
|
+
* An `id()` column is `VARCHAR(64)` on MySQL/MariaDB (`applier.ts`), while an
|
|
1595
|
+
* event name is allowed 191. Using the name as the id would make a namespaced
|
|
1596
|
+
* event longer than 64 characters fail its insert — and because the stats
|
|
1597
|
+
* write is best-effort and swallows every error, it would fail SILENTLY and
|
|
1598
|
+
* the dashboard would report "never emitted" for a live event. That is
|
|
1599
|
+
* precisely the false positive this table exists to remove, reintroduced by
|
|
1600
|
+
* its own primary key, on exactly the dialect that reported the original
|
|
1601
|
+
* defect.
|
|
1602
|
+
*/
|
|
1603
|
+
readonly id: ColumnBuilder<string, "id", boolean>;
|
|
1604
|
+
/** The natural key. Unique, so a concurrent double-insert loses rather than
|
|
1605
|
+
* duplicating the row. */
|
|
1606
|
+
readonly event: ColumnBuilder<string, "text", boolean>;
|
|
1607
|
+
/** Total emits seen, including those that matched no target. */
|
|
1608
|
+
readonly emitCount: ColumnBuilder<number, "integer", true>;
|
|
1609
|
+
readonly lastEmitAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1610
|
+
readonly createdAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1611
|
+
readonly updatedAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
1458
1612
|
}>, true, never>];
|
|
1459
1613
|
|
|
1460
1614
|
export declare type WireFormat = 'json' | 'form' | 'xml';
|