autotel-message-contract 0.1.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.
@@ -0,0 +1,44 @@
1
+ //#region src/serializer.ts
2
+ /**
3
+ * Recursively sort object keys so two values with the same fields serialize
4
+ * identically regardless of insertion order. Arrays keep their order (it is
5
+ * semantically meaningful); plain objects are reordered; class instances,
6
+ * Maps, Sets, Dates, etc. are left untouched so the serializer can decide.
7
+ */
8
+ function sortKeysDeep(value) {
9
+ if (Array.isArray(value)) return value.map((element) => sortKeysDeep(element));
10
+ if (value !== null && typeof value === "object" && isPlainObject(value)) {
11
+ const sorted = {};
12
+ for (const key of Object.keys(value).toSorted()) sorted[key] = sortKeysDeep(value[key]);
13
+ return sorted;
14
+ }
15
+ return value;
16
+ }
17
+ function isPlainObject(value) {
18
+ const proto = Object.getPrototypeOf(value);
19
+ return proto === Object.prototype || proto === null;
20
+ }
21
+ /**
22
+ * The default serializer: `JSON.stringify` with deterministic key ordering.
23
+ * Good enough to pin most events and commands; swap it for your own when the
24
+ * bytes you ship differ (custom date formats, snake_case, omitted nulls…).
25
+ */
26
+ function jsonSerializer(options = {}) {
27
+ const { indent = 2, sortKeys = true } = options;
28
+ return {
29
+ name: sortKeys ? "json" : "json (key-order preserved)",
30
+ serialize(value) {
31
+ const prepared = sortKeys ? sortKeysDeep(value) : value;
32
+ return JSON.stringify(prepared, void 0, indent);
33
+ },
34
+ deserialize(serialized) {
35
+ return JSON.parse(serialized);
36
+ }
37
+ };
38
+ }
39
+ /** The serializer used when a contract does not specify one. */
40
+ const defaultSerializer = jsonSerializer();
41
+
42
+ //#endregion
43
+ export { defaultSerializer, jsonSerializer };
44
+ //# sourceMappingURL=serializer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serializer.js","names":[],"sources":["../src/serializer.ts"],"sourcesContent":["/**\n * Message serializers.\n *\n * A contract pins the bytes a message becomes *once serialized* — the exact\n * shape a consumer, a queue, or a stored event reads. The only way that\n * snapshot is meaningful is if it is produced by the **same serializer your\n * application ships with**. Pin a shape your consumers never see and you have\n * pinned nothing.\n *\n * So a {@link MessageSerializer} is a tiny, explicit seam: `serialize` /\n * `deserialize`. The default is JSON with deterministic key ordering (so a\n * snapshot does not churn when object construction order changes), but you are\n * encouraged to pass your app's real serializer — `superjson`, `devalue`, a\n * snake_case Jackson-equivalent, a protobuf codec — so the snapshot records the\n * exact bytes you put on the wire.\n */\n\n/** A reversible mapping between an in-memory value and its serialized form. */\nexport interface MessageSerializer<Serialized = string> {\n /** Human-facing name, surfaced in snapshot headers and failure messages. */\n readonly name: string;\n /** Turn a value into its serialized form (the bytes you ship). */\n serialize(value: unknown): Serialized;\n /** Turn a serialized form back into a value. */\n deserialize(serialized: Serialized): unknown;\n}\n\n/**\n * Recursively sort object keys so two values with the same fields serialize\n * identically regardless of insertion order. Arrays keep their order (it is\n * semantically meaningful); plain objects are reordered; class instances,\n * Maps, Sets, Dates, etc. are left untouched so the serializer can decide.\n */\nfunction sortKeysDeep(value: unknown): unknown {\n if (Array.isArray(value)) {\n return value.map((element) => sortKeysDeep(element));\n }\n if (value !== null && typeof value === 'object' && isPlainObject(value)) {\n const sorted: Record<string, unknown> = {};\n for (const key of Object.keys(value).toSorted()) {\n sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);\n }\n return sorted;\n }\n return value;\n}\n\nfunction isPlainObject(value: object): boolean {\n const proto = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n\nexport interface JsonSerializerOptions {\n /**\n * Pretty-print with this indent. Defaults to `2` so the approved file reads\n * cleanly in a diff. Set to `0` to pin the compact bytes you actually ship.\n */\n indent?: number;\n /**\n * Sort object keys deterministically before serializing. Defaults to `true`\n * so a snapshot reflects *fields*, not construction order. Turn it off when\n * key order is itself part of the contract.\n */\n sortKeys?: boolean;\n}\n\n/**\n * The default serializer: `JSON.stringify` with deterministic key ordering.\n * Good enough to pin most events and commands; swap it for your own when the\n * bytes you ship differ (custom date formats, snake_case, omitted nulls…).\n */\nexport function jsonSerializer(\n options: JsonSerializerOptions = {},\n): MessageSerializer<string> {\n const { indent = 2, sortKeys = true } = options;\n return {\n name: sortKeys ? 'json' : 'json (key-order preserved)',\n serialize(value) {\n const prepared = sortKeys ? sortKeysDeep(value) : value;\n return JSON.stringify(prepared, undefined, indent);\n },\n deserialize(serialized) {\n return JSON.parse(serialized) as unknown;\n },\n };\n}\n\n/** The serializer used when a contract does not specify one. */\nexport const defaultSerializer: MessageSerializer<string> = jsonSerializer();\n"],"mappings":";;;;;;;AAiCA,SAAS,aAAa,OAAyB;CAC7C,IAAI,MAAM,QAAQ,KAAK,GACrB,OAAO,MAAM,KAAK,YAAY,aAAa,OAAO,CAAC;CAErD,IAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,cAAc,KAAK,GAAG;EACvE,MAAM,SAAkC,CAAC;EACzC,KAAK,MAAM,OAAO,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,GAC5C,OAAO,OAAO,aAAc,MAAkC,IAAI;EAEpE,OAAO;CACT;CACA,OAAO;AACT;AAEA,SAAS,cAAc,OAAwB;CAC7C,MAAM,QAAQ,OAAO,eAAe,KAAK;CACzC,OAAO,UAAU,OAAO,aAAa,UAAU;AACjD;;;;;;AAqBA,SAAgB,eACd,UAAiC,CAAC,GACP;CAC3B,MAAM,EAAE,SAAS,GAAG,WAAW,SAAS;CACxC,OAAO;EACL,MAAM,WAAW,SAAS;EAC1B,UAAU,OAAO;GACf,MAAM,WAAW,WAAW,aAAa,KAAK,IAAI;GAClD,OAAO,KAAK,UAAU,UAAU,QAAW,MAAM;EACnD;EACA,YAAY,YAAY;GACtB,OAAO,KAAK,MAAM,UAAU;EAC9B;CACF;AACF;;AAGA,MAAa,oBAA+C,eAAe"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "autotel-message-contract",
3
+ "version": "0.1.0",
4
+ "description": "Brokerless message contract testing for autotel — pin the serialized shape of your events/commands/messages and prove old and new versions stay compatible, as ordinary unit tests.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs"
13
+ },
14
+ "./serializer": {
15
+ "types": "./dist/serializer.d.ts",
16
+ "import": "./dist/serializer.js",
17
+ "require": "./dist/serializer.cjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsdown",
27
+ "dev": "tsdown --watch",
28
+ "lint": "eslint src/**/*.ts",
29
+ "lint:fix": "eslint src/**/*.ts --fix",
30
+ "type-check": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "clean": "rimraf dist"
34
+ },
35
+ "keywords": [
36
+ "autotel",
37
+ "contract-testing",
38
+ "serialization",
39
+ "snapshot",
40
+ "approval-testing",
41
+ "backward-compatibility",
42
+ "forward-compatibility",
43
+ "event-sourcing",
44
+ "schema-evolution",
45
+ "messages"
46
+ ],
47
+ "author": "Jag Reehal <jag@jagreehal.com> (https://jagreehal.com)",
48
+ "license": "MIT",
49
+ "peerDependencies": {
50
+ "autotel": "workspace:*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "autotel": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^25.9.2",
59
+ "rimraf": "^6.1.3",
60
+ "tsdown": "^0.22.2",
61
+ "typescript": "^6.0.3",
62
+ "vitest": "^4.1.8"
63
+ },
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "https://github.com/jagreehal/autotel",
67
+ "directory": "packages/autotel-message-contract"
68
+ },
69
+ "bugs": {
70
+ "url": "https://github.com/jagreehal/autotel/issues"
71
+ },
72
+ "homepage": "https://github.com/jagreehal/autotel/tree/main/packages/autotel-message-contract#readme"
73
+ }
@@ -0,0 +1,279 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import {
7
+ approvedSnapshot,
8
+ ContractViolationError,
9
+ messageContract,
10
+ } from './contract.js';
11
+ import { jsonSerializer } from './serializer.js';
12
+
13
+ let dir: string;
14
+
15
+ beforeEach(() => {
16
+ dir = mkdtempSync(path.join(tmpdir(), 'autotel-contract-'));
17
+ });
18
+ afterEach(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe('snapshot contract', () => {
23
+ const order = { orderId: 'ord-1', customer: 'Alice', total: 99.5 };
24
+
25
+ it('writes the approved file and passes on first run', () => {
26
+ expect(() =>
27
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
28
+ .given(order)
29
+ .whenSerialized()
30
+ .thenContractIsUnchanged(),
31
+ ).not.toThrow();
32
+ });
33
+
34
+ it('passes when the serialized shape is unchanged', () => {
35
+ const run = () =>
36
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
37
+ .given(order)
38
+ .whenSerialized()
39
+ .thenContractIsUnchanged();
40
+ run(); // record
41
+ expect(run).not.toThrow(); // compare
42
+ });
43
+
44
+ it('fails with a diff when a field is renamed', () => {
45
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
46
+ .given(order)
47
+ .whenSerialized()
48
+ .thenContractIsUnchanged();
49
+
50
+ const drifted = { orderId: 'ord-1', customerName: 'Alice', total: 99.5 };
51
+ expect(() =>
52
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
53
+ .given(drifted)
54
+ .whenSerialized()
55
+ .thenContractIsUnchanged(),
56
+ ).toThrowError(ContractViolationError);
57
+ });
58
+
59
+ it('is insensitive to object construction order by default', () => {
60
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
61
+ .given({ orderId: 'ord-1', customer: 'Alice', total: 99.5 })
62
+ .whenSerialized()
63
+ .thenContractIsUnchanged();
64
+
65
+ expect(() =>
66
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
67
+ .given({ total: 99.5, customer: 'Alice', orderId: 'ord-1' })
68
+ .whenSerialized()
69
+ .thenContractIsUnchanged(),
70
+ ).not.toThrow();
71
+ });
72
+
73
+ it('rewrites the approved file in update mode without failing', () => {
74
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
75
+ .given(order)
76
+ .whenSerialized()
77
+ .thenContractIsUnchanged();
78
+
79
+ const changed = { orderId: 'ord-1', customer: 'Bob', total: 1 };
80
+ expect(() =>
81
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' }, update: true })
82
+ .given(changed)
83
+ .whenSerialized()
84
+ .thenContractIsUnchanged(),
85
+ ).not.toThrow();
86
+
87
+ // Subsequent non-update run now compares against the rewritten file.
88
+ expect(() =>
89
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
90
+ .given(changed)
91
+ .whenSerialized()
92
+ .thenContractIsUnchanged(),
93
+ ).not.toThrow();
94
+ });
95
+
96
+ it('pins the bytes the app actually ships when given its serializer', () => {
97
+ const snakeCase = jsonSerializer({ indent: 0 });
98
+ const step = messageContract({
99
+ serializer: {
100
+ name: 'snake',
101
+ serialize: (v) =>
102
+ snakeCase.serialize(v).replaceAll('orderId', 'order_id'),
103
+ deserialize: snakeCase.deserialize,
104
+ },
105
+ snapshot: { dir, name: 'OrderPlaced_snake' },
106
+ })
107
+ .given(order)
108
+ .whenSerialized();
109
+ expect(step.output).toContain('order_id');
110
+ expect(() => step.thenContractIsUnchanged()).not.toThrow();
111
+ });
112
+
113
+ it('requires a snapshot name', () => {
114
+ expect(() =>
115
+ messageContract()
116
+ .given(order)
117
+ .whenSerialized()
118
+ .thenContractIsUnchanged(),
119
+ ).toThrowError(/snapshot name is required/);
120
+ });
121
+
122
+ it('rejects trying to serialize an approved snapshot source', () => {
123
+ expect(() =>
124
+ messageContract({ snapshot: { dir, name: 'OrderPlaced' } })
125
+ .given(approvedSnapshot())
126
+ .whenSerialized(),
127
+ ).toThrowError(/Cannot serialize an approved snapshot source/);
128
+ });
129
+ });
130
+
131
+ describe('compatibility checks', () => {
132
+ // A reader is a plain parse function here; a Zod/Valibot schema works identically.
133
+ const orderV2Reader = (value: unknown) => {
134
+ const v = value as Record<string, unknown>;
135
+ if (typeof v.orderId !== 'string') throw new Error('orderId must be a string');
136
+ if (typeof v.customer !== 'string') throw new Error('customer must be a string');
137
+ return {
138
+ orderId: v.orderId,
139
+ customer: v.customer,
140
+ coupon: typeof v.coupon === 'string' ? v.coupon : undefined,
141
+ };
142
+ };
143
+
144
+ it('passes backward compatibility when a newer reader reads older bytes', async () => {
145
+ const v1 = { orderId: 'ord-1', customer: 'Alice' };
146
+ await expect(
147
+ messageContract()
148
+ .given(v1)
149
+ .whenDeserializedAs(orderV2Reader)
150
+ .thenBackwardCompatible((v2) => {
151
+ expect(v2.coupon).toBeUndefined();
152
+ }),
153
+ ).resolves.toMatchObject({ orderId: 'ord-1' });
154
+ });
155
+
156
+ it('fails backward compatibility when a required field was renamed away', async () => {
157
+ const broken = { orderId: 'ord-1', customerName: 'Alice' };
158
+ await expect(
159
+ messageContract()
160
+ .given(broken)
161
+ .whenDeserializedAs(orderV2Reader)
162
+ .thenBackwardCompatible(),
163
+ ).rejects.toThrowError(/Not backward-compatible/);
164
+ });
165
+
166
+ it('passes forward compatibility when the newer writer only adds fields', async () => {
167
+ const v2 = { orderId: 'ord-1', customer: 'Alice', coupon: 'SAVE10' };
168
+ const orderV1Reader = (value: unknown) => {
169
+ const v = value as Record<string, unknown>;
170
+ if (typeof v.orderId !== 'string') throw new Error('orderId must be a string');
171
+ if (typeof v.customer !== 'string') throw new Error('customer must be a string');
172
+ return {
173
+ orderId: v.orderId,
174
+ customer: v.customer,
175
+ };
176
+ };
177
+
178
+ await expect(
179
+ messageContract()
180
+ .given(v2)
181
+ .whenDeserializedAs(orderV1Reader)
182
+ .thenForwardCompatible(),
183
+ ).resolves.toMatchObject({ orderId: 'ord-1', customer: 'Alice' });
184
+ });
185
+
186
+ it('fails compatibility when the reader silently renames a shared field', async () => {
187
+ const driftedReader = (value: unknown) => {
188
+ const v = value as Record<string, unknown>;
189
+ return {
190
+ orderId: v.orderId,
191
+ customerName: v.customer,
192
+ };
193
+ };
194
+
195
+ await expect(
196
+ messageContract()
197
+ .given({ orderId: 'ord-1', customer: 'Alice' })
198
+ .whenDeserializedAs(driftedReader)
199
+ .thenBackwardCompatible(),
200
+ ).rejects.toThrowError(/structural incompatibility/);
201
+ });
202
+
203
+ it('fails compatibility when the reader changes a shared field value', async () => {
204
+ const lossyReader = (value: unknown) => {
205
+ const v = value as Record<string, unknown>;
206
+ return {
207
+ orderId: v.orderId,
208
+ customer: typeof v.customer === 'string' ? v.customer.toUpperCase() : v.customer,
209
+ };
210
+ };
211
+
212
+ await expect(
213
+ messageContract()
214
+ .given({ orderId: 'ord-1', customer: 'Alice' })
215
+ .whenDeserializedAs(lossyReader)
216
+ .thenBackwardCompatible(),
217
+ ).rejects.toThrowError(/\$\.customer: value differs/);
218
+ });
219
+
220
+ it('can replay an approved snapshot as the compatibility source', async () => {
221
+ messageContract({ snapshot: { dir, name: 'OrderPlaced_v1' } })
222
+ .given({ orderId: 'ord-1', customer: 'Alice' })
223
+ .whenSerialized()
224
+ .thenContractIsUnchanged();
225
+
226
+ await expect(
227
+ messageContract()
228
+ .given(approvedSnapshot({ dir, name: 'OrderPlaced_v1' }))
229
+ .whenDeserializedAs(orderV2Reader)
230
+ .thenBackwardCompatible((v2) => {
231
+ expect(v2.coupon).toBeUndefined();
232
+ }),
233
+ ).resolves.toMatchObject({ orderId: 'ord-1', customer: 'Alice' });
234
+ });
235
+
236
+ it('can use the configured snapshot location as the compatibility source', async () => {
237
+ messageContract({ snapshot: { dir, name: 'OrderPlaced_v1' } })
238
+ .given({ orderId: 'ord-1', customer: 'Alice' })
239
+ .whenSerialized()
240
+ .thenContractIsUnchanged();
241
+
242
+ await expect(
243
+ messageContract({ snapshot: { dir, name: 'OrderPlaced_v1' } })
244
+ .given(approvedSnapshot())
245
+ .whenDeserializedAs(orderV2Reader)
246
+ .thenBackwardCompatible(),
247
+ ).resolves.toMatchObject({ orderId: 'ord-1', customer: 'Alice' });
248
+ });
249
+
250
+ it('fails clearly when the approved snapshot source is missing', async () => {
251
+ await expect(
252
+ messageContract({ snapshot: { dir, name: 'missing' } })
253
+ .given(approvedSnapshot())
254
+ .whenDeserializedAs(orderV2Reader)
255
+ .thenBackwardCompatible(),
256
+ ).rejects.toThrowError(/Cannot read approved snapshot/);
257
+ });
258
+
259
+ it('supports a Standard Schema reader', async () => {
260
+ const schema = {
261
+ '~standard': {
262
+ version: 1 as const,
263
+ vendor: 'test',
264
+ validate: (value: unknown) => {
265
+ const v = value as Record<string, unknown>;
266
+ if (typeof v.orderId === 'string') return { value: v };
267
+ return { issues: [{ message: 'orderId required', path: ['orderId'] }] };
268
+ },
269
+ },
270
+ };
271
+ await expect(
272
+ messageContract().given({ orderId: 'ord-1' }).whenDeserializedAs(schema).thenForwardCompatible(),
273
+ ).resolves.toBeDefined();
274
+
275
+ await expect(
276
+ messageContract().given({ nope: true }).whenDeserializedAs(schema).thenForwardCompatible(),
277
+ ).rejects.toThrowError(/orderId required/);
278
+ });
279
+ });