@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/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" | "pending" | "inFlight" | "retryScheduled", "text", boolean>;
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" | "pending" | "inFlight" | "retryScheduled", "text", boolean>;
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';