@end-close/relay 0.12.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/COMPATIBILITY.md +39 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/config/schema.d.ts +993 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +156 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/crypto/at-rest.d.ts +6 -0
- package/dist/crypto/at-rest.d.ts.map +1 -0
- package/dist/crypto/at-rest.js +19 -0
- package/dist/crypto/at-rest.js.map +1 -0
- package/dist/crypto/keys.d.ts +2 -0
- package/dist/crypto/keys.d.ts.map +1 -0
- package/dist/crypto/keys.js +12 -0
- package/dist/crypto/keys.js.map +1 -0
- package/dist/engine/codec.d.ts +12 -0
- package/dist/engine/codec.d.ts.map +1 -0
- package/dist/engine/codec.js +21 -0
- package/dist/engine/codec.js.map +1 -0
- package/dist/engine/hooks.d.ts +84 -0
- package/dist/engine/hooks.d.ts.map +1 -0
- package/dist/engine/hooks.js +34 -0
- package/dist/engine/hooks.js.map +1 -0
- package/dist/engine/ingest.d.ts +39 -0
- package/dist/engine/ingest.d.ts.map +1 -0
- package/dist/engine/ingest.js +131 -0
- package/dist/engine/ingest.js.map +1 -0
- package/dist/engine/memory-store.d.ts +35 -0
- package/dist/engine/memory-store.d.ts.map +1 -0
- package/dist/engine/memory-store.js +205 -0
- package/dist/engine/memory-store.js.map +1 -0
- package/dist/engine/relay.d.ts +116 -0
- package/dist/engine/relay.d.ts.map +1 -0
- package/dist/engine/relay.js +180 -0
- package/dist/engine/relay.js.map +1 -0
- package/dist/engine/secrets.d.ts +18 -0
- package/dist/engine/secrets.d.ts.map +1 -0
- package/dist/engine/secrets.js +42 -0
- package/dist/engine/secrets.js.map +1 -0
- package/dist/engine/settings.d.ts +29 -0
- package/dist/engine/settings.d.ts.map +1 -0
- package/dist/engine/settings.js +13 -0
- package/dist/engine/settings.js.map +1 -0
- package/dist/engine/store.d.ts +141 -0
- package/dist/engine/store.d.ts.map +1 -0
- package/dist/engine/store.js +52 -0
- package/dist/engine/store.js.map +1 -0
- package/dist/forward/backoff.d.ts +3 -0
- package/dist/forward/backoff.d.ts.map +1 -0
- package/dist/forward/backoff.js +10 -0
- package/dist/forward/backoff.js.map +1 -0
- package/dist/forward/dispatcher.d.ts +98 -0
- package/dist/forward/dispatcher.d.ts.map +1 -0
- package/dist/forward/dispatcher.js +444 -0
- package/dist/forward/dispatcher.js.map +1 -0
- package/dist/forward/endclose-client.d.ts +42 -0
- package/dist/forward/endclose-client.d.ts.map +1 -0
- package/dist/forward/endclose-client.js +76 -0
- package/dist/forward/endclose-client.js.map +1 -0
- package/dist/forward/enrich.d.ts +39 -0
- package/dist/forward/enrich.d.ts.map +1 -0
- package/dist/forward/enrich.js +77 -0
- package/dist/forward/enrich.js.map +1 -0
- package/dist/forward/mapper.d.ts +50 -0
- package/dist/forward/mapper.d.ts.map +1 -0
- package/dist/forward/mapper.js +141 -0
- package/dist/forward/mapper.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/adapters/generic-hmac.d.ts +3 -0
- package/dist/ingest/adapters/generic-hmac.d.ts.map +1 -0
- package/dist/ingest/adapters/generic-hmac.js +60 -0
- package/dist/ingest/adapters/generic-hmac.js.map +1 -0
- package/dist/ingest/adapters/payabli.d.ts +3 -0
- package/dist/ingest/adapters/payabli.d.ts.map +1 -0
- package/dist/ingest/adapters/payabli.js +57 -0
- package/dist/ingest/adapters/payabli.js.map +1 -0
- package/dist/ingest/adapters/registry.d.ts +4 -0
- package/dist/ingest/adapters/registry.d.ts.map +1 -0
- package/dist/ingest/adapters/registry.js +16 -0
- package/dist/ingest/adapters/registry.js.map +1 -0
- package/dist/ingest/adapters/types.d.ts +28 -0
- package/dist/ingest/adapters/types.d.ts.map +1 -0
- package/dist/ingest/adapters/types.js +5 -0
- package/dist/ingest/adapters/types.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mask/defaults.d.ts +7 -0
- package/dist/mask/defaults.d.ts.map +1 -0
- package/dist/mask/defaults.js +51 -0
- package/dist/mask/defaults.js.map +1 -0
- package/dist/mask/paths.d.ts +7 -0
- package/dist/mask/paths.d.ts.map +1 -0
- package/dist/mask/paths.js +36 -0
- package/dist/mask/paths.js.map +1 -0
- package/dist/util/payload-shape.d.ts +6 -0
- package/dist/util/payload-shape.d.ts.map +1 -0
- package/dist/util/payload-shape.js +19 -0
- package/dist/util/payload-shape.js.map +1 -0
- package/dist/util/strings.d.ts +3 -0
- package/dist/util/strings.d.ts.map +1 -0
- package/dist/util/strings.js +7 -0
- package/dist/util/strings.js.map +1 -0
- package/package.json +57 -0
- package/src/config/schema.ts +177 -0
- package/src/crypto/at-rest.ts +21 -0
- package/src/crypto/keys.ts +13 -0
- package/src/engine/codec.ts +30 -0
- package/src/engine/hooks.ts +76 -0
- package/src/engine/ingest.ts +194 -0
- package/src/engine/memory-store.ts +232 -0
- package/src/engine/relay.ts +294 -0
- package/src/engine/secrets.ts +51 -0
- package/src/engine/settings.ts +42 -0
- package/src/engine/store.ts +188 -0
- package/src/forward/backoff.ts +10 -0
- package/src/forward/dispatcher.ts +504 -0
- package/src/forward/endclose-client.ts +102 -0
- package/src/forward/enrich.ts +106 -0
- package/src/forward/mapper.ts +191 -0
- package/src/index.ts +47 -0
- package/src/ingest/adapters/generic-hmac.ts +70 -0
- package/src/ingest/adapters/payabli.ts +67 -0
- package/src/ingest/adapters/registry.ts +21 -0
- package/src/ingest/adapters/types.ts +33 -0
- package/src/logger.ts +26 -0
- package/src/mask/defaults.ts +56 -0
- package/src/mask/paths.ts +36 -0
- package/src/util/payload-shape.ts +19 -0
- package/src/util/strings.ts +7 -0
package/COMPATIBILITY.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Compatibility
|
|
2
|
+
|
|
3
|
+
Within a major version the following are stable and change only additively:
|
|
4
|
+
|
|
5
|
+
- **`createRelay()` and its options** (`RelayOptions` including `enrichments`,
|
|
6
|
+
`DispatchSettings` including `enrichTimeoutMs`, `RetentionSettings` and their defaults),
|
|
7
|
+
the `Relay` methods (`ingest`, `start`, `stop`, `dispatchOnce`, `flush`, `prune`, `preview`,
|
|
8
|
+
`readPayload`, `on`) and their result shapes (`IngestResult`, `DispatchOnceResult`,
|
|
9
|
+
`FlushResult` including its `reason` values).
|
|
10
|
+
- **The enrichment contract**: `Enrichment` (`(input, ctx) => value | undefined`, sync or
|
|
11
|
+
async), `EnrichContext` fields (may be added, never removed or retyped), the meaning of
|
|
12
|
+
`undefined` (omit), a throw (retry) and `EnrichmentError` (park). Validation of returned
|
|
13
|
+
values may tighten in a minor release, like denylist patterns.
|
|
14
|
+
- **The routes document** (`routes:` in relay.yaml / `parseRoutes`): field names, transforms
|
|
15
|
+
(`trim`, `lowercase`, `hash`), the `enrich:` key on `description` and `metadata` field refs,
|
|
16
|
+
`auth` modes, the hard denylist's *existence*. Field-ref objects are strict: unknown keys
|
|
17
|
+
are errors. Secrets are referenced by name (`auth.secret_env`), never by value. Denylist
|
|
18
|
+
*patterns* may tighten in a minor release.
|
|
19
|
+
- **`mapEvent` output**: the record shape (`date`, `data_stream_key`, `amount` in integer
|
|
20
|
+
cents, `direction`, `external_id`, `currency?`, `description?`, `metadata`), plus
|
|
21
|
+
`pending` (enriched fields the dispatcher fills) and `report.enriched`.
|
|
22
|
+
- **Idempotency derivations**: per event `sha256(source + ":" + eventId)`; per bulk request
|
|
23
|
+
`"relay-" + sha256("<data_stream_key>:<external_id>\n" per record)[0:40]`; bulk POSTs use
|
|
24
|
+
`on_conflict: "skip"`.
|
|
25
|
+
- **`EventStore` / `EventStoreAdmin` / `ControlStore` / `RouteProvider`** interfaces and the
|
|
26
|
+
behavioural contract in `@end-close/relay-store-contract`. New optional methods may be added;
|
|
27
|
+
required methods are not. A store signals lock contention or loss of connection by throwing
|
|
28
|
+
`StoreUnavailableError` (ingest answers 503); any other `StoreError` answers 500.
|
|
29
|
+
- **`IngestResult`** status codes and outcomes.
|
|
30
|
+
- **Hook event names and payload fields** (fields may be added).
|
|
31
|
+
- **Adapter interface** `ProcessorAdapter` (`verify`, `extractEventId`, `extractEventType`).
|
|
32
|
+
|
|
33
|
+
Not covered: the internal `Dispatcher` class, package file layout, log message text.
|
|
34
|
+
|
|
35
|
+
`@end-close/relay`, `@end-close/relay-sqlite` and `@end-close/relay-store-contract` are published
|
|
36
|
+
to npm in lockstep with the relay application: every release tags one version, and all three
|
|
37
|
+
packages carry it. Install them at the same version. The release PR stamps the product
|
|
38
|
+
version into each package manifest (`0.0.0` only ever means "not yet released"). While the
|
|
39
|
+
major is `0`, a minor bump may carry breaking changes; patch releases are additive.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 End Close
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# @end-close/relay
|
|
2
|
+
|
|
3
|
+
The store-and-forward engine behind the End Close relay application, as a library you embed in
|
|
4
|
+
your own Node backend. Same code, same guarantees:
|
|
5
|
+
|
|
6
|
+
1. **Verify** each webhook over the raw bytes (Payabli static header + source IP; HMAC for
|
|
7
|
+
processors that sign).
|
|
8
|
+
2. **Persist it durably before acknowledging** in a store you choose.
|
|
9
|
+
3. **Map** it to an End Close record through an explicit field map: the map *is* the
|
|
10
|
+
allowlist, and a non-configurable hard denylist (PANs, SSNs, secret-named fields) applies
|
|
11
|
+
on top. A mapped field may name an **enrichment**: a function of yours that turns the
|
|
12
|
+
payload value into what is forwarded (a resident's name from a payer id, say). The map
|
|
13
|
+
still names every field; your code only fills the ones it points at.
|
|
14
|
+
4. **Forward** in batches to End Close's public API with exponential backoff, idempotency
|
|
15
|
+
at both ends, and parking (never silent dropping) of events that cannot be delivered.
|
|
16
|
+
|
|
17
|
+
Node `>=22.12`, ESM. The only runtime dependency is `zod`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install @end-close/relay @end-close/relay-sqlite
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Install both at the same version: `@end-close/relay-sqlite` peers on `@end-close/relay`, and the
|
|
26
|
+
packages are released together in lockstep with the relay application (the version on npm is the
|
|
27
|
+
product version). Skip `@end-close/relay-sqlite` if you bring your own `EventStore`. MIT licensed.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createRelay, parseRoutes, envSecrets } from '@end-close/relay'
|
|
33
|
+
import { SqliteEventStore, SqliteControlStore, openDb, migrate } from '@end-close/relay-sqlite'
|
|
34
|
+
import { parse } from 'yaml'
|
|
35
|
+
import { readFileSync } from 'node:fs'
|
|
36
|
+
|
|
37
|
+
const db = openDb('/var/lib/myapp/relay.db')
|
|
38
|
+
migrate(db)
|
|
39
|
+
|
|
40
|
+
const relay = createRelay({
|
|
41
|
+
// The `routes` block of relay.yaml, validated. Only fields named in each `map` are forwarded
|
|
42
|
+
// (pass `{ enrichments }` as the second argument if any route uses `enrich:`).
|
|
43
|
+
routes: parseRoutes(parse(readFileSync('relay.yaml', 'utf8'))),
|
|
44
|
+
store: new SqliteEventStore(db),
|
|
45
|
+
control: new SqliteControlStore(db), // killswitch + per-route pause; omit for in-memory
|
|
46
|
+
secrets: envSecrets(process.env), // resolves `auth.secret_env` names
|
|
47
|
+
endclose: { apiKey: process.env.ENDCLOSE_API_KEY! },
|
|
48
|
+
encryption: { dataKey: process.env.RELAY_DATA_KEY! }, // or 'none' (be explicit)
|
|
49
|
+
maskingKey: process.env.MASKING_HMAC_KEY!, // keys the `hash` transform
|
|
50
|
+
logger: myLogger, // { debug, info, warn, error }(msg, scalarMeta)
|
|
51
|
+
instanceId: 'api-1', // stable per replica: a restart reclaims its own batch at once
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// 1. Mount the ingest path in your HTTP framework. Give it the RAW body bytes.
|
|
55
|
+
app.post('/webhooks/:route', async (req, res) => {
|
|
56
|
+
const result = await relay.ingest(req.params.route, {
|
|
57
|
+
rawBody: req.rawBody, // Buffer, exactly as sent
|
|
58
|
+
headers: req.headers,
|
|
59
|
+
remoteIp: req.ip, // the true client IP (see below)
|
|
60
|
+
})
|
|
61
|
+
res.status(result.status).json(result.body)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// 2a. Long-lived process: run the dispatch loop.
|
|
65
|
+
relay.start()
|
|
66
|
+
process.on('SIGTERM', () => relay.stop().then(() => process.exit(0)))
|
|
67
|
+
|
|
68
|
+
// 2b. Serverless / cron: run one cycle per invocation instead.
|
|
69
|
+
// await relay.dispatchOnce({ prune: true })
|
|
70
|
+
// 2c. Deliver before this process exits (shutdown, a serverless function):
|
|
71
|
+
// const out = await relay.flush({ timeoutMs: 5_000 }) // { delivered, retried, parked, drained, reason? }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Knowing what happened to an event
|
|
75
|
+
|
|
76
|
+
`ingest()` resolves as soon as the event is durably in the store; a 2xx means **buffered**,
|
|
77
|
+
never **sent**. The accepted result carries the store `id`. From there:
|
|
78
|
+
|
|
79
|
+
- `dispatchOnce()` runs exactly one cycle (at most `batchMax` per route) and returns counts.
|
|
80
|
+
Use it from a scheduler; overlapping runs are safe because claims are leased.
|
|
81
|
+
- `flush()` loops cycles until nothing deliverable remains or the deadline passes, retrying
|
|
82
|
+
as backoff timers expire. It returns immediately with `reason: 'paused'` if forwarding is
|
|
83
|
+
paused, `reason: 'unroutable'` if due events belong to routes the provider no longer
|
|
84
|
+
returns, and `reason: 'timeout'` with `retried > 0` if End Close stayed down. Flushing
|
|
85
|
+
cannot make an unavailable End Close accept records: with `memoryStore()` those events
|
|
86
|
+
are lost when the process exits, with a durable store the next cycle picks them up.
|
|
87
|
+
- `relay.on('settled', e => …)` fires per event with `{ id, routeId, result, error? }` where
|
|
88
|
+
`result` is `delivered`, `retried` or `parked` — correlate with the `id` from `ingest()`.
|
|
89
|
+
- `relay.store.getById(id)` (stores with the admin capability) gives the current `status`,
|
|
90
|
+
`attempts`, `next_attempt_at` and `last_error`.
|
|
91
|
+
|
|
92
|
+
**Serverless recipe:** a durable store shared across invocations, `dispatchOnce()` on a
|
|
93
|
+
schedule as the guarantee, and optionally `await relay.flush()` after `ingest()` for low
|
|
94
|
+
latency when End Close is healthy.
|
|
95
|
+
|
|
96
|
+
## Enriching fields from your own data
|
|
97
|
+
|
|
98
|
+
A webhook rarely carries everything End Close should see. When the missing value lives in
|
|
99
|
+
your own systems, register an **enrichment** and name it from the map:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const enrichments = {
|
|
103
|
+
// (value at `source` after transforms, ctx) → the value to forward. undefined → omit the field.
|
|
104
|
+
resident_name: async (payorId, ctx) => {
|
|
105
|
+
const r = await residents.findByPayerId(String(payorId)) // your database, your code
|
|
106
|
+
return r?.fullName
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
const relay = createRelay({
|
|
110
|
+
routes: parseRoutes(parse(yaml), { enrichments }), // validation knows the registered names
|
|
111
|
+
enrichments,
|
|
112
|
+
dispatch: { enrichTimeoutMs: 3_000 }, // per call; default 5 s
|
|
113
|
+
...
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
map:
|
|
119
|
+
external_id: TransactionId
|
|
120
|
+
amount: NetAmount
|
|
121
|
+
direction: credit
|
|
122
|
+
metadata:
|
|
123
|
+
paypoint: Paypoint
|
|
124
|
+
resident_name: { source: PayorId, enrich: resident_name }
|
|
125
|
+
resident_unit: { source: PayorId, transform: trim, enrich: resident_unit }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
How it behaves:
|
|
129
|
+
|
|
130
|
+
- **The map stays the allowlist.** `enrich:` is allowed on `metadata` entries and
|
|
131
|
+
`description` only; `external_id` and `amount` are never host-computed. A route that names
|
|
132
|
+
an enrichment you have not registered fails `parseRoutes` / `createRelay` (or parks its
|
|
133
|
+
events, for routes from a `RouteProvider`). The shipped application registers none, so it
|
|
134
|
+
rejects `enrich:` outright.
|
|
135
|
+
- **Order per field:** source value → `transform`s → your function → validation → hard
|
|
136
|
+
denylist → record. If the source is absent the function is not called and the field is
|
|
137
|
+
omitted. `ctx` carries `routeId`, `source`, `eventId`, `eventType`, `receivedAt`, `field`
|
|
138
|
+
and the decrypted `payload` for inputs beyond the one value.
|
|
139
|
+
- **Return `undefined`** to omit the field and still send the record (unknown payer).
|
|
140
|
+
**Throw** to retry the whole event with backoff — the rest of its batch still ships, and it
|
|
141
|
+
parks after `parkAfterMs` like any retrying event. **Throw `EnrichmentError`** to park it
|
|
142
|
+
now. A call slower than `enrichTimeoutMs` counts as a throw.
|
|
143
|
+
- **What you return is checked like any mapped value:** JSON only, no sensitive key names
|
|
144
|
+
at any depth, PANs and SSNs inside strings redacted, and a sensitive output name (`ssn`,
|
|
145
|
+
`account_number`, …) is refused by the schema even with `transform: hash`, since the
|
|
146
|
+
hash protects the input, not your output. A bad value parks the event with the reason.
|
|
147
|
+
- **Enrichments are reads that may run more than once per event** (retry, lease recovery,
|
|
148
|
+
replay). Keep them idempotent and side-effect free, and keep thrown messages free of
|
|
149
|
+
personal data: they are stored as `last_error` and emitted on `settled`.
|
|
150
|
+
- Calls run one at a time per event, so a batch can take up to `batchMax × enrichments per
|
|
151
|
+
event × enrichTimeoutMs`; keep that under `leaseMs` (defaults: 100 × 1 × 5 s vs 600 s).
|
|
152
|
+
- `relay.on('enrich', e => …)` reports each call as `{ routeId, id, field, enrichment,
|
|
153
|
+
result: applied | omitted | failed | rejected, error? }` — names only, never values.
|
|
154
|
+
- `relay.preview(route, sample)` does not run enrichments; it lists their fields in
|
|
155
|
+
`report.enriched` and `pending`.
|
|
156
|
+
|
|
157
|
+
What your framework must do because the engine cannot:
|
|
158
|
+
|
|
159
|
+
- **Hand over the raw body.** Signature verification and the stored payload operate on the
|
|
160
|
+
exact bytes the processor sent. Disable JSON pre-parsing for the webhook route.
|
|
161
|
+
- **Cap request size** before it reaches `ingest` (the application uses 10 MB); per-route
|
|
162
|
+
`max_body_bytes` is enforced inside.
|
|
163
|
+
- **Pass the real client IP** if you use `allowed_ips` (configure your proxy trust).
|
|
164
|
+
|
|
165
|
+
## Storage
|
|
166
|
+
|
|
167
|
+
`EventStore` is an async interface (`packages/core/src/engine/store.ts`). Ships with:
|
|
168
|
+
|
|
169
|
+
- `@end-close/relay-sqlite` — the application's store. Rollback journal + `synchronous=FULL`,
|
|
170
|
+
safe on network filesystems. Add `SqliteControlStore` for killswitch state.
|
|
171
|
+
- `memoryStore()` — in-process, **not durable**. For development and tests.
|
|
172
|
+
|
|
173
|
+
Any implementation that passes `describeEventStoreContract()` from
|
|
174
|
+
`@end-close/relay-store-contract` works. Claiming is lease-based, so several instances can
|
|
175
|
+
share one store (a SQL store would use `FOR UPDATE SKIP LOCKED` in `claimDue`). Give each
|
|
176
|
+
long-lived replica a stable, distinct `instanceId`: on boot an instance reclaims batches it
|
|
177
|
+
left `delivering` (a crash), and every `recoverIntervalMs` it sweeps leases other instances
|
|
178
|
+
let expire. A random id works too; a crashed replica's batch then waits out `leaseMs`.
|
|
179
|
+
|
|
180
|
+
## Observability
|
|
181
|
+
|
|
182
|
+
`relay.on(event, handler)` delivers metadata-only events: `ingest`, `stored`, `settled`,
|
|
183
|
+
`forward`, `delivered`, `enrich`, `batch.forwarded`, `batch.parked`, `prune`, `error`. Payloads
|
|
184
|
+
and enriched values are never included.
|
|
185
|
+
The application drives its Prometheus metrics and call-home from these; the engine itself
|
|
186
|
+
never phones home.
|
|
187
|
+
|
|
188
|
+
## Operating
|
|
189
|
+
|
|
190
|
+
- `relay.control.setKillswitch('pause' | 'panic' | 'none')`, `setRoutePaused(id, bool)`.
|
|
191
|
+
- `relay.store` with `EventStoreAdmin` (SQLite and memory stores have it): `list`, `getById`,
|
|
192
|
+
`replay`, `replayAllParked`, `countByStatus`, `perRouteStats`.
|
|
193
|
+
- `relay.preview(route, samplePayload)` shows exactly what would leave your network, with
|
|
194
|
+
enriched fields listed as pending rather than computed.
|
|
195
|
+
- `relay.readPayload(id)` decrypts a buffered payload. Audit it yourself.
|
|
196
|
+
- Retention (`retention: { deliveredDays, ledgerDays }`, or `false`) runs hourly under
|
|
197
|
+
`start()` or on `dispatchOnce({ prune: true })` / `relay.prune()`.
|
|
198
|
+
|
|
199
|
+
## Lower-level pieces
|
|
200
|
+
|
|
201
|
+
For a backend with its own queue: `verify` via `adapterFor(source).verify(raw, route, { secret })`,
|
|
202
|
+
`mapEvent(route, payload, receivedAt, maskingKey)`, `EndCloseClient`, `hardDenyDeep`,
|
|
203
|
+
`keyNameIsSensitive`, `parseRoutes`.
|
|
204
|
+
|
|
205
|
+
See [`COMPATIBILITY.md`](./COMPATIBILITY.md) for what is a stable contract.
|