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,89 @@
1
+ /**
2
+ * Message serializers.
3
+ *
4
+ * A contract pins the bytes a message becomes *once serialized* — the exact
5
+ * shape a consumer, a queue, or a stored event reads. The only way that
6
+ * snapshot is meaningful is if it is produced by the **same serializer your
7
+ * application ships with**. Pin a shape your consumers never see and you have
8
+ * pinned nothing.
9
+ *
10
+ * So a {@link MessageSerializer} is a tiny, explicit seam: `serialize` /
11
+ * `deserialize`. The default is JSON with deterministic key ordering (so a
12
+ * snapshot does not churn when object construction order changes), but you are
13
+ * encouraged to pass your app's real serializer — `superjson`, `devalue`, a
14
+ * snake_case Jackson-equivalent, a protobuf codec — so the snapshot records the
15
+ * exact bytes you put on the wire.
16
+ */
17
+
18
+ /** A reversible mapping between an in-memory value and its serialized form. */
19
+ export interface MessageSerializer<Serialized = string> {
20
+ /** Human-facing name, surfaced in snapshot headers and failure messages. */
21
+ readonly name: string;
22
+ /** Turn a value into its serialized form (the bytes you ship). */
23
+ serialize(value: unknown): Serialized;
24
+ /** Turn a serialized form back into a value. */
25
+ deserialize(serialized: Serialized): unknown;
26
+ }
27
+
28
+ /**
29
+ * Recursively sort object keys so two values with the same fields serialize
30
+ * identically regardless of insertion order. Arrays keep their order (it is
31
+ * semantically meaningful); plain objects are reordered; class instances,
32
+ * Maps, Sets, Dates, etc. are left untouched so the serializer can decide.
33
+ */
34
+ function sortKeysDeep(value: unknown): unknown {
35
+ if (Array.isArray(value)) {
36
+ return value.map((element) => sortKeysDeep(element));
37
+ }
38
+ if (value !== null && typeof value === 'object' && isPlainObject(value)) {
39
+ const sorted: Record<string, unknown> = {};
40
+ for (const key of Object.keys(value).toSorted()) {
41
+ sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
42
+ }
43
+ return sorted;
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function isPlainObject(value: object): boolean {
49
+ const proto = Object.getPrototypeOf(value);
50
+ return proto === Object.prototype || proto === null;
51
+ }
52
+
53
+ export interface JsonSerializerOptions {
54
+ /**
55
+ * Pretty-print with this indent. Defaults to `2` so the approved file reads
56
+ * cleanly in a diff. Set to `0` to pin the compact bytes you actually ship.
57
+ */
58
+ indent?: number;
59
+ /**
60
+ * Sort object keys deterministically before serializing. Defaults to `true`
61
+ * so a snapshot reflects *fields*, not construction order. Turn it off when
62
+ * key order is itself part of the contract.
63
+ */
64
+ sortKeys?: boolean;
65
+ }
66
+
67
+ /**
68
+ * The default serializer: `JSON.stringify` with deterministic key ordering.
69
+ * Good enough to pin most events and commands; swap it for your own when the
70
+ * bytes you ship differ (custom date formats, snake_case, omitted nulls…).
71
+ */
72
+ export function jsonSerializer(
73
+ options: JsonSerializerOptions = {},
74
+ ): MessageSerializer<string> {
75
+ const { indent = 2, sortKeys = true } = options;
76
+ return {
77
+ name: sortKeys ? 'json' : 'json (key-order preserved)',
78
+ serialize(value) {
79
+ const prepared = sortKeys ? sortKeysDeep(value) : value;
80
+ return JSON.stringify(prepared, undefined, indent);
81
+ },
82
+ deserialize(serialized) {
83
+ return JSON.parse(serialized) as unknown;
84
+ },
85
+ };
86
+ }
87
+
88
+ /** The serializer used when a contract does not specify one. */
89
+ export const defaultSerializer: MessageSerializer<string> = jsonSerializer();
@@ -0,0 +1,113 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Snapshot storage.
6
+ *
7
+ * A snapshot is just a file you commit: the serialized shape of a message,
8
+ * reviewed once and from then on compared on every run. There is no broker, no
9
+ * registry, no service to start — a format change shows up in a normal diff,
10
+ * reviewed like any other code. This mirrors the "approved file" convention
11
+ * (`<name>.approved.txt`) familiar from approval testing.
12
+ *
13
+ * By default the file lives in a `__contracts__` directory beside the test that
14
+ * created it, resolved from the call stack so you do not have to thread paths
15
+ * around. Override `dir` / `path` when you keep snapshots elsewhere.
16
+ */
17
+
18
+ /** Set any of these (e.g. `AUTOTEL_CONTRACT_UPDATE=1`) to (re)write approved files. */
19
+ const UPDATE_ENV_VARS = [
20
+ 'AUTOTEL_CONTRACT_UPDATE',
21
+ 'UPDATE_CONTRACTS',
22
+ 'UPDATE_SNAPSHOTS',
23
+ ] as const;
24
+
25
+ export function isUpdateMode(env: NodeJS.ProcessEnv = process.env): boolean {
26
+ return UPDATE_ENV_VARS.some((name) => {
27
+ const value = env[name];
28
+ return value === '1' || value === 'true';
29
+ });
30
+ }
31
+
32
+ export interface SnapshotLocation {
33
+ /** Directory holding the approved file. */
34
+ dir?: string;
35
+ /** Logical name; the file becomes `<name>.approved.txt`. */
36
+ name: string;
37
+ /** Fully-qualified path; when set, `dir`/`name` are ignored for placement. */
38
+ path?: string;
39
+ }
40
+
41
+ const DEFAULT_DIR_NAME = '__contracts__';
42
+ const APPROVED_SUFFIX = '.approved.txt';
43
+
44
+ /** Resolve the absolute file path for a snapshot. */
45
+ export function resolveSnapshotPath(location: SnapshotLocation): string {
46
+ if (location.path) {
47
+ return path.isAbsolute(location.path)
48
+ ? location.path
49
+ : path.resolve(process.cwd(), location.path);
50
+ }
51
+ const dir = location.dir ?? defaultSnapshotDir();
52
+ return path.join(dir, `${sanitize(location.name)}${APPROVED_SUFFIX}`);
53
+ }
54
+
55
+ function sanitize(name: string): string {
56
+ // Keep it filesystem-safe while preserving readable type names.
57
+ return name.replaceAll(/[^\w.@-]+/g, '_');
58
+ }
59
+
60
+ export interface ReadSnapshotResult {
61
+ exists: boolean;
62
+ content?: string;
63
+ path: string;
64
+ }
65
+
66
+ export function readSnapshot(location: SnapshotLocation): ReadSnapshotResult {
67
+ const filePath = resolveSnapshotPath(location);
68
+ if (!existsSync(filePath)) return { exists: false, path: filePath };
69
+ return { exists: true, content: readFileSync(filePath, 'utf8'), path: filePath };
70
+ }
71
+
72
+ export function writeSnapshot(
73
+ location: SnapshotLocation,
74
+ content: string,
75
+ ): string {
76
+ const filePath = resolveSnapshotPath(location);
77
+ mkdirSync(path.dirname(filePath), { recursive: true });
78
+ writeFileSync(filePath, content, 'utf8');
79
+ return filePath;
80
+ }
81
+
82
+ /**
83
+ * Best-effort `__contracts__` directory beside the calling test file, found by
84
+ * walking the stack past this module and the contract internals. Falls back to
85
+ * `<cwd>/__contracts__` when the caller cannot be determined (e.g. bundled).
86
+ */
87
+ function defaultSnapshotDir(): string {
88
+ const callerFile = callerOutsidePackage();
89
+ const base = callerFile ? path.dirname(callerFile) : process.cwd();
90
+ return path.join(base, DEFAULT_DIR_NAME);
91
+ }
92
+
93
+ function callerOutsidePackage(): string | undefined {
94
+ const stack = new Error('stack probe').stack;
95
+ if (!stack) return undefined;
96
+ const lines = stack.split('\n').slice(1);
97
+ for (const line of lines) {
98
+ const match = line.match(/\((.*?):\d+:\d+\)/) ?? line.match(/at (.*?):\d+:\d+/);
99
+ const file = match?.[1];
100
+ if (!file) continue;
101
+ if (file.includes('node:')) continue;
102
+ // Skip frames inside this package's own source/dist.
103
+ if (file.includes(`${path.join('autotel-message-contract', 'src')}`)) continue;
104
+ if (file.includes(`${path.join('autotel-message-contract', 'dist')}`)) continue;
105
+ if (file.includes('node_modules')) continue;
106
+ return file.startsWith('file://') ? fileUrlToPath(file) : file;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ function fileUrlToPath(url: string): string {
112
+ return decodeURIComponent(url.replace(/^file:\/\//, ''));
113
+ }