@voltro/plugin-webhooks 0.22.1 → 0.23.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/CHANGELOG.md +316 -0
- package/dist/index.d.ts +177 -0
- package/dist/index.js +134 -134
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,322 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.23.0] — 2026-08-02
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/plugin-versioning, @voltro/database, @voltro/cli** — **`versioningPlugin({ tables: string[] })` is gone. Row history is ON by default for every table your app declares, and the two escape hatches take table VALUES.**
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
versioningPlugin({}) // every app table
|
|
50
|
+
versioningPlugin({ exclude: [domainEvents] }) // opt one out — by value
|
|
51
|
+
versioningPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The old shape had two failure modes and both were silent:
|
|
55
|
+
|
|
56
|
+
- you listed six tables, forgot the seventh, and nothing ever told you its history was missing; - nothing cross-checked the strings, so `'invoces'` recorded nothing — forever — while the plugin reported itself active at boot.
|
|
57
|
+
|
|
58
|
+
Opt-out fixes the first (forgetting is now the safe direction) and values fix the second (`tsc` catches a misspelling at the call site, exactly as it does for `reference(() => table)`).
|
|
59
|
+
|
|
60
|
+
**Framework- and plugin-owned tables are OUT of the default**, and that is not tidiness. There are 34 of them, and the busiest — `_voltro_cdc_log`, `_voltro_events`, `_voltro_undo_log`, `_voltro_workflow_events`, `_voltro_webhook_rate_windows` — are append-only logs. A full row snapshot per write there is the history of a history, at the highest write rate in the system. `include` is the supported way to version one anyway, and it works whether or not your app declares the table — which answers "can I version a plugin's table I do not own": yes.
|
|
61
|
+
|
|
62
|
+
**The set resolves LAZILY, on first use.** `versioningPlugin()` is called in `app.config.ts`, before a single table has registered; resolving at construction would produce an empty set and record nothing, silently, which is the defect this change removes. Both boot paths register the app's tables during discovery and activate plugins afterwards.
|
|
63
|
+
|
|
64
|
+
A table named in BOTH `include` and `exclude` throws at construction rather than picking one — only the author knows which was the mistake.
|
|
65
|
+
|
|
66
|
+
The boot log prints the **resolved** count (`versioning active · tables: N`), not the configured one: with an opt-out default, "how many did I configure" is not a number anybody has, and "how many am I recording" is the one worth seeing.
|
|
67
|
+
|
|
68
|
+
**Check your storage budget once after upgrading.** If you previously versioned three tables out of forty, you now version forty. The retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) still bounds age.
|
|
69
|
+
|
|
70
|
+
`isFrameworkOwnedLiveTable` is now exported from `@voltro/database` — one copy of that rule, since a second copy of it is how a per-dialect difference in what `voltro dev` does got shipped once already.
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- **@voltro/cli, @voltro/plugin-ai-flows, @voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-versioning, @voltro/plugin-webhooks** — **The direction into a plugin's table — first half.** There were two doors OUT of a plugin's schema (`tables: false` on rbac, `alias` on ai-flows) and none in, so an app with grown data either ran a second source of truth beside the framework or did not use the plugin. A consumer named the cost: five plugins unused, not one of them because the plugin was worse than what they had.
|
|
75
|
+
|
|
76
|
+
`planAdopt` decides whether a move is safe and in what order it must run — the half that costs hours when you get it wrong, and the half that needs no database. It refuses three things rather than guessing:
|
|
77
|
+
|
|
78
|
+
- **a NOT NULL target column nobody maps to.** The alternative is a silent zero that reads as real data forever after. - **a target table that already holds rows.** Adopt MOVES rows into a table; it does not merge into one somebody else already wrote. - **a typo on either side of the map.**
|
|
79
|
+
|
|
80
|
+
And it states, before anything runs, the thing that is expensive to discover late: differing typeid prefixes (`afl_` → `aifl_`) mean every row gets a new id, so every reference elsewhere must be rewritten from a translation table — **including ids embedded in JSON columns**, which is where the reporter's own hand-written migration had its hardest step.
|
|
81
|
+
|
|
82
|
+
A source column nobody carries across is reported but not fatal: it is deliberate often enough, and "I forgot this column" and "I decided" look identical in a map file.
|
|
83
|
+
|
|
84
|
+
The field mapping itself stays the app's — units, merged fields, a status vocabulary that does not line up are domain knowledge, and a tool inventing them silently corrupts data.
|
|
85
|
+
|
|
86
|
+
**The move itself ships with it**, behind `voltro db adopt --from … --into … --map … [--apply]`. **Dry run by default** — `--apply` is the only way anything is written, because the interesting failure is irreversible and the interesting output is the refusal. A refused plan prints no steps at all, rather than a preview of something that will not happen.
|
|
87
|
+
|
|
88
|
+
The ordering is the product, not the SQL, and every step is there because skipping it loses data you find out about later:
|
|
89
|
+
|
|
90
|
+
1. **snapshot** the source into `<table>__adopt_snapshot` — a real table in the same database, so the restore path is a statement rather than an operational procedure at 2am. It keeps the columns the adopt deliberately left behind. 2. **copy**, with the mapping's raw expressions. 3. **verify by count** — this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows. 4. **drop the source, last**, and only if the counts match.
|
|
91
|
+
|
|
92
|
+
Two things it refuses to do, both because the alternative is a silent partial state: it never drops the source on a count mismatch (both tables stay, and it says so), and it never removes the snapshot after a failed verify — the snapshot exists for exactly the run that goes wrong. `--keep-source` copies and verifies without dropping at all.
|
|
93
|
+
|
|
94
|
+
Verified against live postgres (`sql-postgres/__tests__/adoptExecute.integration.test.ts`): the rows move, a unit conversion and a two-field merge come out right, the snapshot holds the originals including the dropped column, a failed adopt leaves the source standing, and a refused plan runs nothing.
|
|
95
|
+
|
|
96
|
+
**Reference rewriting after an id re-mint is deliberately NOT automatic.** The ids live in the app's own columns and inside its JSON, and only the app knows where. The translation table is what we owe it; the rewrite is what it owes itself. Doing that automatically is the one place in this command where being wrong would be silent.
|
|
97
|
+
|
|
98
|
+
Also in this drop, from the same report: every table-carrying plugin exports its table handles, so `reference(() => pluginTable, { onDelete: 'cascade' })` works across the boundary with database-enforced integrity — verified by a planner test against the real `_voltro_ai_flows`, including that the plugin table is created before the app table that points at it.
|
|
99
|
+
- **@voltro/cli** — **`voltro doctor` reports where a plugin's surface meets one the app already has.** An app that did not start on a green field already has a table for half the plugins it installs, and whether it uses them is decided at that seam — which the framework knew both sides of at boot and said nothing about.
|
|
100
|
+
|
|
101
|
+
A consumer measured it across eleven table-carrying plugins: eight model a concept they already had a table for, and every overlap was found when it hurt — `rbac` at the role model, `notifications` on switch-on, `ai-flows` at a blocked boot. Half an hour to several hours of diagnosis, three times.
|
|
102
|
+
|
|
103
|
+
Three findings, all exact:
|
|
104
|
+
|
|
105
|
+
- **a plugin table whose `.renamedFrom()` names a table you declare** — saying explicitly that the plugin's empty table is the INTENDED outcome and not a failed migration, which is the sentence that was missing; - **an exact rpc tag collision** — already fatal at codegen, named here because the codegen error does not mention that `alias` is the way out; - **a shared rpc namespace** — advisory. It is what makes a plugin unusable without anyone noticing: your `notifications.list` and its `notifications.inbox` coexist while one namespace means two things.
|
|
106
|
+
|
|
107
|
+
Deliberately exact, with no name-similarity guessing: a fuzzy matcher over 27 plugin tables produces the noise that gets a check switched off, which is how the authz scan became ignorable on that same repo. The advice names `tables: false` / `alias` only for plugins that actually accept them.
|
|
108
|
+
|
|
109
|
+
Also confirmed while answering the same report, and pinned by test: `versioningPlugin({ tables: [...] })` already works on a plugin-owned table the app never declares — it watches by NAME and contributes only its own history table. Nothing validates those names, so a typo silently records nothing; that is the cost of the decoupling and it is now stated.
|
|
110
|
+
- **@voltro/data-transfer** — **A bundle can be imported into a schema that has moved on.** `classifyImportDrift` compares what a bundle carries against what the target declares and classifies each difference the way `db plan` classifies schema operations, instead of the one all-or-nothing fingerprint comparison that came before.
|
|
111
|
+
|
|
112
|
+
| difference | verdict | |---|---| | a column the SCHEMA dropped | values discarded — said out loud, and the loader skips it | | a NULLABLE / DEFAULTED column the schema added | filled, not refused | | a column whose TYPE changed | **refused** | | a NOT NULL column with no default the bundle cannot fill | **refused** | | a table the target does not have | **refused** — nowhere to put the rows | | a table only the target has | not drift (a `--tables` scope, or added since) |
|
|
113
|
+
|
|
114
|
+
Before this, a bundle exported before a column was added could not be imported at all, even though the difference was additive and harmless. The only escape was `--force`, which this package's own doc comment describes as failing "mid-load with raw DB errors after rows may have landed" — an escape hatch that trades a clean refusal for a dirty one.
|
|
115
|
+
|
|
116
|
+
The line it draws is the one a transport primitive has to draw: a row that lands INCOMPLETE is recoverable and is reported; a row that lands WRONG is not, so a changed column type refuses. That is the same distinction the reporter praised in the planner — additive is safe, the destructive one is blocked with the remedy in the message.
|
|
117
|
+
|
|
118
|
+
**It does not replay authored data migrations, and should not.** A bundle carries no migration ledger, so ordered data steps stay on the physical path (`data restore` → `db apply`), where the restored database brings its own `_voltro_migration_plans` and the diff moves forward from there — which is exactly what the report concluded and demonstrated row by row.
|
|
119
|
+
- **@voltro/web, @voltro/cli** — **`LoaderContext.search`** — the raw query string (leading `?` included, `''` when absent), filled identically on client navigation, `voltro dev` SSR and `voltro start` SSR.
|
|
120
|
+
|
|
121
|
+
`pathname` is query-free by contract, and for DATA that is right — a loader keyed on `?tab=2` caches badly. It is wrong for CONTROL FLOW, which is what a loader does since 0.22.0 made it throw `RedirectError` correctly: a redirect target routinely depends on a query parameter, so **the only place a redirect belongs was the only place with no access to one**.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const mode = new URLSearchParams(ctx.search).get('mode')
|
|
125
|
+
throw new RedirectError(`/?error=${code}${mode ? `&mode=${mode}` : ''}`)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The reported case: a player page redirects an unknown wristband code back to the entry page and must preserve `?mode=kiosk`, or a kiosk terminal drops to normal mode after every failed scan. Both workarounds are bad — moving the redirect into a component gives up the 303 (back to what 0.22.0 just fixed), and `window.location.search` exists only on the client-navigation path, so a fresh SSR request loses it.
|
|
129
|
+
|
|
130
|
+
**The testability half is why it is a FIELD and not advice.** Because `pathname` is a free-form string in the spec, their loader test passed `'/evo5/abc?mode=kiosk'` — a shape the runtime never produces — and was green for as long as production dropped the parameter on every request. In their words: *wo der Harness etwas liefern kann, das die Laufzeit nicht hat, wird ein kaputter Pfad grün.* A separate field makes that mistake impossible rather than unlikely.
|
|
131
|
+
|
|
132
|
+
Both SSR paths derive it through one shared `splitPathAndSearch`, with a test that fails if either grows its own copy back — a two-line `url.split('?')` is exactly what two independent boot paths write for themselves and then disagree about. A prerendered (SSG) page has no request, so its `search` is `''`.
|
|
133
|
+
- **@voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-flags, @voltro/plugin-versioning, @voltro/plugin-webhooks, @voltro/cli** — **Every table-carrying plugin now exports its table handles, so an app can point a column at a plugin row.**
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { aiFlowsTable } from '@voltro/plugin-ai-flows'
|
|
137
|
+
|
|
138
|
+
export const flowFavourites = table('flow_favourites', {
|
|
139
|
+
id: id({ prefix: 'fav' }),
|
|
140
|
+
flowId: reference(() => aiFlowsTable, { onDelete: 'cascade' }),
|
|
141
|
+
})
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A consumer measured **711** app→app references against **2** app→plugin ones and diagnosed it exactly: *"Das liegt nicht daran, dass man selten auf Plugin-Zeilen zeigen will. Es liegt daran, dass es dafür kein Muster gibt — und man deshalb aufhört, es zu wollen."*
|
|
145
|
+
|
|
146
|
+
**The pattern existed and was unreachable.** `plugin-storage`'s `assetRef()` is, by default, a real foreign key to `_voltro_storage_refs` with `onDelete: 'setNull'` — database-enforced integrity across the plugin boundary, shipping since it was written. It was simply impossible for every plugin that kept its `table(...)` handles module-local: `notifications` declared six as private `const`, `presence` one, and `flags` / `versioning` / `webhooks` exported theirs from a module but not from the package entrypoint.
|
|
147
|
+
|
|
148
|
+
So this needed no new primitive and no new machinery — it needed the `export` keyword in seven places. `pluginTableExports.test.ts` fails on the eighth: a plugin whose tables nobody can name is a plugin nobody can point at, and that is invisible, because everything still compiles while the app quietly writes a plain `text()` column plus a hand-rolled cleanup subscriber.
|
|
149
|
+
|
|
150
|
+
**Two corrections that came out of building it**, because designing on the stated model would have produced the wrong thing:
|
|
151
|
+
|
|
152
|
+
- **`orphanPolicy` has no runtime semantics.** Its own doc comment says so — it is planner metadata deciding how existing orphans are cleaned up *before* the FK constraint is added. Runtime referential integrity comes from the FOREIGN KEY (`onDelete`), executed by the database. A proposal to have "the framework execute the orphan policy over the post-commit channel" described machinery that does not exist and did not need to. - **A foreign key across the plugin boundary survives the plugin renaming its table.** Referencing the table as a VALUE is what makes that true; the 0.22.0 `_voltro_` namespace move was catalog-only and the constraint travelled with it. A `text()` column holding ids would have told you nothing.
|
|
153
|
+
|
|
154
|
+
`fk: false`-style decoupling remains available — declare a plain `text()` column — but it should be a deliberate choice, not the default that an unreachable handle forces.
|
|
155
|
+
- **@voltro/runtime, @voltro/cli** — **`serveApi` / `startRpcServer` take a `host`.** Absent → the wildcard, which is what a container needs and stays the default. It exists because of what a wildcard bind does to a server that its OWN process then connects to.
|
|
156
|
+
|
|
157
|
+
**The bug it closes had been read as "flaky tests" for eight occurrences.** A test boots a server with `{ port: 0 }`, fetches it, and the fetch never returns — the test dies at its timeout on an operation that takes 20ms. It moved between files and packages every time, which is what made it look like machine contention.
|
|
158
|
+
|
|
159
|
+
It is not. A wildcard bind lands on `:::<port>` — IPv6. The client fetches `127.0.0.1:<port>` — IPv4. Those are two independent binds of the same number, so a lingering IPv4 socket on that port takes the connection instead: the kernel completes the handshake into ITS backlog, `lsof` reports `ESTABLISHED`, and the server under test never receives a `connection` event. The request then waits against a peer that will never answer.
|
|
160
|
+
|
|
161
|
+
**Every symptom follows from that**, including the ones that made "the machine is busy" look right: it needs earlier files in the same process (they leave the IPv4 sockets), it is intermittent (an ephemeral-port collision), and a diagnostic report taken mid-hang shows an idle event loop with an empty JavaScript stack — because there is genuinely nothing to run. It reproduces at rest, roughly one run in nine, with no docker stack and a load average of 3, and it has failed on a dedicated CI runner.
|
|
162
|
+
|
|
163
|
+
Found by instrumenting `net.Server.prototype.listen` and catching a hung run: `listener#3 bound :::53011 … closed after 0 connection(s)` while its client sat in `fetch`. That instrument ships behind `VOLTRO_TEST_DIAG=1` (`packages/cli/src/integrationDiagnostics.ts`) together with the harness-level fix — a port-0 bind with no host goes to the loopback, so server and client share an address family and a collision becomes an ordinary `EADDRINUSE` at bind time instead of a silent hang.
|
|
164
|
+
|
|
165
|
+
Measured after: **0 failures in 25 consecutive runs** of the suite that previously failed about one run in nine.
|
|
166
|
+
|
|
167
|
+
Production is untouched: the wildcard is still the default, and nothing here runs outside a test process.
|
|
168
|
+
|
|
169
|
+
### Fixed
|
|
170
|
+
|
|
171
|
+
- **@voltro/database, @voltro/sql-postgres** — **`cdcChannel` was an option that did nothing.** Setting it produced zero change events and zero errors.
|
|
172
|
+
|
|
173
|
+
The store read it and issued `LISTEN <channel>`. The DDL never received it: `emitSchemaSql` hardcoded `pg_notify('framework_changes', …)` inside the trigger function. So a store configured with its own channel listened somewhere nobody ever sent, and — because a NOTIFY with no listener is not an error — nothing said so. Measured before the fix:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
cdcChannel=(default) → events received: 1
|
|
177
|
+
cdcChannel=my_own_channel → events received: 0
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
It could not have worked even with the channel threaded through, because the trigger function had ONE database-global name. `CREATE OR REPLACE FUNCTION framework_notify_change()` is a single `pg_proc` row, so two schemas applied with different channels overwrote each other and the last one won — everything applied earlier then emitted on somebody else's channel, silently. The function and the per-table trigger are both named after the channel now, so channels coexist. The default keeps its old names, so nothing existing is renamed.
|
|
181
|
+
|
|
182
|
+
`applySchema` / `emitSchemaSql` / `emitFrameworkBootstrapSql` take the channel as an optional third argument defaulting to `DEFAULT_CDC_CHANNEL` (now exported, so the store and the DDL cannot drift apart again). Pass the SAME value to the store and to `applySchema`: they are two halves of one contract, and giving only one still yields silence.
|
|
183
|
+
|
|
184
|
+
**Why it surfaced now.** `cdcAttribution.integration.test.ts` failed twice in CI with `delivered 0×` and never once locally. Forty test files write to that one postgres in a gate run, and on the shared default channel every NOTIFY they emit lands in this suite's consumer — its assertions depended on traffic it does not control. It now uses a per-run channel and is hermetic by construction rather than by luck. Verified: 4/4 in the suite, 101/101 in `sql-postgres`, 1335/1335 in `database`.
|
|
185
|
+
|
|
186
|
+
Stated plainly because the earlier attempt at this failure was not: raising that suite's delivery wait from 10s to 20s was tried first and changed nothing, which is what a patience bound does when the problem is not patience.
|
|
187
|
+
- **@voltro/cli, @voltro/data-transfer** — **`voltro db apply` never ran file-based migrations, and the deployment topology we recommend has no other path that does.** A data step authored in `migrations/` would never execute in staging or production — silently, because the planner still converged the schema, so the Job went green and the deploy succeeded.
|
|
188
|
+
|
|
189
|
+
A consumer mapped it exactly while working out how a months-old dump lands on today's schema:
|
|
190
|
+
|
|
191
|
+
| command | ran `migrations/*.ts`? | |---|---| | `voltro dev` (boot) | yes, before the diff | | `voltro db files` | yes | | `voltro db apply` / `--plan` | **no** | | `voltro serve` | no (fingerprint check only) |
|
|
192
|
+
|
|
193
|
+
Their pipeline is the documented one — a pre-upgrade Job running `db plan --json` → `db apply --plan`, pods on `voltro serve`. Nothing in it ran a file migration. And file migrations are the escape hatch for precisely what a state diff cannot infer (table splits, cross-table data moves, USING-expression type changes), which makes them exactly the steps whose absence a schema diff cannot detect: the shape is right either way.
|
|
194
|
+
|
|
195
|
+
**Two different answers, because the two paths are not the same problem.**
|
|
196
|
+
|
|
197
|
+
- **`db apply`** diffs live, so it now runs pending file migrations FIRST and then diffs — the same order boot uses, with nothing to invalidate. If one fails, the diff does not run: a half-migrated database with the schema already reshaped underneath it is harder to reason about than one that stopped where it broke. - **`db apply --plan`** applies a plan computed and REVIEWED against an earlier state, so it **refuses** when any are pending, before touching anything. Running them first would reshape the schema and trip the fingerprint guard immediately after — a half-applied deploy plus a drift message the operator did not cause. Running them after would apply a plan reviewed against a state that no longer exists. The refusal names the three commands that recover it, because a message that stops a deploy without restarting it is half a message.
|
|
198
|
+
|
|
199
|
+
### Also from the same report
|
|
200
|
+
|
|
201
|
+
**`data backup` says what it did not do.** It runs the native dump and nothing else, while this module's own header claimed it reused "the shared content-addressed asset pipeline for blobs" — true of the logical `data export --assets`, never of a native backup. The consumer had retired the system this data came from, which made that artifact their entire rollback story, and they found out by listing the output directory. The command now prints `assets: 'NOT included — use \`voltro data export --assets\`'`, and the header and CLI summary no longer claim otherwise.
|
|
202
|
+
|
|
203
|
+
**`data backup` prefers `mariadb-dump` on MariaDB.** The `mysql | mariadb` branch spawned a fixed `mysqldump` and took whichever was on PATH. Oracle's MySQL 8 client queries `information_schema.COLUMN_STATISTICS`, which MariaDB does not have, so the dump died after the first table — leaving a partial `db.sql` that looks like a file. MariaDB has shipped `mariadb-dump` / `mariadb` since 10.5 for exactly this split, and on a MariaDB install `mysqldump` is a symlink to it anyway, so preferring the real name costs nothing and removes the guess. NOT fixed with `--column-statistics=0`: that flag does not exist on `mariadb-dump`, so it would break the correct client to accommodate the wrong one.
|
|
204
|
+
|
|
205
|
+
**`NativeToolError` shows the child's stderr.** It was being CAPTURED and then never rendered — `Data.TaggedError` with no `message` prints the Effect default, so the failure above surfaced as `NativeToolError: An error has occurred` and diagnosing it meant reconstructing the argv by hand out of our source.
|
|
206
|
+
|
|
207
|
+
**`db plan` / `db apply` name the rows a default will fill.** "47,000 existing rows in `todos` will get the default for `slug`" is a sentence a reviewer acts on; a plan line that reads the same whether the table is empty or not is one they scroll past. The PLANNER cannot say this — it is pure by design and does no row counts, which is the property that lets a plan be computed in CI, reviewed and saved — so the count is taken at the command layer, which holds both the classification and the connection. Asked for as the one thing a state diff structurally cannot catch: it gets the shape right and is silently wrong about values.
|
|
208
|
+
- **@voltro/cli** — **`voltro doctor`'s authz scan could not see an app's guards, and said so without anyone being able to act on it.** An app exporting 17 guards was told `guard vocabulary: framework names only — no exported require*/assert* found in this app`, and the scan reported **447** findings of which **5** were real.
|
|
209
|
+
|
|
210
|
+
The inference was handed the DISCOVERY file set — dev.ts's `walk()`, which returns only convention-named files (`*.query.ts`, `*.mutation.ts`, `schema.ts`, …). Guards do not live in those. They live in `lib/access.ts`, which that walk never yields, so the vocabulary read every file EXCEPT the ones that could have taught it anything. It reads the whole source tree now.
|
|
211
|
+
|
|
212
|
+
The report came with a measurement rather than an argument, which is why the cause was findable in one hop: they moved two throwaway exports into a file the discovery set does cover, re-ran, and took it back.
|
|
213
|
+
|
|
214
|
+
| | before | after two names | |---|---|---| | ✗ no access check | 447 | 246 | | ✓ vocabulary | 91 | 294 |
|
|
215
|
+
|
|
216
|
+
Two names out of seventeen removed 201 false findings. And the 91 originally recognised were **coincidence**: one of their guards is called `requireScope`, which collides with a framework name, so it was in the set without the inference ever having run. "Partially working" was zero inference plus one collision.
|
|
217
|
+
|
|
218
|
+
**Why no test caught it.** Every unit test of `inferGuardVocabulary` passed throughout, because the function was never wrong — the caller handed it the wrong files. The vocabulary is computed by an exported `root`-taking function now, tested against real trees, because the defect lives in *which files reach the function* and no test that hands it strings can see that.
|
|
219
|
+
|
|
220
|
+
They declined to write the allowlist ratchet, and were right to: *"442 false lines in a file that says DEBT lead the next reader further astray than no file at all."* The ratchet is worth using now that the vocabulary is.
|
|
221
|
+
- **@voltro/database** — **`voltro dev` sent plpgsql to MariaDB and could not boot.** With auto-migrate on, an app whose plugins declare a reactive `_voltro_*` table (plugin-versioning, among others) failed at startup with `Unknown data type: 'trigger'` — the framework bootstrap emitting `CREATE OR REPLACE FUNCTION … RETURNS trigger AS $$` to a driver that has no such thing. Reported against 0.22.1 and measured on the SHIPPED build rather than inferred from source, on all five dialects.
|
|
222
|
+
|
|
223
|
+
Two emitters write schema DDL — `emitSchemaSql` for user tables and `emitFrameworkBootstrapSql` for `_voltro_*` — and each carried a hand-written copy of the reactive-trigger block. Only one had the postgres gate. The trigger function is plpgsql and `pg_notify` has no equivalent elsewhere (the other dialects get cross-instance capture from a binlog/CDC reader), so the gate is a gate and not a missing implementation.
|
|
224
|
+
|
|
225
|
+
It is one function now, and the tests assert the OUTCOME rather than the presence of a gate: the two emitters must agree, per dialect, about whether a reactive table produces plpgsql.
|
|
226
|
+
|
|
227
|
+
**Why the existing dialect tests did not catch it.** They already passed a reactive table through the emitter — `table()` sets `isReactive: true`, so every case in that file did. Their "every dialect gets the same shape of DDL" test compared `CREATE TABLE` / `ADD COLUMN` / `CREATE INDEX` and simply did not list the trigger block, so the one statement kind that legitimately differs per dialect was the one kind nothing looked at. It is asserted explicitly now, per dialect, including the exception.
|
|
228
|
+
|
|
229
|
+
The block is byte-identical in 0.21.0, so this is not a regression — it was reachable only with auto-migrate enabled, which is why it surfaced now.
|
|
230
|
+
- **@voltro/cli, @voltro/protocol** — **`internal: true` took the rpc server down instead of taking a procedure off the wire.** The flag shipped in 0.22.0. Marking five procedures with it produced
|
|
231
|
+
|
|
232
|
+
TypeError: Cannot read properties of undefined (reading 'key')
|
|
233
|
+
|
|
234
|
+
and no server — on `voltro dev` and, identically, on `voltro serve`. The consumer isolated it by toggling one at a time (an action alone, mutations alone), confirmed the codegen half was correct (603 → 598 procedures, zero dangling references), and left the flag commented out.
|
|
235
|
+
|
|
236
|
+
Each boot path builds TWO things from the discovered procedure lists — the rpc GROUP and the HANDLER MAP, several hundred lines apart. Only the group consulted the filter. `RpcGroup.toHandlersContext` then looks a bound handler's tag up in the group, gets `undefined`, and reads `.key` off it.
|
|
237
|
+
|
|
238
|
+
`serveApi.ts` already carried a comment describing that exact crash in the opposite direction — a handler bound with no group entry, for the undo and connection built-ins — and it did not generalise to the new filter. Both paths now filter ONCE and read the filtered bindings, so the group and the handler map cannot be built from different sets.
|
|
239
|
+
|
|
240
|
+
Three further holes came out with it:
|
|
241
|
+
|
|
242
|
+
- **A `internal: true` STREAM was still served in production.** `serveApi`'s group filtered queries, mutations and actions and not streams, so dev crashed at boot while serve quietly kept the stream on the wire — two paths, two wrong behaviours, and the silent one in production. - **The dev inspect invoker** routed internal procedures. It is filtered too: an internal procedure is the one MOST likely to have no guard ("only server code calls this" is the reason people write them), so an admin-token surface is a narrower door, not a closed one. - **`internal: true` combined with `publicApi` or `exposeAsTool` now THROWS at declaration.** Those projections add a REST route / an agent tool and never consulted the flag, so a procedure carrying both was off the WebSocket and still served over HTTP — the same hole, one surface across. Neither silent resolution is acceptable (dropping the route breaks a live endpoint invisibly; keeping it defeats the flag), so the author decides while both fields are still in front of them.
|
|
243
|
+
|
|
244
|
+
**Why the parity guard was green.** It reads each assembly site's source and asserts it mentions `isWireReachable`. Every site did; the handler map is not an assembly site by that definition and never calls an `xToRpc` lifter, so the offender scan was structurally blind to it. There is a shape-based check for the binding loops now, and — because the defect satisfied every source-level rule stated — a test that BOOTS a server with an internal procedure present. That is the one that fails.
|
|
245
|
+
- **@voltro/database** — **A plugin table whose `.renamedFrom()` names a table the APP owns made every boot after the first one impossible.** Reported against `@voltro/plugin-ai-flows@0.22.1`; the mechanic applies to any plugin table carrying `.renamedFrom(<a name the app declares>)`.
|
|
246
|
+
|
|
247
|
+
Boot one decided correctly and said so:
|
|
248
|
+
|
|
249
|
+
```txt
|
|
250
|
+
✓ CREATE TABLE _voltro_ai_flows (27 cols)
|
|
251
|
+
# .renamedFrom('ai_flows') NOT applied — 'ai_flows' is still declared by this
|
|
252
|
+
# schema … that is the intended outcome when an app owns a table of the same name.
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Boot two, over exactly that state, refused:
|
|
256
|
+
|
|
257
|
+
```txt
|
|
258
|
+
auto-migrate: REFUSED — 2 blocked operation(s)
|
|
259
|
+
- rename-table : both 'ai_flows' and '_voltro_ai_flows' exist in the database
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**The guards were evaluated in the wrong order.** Guard 1 ("the old name must not still be declared") answers the question completely: if the app declares the old name, the marker is INAPPLICABLE and there is no rename to have a conflict about. Guard 2 ("the target must not already exist live") asks a follow-up — *which of these two holds the real rows?* — that only makes sense once a rename is actually on the table. Guard 2 ran first.
|
|
263
|
+
|
|
264
|
+
So the planner blocked the boot over the exact state it had itself produced one pass earlier and documented as intended, and the state was not stabilisable: dropping the empty `_voltro_*` table just let boot one recreate it. Neither remedy in the message worked either — the rows belong to the app's own schema, and letting the rename run would take them.
|
|
265
|
+
|
|
266
|
+
The reporter's case makes it worse than a name collision: the plugin is a port of *their* engine, so it carries the names of the tables it grew out of. They had to unregister the plugin — losing its inspect endpoints — to boot at all. The changelog's "**Nothing is required of you**" was false for precisely the case guard 1 exists to protect.
|
|
267
|
+
|
|
268
|
+
Guard 1 runs first now. The test that pins it models TWO passes, because one pass is what the original test did and one pass is green either way.
|
|
269
|
+
- **@voltro/cli** — **`voltro update` bumped `@voltro/*` and left what `@voltro/*` requires behind.** The `@effect/*` packages are peer dependencies, so a user app declares them directly. When a release moved its peer range, `update` rewrote every `@voltro/*` spec, installed, and left the app pinned to the old peers:
|
|
270
|
+
|
|
271
|
+
0.22.1 requires @effect/rpc ^0.76.0 @effect/platform ^0.97.0 the app declared @effect/rpc ^0.75.1 @effect/platform ^0.96.2
|
|
272
|
+
|
|
273
|
+
pnpm warns about that and installs anyway. The app compiles and boots — on a dependency graph the framework was never tested against, which is the worst shape a version mismatch takes: nothing fails, so nothing points at the cause. Found by a consumer while diagnosing something unrelated.
|
|
274
|
+
|
|
275
|
+
`update` now reads the peer requirements off the freshly installed `@voltro/*` packages (disk, after the install — no second per-package-manager registry query to get wrong, and no exposure to the yarn-classic hazard where `yarn npm info …` parses as `yarn run npm`), aligns the app's declared ranges, and re-installs if anything moved.
|
|
276
|
+
|
|
277
|
+
Three rules keep it from doing damage:
|
|
278
|
+
|
|
279
|
+
- **Only peers the app already declares.** One resolved transitively is not ours to add — that would change the app's dependency surface on its behalf. - **Only when the declared floor is genuinely BELOW the requirement.** An app pinned ahead, or pinned exactly at the floor with different syntax (`0.76.0` vs `^0.76.0`), is left alone. Those are choices, not drift. - **Only ranges it can judge** (`^`, `~`, `>=`, exact). A union, an upper bound, `workspace:` / `catalog:` — left alone. Under-reporting an exotic range is safe; rewriting one we did not understand is not.
|
|
280
|
+
|
|
281
|
+
If two framework packages disagree about one peer, that is REPORTED with both names and skipped — it is our bug, and resolving it inside a user's upgrade would hide it.
|
|
282
|
+
- **@voltro/cli** — **`voltro update` now also reports a peer that NOBODY declares.** The alignment added alongside this rewrites ranges an app already declares; a second consumer hit the other half of the same problem.
|
|
283
|
+
|
|
284
|
+
Their `apps/voltro-api/package.json` declared the three `@effect/*` packages. The workspace ROOT did not — and the root's `@voltro/client` / `web` / `database` / `protocol` / `ai`, the ones all three frontends use, all require `effect ^3.22`. It resolved `3.21.4` transitively. The peer was unsatisfied workspace-wide, the install succeeded, and nothing said a word.
|
|
285
|
+
|
|
286
|
+
That matters more than a version skew usually does because **Effect types are nominal**: two copies produce red `tsc` on `rpcGroup.generated.ts` while the server runs green — the exact symptom `voltro doctor`'s duplicate-install check describes. Doctor already caught it after the fact, with cause and recipe, and the reporter says so; the point of this is to stop the state being created.
|
|
287
|
+
|
|
288
|
+
It is REPORTED, not repaired: the fix is to declare a dependency the app never declared, which changes its dependency surface. That is the user's call.
|
|
289
|
+
|
|
290
|
+
### Internal (no consumer-facing effect)
|
|
291
|
+
|
|
292
|
+
- **The four 0.22.0 codemods gain the gate tests the convention asks for.**
|
|
293
|
+
|
|
294
|
+
`codemodRegistry.test.ts` asserts that every `*.codemod.ts` on disk is registered and that ids are unique — registration, not behaviour. The way a `manual` codemod actually fails is an `appliesTo` that is too broad, so the note prints for projects with nothing to do. That is not cosmetic: a note everyone sees is a note nobody reads, and the next one in the series announces a boot refusal or an irreversible deletion.
|
|
295
|
+
|
|
296
|
+
Fifteen cases, both directions for each codemod. The silent direction is the one that needed pinning — `rename-index` must not fire on an app that merely READS a plan (additive there), the plugin-table move must not fire on an app that installs none of the three, and `cache.scope` must not fire on a logger scope or an OAuth scope, both of which are ordinary English in any codebase.
|
|
297
|
+
|
|
298
|
+
Verified by breaking a gate rather than by watching green: widening `TOUCHES_SCOPE` to match everything fails exactly the two silent-direction cases and nothing else. A test that has never been seen to fail is not evidence that it checks anything.
|
|
299
|
+
|
|
300
|
+
**One finding, pinned rather than quietly fixed.** `04_inspect-write-credential` gates on `envTokenAuthResolver|InspectAuthResolver|authResolver`, and the third alternative is not inspect-scoped — any project with its own unrelated `authResolver` gets the note. It is the loosest gate in the set. There is a test asserting the current behaviour, so tightening it is a deliberate act with a failing test to update, rather than a silent change to who hears about a credential split.
|
|
301
|
+
- **The test harness sends ANY hostless bind to the loopback, not just `port: 0`.**
|
|
302
|
+
|
|
303
|
+
The first version rewrote only ephemeral binds, and that was worse than not doing it at all: it made the two halves of a single test disagree about address family.
|
|
304
|
+
|
|
305
|
+
`devHealthServer`'s conflict case caught it in the next gate run. That test binds ephemerally, then asks for the SAME port again and expects `EADDRINUSE` to degrade the handle to `port: null`. With only port-0 rewritten, the first server took `127.0.0.1:P` while the second — an explicit port, so untouched — took `:::P`. Those do not collide. The expected conflict silently stopped happening and the assertion read `expected 51500 to be null`.
|
|
306
|
+
|
|
307
|
+
The failure is worth keeping in view because it is the same mechanism the harness exists to remove, produced by a half-applied fix: two binds of one port number in different families are two independent binds. A test that names its own interface still keeps it, and production is untouched — the wildcard remains the default there, because a container must be reachable from outside.
|
|
308
|
+
|
|
309
|
+
Re-verified after: `devHealthServer` 5 passed, `mcp` 13, `protocol` 301, `runtime` 1038, and an instrumented run still reports `bound 127.0.0.1:<port>`.
|
|
310
|
+
- **The net harness is shared across every package that binds a listener, and a derived guard keeps it that way.**
|
|
311
|
+
|
|
312
|
+
The bind fix itself ships with the `host` option in this same release. What did not ship with it was reach: the mitigation lived in `packages/cli/vitest.config.ts`, written where the symptom appeared, so the other eleven packages whose tests bind a real listener never had it. `@voltro/mcp` then failed a release gate with the identical signature — bound, zero connections, its client stuck in `fetch` — and that read as a NEW problem rather than as the containment being too narrow. It is the second time this repo fixed a real-listener flake inside one package's config.
|
|
313
|
+
|
|
314
|
+
`test/harness/setup.ts` is now loaded by all twelve. It is deliberately NOT in `@voltro/testing`: that package is published, and a `net.Server` monkey-patch does not belong in a shipped API surface.
|
|
315
|
+
|
|
316
|
+
`packages/cli/src/netHarnessPackages.test.ts` DERIVES the required set — any test file calling `.listen(` / `createServer(` / `serveApi(` / `startRpcServer(` — instead of curating a list that would rot exactly the way the original mitigation did. Remove a package's config and it fails naming that package; verified by deleting `@voltro/mcp`'s and watching it go red. It also asserts the derivation matches more than five packages, because a guard that silently matches nothing reads exactly like a clean repo.
|
|
317
|
+
|
|
318
|
+
The harness covers `@voltro/cli`'s `unit` project too, not only `integration`: the light mock-server suites live there, and `devHealthServer` — one of them — is among the files this failure mode has hung.
|
|
319
|
+
- **Thirty-two tests reported `passed` when their service was absent. They skip now, and a derived check keeps it that way.**
|
|
320
|
+
|
|
321
|
+
The rule is not new — `voltro/CLAUDE.md` states that a suite needing a live service must SKIP rather than pass, that all 36 suites with the hand-rolled shape were converted, and that a new one must never be added. It was enforced by prose, so it rotted: seven files had grown it back.
|
|
322
|
+
|
|
323
|
+
Measured, not inferred:
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
$ PG_PORT=1 vitest run plugin-ratelimit/src/postgresStore.test.ts
|
|
327
|
+
Tests 5 passed (5)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Five tests that connected to nothing. The shape is a `beforeAll` probe plus `if (!ok) return` in each body: an absent dependency becomes a PASS, the only trace is a shorter duration, and vitest swallows the `console.warn` meant to say otherwise. Two of the seven — `concurrency.pg` and `jsonArrayWrite.pg` — exist specifically to prove atomicity under real concurrency, so a green there was evidence for a claim nobody had checked.
|
|
331
|
+
|
|
332
|
+
All seven now use `describeIfReachable` (`plugin-ratelimit` ×2, `plugin-broadcast`, `plugin-flags`, `plugin-versioning`, `integration-harness` ×2), and four packages gained the `@voltro/testing` devDependency they were missing — the import would have typechecked clean and died at runtime with `Cannot find package`, which this repo has been bitten by before.
|
|
333
|
+
|
|
334
|
+
Verified in BOTH directions, because only one is obvious: with no service, `2 passed | 3 skipped` where it used to be `5 passed`; with the stack up, 5/10/4/2 tests actually run and pass.
|
|
335
|
+
|
|
336
|
+
`packages/cli/src/noHandRolledReachability.test.ts` makes the rule mechanical. It DERIVES the offenders from source rather than curating a list, and it strips comments first — the first version flagged two files whose only offence was a comment *explaining* the anti-pattern, and a check that punishes documenting a hazard teaches people to stop documenting it. It also excludes itself, since it must state the pattern in order to forbid it, and asserts the supported helper is used by more than twenty files, so an empty repo could not make it vacuous. Red-checked by reintroducing the guard into `plugin-flags`: it fails and names the file.
|
|
337
|
+
- **@voltro/runtime, @voltro/cli, @voltro/mcp** — Two `@voltro/runtime` tests asserting that a symbol is EXPORTED carried vitest's 5s default timeout around a dynamic `import('./index')`. That silently added a second assertion nobody meant to make — "…and a cold import of this package's whole barrel completes within 5 seconds" — which is a claim about the MACHINE.
|
|
338
|
+
|
|
339
|
+
In a full uncached monorepo run the package's import phase alone was 88s and the file went red while every assertion in it would have passed. Given an explicit 60s ceiling: the timeout is now a backstop rather than the assertion, which is the same correction already applied to `coordinatedSchedule.test.ts`.
|
|
340
|
+
|
|
341
|
+
**Three more files had the same shape**, and they are the ones this repo's maintainer notes already list as "rotating victims" of full-monorepo runs: `cli/src/adminExportServe.test.ts`, `cli/src/connectionServe.test.ts` and `mcp/src/http.test.ts`. All three BOOT a real listener and make real HTTP round-trips — the last one boots two servers — against the same 5s default. Each went red in an uncached full run under load ~19 and green alone seconds later, with every assertion in them passing either way.
|
|
342
|
+
|
|
343
|
+
That is worth naming precisely, because "it passes in isolation" has been the signature of both machine load AND a defect the suite carried itself, and this repo has been wrong in both directions. Here it is neither: the suites are correct and the timeout was measuring the wrong thing. A test whose claim is "these two endpoints compose" should not also be claiming how many milliseconds that takes on a saturated machine.
|
|
344
|
+
|
|
345
|
+
**And one of the four turned out NOT to be the machine.** With the 60s ceiling in place, `connectionServe.test.ts`'s "callback route is NOT mounted" test consumed the entire budget in a full parallel run — 60006ms — while its four siblings in the same file took 82ms, 50ms, 38ms and 1ms. A test that is 700× slower than its neighbours is hanging, not slow, and the raised ceiling is what made that readable: at 5s it looked like every other saturation red.
|
|
346
|
+
|
|
347
|
+
The cause is **not** known. It does not reproduce alone (3 runs) or as a whole file (4 runs), which leaves the full-parallel context and nothing more specific. So this does not claim a fix. Every request in that file now carries `AbortSignal.timeout(10_000)`, which turns the next occurrence into a named `TimeoutError` on a specific request instead of an anonymous test timeout that eats a minute of the run and reports nothing — the difference between an observation and a diagnosis.
|
|
348
|
+
|
|
349
|
+
Recorded rather than resolved, because "it passes in isolation" has been the signature of both machine load and a real defect in this repo, and this one has not been told apart yet.
|
|
350
|
+
- **The 0.23.0 versioning codemod gains its gate test.**
|
|
351
|
+
|
|
352
|
+
Same reason as the four before it: `codemodRegistry.test.ts` covers registration, not behaviour, and what a `manual` codemod gets wrong is an `appliesTo` that fires for projects with nothing to do. This note is long and carries a storage-budget warning, which makes a spurious print worse than usual — a long note on an app that is unaffected is the most reliable way to teach someone to stop reading them.
|
|
353
|
+
|
|
354
|
+
Four cases, both directions. Verified by breaking the gate: widening `TOUCHES_VERSIONING` to match everything fails exactly the two silent-direction cases.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
42
358
|
## [0.22.1] — 2026-08-01
|
|
43
359
|
|
|
44
360
|
### Fixed
|
package/dist/index.d.ts
CHANGED
|
@@ -666,6 +666,183 @@ export declare const verifySignature: (scheme: SignatureScheme, rawBody: Uint8Ar
|
|
|
666
666
|
*/
|
|
667
667
|
export declare type VersionState = 'current' | 'behind' | 'ahead';
|
|
668
668
|
|
|
669
|
+
/**
|
|
670
|
+
* One row per delivery attempt — NOT per emit. An emit fans out to
|
|
671
|
+
* N targets; each target then runs ≤ `maxAttempts` deliveries. The
|
|
672
|
+
* primary key is `(deliveryId, attempt)`; `deliveryId` is shared
|
|
673
|
+
* across retries of the SAME (event, payload, target) tuple so the
|
|
674
|
+
* dashboard groups them.
|
|
675
|
+
*
|
|
676
|
+
* Status lifecycle: `pending` (queued — the target was paused at emit
|
|
677
|
+
* time, or the attempt is rate-deferred to the next window) →
|
|
678
|
+
* `inFlight` → `succeeded` | `failed` | `retryScheduled`. On retry the
|
|
679
|
+
* workflow creates a new `(deliveryId, attempt+1)` row; on
|
|
680
|
+
* resume/deferral the SAME attempt-1 row transitions out of `pending`.
|
|
681
|
+
*/
|
|
682
|
+
export declare const _voltroWebhookDeliveriesTable: Table<"_voltro_webhook_deliveries", FieldDefinitions<{
|
|
683
|
+
readonly id: ColumnBuilder<string, "id", boolean>;
|
|
684
|
+
/** Grouping key for retries of the same delivery. See above. */
|
|
685
|
+
readonly deliveryId: ColumnBuilder<string, "text", boolean>;
|
|
686
|
+
/** FK to `_voltro_webhook_targets.id`. */
|
|
687
|
+
readonly targetId: ColumnBuilder<string, "text", boolean>;
|
|
688
|
+
/** Event id at emit time — denormalised so dashboard listings
|
|
689
|
+
* don't need to JOIN through `_voltro_webhook_targets` (which
|
|
690
|
+
* may have been deleted by the time someone audits this row). */
|
|
691
|
+
readonly event: ColumnBuilder<string, "text", boolean>;
|
|
692
|
+
/** The emit's `eventId` (shared by every target fan-out of one
|
|
693
|
+
* emit) — lets `resumeTarget`'s flush re-trigger a queued delivery
|
|
694
|
+
* with its ORIGINAL event id, and correlates rows across targets. */
|
|
695
|
+
readonly eventId: ColumnBuilder<string | null, "text", boolean>;
|
|
696
|
+
/** Attempt counter (1-indexed). */
|
|
697
|
+
readonly attempt: ColumnBuilder<number, "integer", boolean>;
|
|
698
|
+
readonly status: ColumnBuilder<"failed" | "succeeded" | "pending" | "inFlight" | "retryScheduled", "text", boolean>;
|
|
699
|
+
/** Payload as sent over the wire. Stored verbatim — re-rendering
|
|
700
|
+
* from a referenced event row would lose the snapshot if the
|
|
701
|
+
* source event was deleted. */
|
|
702
|
+
readonly payload: ColumnBuilder<unknown, "json", boolean>;
|
|
703
|
+
/** HTTP status code returned. `null` for transport errors (DNS,
|
|
704
|
+
* TLS, timeout) — `errorMessage` carries the detail. */
|
|
705
|
+
readonly responseStatus: ColumnBuilder<number | null, "integer", boolean>;
|
|
706
|
+
/** Response body sample (clipped to 8 KB). Lets the dashboard
|
|
707
|
+
* show the recipient's error reply inline. */
|
|
708
|
+
readonly responseBody: ColumnBuilder<string | null, "text", boolean>;
|
|
709
|
+
/** Transport-layer error message ("ENOTFOUND", "ETIMEDOUT", TLS
|
|
710
|
+
* handshake failure). Null on HTTP-layer errors (those carry
|
|
711
|
+
* `responseStatus`). */
|
|
712
|
+
readonly errorMessage: ColumnBuilder<string | null, "text", boolean>;
|
|
713
|
+
/** End-to-end attempt latency in ms — includes DNS, TLS, request,
|
|
714
|
+
* response read. Useful for the dashboard's "slowest endpoint"
|
|
715
|
+
* ranking. */
|
|
716
|
+
readonly latencyMs: ColumnBuilder<number | null, "integer", boolean>;
|
|
717
|
+
/** When this attempt was scheduled (NOT when it was sent — sent
|
|
718
|
+
* time is approximately `scheduledAt + queueDelay`). */
|
|
719
|
+
readonly scheduledAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
720
|
+
/** When the next retry is due (set ONLY when `status =
|
|
721
|
+
* retryScheduled`). Lets the workflow's sleep block read its
|
|
722
|
+
* wake time from the persisted row across restarts. */
|
|
723
|
+
readonly nextAttemptAt: ColumnBuilder<Date | null, "timestamp", boolean>;
|
|
724
|
+
}> & {
|
|
725
|
+
tenantId: ColumnDefinition<string>;
|
|
726
|
+
} & {
|
|
727
|
+
readonly createdAt: ColumnDefinition<Date, "timestamp", true>;
|
|
728
|
+
readonly updatedAt: ColumnDefinition<Date, "timestamp", true>;
|
|
729
|
+
readonly createdBy: ColumnDefinition<string | null, "reference", boolean>;
|
|
730
|
+
readonly updatedBy: ColumnDefinition<string | null, "reference", boolean>;
|
|
731
|
+
}, true, never>;
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Fixed-window rate-limit counters — one row per (scope, minute
|
|
735
|
+
* bucket), where scope is `target:<targetId>` (per-target
|
|
736
|
+
* `rateLimitPerMinute`) or `event:<eventId>` (an outgoing event's
|
|
737
|
+
* `globalRateLimit`). The delivery workflow claims a slot via a CAS
|
|
738
|
+
* loop over `count` (see `rateLimit.ts`) BEFORE every wire POST, so
|
|
739
|
+
* the cap holds across replicas — the counter lives here, never in
|
|
740
|
+
* process memory.
|
|
741
|
+
*
|
|
742
|
+
* Deliberately NOT tenant-scoped: rows carry only a scope key + an
|
|
743
|
+
* integer count (no payload, no secret, no tenant data), and the
|
|
744
|
+
* background delivery workflow that writes them has no request
|
|
745
|
+
* subject. The deterministic PK `<scope>@<bucket>` is the
|
|
746
|
+
* `insertIgnore` conflict target for the first-in-window create race.
|
|
747
|
+
*/
|
|
748
|
+
export declare const _voltroWebhookRateWindowsTable: Table<"_voltro_webhook_rate_windows", FieldDefinitions<{
|
|
749
|
+
/** Deterministic `<scope>@<bucket>` — always supplied explicitly by
|
|
750
|
+
* the CAS writer (the prefix scheme only fires for omitted ids,
|
|
751
|
+
* which never happens here). */
|
|
752
|
+
readonly id: ColumnBuilder<string, "id", boolean>;
|
|
753
|
+
/** `target:<targetId>` | `event:<eventId>`. */
|
|
754
|
+
readonly scope: ColumnBuilder<string, "text", boolean>;
|
|
755
|
+
/** Epoch-minute bucket (`floor(now / windowMs)`). */
|
|
756
|
+
readonly bucket: ColumnBuilder<number, "integer", boolean>;
|
|
757
|
+
/** Slots consumed in this window. */
|
|
758
|
+
readonly count: ColumnBuilder<number, "integer", true>;
|
|
759
|
+
readonly createdAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
760
|
+
readonly updatedAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
761
|
+
}>, true, never>;
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* One row per subscribed delivery target. Created via
|
|
765
|
+
* `webhooks.subscribe(...)`. Read-only from app code; mutate via
|
|
766
|
+
* the `webhooks` service so the framework can run validation +
|
|
767
|
+
* generate secrets + invalidate caches.
|
|
768
|
+
*/
|
|
769
|
+
export declare const _voltroWebhookTargetsTable: Table<"_voltro_webhook_targets", FieldDefinitions<{
|
|
770
|
+
readonly id: ColumnBuilder<string, "id", boolean>;
|
|
771
|
+
/** Event id from the outgoing-event descriptor (e.g. `'order.completed'`). */
|
|
772
|
+
readonly event: ColumnBuilder<string, "text", boolean>;
|
|
773
|
+
/** Active subscription URL the delivery posts to. */
|
|
774
|
+
readonly url: ColumnBuilder<string, "text", boolean>;
|
|
775
|
+
/** Per-target signing secret. Generated at subscribe time when not
|
|
776
|
+
* supplied. Stored as-is — encryption is a deployment concern
|
|
777
|
+
* (Postgres-at-rest, KMS-wrapped column, vault sidecar). */
|
|
778
|
+
readonly secret: ColumnBuilder<string, "text", boolean>;
|
|
779
|
+
/** Serialised `SignatureScheme` discriminated union — see `signing.ts`.
|
|
780
|
+
* Stored as JSON so future schemes don't require a schema migration. */
|
|
781
|
+
readonly signing: ColumnBuilder<unknown, "json", boolean>;
|
|
782
|
+
/** Serialised `RetryPolicy` — see `retry.ts`. */
|
|
783
|
+
readonly retry: ColumnBuilder<unknown, "json", boolean>;
|
|
784
|
+
/** Optional predicate filter (subset of `Predicate`) — the engine
|
|
785
|
+
* evaluates this against each emit's payload to decide whether
|
|
786
|
+
* this target receives the delivery. */
|
|
787
|
+
readonly filter: ColumnBuilder<unknown, "json", boolean>;
|
|
788
|
+
/** Optional custom headers merged with the framework's
|
|
789
|
+
* Content-Type + signature header. Values larger than 2 KB are
|
|
790
|
+
* rejected at subscribe time. */
|
|
791
|
+
readonly headers: ColumnBuilder<unknown, "json", boolean>;
|
|
792
|
+
/** Per-target rate limit — at most N wire POSTs per minute to this
|
|
793
|
+
* target, enforced by the delivery workflow via a shared-store
|
|
794
|
+
* fixed-window counter (`_voltro_webhook_rate_windows`, so the cap
|
|
795
|
+
* holds across replicas). Excess deliveries are DEFERRED: parked as
|
|
796
|
+
* `status='pending'` rows and durable-slept until the next window
|
|
797
|
+
* opens — they're never dropped silently. */
|
|
798
|
+
readonly rateLimitPerMinute: ColumnBuilder<number | null, "integer", boolean>;
|
|
799
|
+
/** Soft-disable without deleting the row — the dashboard's
|
|
800
|
+
* "Pause" affordance flips this. While paused, emits against this
|
|
801
|
+
* target accumulate as `_voltro_webhook_deliveries` rows with
|
|
802
|
+
* status `'pending'` (no POST happens); `resumeTarget` flushes
|
|
803
|
+
* them through the delivery workflow in emit order. */
|
|
804
|
+
readonly active: ColumnBuilder<boolean, "boolean", true>;
|
|
805
|
+
/** Format the payload is delivered as. `json` is the default and
|
|
806
|
+
* what every modern integration expects. `form`
|
|
807
|
+
* (`application/x-www-form-urlencoded`, bracketed-key flattening)
|
|
808
|
+
* and `xml` (`application/xml`, `<webhook>`-rooted) exist for
|
|
809
|
+
* SOAP-era partners; the delivery workflow re-encodes the payload
|
|
810
|
+
* into this format and signs the re-encoded bytes. */
|
|
811
|
+
readonly format: ColumnBuilder<"json" | "form" | "xml", "text", true>;
|
|
812
|
+
/** Auto-disable threshold — after this many CONSECUTIVE terminal
|
|
813
|
+
* delivery failures the target is auto-paused (dead-letter guard).
|
|
814
|
+
* `null` (the default) disables the feature. When it trips, the
|
|
815
|
+
* target flips to `active=false` and subsequent emits QUEUE as
|
|
816
|
+
* `status='pending'` rows (same as a manual pause — nothing is
|
|
817
|
+
* dropped); a manual `resumeTarget` re-activates, flushes the queue,
|
|
818
|
+
* and clears the streak. */
|
|
819
|
+
readonly autoDisableAfter: ColumnBuilder<number | null, "integer", boolean>;
|
|
820
|
+
/** Consecutive terminal-failure streak. Incremented on each terminal
|
|
821
|
+
* `failed` delivery, reset to 0 on any `succeeded`. Drives
|
|
822
|
+
* `autoDisableAfter`. Multi-replica-correct via a CAS loop
|
|
823
|
+
* (`autoDisable.ts`). */
|
|
824
|
+
readonly consecutiveFailures: ColumnBuilder<number, "integer", true>;
|
|
825
|
+
/** When the auto-disable last fired (`null` = never / cleared by a
|
|
826
|
+
* manual resume). Surfaced on the inspect panel. */
|
|
827
|
+
readonly autoDisabledAt: ColumnBuilder<Date | null, "timestamp", boolean>;
|
|
828
|
+
/** The terminal failure reason that tripped the auto-disable
|
|
829
|
+
* (`null` = not auto-disabled). Surfaced on the inspect panel. */
|
|
830
|
+
readonly autoDisableReason: ColumnBuilder<string | null, "text", boolean>;
|
|
831
|
+
/** Schema version bound at subscribe time. Lets the dashboard
|
|
832
|
+
* show which targets are still pinned to an older event version
|
|
833
|
+
* after the producer bumps it. */
|
|
834
|
+
readonly payloadVersion: ColumnBuilder<number, "integer", true>;
|
|
835
|
+
/** Human-readable label surfaced in the dashboard listing. */
|
|
836
|
+
readonly description: ColumnBuilder<string | null, "text", boolean>;
|
|
837
|
+
}> & {
|
|
838
|
+
tenantId: ColumnDefinition<string>;
|
|
839
|
+
} & {
|
|
840
|
+
readonly createdAt: ColumnDefinition<Date, "timestamp", true>;
|
|
841
|
+
readonly updatedAt: ColumnDefinition<Date, "timestamp", true>;
|
|
842
|
+
readonly createdBy: ColumnDefinition<string | null, "reference", boolean>;
|
|
843
|
+
readonly updatedBy: ColumnDefinition<string | null, "reference", boolean>;
|
|
844
|
+
}, true, never>;
|
|
845
|
+
|
|
669
846
|
/**
|
|
670
847
|
* Thrown by `WebhooksService.replay` when no delivery row exists for the
|
|
671
848
|
* given `deliveryId` — the row can't be re-triggered because the
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { a as e, c as t, i as n, l as r, n as i, o as a, r as o, s, t as c, u as l } from "./signing-BOUxHdAo.js";
|
|
2
2
|
import { WebhookDeliveryNotFound as u, WebhookPayloadInvalid as d, WebhookPayloadUnrepresentable as f, WebhookPayloadVersionInvalid as p, WebhookSubscribeInvalid as m } from "./errors.js";
|
|
3
|
-
import {
|
|
4
|
-
import { randomBytes as
|
|
5
|
-
import { Context as
|
|
6
|
-
import { and as
|
|
7
|
-
import { assertPublicUrl as
|
|
8
|
-
import { publishServerError as
|
|
9
|
-
import { createLogger as
|
|
10
|
-
import { Activity as E, DurableClock as D, Workflow as
|
|
3
|
+
import { _voltroWebhookDeliveriesTable as h, _voltroWebhookRateWindowsTable as g, _voltroWebhookTargetsTable as _, webhookTables as v } from "./mixin.js";
|
|
4
|
+
import { randomBytes as y, randomUUID as b } from "node:crypto";
|
|
5
|
+
import { Context as ee, Effect as x, Either as te, Schema as S } from "effect";
|
|
6
|
+
import { and as C, eq as w } from "@voltro/database";
|
|
7
|
+
import { assertPublicUrl as T } from "@voltro/integration-http";
|
|
8
|
+
import { publishServerError as ne } from "@voltro/protocol";
|
|
9
|
+
import { createLogger as re } from "@voltro/logger";
|
|
10
|
+
import { Activity as E, DurableClock as D, Workflow as ie } from "@effect/workflow";
|
|
11
11
|
//#region src/retry.ts
|
|
12
|
-
var
|
|
13
|
-
let t =
|
|
12
|
+
var ae = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
13
|
+
let t = ae.exec(e);
|
|
14
14
|
if (!t) throw Error(`invalid duration literal: ${e}`);
|
|
15
15
|
let n = Number(t[1]), r = t[2] ?? "s";
|
|
16
16
|
switch (r) {
|
|
@@ -64,7 +64,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
64
64
|
retryOn: k,
|
|
65
65
|
honourRetryAfter: !0,
|
|
66
66
|
jitter: "full"
|
|
67
|
-
}),
|
|
67
|
+
}), oe = () => ({
|
|
68
68
|
strategy: "fixed",
|
|
69
69
|
maxAttempts: 120,
|
|
70
70
|
initialDelay: "30s",
|
|
@@ -72,7 +72,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
72
72
|
retryOn: k,
|
|
73
73
|
honourRetryAfter: !0,
|
|
74
74
|
jitter: "full"
|
|
75
|
-
}), M = class extends
|
|
75
|
+
}), M = class extends ee.Tag("@voltro/webhooks/WebhooksService")() {}, se = 32, N = 32, P = () => y(se).toString("hex"), F = (e) => {
|
|
76
76
|
let t;
|
|
77
77
|
try {
|
|
78
78
|
t = new URL(e.url);
|
|
@@ -87,7 +87,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
87
87
|
reason: `url protocol must be http or https, got ${t.protocol}`
|
|
88
88
|
});
|
|
89
89
|
if (process.env.NODE_ENV === "production") try {
|
|
90
|
-
|
|
90
|
+
T(e.url);
|
|
91
91
|
} catch (e) {
|
|
92
92
|
throw new m({
|
|
93
93
|
field: "url",
|
|
@@ -121,7 +121,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
121
121
|
reason: `autoDisableAfter must be a positive integer, got ${e.autoDisableAfter}`
|
|
122
122
|
});
|
|
123
123
|
}, I = (e, t) => ({
|
|
124
|
-
id:
|
|
124
|
+
id: b(),
|
|
125
125
|
event: e.event,
|
|
126
126
|
url: e.url,
|
|
127
127
|
secret: e.secret ?? P(),
|
|
@@ -138,19 +138,19 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
138
138
|
autoDisableReason: null,
|
|
139
139
|
payloadVersion: e.payloadVersion ?? t?.version ?? 1,
|
|
140
140
|
description: e.description ?? null
|
|
141
|
-
}),
|
|
141
|
+
}), ce = (e, t) => e === t ? "current" : e < t ? "behind" : "ahead", L = (e, t) => {
|
|
142
142
|
if (e === null) return !0;
|
|
143
143
|
let n = { payload: t };
|
|
144
|
-
for (let [t, r] of Object.entries(e)) if (!
|
|
144
|
+
for (let [t, r] of Object.entries(e)) if (!ue(le(n, t), r)) return !1;
|
|
145
145
|
return !0;
|
|
146
|
-
},
|
|
146
|
+
}, le = (e, t) => {
|
|
147
147
|
let n = t.split("."), r = e;
|
|
148
148
|
for (let e of n) {
|
|
149
149
|
if (typeof r != "object" || !r) return;
|
|
150
150
|
r = r[e];
|
|
151
151
|
}
|
|
152
152
|
return r;
|
|
153
|
-
},
|
|
153
|
+
}, ue = (e, t) => {
|
|
154
154
|
if (typeof t == "object" && t && !Array.isArray(t)) {
|
|
155
155
|
let n = t;
|
|
156
156
|
if ("eq" in n) return e === n.eq;
|
|
@@ -161,7 +161,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
161
161
|
if ("in" in n && Array.isArray(n.in)) return n.in.includes(e);
|
|
162
162
|
}
|
|
163
163
|
return e === t;
|
|
164
|
-
},
|
|
164
|
+
}, de = (e, t, n = {}) => {
|
|
165
165
|
let r = new Map((n.events ?? []).map((e) => [e.id, e]));
|
|
166
166
|
return {
|
|
167
167
|
subscribe: async (t) => {
|
|
@@ -183,15 +183,15 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
183
183
|
emit: async (n, i) => {
|
|
184
184
|
let a = typeof n == "string" ? r.get(n) : n, o = typeof n == "string" ? n : n.id;
|
|
185
185
|
if (a !== void 0) {
|
|
186
|
-
let e =
|
|
187
|
-
if (
|
|
186
|
+
let e = S.decodeUnknownEither(a.payload)(i, { errors: "all" });
|
|
187
|
+
if (te.isLeft(e)) throw new d({
|
|
188
188
|
event: o,
|
|
189
189
|
issues: e.left.message
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
|
-
let s =
|
|
192
|
+
let s = b(), c = (await e.store.query({
|
|
193
193
|
table: "_voltro_webhook_targets",
|
|
194
|
-
predicate:
|
|
194
|
+
predicate: w("event", o),
|
|
195
195
|
order: [{
|
|
196
196
|
column: "createdAt",
|
|
197
197
|
direction: "asc"
|
|
@@ -201,7 +201,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
201
201
|
projection: void 0
|
|
202
202
|
})).filter((e) => L(e.filter, i)), l = [], u = JSON.stringify(i);
|
|
203
203
|
for (let n of c) {
|
|
204
|
-
let r =
|
|
204
|
+
let r = b();
|
|
205
205
|
n.active ? (await t({
|
|
206
206
|
deliveryId: r,
|
|
207
207
|
targetId: n.id,
|
|
@@ -239,7 +239,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
239
239
|
replay: async (n) => {
|
|
240
240
|
let r = (await e.store.query({
|
|
241
241
|
table: "_voltro_webhook_deliveries",
|
|
242
|
-
predicate:
|
|
242
|
+
predicate: w("deliveryId", n),
|
|
243
243
|
order: [{
|
|
244
244
|
column: "attempt",
|
|
245
245
|
direction: "asc"
|
|
@@ -253,7 +253,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
253
253
|
deliveryId: r.deliveryId,
|
|
254
254
|
targetId: r.targetId,
|
|
255
255
|
event: r.event,
|
|
256
|
-
eventId: r.eventId ??
|
|
256
|
+
eventId: r.eventId ?? b(),
|
|
257
257
|
payloadJson: typeof r.payload == "string" ? r.payload : JSON.stringify(r.payload),
|
|
258
258
|
attemptEpoch: Date.now()
|
|
259
259
|
});
|
|
@@ -274,7 +274,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
274
274
|
});
|
|
275
275
|
let r = await e.store.query({
|
|
276
276
|
table: "_voltro_webhook_deliveries",
|
|
277
|
-
predicate:
|
|
277
|
+
predicate: C(w("targetId", n), w("status", "pending")),
|
|
278
278
|
order: [{
|
|
279
279
|
column: "createdAt",
|
|
280
280
|
direction: "asc"
|
|
@@ -287,13 +287,13 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
287
287
|
deliveryId: e.deliveryId,
|
|
288
288
|
targetId: n,
|
|
289
289
|
event: e.event,
|
|
290
|
-
eventId: e.eventId ??
|
|
290
|
+
eventId: e.eventId ?? b(),
|
|
291
291
|
payloadJson: typeof e.payload == "string" ? e.payload : JSON.stringify(e.payload),
|
|
292
292
|
attemptEpoch: i
|
|
293
293
|
});
|
|
294
294
|
},
|
|
295
295
|
deleteTarget: async (t) => {
|
|
296
|
-
await e.store.delete("_voltro_webhook_targets", t), await e.store.deleteMany("_voltro_webhook_deliveries", { where:
|
|
296
|
+
await e.store.delete("_voltro_webhook_targets", t), await e.store.deleteMany("_voltro_webhook_deliveries", { where: C(w("targetId", t), w("status", "pending")) });
|
|
297
297
|
},
|
|
298
298
|
rotateSecret: async (t) => {
|
|
299
299
|
let n = P();
|
|
@@ -311,7 +311,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
311
311
|
},
|
|
312
312
|
listTargets: async (t) => (await e.store.query({
|
|
313
313
|
table: "_voltro_webhook_targets",
|
|
314
|
-
predicate: t === void 0 ? void 0 :
|
|
314
|
+
predicate: t === void 0 ? void 0 : w("event", t),
|
|
315
315
|
order: [{
|
|
316
316
|
column: "createdAt",
|
|
317
317
|
direction: "desc"
|
|
@@ -333,15 +333,15 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
333
333
|
autoDisableReason: e.autoDisableReason
|
|
334
334
|
}))
|
|
335
335
|
};
|
|
336
|
-
},
|
|
336
|
+
}, fe = x.gen(function* () {
|
|
337
337
|
return yield* M;
|
|
338
|
-
}),
|
|
338
|
+
}), pe = (e) => {
|
|
339
339
|
if (e.webhooks === void 0) throw Error("useWebhooks: `ctx.webhooks` is not set. Add @voltro/plugin-webhooks to your app.config.ts plugin list and declare at least one *.webhook.tsx file.");
|
|
340
340
|
return e.webhooks;
|
|
341
|
-
},
|
|
341
|
+
}, me = 1e4, R = class {
|
|
342
342
|
capacity;
|
|
343
343
|
cache = /* @__PURE__ */ new Map();
|
|
344
|
-
constructor(e =
|
|
344
|
+
constructor(e = me) {
|
|
345
345
|
this.capacity = e;
|
|
346
346
|
}
|
|
347
347
|
evictExpired() {
|
|
@@ -387,9 +387,9 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
387
387
|
status: e,
|
|
388
388
|
contentType: "application/json; charset=utf-8",
|
|
389
389
|
body: JSON.stringify(t)
|
|
390
|
-
}),
|
|
390
|
+
}), he = (e, t) => {
|
|
391
391
|
let n = e.provider, r = e.signature ?? n?.signature ?? null, i = e.idempotency ?? n?.idempotency ?? null, o = e.bodyType ?? n?.bodyType ?? "json", s = t.idempotencyCache ?? B(), c = H(i?.ttl), l = t.log ?? ((e) => {
|
|
392
|
-
|
|
392
|
+
re({ scope: `webhook:${e.webhookId}` }).debug("incoming webhook", {
|
|
393
393
|
status: e.status,
|
|
394
394
|
signatureOk: e.signatureOk,
|
|
395
395
|
idempotency: e.idempotency,
|
|
@@ -462,7 +462,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
462
462
|
}
|
|
463
463
|
let p;
|
|
464
464
|
try {
|
|
465
|
-
p =
|
|
465
|
+
p = S.decodeUnknownSync(e.payload)(f);
|
|
466
466
|
} catch (t) {
|
|
467
467
|
let n = U(422, {
|
|
468
468
|
error: "schema validation failed",
|
|
@@ -540,7 +540,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
540
540
|
idempotency: h,
|
|
541
541
|
durationMs: Date.now() - u,
|
|
542
542
|
errorMessage: t.message
|
|
543
|
-
}),
|
|
543
|
+
}), ne({
|
|
544
544
|
error: t,
|
|
545
545
|
source: "webhook",
|
|
546
546
|
name: e.id,
|
|
@@ -548,9 +548,9 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
548
548
|
}), n;
|
|
549
549
|
}
|
|
550
550
|
};
|
|
551
|
-
},
|
|
551
|
+
}, ge = (e) => `/webhooks/${e}`, W = "_voltro_webhook_rate_windows", G = 6e4, _e = (e, t) => `${e}@${t}`, K = async (e, t, n) => (await e.query({
|
|
552
552
|
table: "_voltro_webhook_rate_windows",
|
|
553
|
-
predicate:
|
|
553
|
+
predicate: C(w("scope", t), w("bucket", n)),
|
|
554
554
|
order: [],
|
|
555
555
|
take: 1,
|
|
556
556
|
skip: void 0,
|
|
@@ -561,7 +561,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
561
561
|
let i = await K(e, t.key, n);
|
|
562
562
|
if (i === null) {
|
|
563
563
|
await e.insertIgnore(W, {
|
|
564
|
-
id:
|
|
564
|
+
id: _e(t.key, n),
|
|
565
565
|
scope: t.key,
|
|
566
566
|
bucket: n,
|
|
567
567
|
count: 0,
|
|
@@ -574,7 +574,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
574
574
|
if (await e.updateMany("_voltro_webhook_rate_windows", {
|
|
575
575
|
count: i.count + 1,
|
|
576
576
|
updatedAt: /* @__PURE__ */ new Date()
|
|
577
|
-
}, { where:
|
|
577
|
+
}, { where: C(w("id", i.id), w("count", i.count)) }) === 1) return !0;
|
|
578
578
|
}
|
|
579
579
|
throw Error(`webhook rate window: contention on (${t.key}, ${n}) exceeded ${q} attempts`);
|
|
580
580
|
}, Y = async (e, t, n) => {
|
|
@@ -584,7 +584,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
584
584
|
if (i === null || i.count <= 0 || await e.updateMany("_voltro_webhook_rate_windows", {
|
|
585
585
|
count: i.count - 1,
|
|
586
586
|
updatedAt: /* @__PURE__ */ new Date()
|
|
587
|
-
}, { where:
|
|
587
|
+
}, { where: C(w("id", i.id), w("count", i.count)) }) === 1) return;
|
|
588
588
|
}
|
|
589
589
|
throw Error(`webhook rate window: release contention on (${t}) exceeded ${q} attempts`);
|
|
590
590
|
}, X = async (e, t, n, r = G) => {
|
|
@@ -624,15 +624,15 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
624
624
|
}
|
|
625
625
|
return e === "form" ? {
|
|
626
626
|
contentType: "application/x-www-form-urlencoded",
|
|
627
|
-
bytes: Buffer.from(
|
|
627
|
+
bytes: Buffer.from(ve(n), "utf8")
|
|
628
628
|
} : {
|
|
629
629
|
contentType: "application/xml",
|
|
630
|
-
bytes: Buffer.from(
|
|
630
|
+
bytes: Buffer.from(be(n), "utf8")
|
|
631
631
|
};
|
|
632
|
-
},
|
|
632
|
+
}, ve = (e) => {
|
|
633
633
|
if (typeof e != "object" || !e || Array.isArray(e)) throw new f({
|
|
634
634
|
format: "form",
|
|
635
|
-
reason: `form encoding requires a JSON object at the top level, got ${
|
|
635
|
+
reason: `form encoding requires a JSON object at the top level, got ${Ce(e)}`
|
|
636
636
|
});
|
|
637
637
|
let t = new URLSearchParams();
|
|
638
638
|
for (let [n, r] of Object.entries(e)) Q(t, n, r);
|
|
@@ -651,13 +651,13 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
651
651
|
return;
|
|
652
652
|
}
|
|
653
653
|
e.append(t, String(n));
|
|
654
|
-
},
|
|
655
|
-
if (!
|
|
654
|
+
}, ye = /^[A-Za-z_][A-Za-z0-9_.-]*$/, be = (e) => `<?xml version="1.0" encoding="UTF-8"?>${$("webhook", e)}`, $ = (e, t) => {
|
|
655
|
+
if (!ye.test(e)) throw new f({
|
|
656
656
|
format: "xml",
|
|
657
657
|
reason: `object key ${JSON.stringify(e)} is not a valid XML element name`
|
|
658
658
|
});
|
|
659
|
-
return `<${e}>${
|
|
660
|
-
},
|
|
659
|
+
return `<${e}>${xe(e, t)}</${e}>`;
|
|
660
|
+
}, xe = (e, t) => t == null ? "" : Array.isArray(t) ? t.map((e) => $("item", e)).join("") : typeof t == "object" ? Object.entries(t).map(([e, t]) => $(e, t)).join("") : Se(String(t)), Se = (e) => e.replace(/[&<>"']/g, (e) => {
|
|
661
661
|
switch (e) {
|
|
662
662
|
case "&": return "&";
|
|
663
663
|
case "<": return "<";
|
|
@@ -665,17 +665,17 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
665
665
|
case "\"": return """;
|
|
666
666
|
default: return "'";
|
|
667
667
|
}
|
|
668
|
-
}),
|
|
668
|
+
}), Ce = (e) => e === null ? "null" : Array.isArray(e) ? "array" : typeof e, we = "_voltro_webhook_targets", Te = 32, Ee = async (e, t) => (await e.query({
|
|
669
669
|
table: "_voltro_webhook_targets",
|
|
670
|
-
predicate:
|
|
670
|
+
predicate: w("id", t),
|
|
671
671
|
order: [],
|
|
672
672
|
take: 1,
|
|
673
673
|
skip: void 0,
|
|
674
674
|
projection: void 0
|
|
675
|
-
}))[0] ?? null,
|
|
676
|
-
for (let a = 0; a <
|
|
675
|
+
}))[0] ?? null, De = async (e, t, n, r, i = /* @__PURE__ */ new Date()) => {
|
|
676
|
+
for (let a = 0; a < Te; a++) {
|
|
677
677
|
a > 0 && await new Promise((e) => setTimeout(e, a));
|
|
678
|
-
let o = await
|
|
678
|
+
let o = await Ee(e, t);
|
|
679
679
|
if (o === null) return {
|
|
680
680
|
consecutiveFailures: 0,
|
|
681
681
|
autoDisabled: !1
|
|
@@ -685,7 +685,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
685
685
|
if (s === 0 || await e.updateMany("_voltro_webhook_targets", {
|
|
686
686
|
consecutiveFailures: 0,
|
|
687
687
|
updatedAt: i
|
|
688
|
-
}, { where:
|
|
688
|
+
}, { where: C(w("id", t), w("consecutiveFailures", s)) }) === 1) return {
|
|
689
689
|
consecutiveFailures: 0,
|
|
690
690
|
autoDisabled: !1
|
|
691
691
|
};
|
|
@@ -695,68 +695,68 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
695
695
|
consecutiveFailures: c,
|
|
696
696
|
updatedAt: i
|
|
697
697
|
};
|
|
698
|
-
if (u && (d.active = !1, d.autoDisabledAt = i, d.autoDisableReason = r.slice(0, 500)), await e.updateMany("_voltro_webhook_targets", d, { where:
|
|
698
|
+
if (u && (d.active = !1, d.autoDisabledAt = i, d.autoDisableReason = r.slice(0, 500)), await e.updateMany("_voltro_webhook_targets", d, { where: C(w("id", t), w("consecutiveFailures", s)) }) === 1) return {
|
|
699
699
|
consecutiveFailures: c,
|
|
700
700
|
autoDisabled: u
|
|
701
701
|
};
|
|
702
702
|
}
|
|
703
|
-
throw Error(`webhook auto-disable: streak CAS contention on target ${t} exceeded ${
|
|
704
|
-
},
|
|
705
|
-
status:
|
|
706
|
-
body:
|
|
707
|
-
latencyMs:
|
|
708
|
-
retryAfterSec:
|
|
709
|
-
transportError:
|
|
710
|
-
encodeError:
|
|
711
|
-
}),
|
|
712
|
-
outcome:
|
|
713
|
-
delayMs:
|
|
714
|
-
reason:
|
|
715
|
-
autoDisabled:
|
|
716
|
-
consecutiveFailures:
|
|
717
|
-
}),
|
|
718
|
-
acquired:
|
|
719
|
-
retryInMs:
|
|
720
|
-
}),
|
|
703
|
+
throw Error(`webhook auto-disable: streak CAS contention on target ${t} exceeded ${Te} attempts`);
|
|
704
|
+
}, Oe = S.Struct({
|
|
705
|
+
status: S.Number,
|
|
706
|
+
body: S.String,
|
|
707
|
+
latencyMs: S.Number,
|
|
708
|
+
retryAfterSec: S.Union(S.Number, S.Undefined),
|
|
709
|
+
transportError: S.Union(S.String, S.Undefined),
|
|
710
|
+
encodeError: S.Union(S.String, S.Undefined)
|
|
711
|
+
}), ke = S.Struct({
|
|
712
|
+
outcome: S.Literal("succeeded", "failed", "retry"),
|
|
713
|
+
delayMs: S.Union(S.Number, S.Undefined),
|
|
714
|
+
reason: S.Union(S.String, S.Undefined),
|
|
715
|
+
autoDisabled: S.Union(S.Boolean, S.Undefined),
|
|
716
|
+
consecutiveFailures: S.Union(S.Number, S.Undefined)
|
|
717
|
+
}), Ae = S.Struct({
|
|
718
|
+
acquired: S.Boolean,
|
|
719
|
+
retryInMs: S.Number
|
|
720
|
+
}), je = ie.make({
|
|
721
721
|
name: "voltro.deliverWebhook",
|
|
722
722
|
payload: {
|
|
723
|
-
deliveryId:
|
|
724
|
-
targetId:
|
|
725
|
-
event:
|
|
726
|
-
eventId:
|
|
727
|
-
payloadJson:
|
|
728
|
-
attemptEpoch:
|
|
723
|
+
deliveryId: S.String,
|
|
724
|
+
targetId: S.String,
|
|
725
|
+
event: S.String,
|
|
726
|
+
eventId: S.String,
|
|
727
|
+
payloadJson: S.String,
|
|
728
|
+
attemptEpoch: S.optionalWith(S.Number, { default: () => 0 })
|
|
729
729
|
},
|
|
730
|
-
success:
|
|
731
|
-
finalStatus:
|
|
732
|
-
attempts:
|
|
730
|
+
success: S.Struct({
|
|
731
|
+
finalStatus: S.Literal("succeeded", "failed", "deferred"),
|
|
732
|
+
attempts: S.Number
|
|
733
733
|
}),
|
|
734
734
|
idempotencyKey: ({ deliveryId: e, attemptEpoch: t }) => `voltro.deliverWebhook:${e}:${t ?? 0}`
|
|
735
|
-
}),
|
|
736
|
-
let { deliveryId: i, targetId: a, event: s, eventId: c, payloadJson: l } = n, u =
|
|
737
|
-
return
|
|
735
|
+
}), Me = (e, t = {}) => (n, r) => {
|
|
736
|
+
let { deliveryId: i, targetId: a, event: s, eventId: c, payloadJson: l } = n, u = re({ scope: `webhook:${i}` });
|
|
737
|
+
return x.gen(function* () {
|
|
738
738
|
let n = yield* E.make({
|
|
739
739
|
name: "fetch-target",
|
|
740
|
-
success:
|
|
741
|
-
id:
|
|
742
|
-
event:
|
|
743
|
-
url:
|
|
744
|
-
secret:
|
|
745
|
-
signing:
|
|
746
|
-
retry:
|
|
747
|
-
headers:
|
|
748
|
-
key:
|
|
749
|
-
value:
|
|
750
|
-
}),
|
|
751
|
-
rateLimitPerMinute:
|
|
752
|
-
active:
|
|
753
|
-
format:
|
|
754
|
-
tenantId:
|
|
740
|
+
success: S.Union(S.Null, S.Struct({
|
|
741
|
+
id: S.String,
|
|
742
|
+
event: S.String,
|
|
743
|
+
url: S.String,
|
|
744
|
+
secret: S.String,
|
|
745
|
+
signing: S.Any,
|
|
746
|
+
retry: S.Any,
|
|
747
|
+
headers: S.Union(S.Record({
|
|
748
|
+
key: S.String,
|
|
749
|
+
value: S.String
|
|
750
|
+
}), S.Null),
|
|
751
|
+
rateLimitPerMinute: S.Union(S.Number, S.Null),
|
|
752
|
+
active: S.Boolean,
|
|
753
|
+
format: S.Literal("json", "form", "xml"),
|
|
754
|
+
tenantId: S.Union(S.String, S.Null)
|
|
755
755
|
})),
|
|
756
|
-
execute:
|
|
756
|
+
execute: x.tryPromise({
|
|
757
757
|
try: async () => (await e.store.query({
|
|
758
758
|
table: "_voltro_webhook_targets",
|
|
759
|
-
predicate:
|
|
759
|
+
predicate: w("id", a),
|
|
760
760
|
order: [{
|
|
761
761
|
column: "createdAt",
|
|
762
762
|
direction: "asc"
|
|
@@ -770,7 +770,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
770
770
|
event: s,
|
|
771
771
|
cause: String(e)
|
|
772
772
|
}), /* @__PURE__ */ Error(`fetch-target failed: ${e.message}`))
|
|
773
|
-
}).pipe(
|
|
773
|
+
}).pipe(x.orDie)
|
|
774
774
|
});
|
|
775
775
|
if (n === null) return {
|
|
776
776
|
finalStatus: "failed",
|
|
@@ -797,11 +797,11 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
797
797
|
};
|
|
798
798
|
if (n.active !== !0) return yield* E.make({
|
|
799
799
|
name: "record-paused-pending",
|
|
800
|
-
success:
|
|
801
|
-
execute:
|
|
800
|
+
success: S.Void,
|
|
801
|
+
execute: x.tryPromise({
|
|
802
802
|
try: () => r(`${i}:1`, 1, { status: "pending" }),
|
|
803
803
|
catch: (e) => /* @__PURE__ */ Error(`record-paused-pending failed: ${e.message}`)
|
|
804
|
-
}).pipe(
|
|
804
|
+
}).pipe(x.orDie)
|
|
805
805
|
}), {
|
|
806
806
|
finalStatus: "deferred",
|
|
807
807
|
attempts: 0
|
|
@@ -818,8 +818,8 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
818
818
|
if (v.length > 0) for (let n = 0;; n++) {
|
|
819
819
|
let i = yield* E.make({
|
|
820
820
|
name: `rate-acquire-${t}-${n}`,
|
|
821
|
-
success:
|
|
822
|
-
execute:
|
|
821
|
+
success: Ae,
|
|
822
|
+
execute: x.tryPromise({
|
|
823
823
|
try: () => X(e.store, v, Date.now(), g),
|
|
824
824
|
catch: (e) => (u.debug("rate-acquire raw error", {
|
|
825
825
|
targetId: a,
|
|
@@ -827,19 +827,19 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
827
827
|
attempt: t,
|
|
828
828
|
cause: String(e)
|
|
829
829
|
}), /* @__PURE__ */ Error(`rate-acquire failed: ${e.message}`))
|
|
830
|
-
}).pipe(
|
|
830
|
+
}).pipe(x.orDie)
|
|
831
831
|
});
|
|
832
832
|
if (i.acquired) break;
|
|
833
833
|
yield* E.make({
|
|
834
834
|
name: `record-rate-deferral-${t}-${n}`,
|
|
835
|
-
success:
|
|
836
|
-
execute:
|
|
835
|
+
success: S.Void,
|
|
836
|
+
execute: x.tryPromise({
|
|
837
837
|
try: () => r(d, t, {
|
|
838
838
|
status: "pending",
|
|
839
839
|
nextAttemptAt: new Date(Date.now() + i.retryInMs)
|
|
840
840
|
}),
|
|
841
841
|
catch: (e) => /* @__PURE__ */ Error(`record-rate-deferral failed: ${e.message}`)
|
|
842
|
-
}).pipe(
|
|
842
|
+
}).pipe(x.orDie)
|
|
843
843
|
}), yield* D.sleep({
|
|
844
844
|
name: `rate-window-sleep-${t}-${n}`,
|
|
845
845
|
duration: `${i.retryInMs} millis`
|
|
@@ -847,20 +847,20 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
847
847
|
}
|
|
848
848
|
yield* E.make({
|
|
849
849
|
name: `record-attempt-start-${t}`,
|
|
850
|
-
success:
|
|
851
|
-
execute:
|
|
850
|
+
success: S.Void,
|
|
851
|
+
execute: x.tryPromise({
|
|
852
852
|
try: () => r(d, t, {
|
|
853
853
|
status: "inFlight",
|
|
854
854
|
nextAttemptAt: null,
|
|
855
855
|
scheduledAt: /* @__PURE__ */ new Date()
|
|
856
856
|
}),
|
|
857
857
|
catch: (e) => /* @__PURE__ */ Error(`record-attempt-start failed: ${e.message}`)
|
|
858
|
-
}).pipe(
|
|
858
|
+
}).pipe(x.orDie)
|
|
859
859
|
});
|
|
860
860
|
let _ = yield* E.make({
|
|
861
861
|
name: `sign-and-post-${t}`,
|
|
862
|
-
success:
|
|
863
|
-
execute:
|
|
862
|
+
success: Oe,
|
|
863
|
+
execute: x.promise(async () => {
|
|
864
864
|
let e = Date.now(), r;
|
|
865
865
|
try {
|
|
866
866
|
r = Z(n.format, l);
|
|
@@ -876,7 +876,7 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
876
876
|
throw t;
|
|
877
877
|
}
|
|
878
878
|
let a = Buffer.from(r.bytes), { headerName: u, headerValue: d } = o(m, a, n.secret);
|
|
879
|
-
process.env.NODE_ENV === "production" &&
|
|
879
|
+
process.env.NODE_ENV === "production" && T(n.url);
|
|
880
880
|
try {
|
|
881
881
|
let o = await fetch(n.url, {
|
|
882
882
|
method: "POST",
|
|
@@ -911,12 +911,12 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
911
911
|
};
|
|
912
912
|
}
|
|
913
913
|
})
|
|
914
|
-
}),
|
|
914
|
+
}), y = yield* E.make({
|
|
915
915
|
name: `classify-and-record-${t}`,
|
|
916
|
-
success:
|
|
917
|
-
execute:
|
|
916
|
+
success: ke,
|
|
917
|
+
execute: x.tryPromise({
|
|
918
918
|
try: async () => {
|
|
919
|
-
let r = _.status >= 200 && _.status < 300, i = _.transportError !== void 0, o = _.encodeError !== void 0, s = (t, n) =>
|
|
919
|
+
let r = _.status >= 200 && _.status < 300, i = _.transportError !== void 0, o = _.encodeError !== void 0, s = (t, n) => De(e.store, a, t, n);
|
|
920
920
|
if (r) {
|
|
921
921
|
await e.store.update("_voltro_webhook_deliveries", d, {
|
|
922
922
|
status: "succeeded",
|
|
@@ -979,24 +979,24 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
979
979
|
attempt: t,
|
|
980
980
|
cause: String(e)
|
|
981
981
|
}), /* @__PURE__ */ Error(`classify-and-record failed: ${e.message}`))
|
|
982
|
-
}).pipe(
|
|
982
|
+
}).pipe(x.orDie)
|
|
983
983
|
});
|
|
984
|
-
if (
|
|
984
|
+
if (y.autoDisabled === !0 && u.warn("target auto-disabled after consecutive failures", {
|
|
985
985
|
targetId: a,
|
|
986
986
|
event: s,
|
|
987
|
-
consecutiveFailures:
|
|
988
|
-
reason:
|
|
989
|
-
}),
|
|
987
|
+
consecutiveFailures: y.consecutiveFailures ?? null,
|
|
988
|
+
reason: y.reason ?? null
|
|
989
|
+
}), y.outcome === "succeeded") return {
|
|
990
990
|
finalStatus: "succeeded",
|
|
991
991
|
attempts: t
|
|
992
992
|
};
|
|
993
|
-
if (
|
|
993
|
+
if (y.outcome === "failed") return {
|
|
994
994
|
finalStatus: "failed",
|
|
995
995
|
attempts: t
|
|
996
996
|
};
|
|
997
|
-
|
|
997
|
+
y.delayMs !== void 0 && (yield* D.sleep({
|
|
998
998
|
name: `retry-sleep-${t}`,
|
|
999
|
-
duration: `${
|
|
999
|
+
duration: `${y.delayMs} millis`
|
|
1000
1000
|
}));
|
|
1001
1001
|
}
|
|
1002
1002
|
return {
|
|
@@ -1006,4 +1006,4 @@ var ne = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, O = (e) => {
|
|
|
1006
1006
|
});
|
|
1007
1007
|
};
|
|
1008
1008
|
//#endregion
|
|
1009
|
-
export { R as IdempotencyCache, G as RATE_WINDOW_MS, W as RATE_WINDOW_TABLE,
|
|
1009
|
+
export { R as IdempotencyCache, G as RATE_WINDOW_MS, W as RATE_WINDOW_TABLE, we as TARGETS_TABLE, u as WebhookDeliveryNotFound, d as WebhookPayloadInvalid, f as WebhookPayloadUnrepresentable, p as WebhookPayloadVersionInvalid, m as WebhookSubscribeInvalid, M as WebhooksService, h as _voltroWebhookDeliveriesTable, g as _voltroWebhookRateWindowsTable, _ as _voltroWebhookTargetsTable, X as acquireRateSlots, Me as buildDeliverWebhookExecute, de as buildWebhooksService, ce as compareVersions, J as consumeRateSlot, ge as defaultIncomingPath, c as defaultOutgoingSignature, j as defaultRetryPolicy, s as defineIncomingWebhook, t as defineOutgoingEvent, r as defineWebhookProvider, je as deliverWebhookWorkflow, Z as encodePayload, oe as fastRetryPolicy, P as generateSecret, B as getIdempotencyCache, i as githubSignature, l as isWebhookDescriptor, L as matchesFilter, he as mountIncomingWebhook, A as nextRetry, O as parseDuration, H as parseTtl, De as recordDeliveryOutcome, Y as releaseRateSlot, I as resolveSubscribe, o as signPayload, n as slackSignature, e as stripeSignature, pe as useWebhooks, fe as useWebhooksEffect, F as validateSubscribe, a as verifySignature, v as webhookTables };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-webhooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Webhooks plugin — first-class outgoing (multi-target, durable-workflow delivery, HMAC signing, retry policies, rate limits) + incoming (signature verification, idempotency, provider templates for Stripe / GitHub / Slack / generic).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@effect/workflow": "^0.19.0",
|
|
51
|
-
"@voltro/database": "0.
|
|
52
|
-
"@voltro/integration-http": "0.
|
|
53
|
-
"@voltro/logger": "0.
|
|
54
|
-
"@voltro/plugin-multitenancy": "0.
|
|
55
|
-
"@voltro/protocol": "0.
|
|
56
|
-
"@voltro/runtime": "0.
|
|
51
|
+
"@voltro/database": "0.23.0",
|
|
52
|
+
"@voltro/integration-http": "0.23.0",
|
|
53
|
+
"@voltro/logger": "0.23.0",
|
|
54
|
+
"@voltro/plugin-multitenancy": "0.23.0",
|
|
55
|
+
"@voltro/protocol": "0.23.0",
|
|
56
|
+
"@voltro/runtime": "0.23.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"effect": "^3.22.0"
|