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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +12 -0
  3. package/dist/cjs/envelope.d.ts +91 -0
  4. package/dist/cjs/envelope.js +54 -0
  5. package/dist/cjs/envelope.js.map +1 -0
  6. package/dist/cjs/index.d.ts +20 -0
  7. package/dist/cjs/index.js +29 -0
  8. package/dist/cjs/index.js.map +1 -0
  9. package/dist/cjs/package.json +3 -0
  10. package/dist/cjs/postgres.d.ts +152 -0
  11. package/dist/cjs/postgres.js +373 -0
  12. package/dist/cjs/postgres.js.map +1 -0
  13. package/dist/cjs/signing.d.ts +66 -0
  14. package/dist/cjs/signing.js +108 -0
  15. package/dist/cjs/signing.js.map +1 -0
  16. package/dist/cjs/subjects.d.ts +53 -0
  17. package/dist/cjs/subjects.js +88 -0
  18. package/dist/cjs/subjects.js.map +1 -0
  19. package/dist/cjs/webhooks.d.ts +100 -0
  20. package/dist/cjs/webhooks.js +214 -0
  21. package/dist/cjs/webhooks.js.map +1 -0
  22. package/dist/esm/envelope.d.ts +91 -0
  23. package/dist/esm/envelope.js +50 -0
  24. package/dist/esm/envelope.js.map +1 -0
  25. package/dist/esm/index.d.ts +20 -0
  26. package/dist/esm/index.js +21 -0
  27. package/dist/esm/index.js.map +1 -0
  28. package/dist/esm/package.json +3 -0
  29. package/dist/esm/postgres.d.ts +152 -0
  30. package/dist/esm/postgres.js +366 -0
  31. package/dist/esm/postgres.js.map +1 -0
  32. package/dist/esm/signing.d.ts +66 -0
  33. package/dist/esm/signing.js +101 -0
  34. package/dist/esm/signing.js.map +1 -0
  35. package/dist/esm/subjects.d.ts +53 -0
  36. package/dist/esm/subjects.js +83 -0
  37. package/dist/esm/subjects.js.map +1 -0
  38. package/dist/esm/webhooks.d.ts +100 -0
  39. package/dist/esm/webhooks.js +209 -0
  40. package/dist/esm/webhooks.js.map +1 -0
  41. package/package.json +108 -0
@@ -0,0 +1,152 @@
1
+ import { type EventSubject } from './subjects.js';
2
+ /** Registered globally by description, so every copy of this package agrees on it. */
3
+ declare const CONFLICTING_EVENT_BRAND: unique symbol;
4
+ /**
5
+ * The minimum CommitRail needs to write a row.
6
+ *
7
+ * Structural on purpose, with no dependency on any driver: a `pg` `PoolClient` satisfies
8
+ * it as-is, and `fromPrisma` adapts a Prisma transaction client. Requiring a particular
9
+ * driver would make adoption a migration.
10
+ */
11
+ export interface OutboxWriter {
12
+ query(sql: string, params: unknown[]): Promise<unknown>;
13
+ }
14
+ export interface EmitEvent<TData = unknown> {
15
+ type: string;
16
+ data: TData;
17
+ version?: number;
18
+ occurredAt?: Date;
19
+ /** Supply one to make emitting idempotent under your own retries. */
20
+ eventId?: string;
21
+ /**
22
+ * The logical operation this event belongs to — an order number, a checkout id, whatever
23
+ * your application already calls it.
24
+ *
25
+ * CommitRail groups events that share one and never invents one. Propagate it into the
26
+ * events your consumers emit in turn, and the chain joins up on its own.
27
+ */
28
+ correlationId?: string;
29
+ /** The event that caused this one, if your application knows. */
30
+ causationId?: string;
31
+ /**
32
+ * The business identities this event concerns — `{ type: 'order', id: 'order_1264' }`.
33
+ *
34
+ * Distinct from everything above: the event type says *what happened*, the correlation id
35
+ * says *which operation it belongs to*, the causation id says *what caused it* — subjects
36
+ * say *what it was about*. Declare every relevant identity; CommitRail indexes them so an
37
+ * investigation can start from an order or customer id rather than an event id. Exact
38
+ * duplicates are dropped; order carries no meaning.
39
+ */
40
+ subjects?: EventSubject[];
41
+ }
42
+ /**
43
+ * The outbox schema, as an append-only list of migrations.
44
+ *
45
+ * A list rather than one script, because the alternative already caused a problem. A single
46
+ * converge-to-latest constant changes when the package changes, so a customer who pasted it
47
+ * into `001_add_commitrail.sql` finds that migration meaning something different a year later
48
+ * — which is the one thing migrations exist not to do. Pinning a version instead gives them an
49
+ * immutable statement of what they applied.
50
+ *
51
+ * **Every entry here is frozen once released.** A released migration has run against databases
52
+ * we do not own and cannot re-run; editing one changes what a version *means* while leaving
53
+ * every already-migrated database untouched. Fix a mistake by appending, never by amending.
54
+ *
55
+ * Each is written to be safely re-runnable — `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` — so
56
+ * applying the whole list to any database converges it, whatever it started from.
57
+ */
58
+ export declare const OUTBOX_MIGRATIONS: readonly {
59
+ readonly version: number;
60
+ readonly sql: string;
61
+ }[];
62
+ /** The newest schema this package knows how to write. */
63
+ export declare const OUTBOX_SCHEMA_VERSION: number;
64
+ /**
65
+ * Every migration, in order, for a fresh install or to bring an old outbox up to date.
66
+ *
67
+ * Safe to run against any database at any version: every statement is conditional, and the
68
+ * marker only moves forward. This is the convenience; `OUTBOX_MIGRATIONS` is the contract.
69
+ *
70
+ * Versions before the marker table existed record nothing — there is nowhere to record it. The
71
+ * migration that creates the table sets the version to its own, which is sound because the list
72
+ * is ordered and everything before it has just run.
73
+ */
74
+ export declare const OUTBOX_SCHEMA_SQL: string;
75
+ /**
76
+ * A different event was already written under this `eventId`.
77
+ *
78
+ * Supplying an `eventId` is a claim that two calls describe the same event. When they do not,
79
+ * one of them is a mistake — a reused id, or a collision — and CommitRail has no way to know
80
+ * which. Discarding the second silently would leave the producer believing it was written.
81
+ *
82
+ * Thrown from inside the caller's transaction. **When the exception is allowed to propagate
83
+ * out of a `transaction()` callback, the transaction rolls back** and nothing half-happens.
84
+ * That is the intended handling and the reason this throws rather than returning a flag.
85
+ *
86
+ * Note what a throw does not do: it does not poison the PostgreSQL transaction by itself. A
87
+ * caller managing `BEGIN`/`COMMIT` themselves can catch this and commit anyway, and would then
88
+ * have committed business state describing an event that was never written. There is no way for
89
+ * an SDK to prevent that; enforcing it against every writer would mean putting the rule in the
90
+ * customer's database, which is a schema-versioning cost not worth paying yet. Catching this and
91
+ * continuing is almost certainly wrong.
92
+ */
93
+ export declare class EventIdConflictError extends Error {
94
+ readonly eventId: string;
95
+ static readonly brand: symbol;
96
+ readonly [CONFLICTING_EVENT_BRAND] = true;
97
+ constructor(eventId: string);
98
+ static is(error: unknown): error is EventIdConflictError;
99
+ }
100
+ /**
101
+ * Write an event to the outbox **inside the caller's transaction**.
102
+ *
103
+ * That is the entire point, and the only thing that can go wrong here. Pass a transaction
104
+ * and the event commits with your business state or not at all. Pass a pool — which also
105
+ * has `.query` — and you have written the event on a separate connection, which is the
106
+ * dual write CommitRail exists to eliminate. Nothing at runtime can tell the two apart, so
107
+ * prefer `transaction()` below, which does not give you the chance.
108
+ *
109
+ * If you already manage transactions yourself, call `emit` on your transaction-scoped client.
110
+ * The example is deliberately not a `BEGIN`/`COMMIT` pair: written short it has no rollback
111
+ * path, and `emit` throwing would leave the connection inside a failed transaction.
112
+ *
113
+ * ```ts
114
+ * // `tx` is whatever your code already uses for the surrounding transaction.
115
+ * await emit(tx, { type: 'order.created', data: { orderId } });
116
+ * ```
117
+ */
118
+ export declare function emit<TData>(writer: OutboxWriter, event: EmitEvent<TData>): Promise<string>;
119
+ export interface TransactionalWriter extends OutboxWriter {
120
+ emit<TData>(event: EmitEvent<TData>): Promise<string>;
121
+ }
122
+ interface Pool {
123
+ connect(): Promise<OutboxWriter & {
124
+ release(): void;
125
+ }>;
126
+ }
127
+ /**
128
+ * Run work in a transaction, with `emit` bound to it.
129
+ *
130
+ * The recommended shape, because it removes the one mistake available: the writer handed
131
+ * to the callback is the transaction, so an event cannot accidentally be written outside
132
+ * it.
133
+ *
134
+ * ```ts
135
+ * await transaction(pool, async (tx) => {
136
+ * await tx.query('INSERT INTO orders (id, total) VALUES ($1, $2)', [id, total]);
137
+ * await tx.emit({ type: 'order.created', data: { orderId: id } });
138
+ * });
139
+ * ```
140
+ */
141
+ export declare function transaction<T>(pool: Pool, work: (tx: TransactionalWriter) => Promise<T>): Promise<T>;
142
+ /**
143
+ * Adapt a Prisma transaction client.
144
+ *
145
+ * Typed structurally so this package does not depend on Prisma. Uses `$executeRawUnsafe`
146
+ * because the SQL is a constant defined above and the values are parameterised — the
147
+ * "unsafe" in the name refers to interpolating the statement, which never happens here.
148
+ */
149
+ export declare function fromPrisma(tx: {
150
+ $executeRawUnsafe(sql: string, ...values: unknown[]): Promise<number>;
151
+ }): OutboxWriter;
152
+ export {};
@@ -0,0 +1,366 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { normalizeSubjects } from './subjects.js';
3
+ /** Registered globally by description, so every copy of this package agrees on it. */
4
+ const CONFLICTING_EVENT_BRAND = Symbol.for('commitrail.EventIdConflictError');
5
+ /**
6
+ * The outbox schema, as an append-only list of migrations.
7
+ *
8
+ * A list rather than one script, because the alternative already caused a problem. A single
9
+ * converge-to-latest constant changes when the package changes, so a customer who pasted it
10
+ * into `001_add_commitrail.sql` finds that migration meaning something different a year later
11
+ * — which is the one thing migrations exist not to do. Pinning a version instead gives them an
12
+ * immutable statement of what they applied.
13
+ *
14
+ * **Every entry here is frozen once released.** A released migration has run against databases
15
+ * we do not own and cannot re-run; editing one changes what a version *means* while leaving
16
+ * every already-migrated database untouched. Fix a mistake by appending, never by amending.
17
+ *
18
+ * Each is written to be safely re-runnable — `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` — so
19
+ * applying the whole list to any database converges it, whatever it started from.
20
+ */
21
+ export const OUTBOX_MIGRATIONS = [
22
+ {
23
+ version: 1,
24
+ sql: `
25
+ CREATE SCHEMA IF NOT EXISTS commitrail;
26
+
27
+ CREATE TABLE IF NOT EXISTS commitrail.outbox_events (
28
+ event_id UUID PRIMARY KEY,
29
+ source_sequence BIGINT GENERATED ALWAYS AS IDENTITY,
30
+ transaction_id xid8 NOT NULL DEFAULT pg_current_xact_id(),
31
+ event_type TEXT NOT NULL,
32
+ event_version INTEGER NOT NULL DEFAULT 1,
33
+ payload JSONB NOT NULL,
34
+
35
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
36
+
37
+ -- Which logical operation this event belongs to, and which event caused it.
38
+ --
39
+ -- Supplied by the application or left null; CommitRail never infers either. Chronological
40
+ -- proximity is not causation, and a timeline drawn from a guess is worse than no timeline.
41
+ -- A null correlation groups with nothing rather than with every other null.
42
+ --
43
+ -- Text rather than uuid: a correlation is the customer's own identifier for a business
44
+ -- operation — an order number, a checkout id — and forcing it into a uuid would make them
45
+ -- invent a second one and keep a mapping.
46
+ correlation_id TEXT,
47
+ causation_id TEXT
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS outbox_events_capture_idx
51
+ ON commitrail.outbox_events (transaction_id, source_sequence, event_id);
52
+ `,
53
+ },
54
+ {
55
+ version: 2,
56
+ sql: `
57
+ -- The business identities an event concerns — a JSON array of {"type","id"} pairs, or null
58
+ -- when the event declares none. CommitRail normalises these into its own indexed table at
59
+ -- acceptance; they are never queried in the outbox.
60
+ --
61
+ -- Appended rather than declared in the table above, so that a fresh install and an upgraded
62
+ -- one have the same column order rather than merely the same columns.
63
+ ALTER TABLE commitrail.outbox_events ADD COLUMN IF NOT EXISTS subjects JSONB;
64
+ `,
65
+ },
66
+ {
67
+ version: 3,
68
+ sql: `
69
+ -- Finding every event in one logical operation is the correlation timeline's whole query.
70
+ CREATE INDEX IF NOT EXISTS outbox_events_correlation_idx
71
+ ON commitrail.outbox_events (correlation_id)
72
+ WHERE correlation_id IS NOT NULL;
73
+ `,
74
+ },
75
+ {
76
+ version: 4,
77
+ sql: `
78
+ -- Which migrations have been applied. Bookkeeping, and only bookkeeping.
79
+ --
80
+ -- CommitRail never decides what it can read from this number. Capture inspects the actual
81
+ -- columns and preflight validates the actual structure, because a marker can be wrong — hand
82
+ -- edited, restored from a backup taken mid-migration, or written by a migration that half
83
+ -- applied — and a version that outranks reality is worse than no version at all. What this buys
84
+ -- is the ability to say "you are on 3 of 4" rather than "something is missing", and to give a
85
+ -- customer an immutable thing to pin in their own migration history.
86
+ CREATE TABLE IF NOT EXISTS commitrail.outbox_schema (
87
+ -- One row, enforced rather than assumed.
88
+ singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
89
+ version INTEGER NOT NULL,
90
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
91
+ );
92
+ `,
93
+ },
94
+ ];
95
+ /** The newest schema this package knows how to write. */
96
+ export const OUTBOX_SCHEMA_VERSION = OUTBOX_MIGRATIONS[OUTBOX_MIGRATIONS.length - 1].version;
97
+ /**
98
+ * Record which version has been applied.
99
+ *
100
+ * `GREATEST` so that re-running an older migration cannot walk the marker backwards — applying
101
+ * the whole list to a database that is already current is a supported thing to do, and it must
102
+ * be a no-op rather than a downgrade.
103
+ */
104
+ const RECORD_VERSION = (version) => `
105
+ INSERT INTO commitrail.outbox_schema (version) VALUES (${version})
106
+ ON CONFLICT (singleton) DO UPDATE
107
+ SET version = GREATEST(commitrail.outbox_schema.version, EXCLUDED.version),
108
+ applied_at = now();
109
+ `;
110
+ /** The migration that introduced the marker table; nothing before it can record anything. */
111
+ const FIRST_RECORDED_VERSION = 4;
112
+ /**
113
+ * Every migration, in order, for a fresh install or to bring an old outbox up to date.
114
+ *
115
+ * Safe to run against any database at any version: every statement is conditional, and the
116
+ * marker only moves forward. This is the convenience; `OUTBOX_MIGRATIONS` is the contract.
117
+ *
118
+ * Versions before the marker table existed record nothing — there is nowhere to record it. The
119
+ * migration that creates the table sets the version to its own, which is sound because the list
120
+ * is ordered and everything before it has just run.
121
+ */
122
+ export const OUTBOX_SCHEMA_SQL = OUTBOX_MIGRATIONS.map((m) => m.version < FIRST_RECORDED_VERSION ? m.sql : `${m.sql}${RECORD_VERSION(m.version)}`).join('\n');
123
+ const INSERT = `
124
+ INSERT INTO commitrail.outbox_events
125
+ (event_id, event_type, event_version, payload, occurred_at, correlation_id, causation_id, subjects)
126
+ VALUES ($1::uuid, $2, $3, $4::jsonb, COALESCE($5::timestamptz, now()), $6, $7, $8::jsonb)
127
+ ON CONFLICT (event_id) DO NOTHING
128
+ `;
129
+ /**
130
+ * What makes two emissions THE SAME EVENT.
131
+ *
132
+ * Every producer-controlled field participates, because every one of them changes what the event
133
+ * means, where it routes, or how it is ordered:
134
+ *
135
+ * event_type, event_version, payload, correlation_id, causation_id, subjects
136
+ * occurred_at — only when the caller supplied one; see below
137
+ *
138
+ * Database-assigned fields never participate: `source_sequence` and `transaction_id` are issued
139
+ * per attempt and can never match, and comparing them would make every retry a conflict.
140
+ *
141
+ * **A new producer-controlled column must be added to `CONFLICTS` in the same change that adds
142
+ * it to the schema.** An ordering key is already planned, and it is exactly the kind of field
143
+ * that changes an event's meaning while being easy to forget here — a forgotten field makes two
144
+ * genuinely different events compare equal, which turns this check back into the silent discard
145
+ * it exists to remove. `tests/integration/sdk/postgres.test.ts` fails if a column appears in the
146
+ * outbox that this comment has not accounted for, so the reminder is a test rather than a hope.
147
+ *
148
+ * Comparison happens in PostgreSQL rather than in JavaScript: `jsonb` equality is semantic, so
149
+ * key order and whitespace do not matter, and `IS DISTINCT FROM` gets NULL right without a
150
+ * special case. Inventing JSON equality rules in JS would mean inventing them twice.
151
+ */
152
+ /**
153
+ * Does an event already exist under this id that is not the event being written?
154
+ *
155
+ * Runs only after the INSERT above did nothing, and only inside the caller's transaction.
156
+ * There is no race: the unique index on `event_id` serialises two transactions emitting the
157
+ * same id, so the second one blocks until the first commits or rolls back and then sees a
158
+ * settled answer.
159
+ *
160
+ * `occurred_at` is compared only when the caller supplied one. It defaults to `now()`, so a
161
+ * legitimate retry that omitted it writes a different timestamp every attempt — comparing it
162
+ * unconditionally would report every such retry as a conflict, which is precisely the case
163
+ * `ON CONFLICT DO NOTHING` exists to allow.
164
+ *
165
+ * `transaction_id` and `source_sequence` are excluded for the same reason and more strongly:
166
+ * they are assigned by the database per attempt and can never match.
167
+ *
168
+ * The comparison is on `jsonb`, so key order and whitespace do not matter — two payloads that
169
+ * differ only in serialisation are the same event, which is what a retrying producer produces.
170
+ */
171
+ const CONFLICTS = `
172
+ SELECT 1
173
+ FROM commitrail.outbox_events e
174
+ WHERE e.event_id = $1::uuid
175
+ AND (
176
+ (e.event_type, e.event_version, e.payload, e.correlation_id, e.causation_id, e.subjects)
177
+ IS DISTINCT FROM ($2, $3::integer, $4::jsonb, $6, $7, $8::jsonb)
178
+ OR ($5::timestamptz IS NOT NULL AND e.occurred_at IS DISTINCT FROM $5::timestamptz)
179
+ )
180
+ `;
181
+ /**
182
+ * A different event was already written under this `eventId`.
183
+ *
184
+ * Supplying an `eventId` is a claim that two calls describe the same event. When they do not,
185
+ * one of them is a mistake — a reused id, or a collision — and CommitRail has no way to know
186
+ * which. Discarding the second silently would leave the producer believing it was written.
187
+ *
188
+ * Thrown from inside the caller's transaction. **When the exception is allowed to propagate
189
+ * out of a `transaction()` callback, the transaction rolls back** and nothing half-happens.
190
+ * That is the intended handling and the reason this throws rather than returning a flag.
191
+ *
192
+ * Note what a throw does not do: it does not poison the PostgreSQL transaction by itself. A
193
+ * caller managing `BEGIN`/`COMMIT` themselves can catch this and commit anyway, and would then
194
+ * have committed business state describing an event that was never written. There is no way for
195
+ * an SDK to prevent that; enforcing it against every writer would mean putting the rule in the
196
+ * customer's database, which is a schema-versioning cost not worth paying yet. Catching this and
197
+ * continuing is almost certainly wrong.
198
+ */
199
+ export class EventIdConflictError extends Error {
200
+ eventId;
201
+ static brand = CONFLICTING_EVENT_BRAND;
202
+ [CONFLICTING_EVENT_BRAND] = true;
203
+ constructor(eventId) {
204
+ super(`CommitRail: an event already exists with eventId ${eventId} and different content. ` +
205
+ 'An eventId identifies one event; reusing it for a different one is a producer bug.');
206
+ this.eventId = eventId;
207
+ this.name = 'EventIdConflictError';
208
+ }
209
+ static is(error) {
210
+ return (typeof error === 'object' &&
211
+ error !== null &&
212
+ error[CONFLICTING_EVENT_BRAND] === true);
213
+ }
214
+ }
215
+ /**
216
+ * How many rows a driver says it affected.
217
+ *
218
+ * `OutboxWriter` is structural and write-only on purpose, so this is the one place that has to
219
+ * know what real drivers return: `pg` gives a result object with `rowCount`, Prisma's
220
+ * `$executeRawUnsafe` gives a bare number. Anything else returns undefined, and an unknown
221
+ * shape must not be read as "no conflict" — see the caller.
222
+ */
223
+ function affectedRows(result) {
224
+ if (typeof result === 'number') {
225
+ return result;
226
+ }
227
+ if (typeof result === 'object' && result !== null && 'rowCount' in result) {
228
+ const count = result.rowCount;
229
+ return typeof count === 'number' ? count : undefined;
230
+ }
231
+ return undefined;
232
+ }
233
+ /**
234
+ * Write an event to the outbox **inside the caller's transaction**.
235
+ *
236
+ * That is the entire point, and the only thing that can go wrong here. Pass a transaction
237
+ * and the event commits with your business state or not at all. Pass a pool — which also
238
+ * has `.query` — and you have written the event on a separate connection, which is the
239
+ * dual write CommitRail exists to eliminate. Nothing at runtime can tell the two apart, so
240
+ * prefer `transaction()` below, which does not give you the chance.
241
+ *
242
+ * If you already manage transactions yourself, call `emit` on your transaction-scoped client.
243
+ * The example is deliberately not a `BEGIN`/`COMMIT` pair: written short it has no rollback
244
+ * path, and `emit` throwing would leave the connection inside a failed transaction.
245
+ *
246
+ * ```ts
247
+ * // `tx` is whatever your code already uses for the surrounding transaction.
248
+ * await emit(tx, { type: 'order.created', data: { orderId } });
249
+ * ```
250
+ */
251
+ export async function emit(writer, event) {
252
+ if (event.type.trim().length === 0) {
253
+ throw new Error('an event must have a type');
254
+ }
255
+ assertNotAPool(writer);
256
+ const supplied = event.eventId !== undefined;
257
+ const eventId = event.eventId ?? randomUUID();
258
+ const subjects = normalizeSubjects(event.subjects);
259
+ // Normalised before it is written AND before it is compared, so the two are the same value.
260
+ // Comparing raw caller input against a canonicalised row would report a producer that reordered
261
+ // its own subjects as a conflict.
262
+ const params = [
263
+ eventId,
264
+ event.type,
265
+ event.version ?? 1,
266
+ JSON.stringify(event.data),
267
+ event.occurredAt ?? null,
268
+ event.correlationId ?? null,
269
+ event.causationId ?? null,
270
+ subjects === null ? null : JSON.stringify(subjects),
271
+ ];
272
+ const inserted = affectedRows(await writer.query(INSERT, params));
273
+ // Only a caller-supplied id can collide. Without one this is a fresh UUID, so there is nothing
274
+ // to check and nothing to pay for — which also means an exotic writer whose results cannot be
275
+ // interpreted keeps working unless the caller opts into explicit ids.
276
+ if (!supplied || inserted === 1) {
277
+ return eventId;
278
+ }
279
+ const conflicting = affectedRows(await writer.query(CONFLICTS, params));
280
+ if (conflicting === undefined) {
281
+ // Loud, because the alternative is the defect this check exists to remove: an event
282
+ // silently discarded while the caller believes it was written.
283
+ throw new Error('CommitRail: this writer returns a query result the SDK cannot interpret, so the eventId ' +
284
+ 'idempotency check cannot be enforced. Return the driver result unchanged — a `pg` result ' +
285
+ 'object or an affected-row count — or omit `eventId` and let CommitRail generate one.');
286
+ }
287
+ if (conflicting > 0) {
288
+ throw new EventIdConflictError(eventId);
289
+ }
290
+ return eventId;
291
+ }
292
+ /**
293
+ * Refuse a connection pool, which is the one mistake this function makes available.
294
+ *
295
+ * `OutboxWriter` is structural so that any driver satisfies it, and a `pg` Pool has the same
296
+ * `.query` method a transaction client does. Passing one writes the event on a whatever
297
+ * connection the pool hands out — a different connection from the business write, outside the
298
+ * transaction. That is the dual write CommitRail exists to eliminate, produced by code that
299
+ * looks correct and behaves correctly until the day a transaction rolls back.
300
+ *
301
+ * The documentation used to say nothing at runtime could tell the two apart. That is true of the
302
+ * interface and false of the object: a `pg` Pool carries `totalCount`, `idleCount` and
303
+ * `waitingCount`, and a client carries none of them. Checking is cheap, catches the exact
304
+ * documented footgun, and cannot produce a false positive on a transaction client.
305
+ *
306
+ * A driver this does not recognise is passed through, so the guard only ever adds safety. It is
307
+ * not a substitute for `transaction()`, which removes the choice instead of policing it.
308
+ */
309
+ function assertNotAPool(writer) {
310
+ const pool = writer;
311
+ if (typeof pool.totalCount === 'number' &&
312
+ typeof pool.idleCount === 'number' &&
313
+ typeof pool.waitingCount === 'number') {
314
+ throw new Error('CommitRail: emit() was given a connection pool, not a transaction. The event would be ' +
315
+ 'written on a different connection from your business write and would survive a ' +
316
+ 'rollback — the dual write CommitRail exists to eliminate. Use transaction(pool, tx => ' +
317
+ 'tx.emit(...)), or pass the client your own transaction is running on.');
318
+ }
319
+ }
320
+ /**
321
+ * Run work in a transaction, with `emit` bound to it.
322
+ *
323
+ * The recommended shape, because it removes the one mistake available: the writer handed
324
+ * to the callback is the transaction, so an event cannot accidentally be written outside
325
+ * it.
326
+ *
327
+ * ```ts
328
+ * await transaction(pool, async (tx) => {
329
+ * await tx.query('INSERT INTO orders (id, total) VALUES ($1, $2)', [id, total]);
330
+ * await tx.emit({ type: 'order.created', data: { orderId: id } });
331
+ * });
332
+ * ```
333
+ */
334
+ export async function transaction(pool, work) {
335
+ const client = await pool.connect();
336
+ try {
337
+ await client.query('BEGIN', []);
338
+ const tx = {
339
+ query: (sql, params) => client.query(sql, params),
340
+ emit: (event) => emit(client, event),
341
+ };
342
+ const result = await work(tx);
343
+ await client.query('COMMIT', []);
344
+ return result;
345
+ }
346
+ catch (error) {
347
+ await client.query('ROLLBACK', []);
348
+ throw error;
349
+ }
350
+ finally {
351
+ client.release();
352
+ }
353
+ }
354
+ /**
355
+ * Adapt a Prisma transaction client.
356
+ *
357
+ * Typed structurally so this package does not depend on Prisma. Uses `$executeRawUnsafe`
358
+ * because the SQL is a constant defined above and the values are parameterised — the
359
+ * "unsafe" in the name refers to interpolating the statement, which never happens here.
360
+ */
361
+ export function fromPrisma(tx) {
362
+ return {
363
+ query: (sql, params) => tx.$executeRawUnsafe(sql, ...params),
364
+ };
365
+ }
366
+ //# 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;