@sixthsummit/ship-testing 0.0.0-stage → 1.2.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/lib/outbox.js ADDED
@@ -0,0 +1,218 @@
1
+ import { isOutbox, systemActor } from "@sixthsummit/ship-core";
2
+ export function createInMemoryOutbox(outbox) {
3
+ if (!isOutbox(outbox)) {
4
+ throw new TypeError("createInMemoryOutbox requires an outbox created by defineOutbox()");
5
+ }
6
+ let rows = [];
7
+ let sequence = 0;
8
+ const outboxId = outbox.id;
9
+ const find = (id) => rows.findIndex((row) => row.id === id);
10
+ const replace = (index, patch) => {
11
+ const next = Object.freeze({ ...rows[index], ...patch });
12
+ rows[index] = next;
13
+ return next;
14
+ };
15
+ const record = async (_scope, intents) => Object.freeze(intents.map((intent) => {
16
+ if (intent.dedupeKey !== undefined &&
17
+ rows.some((row) => row.intentId === intent.intentId && row.dedupeKey === intent.dedupeKey)) {
18
+ return Object.freeze({ deduplicated: true });
19
+ }
20
+ sequence += 1;
21
+ const id = `${outboxId}:${sequence}`;
22
+ rows.push(Object.freeze({
23
+ id,
24
+ outboxId,
25
+ intentId: intent.intentId,
26
+ payload: intent.payload,
27
+ ...(intent.dedupeKey !== undefined ? { dedupeKey: intent.dedupeKey } : {}),
28
+ status: "pending",
29
+ attempts: 0,
30
+ availableAt: intent.availableAt,
31
+ createdAt: new Date(),
32
+ }));
33
+ return Object.freeze({ recordId: id, deduplicated: false });
34
+ }));
35
+ const claimMatching = async (now, batchSize, leaseMs, matchesIntent) => {
36
+ const claimed = [];
37
+ const due = rows
38
+ .map((row, index) => ({ row, index }))
39
+ .filter(({ row }) => matchesIntent(row.intentId) &&
40
+ ((row.status === "pending" && row.availableAt.getTime() <= now.getTime()) ||
41
+ (row.status === "claimed" && (row.leaseExpiresAt?.getTime() ?? 0) <= now.getTime())))
42
+ .sort((left, right) => left.row.availableAt.getTime() - right.row.availableAt.getTime())
43
+ .slice(0, batchSize);
44
+ for (const { index } of due) {
45
+ claimed.push(replace(index, {
46
+ status: "claimed",
47
+ leaseExpiresAt: new Date(now.getTime() + leaseMs),
48
+ }));
49
+ }
50
+ return Object.freeze(claimed);
51
+ };
52
+ const claimDue = (now, batchSize, leaseMs, intentIds) => claimMatching(now, batchSize, leaseMs, (intentId) => intentIds === undefined ? true : intentIds.includes(intentId));
53
+ const claimUnknownDue = (now, batchSize, leaseMs, knownIntentIds) => claimMatching(now, batchSize, leaseMs, (intentId) => !knownIntentIds.includes(intentId));
54
+ const complete = async (recordId, now) => {
55
+ const index = find(recordId);
56
+ if (index >= 0 && rows[index].status === "claimed") {
57
+ replace(index, { status: "completed", completedAt: now, leaseExpiresAt: undefined });
58
+ }
59
+ };
60
+ const fail = async (recordId, error, disposition, now) => {
61
+ const index = find(recordId);
62
+ if (index < 0 || rows[index].status !== "claimed")
63
+ return;
64
+ const attempts = rows[index].attempts + 1;
65
+ if ("deadLetter" in disposition) {
66
+ replace(index, {
67
+ status: "dead-lettered",
68
+ attempts,
69
+ lastError: error,
70
+ deadLetteredAt: now,
71
+ leaseExpiresAt: undefined,
72
+ });
73
+ }
74
+ else {
75
+ replace(index, {
76
+ status: "pending",
77
+ attempts,
78
+ lastError: error,
79
+ availableAt: disposition.retryAt,
80
+ leaseExpiresAt: undefined,
81
+ });
82
+ }
83
+ };
84
+ const listDeadLettered = async ({ limit }) => Object.freeze(rows.filter((row) => row.status === "dead-lettered").slice(0, limit));
85
+ const retry = async (recordId, now) => {
86
+ const index = find(recordId);
87
+ if (index < 0 || rows[index].status !== "dead-lettered")
88
+ return undefined;
89
+ return replace(index, { status: "pending", availableAt: now, deadLetteredAt: undefined });
90
+ };
91
+ const discard = async (recordId, reason, now) => {
92
+ const index = find(recordId);
93
+ if (index < 0 || rows[index].status !== "dead-lettered")
94
+ return undefined;
95
+ return replace(index, { status: "discarded", discardedAt: now, discardReason: reason });
96
+ };
97
+ const cleanup = async (retention, now) => {
98
+ let completedDeleted = 0;
99
+ let deadLetteredDeleted = 0;
100
+ rows = rows.filter((row) => {
101
+ if (row.status === "completed" &&
102
+ row.completedAt !== undefined &&
103
+ now.getTime() - row.completedAt.getTime() >= retention.completedMs) {
104
+ completedDeleted += 1;
105
+ return false;
106
+ }
107
+ const terminalAt = row.status === "dead-lettered" ? row.deadLetteredAt : row.discardedAt;
108
+ if ((row.status === "dead-lettered" || row.status === "discarded") &&
109
+ terminalAt !== undefined &&
110
+ now.getTime() - terminalAt.getTime() >= retention.deadLetteredMs) {
111
+ deadLetteredDeleted += 1;
112
+ return false;
113
+ }
114
+ return true;
115
+ });
116
+ return Object.freeze({ completedDeleted, deadLetteredDeleted });
117
+ };
118
+ const health = async (now) => {
119
+ const pending = rows.filter((row) => row.status === "pending");
120
+ const oldest = pending.reduce((best, row) => (best === undefined || row.availableAt < best ? row.availableAt : best), undefined);
121
+ return Object.freeze({
122
+ pending: pending.length,
123
+ claimed: rows.filter((row) => row.status === "claimed" && (row.leaseExpiresAt?.getTime() ?? 0) > now.getTime()).length,
124
+ deadLettered: rows.filter((row) => row.status === "dead-lettered").length,
125
+ ...(oldest ? { oldestPendingAt: oldest } : {}),
126
+ });
127
+ };
128
+ return Object.freeze({
129
+ kind: "ship.outbox-backend",
130
+ adapter: "memory",
131
+ outboxId,
132
+ record,
133
+ claimDue,
134
+ claimUnknownDue,
135
+ complete,
136
+ fail,
137
+ listDeadLettered,
138
+ retry,
139
+ discard,
140
+ cleanup,
141
+ health,
142
+ records: () => Object.freeze([...rows]),
143
+ reset: () => {
144
+ rows = [];
145
+ sequence = 0;
146
+ },
147
+ });
148
+ }
149
+ export function createInMemoryIdempotencyStore() {
150
+ const entries = new Map();
151
+ const claim = async (key, ttlMs, context) => {
152
+ const existing = entries.get(key);
153
+ if (existing && existing.expiresAt > context.now.getTime()) {
154
+ return existing.status === "completed" ? "duplicate" : "in-flight";
155
+ }
156
+ entries.set(key, { status: "in-flight", expiresAt: context.now.getTime() + ttlMs });
157
+ return "claimed";
158
+ };
159
+ return Object.freeze({
160
+ kind: "ship.idempotency-store",
161
+ adapter: "memory",
162
+ claim,
163
+ complete: async (key, result) => {
164
+ const existing = entries.get(key);
165
+ if (existing)
166
+ entries.set(key, { ...existing, status: "completed", result });
167
+ },
168
+ release: async (key) => {
169
+ if (entries.get(key)?.status === "in-flight")
170
+ entries.delete(key);
171
+ },
172
+ readResult: async (key, context) => {
173
+ const existing = entries.get(key);
174
+ return existing?.status === "completed" && existing.expiresAt > context.now.getTime()
175
+ ? existing.result
176
+ : undefined;
177
+ },
178
+ reset: () => entries.clear(),
179
+ purgeExpired: (before, prefix = "") => {
180
+ let purged = 0;
181
+ for (const [key, entry] of entries) {
182
+ if (key.startsWith(prefix) && entry.expiresAt < before.getTime()) {
183
+ entries.delete(key);
184
+ purged += 1;
185
+ }
186
+ }
187
+ return purged;
188
+ },
189
+ });
190
+ }
191
+ /**
192
+ * Runs `<outbox>.dispatch` until a pass claims nothing and returns the totals.
193
+ * Pair it with `createInMemoryOutbox()` in unit tests to assert on effects.
194
+ */
195
+ export async function drainOutbox(executor, outbox, options = {}) {
196
+ const outboxId = typeof outbox === "string" ? outbox : outbox.id;
197
+ const operationId = `${outboxId}.dispatch`;
198
+ const actor = options.actor ?? systemActor({ id: "ship.testing.drain" });
199
+ const maxRuns = options.maxRuns ?? 100;
200
+ const totals = { batchSize: 0, claimed: 0, completed: 0, retried: 0, deadLettered: 0 };
201
+ for (let run = 0; run < maxRuns; run += 1) {
202
+ const result = await executor.run(operationId, {}, { actor });
203
+ if (!result.ok) {
204
+ throw new Error(`drainOutbox: ${operationId} failed with ${result.error.code}: ${result.error.message}`);
205
+ }
206
+ const pass = result.data;
207
+ // The dispatcher's batch size, reported once; the totals sum the passes.
208
+ totals.batchSize = pass.batchSize;
209
+ totals.claimed += pass.claimed;
210
+ totals.completed += pass.completed;
211
+ totals.retried += pass.retried;
212
+ totals.deadLettered += pass.deadLettered;
213
+ if (pass.claimed === 0)
214
+ return Object.freeze(totals);
215
+ }
216
+ throw new Error(`drainOutbox: ${operationId} still claimed records after ${maxRuns} runs`);
217
+ }
218
+ //# sourceMappingURL=outbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox.js","sourceRoot":"","sources":["../src/outbox.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * v3↔v4 behavioral-parity harness (A2-6).
3
+ *
4
+ * Exercises a v4 app with a recorded request and asserts the response matches a
5
+ * set of checks — either a v4-conformance contract or a behavior captured from a
6
+ * live v3 app (the parity oracle). Pure + in-process (Hono `app.request`), no
7
+ * Docker. Shipped via `@sixthsummit/ship-testing` so a consumer's migrated
8
+ * app can run its own captured fixtures. See docs/v4/tooling/parity-harness.md.
9
+ */
10
+ /** Anything with Hono's `.request()` — the v4 app under test. */
11
+ export interface RequestableApp {
12
+ request(path: string, init?: RequestInit): Promise<Response>;
13
+ }
14
+ export interface ParityCheck {
15
+ /** Dot-path into the captured response: `status`, `headers.<lowercased>`, `body.<...>`. */
16
+ path: string;
17
+ /** Deep-equality assertion. */
18
+ equals?: unknown;
19
+ /** Presence assertion (`true` = must be present, `false` = must be absent). */
20
+ exists?: boolean;
21
+ }
22
+ export interface ParityFixture {
23
+ id: string;
24
+ metadata: {
25
+ area: string;
26
+ /** The behavioral contract under test, e.g. "401 on missing token". */
27
+ surface: string;
28
+ /**
29
+ * `v4-conformance` = encodes a documented v4 contract (ships green).
30
+ * `v3-capture` = recorded from a live v3 app; asserts the v4 build matches.
31
+ */
32
+ source: "v3-capture" | "v4-conformance";
33
+ [k: string]: unknown;
34
+ };
35
+ request: {
36
+ method: string;
37
+ path: string;
38
+ headers?: Record<string, string>;
39
+ body?: unknown;
40
+ };
41
+ checks: ParityCheck[];
42
+ }
43
+ export interface ParityResult {
44
+ fixture: ParityFixture;
45
+ passed: boolean;
46
+ failures: string[];
47
+ }
48
+ /** Parse a JSONL fixture file (skips blank lines + `#` comments). */
49
+ export declare function parseParityFixtures(jsonl: string): ParityFixture[];
50
+ /** Exercise `app` with the fixture's request and evaluate its checks. */
51
+ export declare function runParityFixture(app: RequestableApp, fixture: ParityFixture): Promise<ParityResult>;
52
+ //# sourceMappingURL=parity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parity.d.ts","sourceRoot":"","sources":["../src/parity.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC9D;AAED,MAAM,WAAW,WAAW;IAC1B,2FAA2F;IAC3F,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE;QACR,IAAI,EAAE,MAAM,CAAC;QACb,uEAAuE;QACvE,OAAO,EAAE,MAAM,CAAC;QAChB;;;WAGG;QACH,MAAM,EAAE,YAAY,GAAG,gBAAgB,CAAC;QACxC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;KACtB,CAAC;IACF,OAAO,EAAE;QACP,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjC,IAAI,CAAC,EAAE,OAAO,CAAC;KAChB,CAAC;IACF,MAAM,EAAE,WAAW,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,aAAa,CAAC;IACvB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,EAAE,CAMlE;AAiBD,yEAAyE;AACzE,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,cAAc,EACnB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,YAAY,CAAC,CAgDvB"}
package/lib/parity.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * v3↔v4 behavioral-parity harness (A2-6).
3
+ *
4
+ * Exercises a v4 app with a recorded request and asserts the response matches a
5
+ * set of checks — either a v4-conformance contract or a behavior captured from a
6
+ * live v3 app (the parity oracle). Pure + in-process (Hono `app.request`), no
7
+ * Docker. Shipped via `@sixthsummit/ship-testing` so a consumer's migrated
8
+ * app can run its own captured fixtures. See docs/v4/tooling/parity-harness.md.
9
+ */
10
+ /** Parse a JSONL fixture file (skips blank lines + `#` comments). */
11
+ export function parseParityFixtures(jsonl) {
12
+ return jsonl
13
+ .split("\n")
14
+ .map((line) => line.trim())
15
+ .filter((line) => line.length > 0 && !line.startsWith("#"))
16
+ .map((line) => JSON.parse(line));
17
+ }
18
+ function resolvePath(captured, path) {
19
+ let cur = captured;
20
+ for (const key of path.split(".")) {
21
+ if (cur == null || typeof cur !== "object")
22
+ return undefined;
23
+ cur = cur[key];
24
+ }
25
+ return cur;
26
+ }
27
+ /** Exercise `app` with the fixture's request and evaluate its checks. */
28
+ export async function runParityFixture(app, fixture) {
29
+ const { method, path, headers, body } = fixture.request;
30
+ const init = { method };
31
+ if (body !== undefined && body !== null) {
32
+ init.body = typeof body === "string" ? body : JSON.stringify(body);
33
+ init.headers = { "content-type": "application/json", ...(headers ?? {}) };
34
+ }
35
+ else if (headers) {
36
+ init.headers = headers;
37
+ }
38
+ const res = await app.request(path, init);
39
+ const rawText = await res.text();
40
+ let parsedBody = rawText;
41
+ if (rawText.length > 0) {
42
+ try {
43
+ parsedBody = JSON.parse(rawText);
44
+ }
45
+ catch {
46
+ /* non-JSON body — keep the raw text */
47
+ }
48
+ }
49
+ else {
50
+ parsedBody = null;
51
+ }
52
+ const captured = {
53
+ status: res.status,
54
+ headers: Object.fromEntries([...res.headers.entries()].map(([k, v]) => [k.toLowerCase(), v])),
55
+ body: parsedBody,
56
+ };
57
+ const failures = [];
58
+ for (const check of fixture.checks) {
59
+ const actual = resolvePath(captured, check.path);
60
+ if (check.exists !== undefined) {
61
+ const present = actual !== undefined;
62
+ if (present !== check.exists) {
63
+ failures.push(`${check.path}: expected ${check.exists ? "present" : "absent"}, got ${present ? "present" : "absent"}`);
64
+ }
65
+ }
66
+ if (check.equals !== undefined && JSON.stringify(actual) !== JSON.stringify(check.equals)) {
67
+ failures.push(`${check.path}: expected ${JSON.stringify(check.equals)}, got ${JSON.stringify(actual)}`);
68
+ }
69
+ }
70
+ return { fixture, passed: failures.length === 0, failures };
71
+ }
72
+ //# sourceMappingURL=parity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parity.js","sourceRoot":"","sources":["../src/parity.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA4CH,qEAAqE;AACrE,MAAM,UAAU,mBAAmB,CAAC,KAAa;IAC/C,OAAO,KAAK;SACT,KAAK,CAAC,IAAI,CAAC;SACX,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC1B,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;SAC1D,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAkB,CAAC,CAAC;AACtD,CAAC;AAQD,SAAS,WAAW,CAAC,QAA0B,EAAE,IAAY;IAC3D,IAAI,GAAG,GAAY,QAAQ,CAAC;IAC5B,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAClC,IAAI,GAAG,IAAI,IAAI,IAAI,OAAO,GAAG,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAC7D,GAAG,GAAI,GAA+B,CAAC,GAAG,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,yEAAyE;AACzE,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,GAAmB,EACnB,OAAsB;IAEtB,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;IACxD,MAAM,IAAI,GAAgB,EAAE,MAAM,EAAE,CAAC;IACrC,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QACxC,IAAI,CAAC,IAAI,GAAG,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACnE,IAAI,CAAC,OAAO,GAAG,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE,CAAC;IAC5E,CAAC;SAAM,IAAI,OAAO,EAAE,CAAC;QACnB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IAED,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC1C,MAAM,OAAO,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IACjC,IAAI,UAAU,GAAY,OAAO,CAAC;IAClC,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvB,IAAI,CAAC;YACH,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACnC,CAAC;QAAC,MAAM,CAAC;YACP,uCAAuC;QACzC,CAAC;IACH,CAAC;SAAM,CAAC;QACN,UAAU,GAAG,IAAI,CAAC;IACpB,CAAC;IAED,MAAM,QAAQ,GAAqB;QACjC,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,OAAO,EAAE,MAAM,CAAC,WAAW,CAAC,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;QAC7F,IAAI,EAAE,UAAU;KACjB,CAAC;IAEF,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QACnC,MAAM,MAAM,GAAG,WAAW,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACjD,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAC/B,MAAM,OAAO,GAAG,MAAM,KAAK,SAAS,CAAC;YACrC,IAAI,OAAO,KAAK,KAAK,CAAC,MAAM,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CACX,GAAG,KAAK,CAAC,IAAI,cAAc,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,SAAS,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,EAAE,CACxG,CAAC;YACJ,CAAC;QACH,CAAC;QACD,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1F,QAAQ,CAAC,IAAI,CACX,GAAG,KAAK,CAAC,IAAI,cAAc,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,SAAS,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CACzF,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;AAC9D,CAAC"}
@@ -0,0 +1,93 @@
1
+ import { type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
2
+ import { type NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import pg from "pg";
4
+ /**
5
+ * Real-Postgres test harness — spins up an ephemeral Postgres
6
+ * container, builds a Drizzle client against it, and pushes the
7
+ * supplied Drizzle schema so the tables exist before tests run.
8
+ *
9
+ * Why testcontainers: the per-module unit tests (accounts /
10
+ * oauth / outbox) drive `DrizzleEntityService` against
11
+ * a fake Drizzle-shaped DB so the test loop is fast and Docker-
12
+ * free. Integration tests under `*.integration.test.ts` exercise
13
+ * the SAME services against a real Postgres so the SQL the
14
+ * framework actually emits — partial indexes, FK cascades,
15
+ * `onConflictDoUpdate`, transactional outbox semantics — gets
16
+ * verified end-to-end. Different surface; both matter.
17
+ *
18
+ * This harness is the foundation for V4_STATUS G3. First
19
+ * integration test ships in this commit; oauth + outbox
20
+ * integration tests layer on in subsequent slices.
21
+ *
22
+ * Usage (suite-scoped, one container per describe block):
23
+ *
24
+ * import { describe, beforeAll, afterAll, it, expect } from "vitest";
25
+ * import { setupPostgresTestDb, type PostgresTestDb }
26
+ * from "@sixthsummit/ship-testing/postgres";
27
+ * import { accountsSchema } from "../../../../../../modules/accounts/index.js";
28
+ *
29
+ * describe("accounts integration", () => {
30
+ * let dbCtx: PostgresTestDb<typeof accountsSchema>;
31
+ * beforeAll(async () => {
32
+ * dbCtx = await setupPostgresTestDb(accountsSchema);
33
+ * }, 60_000);
34
+ * afterAll(async () => { await dbCtx.cleanup(); });
35
+ *
36
+ * it("inserts and reads", async () => {
37
+ * // dbCtx.db is a fully typed NodePgDatabase<typeof schema>.
38
+ * });
39
+ * });
40
+ *
41
+ * The container starts in ~3–10 s on a warm Docker daemon. Tests
42
+ * SHOULD share a container per describe block (suite-scoped
43
+ * lifecycle); per-test container overhead would dominate runtime.
44
+ * Per-test isolation comes from `truncateAll(dbCtx)` between
45
+ * tests, not from a fresh container.
46
+ *
47
+ * NOT included by the default `pnpm test` run — the root Vitest
48
+ * config excludes `*.integration.test.ts`. Run via
49
+ * `pnpm run test:integration`.
50
+ */
51
+ export interface PostgresTestDb<TSchema extends Record<string, unknown>> {
52
+ /** Started Postgres container (testcontainers handle). */
53
+ container: StartedPostgreSqlContainer;
54
+ /** Underlying pg.Pool. Surface for raw queries when Drizzle's API doesn't fit. */
55
+ pool: pg.Pool;
56
+ /** Fully-typed Drizzle client over the supplied schema. */
57
+ db: NodePgDatabase<TSchema>;
58
+ /** The connection string; useful when spawning subprocesses. */
59
+ connectionString: string;
60
+ /** Tear down the container + close the pool. Idempotent. */
61
+ cleanup: () => Promise<void>;
62
+ }
63
+ /**
64
+ * Bring up a Postgres container, build a Drizzle client, and push
65
+ * the supplied schema so the tables exist. Returns the test-db
66
+ * context plus a cleanup hook.
67
+ *
68
+ * The schema arg is a `Record<string, unknown>` matching the shape
69
+ * of `import * as schema from "..."` — drizzle-kit's `pushSchema`
70
+ * walks the imports and creates every `pgTable` it finds, plus
71
+ * any `pgEnum`s, indexes, and foreign keys.
72
+ */
73
+ /**
74
+ * A `pg.Pool` for a test against the disposable container. Attaches the
75
+ * pool-level `error` listener that `pg` requires: when the container stops
76
+ * while an idle client is still connected, the server sends
77
+ * `57P01 terminating connection due to administrator command`, and without
78
+ * a listener that arrives as an uncaught exception after the suite has
79
+ * already passed. Tests that build their own pools should use this too.
80
+ */
81
+ export declare function createPostgresTestPool(connectionString: string, options?: Omit<pg.PoolConfig, "connectionString">): pg.Pool;
82
+ export declare function setupPostgresTestDb<TSchema extends Record<string, unknown>>(schema: TSchema): Promise<PostgresTestDb<TSchema>>;
83
+ /**
84
+ * Truncate every table in the supplied schema, restarting identity
85
+ * columns. Use between tests in a suite to isolate per-test state
86
+ * without paying the container-startup cost.
87
+ *
88
+ * Iterates every value in the schema export that has a `_.name`
89
+ * property (the convention drizzle-orm uses for `pgTable` metadata).
90
+ * Skips relations, enums, and any other non-table exports.
91
+ */
92
+ export declare function truncateAll<TSchema extends Record<string, unknown>>(ctx: PostgresTestDb<TSchema>): Promise<void>;
93
+ //# sourceMappingURL=postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../src/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,0BAA0B,EAAE,MAAM,4BAA4B,CAAC;AAElG,OAAO,EAAW,KAAK,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACzE,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAEH,MAAM,WAAW,cAAc,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACrE,0DAA0D;IAC1D,SAAS,EAAE,0BAA0B,CAAC;IACtC,kFAAkF;IAClF,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC;IACd,2DAA2D;IAC3D,EAAE,EAAE,cAAc,CAAC,OAAO,CAAC,CAAC;IAC5B,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,CAAC;IACzB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,gBAAgB,EAAE,MAAM,EACxB,OAAO,GAAE,IAAI,CAAC,EAAE,CAAC,UAAU,EAAE,kBAAkB,CAAM,GACpD,EAAE,CAAC,IAAI,CAIT;AAED,wBAAsB,mBAAmB,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,MAAM,EAAE,OAAO,GACd,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAkClC;AAED;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACvE,GAAG,EAAE,cAAc,CAAC,OAAO,CAAC,GAC3B,OAAO,CAAC,IAAI,CAAC,CAWf"}
@@ -0,0 +1,83 @@
1
+ import { PostgreSqlContainer } from "@testcontainers/postgresql";
2
+ import { pushSchema } from "drizzle-kit/api";
3
+ import { drizzle } from "drizzle-orm/node-postgres";
4
+ import pg from "pg";
5
+ /**
6
+ * Bring up a Postgres container, build a Drizzle client, and push
7
+ * the supplied schema so the tables exist. Returns the test-db
8
+ * context plus a cleanup hook.
9
+ *
10
+ * The schema arg is a `Record<string, unknown>` matching the shape
11
+ * of `import * as schema from "..."` — drizzle-kit's `pushSchema`
12
+ * walks the imports and creates every `pgTable` it finds, plus
13
+ * any `pgEnum`s, indexes, and foreign keys.
14
+ */
15
+ /**
16
+ * A `pg.Pool` for a test against the disposable container. Attaches the
17
+ * pool-level `error` listener that `pg` requires: when the container stops
18
+ * while an idle client is still connected, the server sends
19
+ * `57P01 terminating connection due to administrator command`, and without
20
+ * a listener that arrives as an uncaught exception after the suite has
21
+ * already passed. Tests that build their own pools should use this too.
22
+ */
23
+ export function createPostgresTestPool(connectionString, options = {}) {
24
+ const pool = new pg.Pool({ connectionString, ...options });
25
+ pool.on("error", () => undefined);
26
+ return pool;
27
+ }
28
+ export async function setupPostgresTestDb(schema) {
29
+ const container = await new PostgreSqlContainer("postgres:16-alpine")
30
+ .withDatabase("ship_api_test")
31
+ .withUsername("ship_api")
32
+ .withPassword("ship_api")
33
+ .start();
34
+ const connectionString = container.getConnectionUri();
35
+ const pool = createPostgresTestPool(connectionString, { max: 5 });
36
+ const db = drizzle(pool, { schema });
37
+ // Programmatic pushSchema — same machinery `drizzle-kit push`
38
+ // uses, no migration files needed. The framework's schema source
39
+ // (.ts files) is the single source of truth for both runtime and
40
+ // tests. Schema changes propagate without committing intermediate
41
+ // migration SQL the test harness would otherwise have to track.
42
+ //
43
+ // The cast is required because drizzle-kit's `pushSchema` is
44
+ // typed against a dialect-agnostic `PgDatabase<any, ...>` rather
45
+ // than the concrete `NodePgDatabase<TSchema>` the runtime uses.
46
+ // Both are functionally compatible.
47
+ const result = await pushSchema(schema, db);
48
+ await result.apply();
49
+ return {
50
+ container,
51
+ pool,
52
+ db,
53
+ connectionString,
54
+ cleanup: async () => {
55
+ await pool.end().catch(() => undefined);
56
+ await container.stop().catch(() => undefined);
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * Truncate every table in the supplied schema, restarting identity
62
+ * columns. Use between tests in a suite to isolate per-test state
63
+ * without paying the container-startup cost.
64
+ *
65
+ * Iterates every value in the schema export that has a `_.name`
66
+ * property (the convention drizzle-orm uses for `pgTable` metadata).
67
+ * Skips relations, enums, and any other non-table exports.
68
+ */
69
+ export async function truncateAll(ctx) {
70
+ const tableNames = [];
71
+ for (const exported of Object.values(ctx.db._.schema ?? {})) {
72
+ const meta = exported?.dbName;
73
+ if (typeof meta === "string" && meta.length > 0)
74
+ tableNames.push(meta);
75
+ }
76
+ if (tableNames.length === 0)
77
+ return;
78
+ // Single TRUNCATE statement covers all tables; CASCADE handles FK
79
+ // references; RESTART IDENTITY resets serial / identity sequences.
80
+ const quoted = tableNames.map((n) => `"${n}"`).join(", ");
81
+ await ctx.pool.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
82
+ }
83
+ //# sourceMappingURL=postgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../src/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAmC,MAAM,4BAA4B,CAAC;AAClG,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAuB,MAAM,2BAA2B,CAAC;AACzE,OAAO,EAAE,MAAM,IAAI,CAAC;AA+DpB;;;;;;;;;GASG;AACH;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CACpC,gBAAwB,EACxB,UAAmD,EAAE;IAErD,MAAM,IAAI,GAAG,IAAI,EAAE,CAAC,IAAI,CAAC,EAAE,gBAAgB,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IAC3D,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IAClC,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,MAAe;IAEf,MAAM,SAAS,GAAG,MAAM,IAAI,mBAAmB,CAAC,oBAAoB,CAAC;SAClE,YAAY,CAAC,eAAe,CAAC;SAC7B,YAAY,CAAC,UAAU,CAAC;SACxB,YAAY,CAAC,UAAU,CAAC;SACxB,KAAK,EAAE,CAAC;IAEX,MAAM,gBAAgB,GAAG,SAAS,CAAC,gBAAgB,EAAE,CAAC;IACtD,MAAM,IAAI,GAAG,sBAAsB,CAAC,gBAAgB,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IAClE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IAErC,8DAA8D;IAC9D,iEAAiE;IACjE,iEAAiE;IACjE,kEAAkE;IAClE,gEAAgE;IAChE,EAAE;IACF,6DAA6D;IAC7D,iEAAiE;IACjE,gEAAgE;IAChE,oCAAoC;IACpC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,MAAM,EAAE,EAAiD,CAAC,CAAC;IAC3F,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;IAErB,OAAO;QACL,SAAS;QACT,IAAI;QACJ,EAAE;QACF,gBAAgB;QAChB,OAAO,EAAE,KAAK,IAAI,EAAE;YAClB,MAAM,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YACxC,MAAM,SAAS,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAChD,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,GAA4B;IAE5B,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,KAAK,MAAM,QAAQ,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC,EAAE,CAAC;QAC5D,MAAM,IAAI,GAAI,QAA4C,EAAE,MAAM,CAAC;QACnE,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACzE,CAAC;IACD,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO;IACpC,kEAAkE;IAClE,mEAAmE;IACnE,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1D,MAAM,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,kBAAkB,MAAM,2BAA2B,CAAC,CAAC;AAC5E,CAAC"}
@@ -0,0 +1,93 @@
1
+ import type { AnyChannel, AnyModule, ApplicationExecutor, CompiledApplication, OutboxDispatchResult, RealtimeBackend, RuntimeSchema, TenantScopeAuthority } from "@sixthsummit/ship-core";
2
+ import { type DrainOutboxOptions } from "./outbox.js";
3
+ export interface RealtimePublication {
4
+ readonly channelId: string;
5
+ readonly scopeKey: string;
6
+ readonly payload: unknown;
7
+ readonly publishedAt: string;
8
+ }
9
+ export interface InMemoryRealtimeBackendOptions {
10
+ readonly channels: readonly AnyChannel[];
11
+ readonly tenantScopes?: TenantScopeAuthority;
12
+ readonly id?: string;
13
+ }
14
+ /**
15
+ * Deterministic process-local realtime backend for unit tests and the raw
16
+ * WebSocket smoke fixture. Delivery is synchronous, presence is held in
17
+ * memory, and every accepted publication is recorded for assertions.
18
+ */
19
+ export interface InMemoryRealtimeBackend extends RealtimeBackend {
20
+ readonly adapter: "memory";
21
+ readonly published: readonly RealtimePublication[];
22
+ /** Registers additional channels after construction. */
23
+ register(channels: readonly AnyChannel[]): void;
24
+ reset(): void;
25
+ }
26
+ export declare function createInMemoryRealtimeBackend(options: InMemoryRealtimeBackendOptions): InMemoryRealtimeBackend;
27
+ export interface CollectedPublishes<TApp extends CompiledApplication<string, readonly AnyModule[]>> {
28
+ /** The same application with every channel publication bound to the recording backend. */
29
+ readonly application: TApp;
30
+ readonly backend: InMemoryRealtimeBackend;
31
+ /** Publications accepted so far, oldest first. */
32
+ publishes(): readonly RealtimePublication[];
33
+ }
34
+ /**
35
+ * Recompiles an application so every channel publication provider records
36
+ * into one in-memory backend, without asking the app factory to accept a
37
+ * backend parameter. Other providers are kept as declared.
38
+ */
39
+ export declare function collectPublishes<TApp extends CompiledApplication<string, readonly AnyModule[]>>(application: TApp, options?: {
40
+ readonly tenantScopes?: TenantScopeAuthority;
41
+ }): CollectedPublishes<TApp>;
42
+ /**
43
+ * Runs `ship-realtime.publication.dispatch` until nothing is claimed, so
44
+ * publications recorded by `transaction: "write"` operations reach the
45
+ * backend `collectPublishes()` records into. The application must mount
46
+ * `defineRealtimePublicationModule()`.
47
+ */
48
+ export declare function drainRealtimePublications(executor: ApplicationExecutor<CompiledApplication<string, readonly AnyModule[]>>, options?: DrainOutboxOptions): Promise<OutboxDispatchResult>;
49
+ export type ConformanceTest = (name: string, run: () => Promise<void>) => unknown;
50
+ export interface RealtimeConformanceOptions {
51
+ /** Builds a fresh backend registered with the suite's fixture channels. */
52
+ readonly createBackend: (channels: readonly AnyChannel[], options: {
53
+ readonly tenantScopes: TenantScopeAuthority;
54
+ }) => RealtimeBackend | Promise<RealtimeBackend>;
55
+ /** The host runner's test registration function, e.g. vitest `it`. */
56
+ readonly test: ConformanceTest;
57
+ /** Upper bound for asynchronous delivery. Defaults to 2000 ms. */
58
+ readonly deliveryTimeoutMs?: number;
59
+ }
60
+ export declare const conformanceChannels: Readonly<{
61
+ feed: import("@sixthsummit/ship-core").Channel<"conformance.feed", "actor", RuntimeSchema<{
62
+ readonly n: number;
63
+ }, {
64
+ readonly n: number;
65
+ }>, false>;
66
+ room: import("@sixthsummit/ship-core").Channel<"conformance.room", "tenant", RuntimeSchema<{
67
+ readonly n: number;
68
+ }, {
69
+ readonly n: number;
70
+ }>, true>;
71
+ board: import("@sixthsummit/ship-core").Channel<"conformance.board", "resource", RuntimeSchema<{
72
+ readonly n: number;
73
+ }, {
74
+ readonly n: number;
75
+ }>, true>;
76
+ ticker: import("@sixthsummit/ship-core").Channel<"conformance.ticker", "actor", RuntimeSchema<{
77
+ readonly id: string;
78
+ readonly n: number;
79
+ }, {
80
+ readonly id: string;
81
+ readonly n: number;
82
+ }>, false>;
83
+ }>;
84
+ export declare const conformanceTenantScopes: TenantScopeAuthority;
85
+ /**
86
+ * Registers the behavioral contract every RealtimeBackend must satisfy:
87
+ * authorized delivery, fail-closed subscription authorization, per-scope
88
+ * isolation and sequencing, presence dedupe, coalescing, disconnect
89
+ * cleanup, and health. Both the in-memory backend and the Socket.IO
90
+ * adapter run exactly this suite.
91
+ */
92
+ export declare function realtimeBackendConformance(options: RealtimeConformanceOptions): void;
93
+ //# sourceMappingURL=realtime.d.ts.map