commitrail 0.1.0-alpha.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/LICENSE +201 -0
- package/README.md +12 -0
- package/dist/cjs/envelope.d.ts +91 -0
- package/dist/cjs/envelope.js +54 -0
- package/dist/cjs/envelope.js.map +1 -0
- package/dist/cjs/index.d.ts +20 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/postgres.d.ts +152 -0
- package/dist/cjs/postgres.js +373 -0
- package/dist/cjs/postgres.js.map +1 -0
- package/dist/cjs/signing.d.ts +66 -0
- package/dist/cjs/signing.js +108 -0
- package/dist/cjs/signing.js.map +1 -0
- package/dist/cjs/subjects.d.ts +53 -0
- package/dist/cjs/subjects.js +88 -0
- package/dist/cjs/subjects.js.map +1 -0
- package/dist/cjs/webhooks.d.ts +100 -0
- package/dist/cjs/webhooks.js +214 -0
- package/dist/cjs/webhooks.js.map +1 -0
- package/dist/esm/envelope.d.ts +91 -0
- package/dist/esm/envelope.js +50 -0
- package/dist/esm/envelope.js.map +1 -0
- package/dist/esm/index.d.ts +20 -0
- package/dist/esm/index.js +21 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/postgres.d.ts +152 -0
- package/dist/esm/postgres.js +366 -0
- package/dist/esm/postgres.js.map +1 -0
- package/dist/esm/signing.d.ts +66 -0
- package/dist/esm/signing.js +101 -0
- package/dist/esm/signing.js.map +1 -0
- package/dist/esm/subjects.d.ts +53 -0
- package/dist/esm/subjects.js +83 -0
- package/dist/esm/subjects.js.map +1 -0
- package/dist/esm/webhooks.d.ts +100 -0
- package/dist/esm/webhooks.js +209 -0
- package/dist/esm/webhooks.js.map +1 -0
- package/package.json +108 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EventIdConflictError = exports.OUTBOX_SCHEMA_SQL = exports.OUTBOX_SCHEMA_VERSION = exports.OUTBOX_MIGRATIONS = void 0;
|
|
4
|
+
exports.emit = emit;
|
|
5
|
+
exports.transaction = transaction;
|
|
6
|
+
exports.fromPrisma = fromPrisma;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const subjects_js_1 = require("./subjects.js");
|
|
9
|
+
/** Registered globally by description, so every copy of this package agrees on it. */
|
|
10
|
+
const CONFLICTING_EVENT_BRAND = Symbol.for('commitrail.EventIdConflictError');
|
|
11
|
+
/**
|
|
12
|
+
* The outbox schema, as an append-only list of migrations.
|
|
13
|
+
*
|
|
14
|
+
* A list rather than one script, because the alternative already caused a problem. A single
|
|
15
|
+
* converge-to-latest constant changes when the package changes, so a customer who pasted it
|
|
16
|
+
* into `001_add_commitrail.sql` finds that migration meaning something different a year later
|
|
17
|
+
* — which is the one thing migrations exist not to do. Pinning a version instead gives them an
|
|
18
|
+
* immutable statement of what they applied.
|
|
19
|
+
*
|
|
20
|
+
* **Every entry here is frozen once released.** A released migration has run against databases
|
|
21
|
+
* we do not own and cannot re-run; editing one changes what a version *means* while leaving
|
|
22
|
+
* every already-migrated database untouched. Fix a mistake by appending, never by amending.
|
|
23
|
+
*
|
|
24
|
+
* Each is written to be safely re-runnable — `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` — so
|
|
25
|
+
* applying the whole list to any database converges it, whatever it started from.
|
|
26
|
+
*/
|
|
27
|
+
exports.OUTBOX_MIGRATIONS = [
|
|
28
|
+
{
|
|
29
|
+
version: 1,
|
|
30
|
+
sql: `
|
|
31
|
+
CREATE SCHEMA IF NOT EXISTS commitrail;
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS commitrail.outbox_events (
|
|
34
|
+
event_id UUID PRIMARY KEY,
|
|
35
|
+
source_sequence BIGINT GENERATED ALWAYS AS IDENTITY,
|
|
36
|
+
transaction_id xid8 NOT NULL DEFAULT pg_current_xact_id(),
|
|
37
|
+
event_type TEXT NOT NULL,
|
|
38
|
+
event_version INTEGER NOT NULL DEFAULT 1,
|
|
39
|
+
payload JSONB NOT NULL,
|
|
40
|
+
|
|
41
|
+
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
42
|
+
|
|
43
|
+
-- Which logical operation this event belongs to, and which event caused it.
|
|
44
|
+
--
|
|
45
|
+
-- Supplied by the application or left null; CommitRail never infers either. Chronological
|
|
46
|
+
-- proximity is not causation, and a timeline drawn from a guess is worse than no timeline.
|
|
47
|
+
-- A null correlation groups with nothing rather than with every other null.
|
|
48
|
+
--
|
|
49
|
+
-- Text rather than uuid: a correlation is the customer's own identifier for a business
|
|
50
|
+
-- operation — an order number, a checkout id — and forcing it into a uuid would make them
|
|
51
|
+
-- invent a second one and keep a mapping.
|
|
52
|
+
correlation_id TEXT,
|
|
53
|
+
causation_id TEXT
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS outbox_events_capture_idx
|
|
57
|
+
ON commitrail.outbox_events (transaction_id, source_sequence, event_id);
|
|
58
|
+
`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
version: 2,
|
|
62
|
+
sql: `
|
|
63
|
+
-- The business identities an event concerns — a JSON array of {"type","id"} pairs, or null
|
|
64
|
+
-- when the event declares none. CommitRail normalises these into its own indexed table at
|
|
65
|
+
-- acceptance; they are never queried in the outbox.
|
|
66
|
+
--
|
|
67
|
+
-- Appended rather than declared in the table above, so that a fresh install and an upgraded
|
|
68
|
+
-- one have the same column order rather than merely the same columns.
|
|
69
|
+
ALTER TABLE commitrail.outbox_events ADD COLUMN IF NOT EXISTS subjects JSONB;
|
|
70
|
+
`,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
version: 3,
|
|
74
|
+
sql: `
|
|
75
|
+
-- Finding every event in one logical operation is the correlation timeline's whole query.
|
|
76
|
+
CREATE INDEX IF NOT EXISTS outbox_events_correlation_idx
|
|
77
|
+
ON commitrail.outbox_events (correlation_id)
|
|
78
|
+
WHERE correlation_id IS NOT NULL;
|
|
79
|
+
`,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
version: 4,
|
|
83
|
+
sql: `
|
|
84
|
+
-- Which migrations have been applied. Bookkeeping, and only bookkeeping.
|
|
85
|
+
--
|
|
86
|
+
-- CommitRail never decides what it can read from this number. Capture inspects the actual
|
|
87
|
+
-- columns and preflight validates the actual structure, because a marker can be wrong — hand
|
|
88
|
+
-- edited, restored from a backup taken mid-migration, or written by a migration that half
|
|
89
|
+
-- applied — and a version that outranks reality is worse than no version at all. What this buys
|
|
90
|
+
-- is the ability to say "you are on 3 of 4" rather than "something is missing", and to give a
|
|
91
|
+
-- customer an immutable thing to pin in their own migration history.
|
|
92
|
+
CREATE TABLE IF NOT EXISTS commitrail.outbox_schema (
|
|
93
|
+
-- One row, enforced rather than assumed.
|
|
94
|
+
singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
|
|
95
|
+
version INTEGER NOT NULL,
|
|
96
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
97
|
+
);
|
|
98
|
+
`,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
/** The newest schema this package knows how to write. */
|
|
102
|
+
exports.OUTBOX_SCHEMA_VERSION = exports.OUTBOX_MIGRATIONS[exports.OUTBOX_MIGRATIONS.length - 1].version;
|
|
103
|
+
/**
|
|
104
|
+
* Record which version has been applied.
|
|
105
|
+
*
|
|
106
|
+
* `GREATEST` so that re-running an older migration cannot walk the marker backwards — applying
|
|
107
|
+
* the whole list to a database that is already current is a supported thing to do, and it must
|
|
108
|
+
* be a no-op rather than a downgrade.
|
|
109
|
+
*/
|
|
110
|
+
const RECORD_VERSION = (version) => `
|
|
111
|
+
INSERT INTO commitrail.outbox_schema (version) VALUES (${version})
|
|
112
|
+
ON CONFLICT (singleton) DO UPDATE
|
|
113
|
+
SET version = GREATEST(commitrail.outbox_schema.version, EXCLUDED.version),
|
|
114
|
+
applied_at = now();
|
|
115
|
+
`;
|
|
116
|
+
/** The migration that introduced the marker table; nothing before it can record anything. */
|
|
117
|
+
const FIRST_RECORDED_VERSION = 4;
|
|
118
|
+
/**
|
|
119
|
+
* Every migration, in order, for a fresh install or to bring an old outbox up to date.
|
|
120
|
+
*
|
|
121
|
+
* Safe to run against any database at any version: every statement is conditional, and the
|
|
122
|
+
* marker only moves forward. This is the convenience; `OUTBOX_MIGRATIONS` is the contract.
|
|
123
|
+
*
|
|
124
|
+
* Versions before the marker table existed record nothing — there is nowhere to record it. The
|
|
125
|
+
* migration that creates the table sets the version to its own, which is sound because the list
|
|
126
|
+
* is ordered and everything before it has just run.
|
|
127
|
+
*/
|
|
128
|
+
exports.OUTBOX_SCHEMA_SQL = exports.OUTBOX_MIGRATIONS.map((m) => m.version < FIRST_RECORDED_VERSION ? m.sql : `${m.sql}${RECORD_VERSION(m.version)}`).join('\n');
|
|
129
|
+
const INSERT = `
|
|
130
|
+
INSERT INTO commitrail.outbox_events
|
|
131
|
+
(event_id, event_type, event_version, payload, occurred_at, correlation_id, causation_id, subjects)
|
|
132
|
+
VALUES ($1::uuid, $2, $3, $4::jsonb, COALESCE($5::timestamptz, now()), $6, $7, $8::jsonb)
|
|
133
|
+
ON CONFLICT (event_id) DO NOTHING
|
|
134
|
+
`;
|
|
135
|
+
/**
|
|
136
|
+
* What makes two emissions THE SAME EVENT.
|
|
137
|
+
*
|
|
138
|
+
* Every producer-controlled field participates, because every one of them changes what the event
|
|
139
|
+
* means, where it routes, or how it is ordered:
|
|
140
|
+
*
|
|
141
|
+
* event_type, event_version, payload, correlation_id, causation_id, subjects
|
|
142
|
+
* occurred_at — only when the caller supplied one; see below
|
|
143
|
+
*
|
|
144
|
+
* Database-assigned fields never participate: `source_sequence` and `transaction_id` are issued
|
|
145
|
+
* per attempt and can never match, and comparing them would make every retry a conflict.
|
|
146
|
+
*
|
|
147
|
+
* **A new producer-controlled column must be added to `CONFLICTS` in the same change that adds
|
|
148
|
+
* it to the schema.** An ordering key is already planned, and it is exactly the kind of field
|
|
149
|
+
* that changes an event's meaning while being easy to forget here — a forgotten field makes two
|
|
150
|
+
* genuinely different events compare equal, which turns this check back into the silent discard
|
|
151
|
+
* it exists to remove. `tests/integration/sdk/postgres.test.ts` fails if a column appears in the
|
|
152
|
+
* outbox that this comment has not accounted for, so the reminder is a test rather than a hope.
|
|
153
|
+
*
|
|
154
|
+
* Comparison happens in PostgreSQL rather than in JavaScript: `jsonb` equality is semantic, so
|
|
155
|
+
* key order and whitespace do not matter, and `IS DISTINCT FROM` gets NULL right without a
|
|
156
|
+
* special case. Inventing JSON equality rules in JS would mean inventing them twice.
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* Does an event already exist under this id that is not the event being written?
|
|
160
|
+
*
|
|
161
|
+
* Runs only after the INSERT above did nothing, and only inside the caller's transaction.
|
|
162
|
+
* There is no race: the unique index on `event_id` serialises two transactions emitting the
|
|
163
|
+
* same id, so the second one blocks until the first commits or rolls back and then sees a
|
|
164
|
+
* settled answer.
|
|
165
|
+
*
|
|
166
|
+
* `occurred_at` is compared only when the caller supplied one. It defaults to `now()`, so a
|
|
167
|
+
* legitimate retry that omitted it writes a different timestamp every attempt — comparing it
|
|
168
|
+
* unconditionally would report every such retry as a conflict, which is precisely the case
|
|
169
|
+
* `ON CONFLICT DO NOTHING` exists to allow.
|
|
170
|
+
*
|
|
171
|
+
* `transaction_id` and `source_sequence` are excluded for the same reason and more strongly:
|
|
172
|
+
* they are assigned by the database per attempt and can never match.
|
|
173
|
+
*
|
|
174
|
+
* The comparison is on `jsonb`, so key order and whitespace do not matter — two payloads that
|
|
175
|
+
* differ only in serialisation are the same event, which is what a retrying producer produces.
|
|
176
|
+
*/
|
|
177
|
+
const CONFLICTS = `
|
|
178
|
+
SELECT 1
|
|
179
|
+
FROM commitrail.outbox_events e
|
|
180
|
+
WHERE e.event_id = $1::uuid
|
|
181
|
+
AND (
|
|
182
|
+
(e.event_type, e.event_version, e.payload, e.correlation_id, e.causation_id, e.subjects)
|
|
183
|
+
IS DISTINCT FROM ($2, $3::integer, $4::jsonb, $6, $7, $8::jsonb)
|
|
184
|
+
OR ($5::timestamptz IS NOT NULL AND e.occurred_at IS DISTINCT FROM $5::timestamptz)
|
|
185
|
+
)
|
|
186
|
+
`;
|
|
187
|
+
/**
|
|
188
|
+
* A different event was already written under this `eventId`.
|
|
189
|
+
*
|
|
190
|
+
* Supplying an `eventId` is a claim that two calls describe the same event. When they do not,
|
|
191
|
+
* one of them is a mistake — a reused id, or a collision — and CommitRail has no way to know
|
|
192
|
+
* which. Discarding the second silently would leave the producer believing it was written.
|
|
193
|
+
*
|
|
194
|
+
* Thrown from inside the caller's transaction. **When the exception is allowed to propagate
|
|
195
|
+
* out of a `transaction()` callback, the transaction rolls back** and nothing half-happens.
|
|
196
|
+
* That is the intended handling and the reason this throws rather than returning a flag.
|
|
197
|
+
*
|
|
198
|
+
* Note what a throw does not do: it does not poison the PostgreSQL transaction by itself. A
|
|
199
|
+
* caller managing `BEGIN`/`COMMIT` themselves can catch this and commit anyway, and would then
|
|
200
|
+
* have committed business state describing an event that was never written. There is no way for
|
|
201
|
+
* an SDK to prevent that; enforcing it against every writer would mean putting the rule in the
|
|
202
|
+
* customer's database, which is a schema-versioning cost not worth paying yet. Catching this and
|
|
203
|
+
* continuing is almost certainly wrong.
|
|
204
|
+
*/
|
|
205
|
+
class EventIdConflictError extends Error {
|
|
206
|
+
eventId;
|
|
207
|
+
static brand = CONFLICTING_EVENT_BRAND;
|
|
208
|
+
[CONFLICTING_EVENT_BRAND] = true;
|
|
209
|
+
constructor(eventId) {
|
|
210
|
+
super(`CommitRail: an event already exists with eventId ${eventId} and different content. ` +
|
|
211
|
+
'An eventId identifies one event; reusing it for a different one is a producer bug.');
|
|
212
|
+
this.eventId = eventId;
|
|
213
|
+
this.name = 'EventIdConflictError';
|
|
214
|
+
}
|
|
215
|
+
static is(error) {
|
|
216
|
+
return (typeof error === 'object' &&
|
|
217
|
+
error !== null &&
|
|
218
|
+
error[CONFLICTING_EVENT_BRAND] === true);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
exports.EventIdConflictError = EventIdConflictError;
|
|
222
|
+
/**
|
|
223
|
+
* How many rows a driver says it affected.
|
|
224
|
+
*
|
|
225
|
+
* `OutboxWriter` is structural and write-only on purpose, so this is the one place that has to
|
|
226
|
+
* know what real drivers return: `pg` gives a result object with `rowCount`, Prisma's
|
|
227
|
+
* `$executeRawUnsafe` gives a bare number. Anything else returns undefined, and an unknown
|
|
228
|
+
* shape must not be read as "no conflict" — see the caller.
|
|
229
|
+
*/
|
|
230
|
+
function affectedRows(result) {
|
|
231
|
+
if (typeof result === 'number') {
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
if (typeof result === 'object' && result !== null && 'rowCount' in result) {
|
|
235
|
+
const count = result.rowCount;
|
|
236
|
+
return typeof count === 'number' ? count : undefined;
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Write an event to the outbox **inside the caller's transaction**.
|
|
242
|
+
*
|
|
243
|
+
* That is the entire point, and the only thing that can go wrong here. Pass a transaction
|
|
244
|
+
* and the event commits with your business state or not at all. Pass a pool — which also
|
|
245
|
+
* has `.query` — and you have written the event on a separate connection, which is the
|
|
246
|
+
* dual write CommitRail exists to eliminate. Nothing at runtime can tell the two apart, so
|
|
247
|
+
* prefer `transaction()` below, which does not give you the chance.
|
|
248
|
+
*
|
|
249
|
+
* If you already manage transactions yourself, call `emit` on your transaction-scoped client.
|
|
250
|
+
* The example is deliberately not a `BEGIN`/`COMMIT` pair: written short it has no rollback
|
|
251
|
+
* path, and `emit` throwing would leave the connection inside a failed transaction.
|
|
252
|
+
*
|
|
253
|
+
* ```ts
|
|
254
|
+
* // `tx` is whatever your code already uses for the surrounding transaction.
|
|
255
|
+
* await emit(tx, { type: 'order.created', data: { orderId } });
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
async function emit(writer, event) {
|
|
259
|
+
if (event.type.trim().length === 0) {
|
|
260
|
+
throw new Error('an event must have a type');
|
|
261
|
+
}
|
|
262
|
+
assertNotAPool(writer);
|
|
263
|
+
const supplied = event.eventId !== undefined;
|
|
264
|
+
const eventId = event.eventId ?? (0, node_crypto_1.randomUUID)();
|
|
265
|
+
const subjects = (0, subjects_js_1.normalizeSubjects)(event.subjects);
|
|
266
|
+
// Normalised before it is written AND before it is compared, so the two are the same value.
|
|
267
|
+
// Comparing raw caller input against a canonicalised row would report a producer that reordered
|
|
268
|
+
// its own subjects as a conflict.
|
|
269
|
+
const params = [
|
|
270
|
+
eventId,
|
|
271
|
+
event.type,
|
|
272
|
+
event.version ?? 1,
|
|
273
|
+
JSON.stringify(event.data),
|
|
274
|
+
event.occurredAt ?? null,
|
|
275
|
+
event.correlationId ?? null,
|
|
276
|
+
event.causationId ?? null,
|
|
277
|
+
subjects === null ? null : JSON.stringify(subjects),
|
|
278
|
+
];
|
|
279
|
+
const inserted = affectedRows(await writer.query(INSERT, params));
|
|
280
|
+
// Only a caller-supplied id can collide. Without one this is a fresh UUID, so there is nothing
|
|
281
|
+
// to check and nothing to pay for — which also means an exotic writer whose results cannot be
|
|
282
|
+
// interpreted keeps working unless the caller opts into explicit ids.
|
|
283
|
+
if (!supplied || inserted === 1) {
|
|
284
|
+
return eventId;
|
|
285
|
+
}
|
|
286
|
+
const conflicting = affectedRows(await writer.query(CONFLICTS, params));
|
|
287
|
+
if (conflicting === undefined) {
|
|
288
|
+
// Loud, because the alternative is the defect this check exists to remove: an event
|
|
289
|
+
// silently discarded while the caller believes it was written.
|
|
290
|
+
throw new Error('CommitRail: this writer returns a query result the SDK cannot interpret, so the eventId ' +
|
|
291
|
+
'idempotency check cannot be enforced. Return the driver result unchanged — a `pg` result ' +
|
|
292
|
+
'object or an affected-row count — or omit `eventId` and let CommitRail generate one.');
|
|
293
|
+
}
|
|
294
|
+
if (conflicting > 0) {
|
|
295
|
+
throw new EventIdConflictError(eventId);
|
|
296
|
+
}
|
|
297
|
+
return eventId;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Refuse a connection pool, which is the one mistake this function makes available.
|
|
301
|
+
*
|
|
302
|
+
* `OutboxWriter` is structural so that any driver satisfies it, and a `pg` Pool has the same
|
|
303
|
+
* `.query` method a transaction client does. Passing one writes the event on a whatever
|
|
304
|
+
* connection the pool hands out — a different connection from the business write, outside the
|
|
305
|
+
* transaction. That is the dual write CommitRail exists to eliminate, produced by code that
|
|
306
|
+
* looks correct and behaves correctly until the day a transaction rolls back.
|
|
307
|
+
*
|
|
308
|
+
* The documentation used to say nothing at runtime could tell the two apart. That is true of the
|
|
309
|
+
* interface and false of the object: a `pg` Pool carries `totalCount`, `idleCount` and
|
|
310
|
+
* `waitingCount`, and a client carries none of them. Checking is cheap, catches the exact
|
|
311
|
+
* documented footgun, and cannot produce a false positive on a transaction client.
|
|
312
|
+
*
|
|
313
|
+
* A driver this does not recognise is passed through, so the guard only ever adds safety. It is
|
|
314
|
+
* not a substitute for `transaction()`, which removes the choice instead of policing it.
|
|
315
|
+
*/
|
|
316
|
+
function assertNotAPool(writer) {
|
|
317
|
+
const pool = writer;
|
|
318
|
+
if (typeof pool.totalCount === 'number' &&
|
|
319
|
+
typeof pool.idleCount === 'number' &&
|
|
320
|
+
typeof pool.waitingCount === 'number') {
|
|
321
|
+
throw new Error('CommitRail: emit() was given a connection pool, not a transaction. The event would be ' +
|
|
322
|
+
'written on a different connection from your business write and would survive a ' +
|
|
323
|
+
'rollback — the dual write CommitRail exists to eliminate. Use transaction(pool, tx => ' +
|
|
324
|
+
'tx.emit(...)), or pass the client your own transaction is running on.');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Run work in a transaction, with `emit` bound to it.
|
|
329
|
+
*
|
|
330
|
+
* The recommended shape, because it removes the one mistake available: the writer handed
|
|
331
|
+
* to the callback is the transaction, so an event cannot accidentally be written outside
|
|
332
|
+
* it.
|
|
333
|
+
*
|
|
334
|
+
* ```ts
|
|
335
|
+
* await transaction(pool, async (tx) => {
|
|
336
|
+
* await tx.query('INSERT INTO orders (id, total) VALUES ($1, $2)', [id, total]);
|
|
337
|
+
* await tx.emit({ type: 'order.created', data: { orderId: id } });
|
|
338
|
+
* });
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
async function transaction(pool, work) {
|
|
342
|
+
const client = await pool.connect();
|
|
343
|
+
try {
|
|
344
|
+
await client.query('BEGIN', []);
|
|
345
|
+
const tx = {
|
|
346
|
+
query: (sql, params) => client.query(sql, params),
|
|
347
|
+
emit: (event) => emit(client, event),
|
|
348
|
+
};
|
|
349
|
+
const result = await work(tx);
|
|
350
|
+
await client.query('COMMIT', []);
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
await client.query('ROLLBACK', []);
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
client.release();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Adapt a Prisma transaction client.
|
|
363
|
+
*
|
|
364
|
+
* Typed structurally so this package does not depend on Prisma. Uses `$executeRawUnsafe`
|
|
365
|
+
* because the SQL is a constant defined above and the values are parameterised — the
|
|
366
|
+
* "unsafe" in the name refers to interpolating the statement, which never happens here.
|
|
367
|
+
*/
|
|
368
|
+
function fromPrisma(tx) {
|
|
369
|
+
return {
|
|
370
|
+
query: (sql, params) => tx.$executeRawUnsafe(sql, ...params),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=postgres.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres.js","sourceRoot":"","sources":["../../src/postgres.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface SigningSecret {
|
|
2
|
+
version: number;
|
|
3
|
+
secret: string;
|
|
4
|
+
}
|
|
5
|
+
export interface SigningMaterial {
|
|
6
|
+
current: SigningSecret;
|
|
7
|
+
/** Present only while a rotation is in progress. */
|
|
8
|
+
previous?: SigningSecret;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The canonical string a signature covers.
|
|
12
|
+
*
|
|
13
|
+
* Consumers reimplement this, so its exact shape matters far less than documenting it
|
|
14
|
+
* precisely — but it must include the delivery id, so that a captured body cannot be
|
|
15
|
+
* replayed as a different obligation.
|
|
16
|
+
*/
|
|
17
|
+
export declare function canonicalPayload(input: {
|
|
18
|
+
timestamp: number;
|
|
19
|
+
deliveryId: string;
|
|
20
|
+
body: string;
|
|
21
|
+
}): string;
|
|
22
|
+
/**
|
|
23
|
+
* The same thing, as bytes, for a caller who has the raw body and has not decoded it.
|
|
24
|
+
*
|
|
25
|
+
* A signature is over bytes. Decoding to a string and re-encoding round-trips exactly for the
|
|
26
|
+
* valid UTF-8 that JSON must be, so the two agree — but making the caller decode first asks them
|
|
27
|
+
* to do something the contract does not need, and `.toString()` with a forgotten or wrong
|
|
28
|
+
* encoding is a real way to break verification for reasons nobody can see.
|
|
29
|
+
*
|
|
30
|
+
* Returns `Uint8Array` rather than `Buffer` deliberately: a `Buffer` in a published `.d.ts` makes
|
|
31
|
+
* `@types/node` a requirement to compile against this package, which a library has no business
|
|
32
|
+
* imposing. The packaging gate caught that, which is what it is for.
|
|
33
|
+
*/
|
|
34
|
+
export declare function canonicalPayloadBytes(input: {
|
|
35
|
+
timestamp: number;
|
|
36
|
+
deliveryId: string;
|
|
37
|
+
body: Uint8Array;
|
|
38
|
+
}): Uint8Array;
|
|
39
|
+
export declare function sign(secret: string, canonical: string | Uint8Array): string;
|
|
40
|
+
/**
|
|
41
|
+
* Build the `CommitRail-Signature` header.
|
|
42
|
+
*
|
|
43
|
+
* During a rotation this carries a signature under each secret, so a consumer's
|
|
44
|
+
* verification is "does one of these match mine". That code never has to know about
|
|
45
|
+
* versions and never changes shape when a secret rotates — which matters more than
|
|
46
|
+
* elegance for something that gets pasted into middleware once and left there.
|
|
47
|
+
*/
|
|
48
|
+
export declare function signatureHeader(input: {
|
|
49
|
+
timestamp: number;
|
|
50
|
+
deliveryId: string;
|
|
51
|
+
body: string;
|
|
52
|
+
material: SigningMaterial;
|
|
53
|
+
}): string;
|
|
54
|
+
/**
|
|
55
|
+
* Verify a header the way a consumer would. Exists so the contract we publish is the one
|
|
56
|
+
* we test against, rather than a description of it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function verifySignatureHeader(input: {
|
|
59
|
+
header: string;
|
|
60
|
+
secret: string;
|
|
61
|
+
deliveryId: string;
|
|
62
|
+
/** The raw body. Bytes are preferred; a string is decoded UTF-8 and equivalent for JSON. */
|
|
63
|
+
body: string | Uint8Array;
|
|
64
|
+
toleranceSeconds?: number;
|
|
65
|
+
now?: number;
|
|
66
|
+
}): boolean;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canonicalPayload = canonicalPayload;
|
|
4
|
+
exports.canonicalPayloadBytes = canonicalPayloadBytes;
|
|
5
|
+
exports.sign = sign;
|
|
6
|
+
exports.signatureHeader = signatureHeader;
|
|
7
|
+
exports.verifySignatureHeader = verifySignatureHeader;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
/**
|
|
10
|
+
* The canonical string a signature covers.
|
|
11
|
+
*
|
|
12
|
+
* Consumers reimplement this, so its exact shape matters far less than documenting it
|
|
13
|
+
* precisely — but it must include the delivery id, so that a captured body cannot be
|
|
14
|
+
* replayed as a different obligation.
|
|
15
|
+
*/
|
|
16
|
+
function canonicalPayload(input) {
|
|
17
|
+
return `${input.timestamp}.${input.deliveryId}.${input.body}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The same thing, as bytes, for a caller who has the raw body and has not decoded it.
|
|
21
|
+
*
|
|
22
|
+
* A signature is over bytes. Decoding to a string and re-encoding round-trips exactly for the
|
|
23
|
+
* valid UTF-8 that JSON must be, so the two agree — but making the caller decode first asks them
|
|
24
|
+
* to do something the contract does not need, and `.toString()` with a forgotten or wrong
|
|
25
|
+
* encoding is a real way to break verification for reasons nobody can see.
|
|
26
|
+
*
|
|
27
|
+
* Returns `Uint8Array` rather than `Buffer` deliberately: a `Buffer` in a published `.d.ts` makes
|
|
28
|
+
* `@types/node` a requirement to compile against this package, which a library has no business
|
|
29
|
+
* imposing. The packaging gate caught that, which is what it is for.
|
|
30
|
+
*/
|
|
31
|
+
function canonicalPayloadBytes(input) {
|
|
32
|
+
return Buffer.concat([
|
|
33
|
+
Buffer.from(`${input.timestamp}.${input.deliveryId}.`, 'utf8'),
|
|
34
|
+
Buffer.from(input.body.buffer, input.body.byteOffset, input.body.byteLength),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
function sign(secret, canonical) {
|
|
38
|
+
return (0, node_crypto_1.createHmac)('sha256', secret).update(canonical).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the `CommitRail-Signature` header.
|
|
42
|
+
*
|
|
43
|
+
* During a rotation this carries a signature under each secret, so a consumer's
|
|
44
|
+
* verification is "does one of these match mine". That code never has to know about
|
|
45
|
+
* versions and never changes shape when a secret rotates — which matters more than
|
|
46
|
+
* elegance for something that gets pasted into middleware once and left there.
|
|
47
|
+
*/
|
|
48
|
+
function signatureHeader(input) {
|
|
49
|
+
const canonical = canonicalPayload(input);
|
|
50
|
+
const signatures = [input.material.current, input.material.previous]
|
|
51
|
+
.filter((s) => s !== undefined)
|
|
52
|
+
.map((s) => `v1=${sign(s.secret, canonical)}`);
|
|
53
|
+
return [`t=${input.timestamp}`, ...signatures].join(',');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Verify a header the way a consumer would. Exists so the contract we publish is the one
|
|
57
|
+
* we test against, rather than a description of it.
|
|
58
|
+
*/
|
|
59
|
+
function verifySignatureHeader(input) {
|
|
60
|
+
const tolerance = input.toleranceSeconds ?? 300;
|
|
61
|
+
const now = input.now ?? Math.floor(Date.now() / 1000);
|
|
62
|
+
const parts = input.header.split(',').map((p) => p.trim());
|
|
63
|
+
const timestampPart = parts.find((p) => p.startsWith('t='));
|
|
64
|
+
if (timestampPart === undefined) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const timestamp = parseTimestamp(timestampPart.slice(2));
|
|
68
|
+
// Bounds replay. A signature stays valid forever otherwise, and a captured request could
|
|
69
|
+
// be resent indefinitely. The bound is inclusive and symmetric: a delivery exactly `tolerance`
|
|
70
|
+
// seconds either side of now is accepted.
|
|
71
|
+
if (timestamp === undefined || Math.abs(now - timestamp) > tolerance) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const expected = sign(input.secret, typeof input.body === 'string'
|
|
75
|
+
? canonicalPayload({ timestamp, deliveryId: input.deliveryId, body: input.body })
|
|
76
|
+
: canonicalPayloadBytes({ timestamp, deliveryId: input.deliveryId, body: input.body }));
|
|
77
|
+
return parts
|
|
78
|
+
.filter((p) => p.startsWith('v1='))
|
|
79
|
+
.some((p) => equalsConstantTime(p.slice(3), expected));
|
|
80
|
+
}
|
|
81
|
+
function equalsConstantTime(a, b) {
|
|
82
|
+
const left = Buffer.from(a, 'utf8');
|
|
83
|
+
const right = Buffer.from(b, 'utf8');
|
|
84
|
+
// Length must be compared first — timingSafeEqual throws on a mismatch — and leaking the
|
|
85
|
+
// length of a hex digest tells an attacker nothing.
|
|
86
|
+
return left.length === right.length && (0, node_crypto_1.timingSafeEqual)(left, right);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The `t=` value, parsed strictly.
|
|
90
|
+
*
|
|
91
|
+
* `Number.parseInt` is lenient: it reads `1787000000.5` and `1787000000junk` as 1787000000, so
|
|
92
|
+
* three different header strings would verify against one signature. Harmless on its own — the
|
|
93
|
+
* signature is computed over the parsed integer, so nothing about the body or the window changes
|
|
94
|
+
* — but it is a divergence, and divergence is the thing this protocol cannot afford. A verifier
|
|
95
|
+
* written from PROTOCOL.md with a strict integer parse would reject what this one accepts, and
|
|
96
|
+
* the customer would meet the difference in production.
|
|
97
|
+
*
|
|
98
|
+
* Digits only. A negative timestamp is refused here rather than by the tolerance check, which
|
|
99
|
+
* makes the rule one sentence instead of two.
|
|
100
|
+
*/
|
|
101
|
+
function parseTimestamp(value) {
|
|
102
|
+
if (!/^\d+$/.test(value)) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const parsed = Number.parseInt(value, 10);
|
|
106
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=signing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signing.js","sourceRoot":"","sources":["../../src/signing.ts"],"names":[],"mappings":";;AAoBA,4CAMC;AAcD,sDASC;AAED,oBAEC;AAUD,0CAaC;AAMD,sDAsCC;AAxHD,6CAA0D;AAa1D;;;;;;GAMG;AACH,SAAgB,gBAAgB,CAAC,KAIhC;IACC,OAAO,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;AAChE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAgB,qBAAqB,CAAC,KAIrC;IACC,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,UAAU,GAAG,EAAE,MAAM,CAAC;QAC9D,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC;KAC7E,CAAC,CAAC;AACL,CAAC;AAED,SAAgB,IAAI,CAAC,MAAc,EAAE,SAA8B;IACjE,OAAO,IAAA,wBAAU,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,eAAe,CAAC,KAK/B;IACC,MAAM,SAAS,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAE1C,MAAM,UAAU,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC;SACjE,MAAM,CAAC,CAAC,CAAC,EAAsB,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC;SAClD,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC,CAAC;IAEjD,OAAO,CAAC,KAAK,KAAK,CAAC,SAAS,EAAE,EAAE,GAAG,UAAU,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC3D,CAAC;AAED;;;GAGG;AACH,SAAgB,qBAAqB,CAAC,KAQrC;IACC,MAAM,SAAS,GAAG,KAAK,CAAC,gBAAgB,IAAI,GAAG,CAAC;IAChD,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAEvD,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IAC3D,MAAM,aAAa,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;IAE5D,IAAI,aAAa,KAAK,SAAS,EAAE,CAAC;QAChC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,SAAS,GAAG,cAAc,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAEzD,yFAAyF;IACzF,+FAA+F;IAC/F,0CAA0C;IAC1C,IAAI,SAAS,KAAK,SAAS,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,SAAS,CAAC,GAAG,SAAS,EAAE,CAAC;QACrE,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,CACnB,KAAK,CAAC,MAAM,EACZ,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ;QAC5B,CAAC,CAAC,gBAAgB,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;QACjF,CAAC,CAAC,qBAAqB,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,CAAC,UAAU,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CACzF,CAAC;IAEF,OAAO,KAAK;SACT,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;SAClC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED,SAAS,kBAAkB,CAAC,CAAS,EAAE,CAAS;IAC9C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IACpC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IAErC,yFAAyF;IACzF,oDAAoD;IACpD,OAAO,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,IAAA,6BAAe,EAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAS,cAAc,CAAC,KAAa;IACnC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAE1C,OAAO,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3D,CAAC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subjects: the business identities an event concerns.
|
|
3
|
+
*
|
|
4
|
+
* A subject is not what happened (the event type), not the operation it belongs to (the
|
|
5
|
+
* correlation id), and not what caused it (the causation id) — it is *who or what it was
|
|
6
|
+
* about*: an order, a payment, a customer, a document. Declaring them makes those
|
|
7
|
+
* identifiers first-class lookup keys in CommitRail, so an investigation can start from
|
|
8
|
+
* "what happened to order_1264" rather than from an event id nobody has.
|
|
9
|
+
*
|
|
10
|
+
* Subjects are an unordered set of `(type, id)` pairs. Plural on purpose: a
|
|
11
|
+
* `payment.captured` legitimately concerns the payment, the order and the customer, and
|
|
12
|
+
* CommitRail has no business deciding which of them is *the* subject.
|
|
13
|
+
*/
|
|
14
|
+
export interface EventSubject {
|
|
15
|
+
/** The kind of thing, in the application's own vocabulary: `order`, `payment`, `customer`. */
|
|
16
|
+
type: string;
|
|
17
|
+
/** The application's identifier for it: `order_1264`, `pay_991`. */
|
|
18
|
+
id: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Sized so normal business modelling never meets them; they exist to stop pathological
|
|
22
|
+
* usage, not to ration subjects. The evidence behind each number — measured throughput,
|
|
23
|
+
* storage and lookup curves, and what still needs re-measuring at production scale —
|
|
24
|
+
* is docs/benchmarks/event-subjects-limits.md; change them there first.
|
|
25
|
+
*/
|
|
26
|
+
export declare const SUBJECT_LIMITS: {
|
|
27
|
+
readonly maxPerEvent: 100;
|
|
28
|
+
readonly maxTypeLength: 200;
|
|
29
|
+
readonly maxIdLength: 500;
|
|
30
|
+
};
|
|
31
|
+
/** Registered globally by description, so every copy of this package agrees on it. */
|
|
32
|
+
declare const BRAND: unique symbol;
|
|
33
|
+
/** Branded like `InvalidDeliveryError`, and for the same dual-package reason. */
|
|
34
|
+
export declare class InvalidSubjectsError extends Error {
|
|
35
|
+
static readonly brand: symbol;
|
|
36
|
+
readonly [BRAND] = true;
|
|
37
|
+
constructor(message: string);
|
|
38
|
+
static is(error: unknown): error is InvalidSubjectsError;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validate and canonicalise a subjects value.
|
|
42
|
+
*
|
|
43
|
+
* Returns `null` for "no subjects" (undefined, null, or an empty array) so callers store
|
|
44
|
+
* nothing rather than an empty list. Exact duplicate `(type, id)` pairs are dropped —
|
|
45
|
+
* duplicates carry no meaning, and producers often assemble subjects from more than one
|
|
46
|
+
* code path — with first-occurrence order preserved. Anything malformed throws: a subject
|
|
47
|
+
* that cannot be represented is an error at the boundary, never a silent truncation.
|
|
48
|
+
*
|
|
49
|
+
* Takes `unknown` because the acceptance side re-validates whatever a producer actually
|
|
50
|
+
* wrote to the outbox, which no type annotation can vouch for.
|
|
51
|
+
*/
|
|
52
|
+
export declare function normalizeSubjects(subjects: unknown): EventSubject[] | null;
|
|
53
|
+
export {};
|