@geekmidas/testkit 10.0.0-alpha.20 → 10.0.0-alpha.22

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,55 @@
1
+ //#region src/context.d.ts
2
+ /**
3
+ * The test a request belongs to.
4
+ *
5
+ * A feature test drives an app the way it runs deployed: a browser calls the
6
+ * API, the API calls the auth server, each over a URL. In a test those URLs are
7
+ * served in-process, and every request made on behalf of one test has to reach
8
+ * *that test's* transactions — its own call to the API, and the API's call to
9
+ * the auth server made while handling it.
10
+ *
11
+ * The id travels two ways. In-process, an `AsyncLocalStorage` carries it
12
+ * through every await a test and its handlers make. On the wire it is the
13
+ * `x-test-context-id` header, which a stamped `fetch` adds to every request made
14
+ * inside a context, so whatever serves the URL can find the context again —
15
+ * without any caller, including code under test, writing the header.
16
+ */
17
+ /** The header a test context travels in between client and server. */
18
+ export declare const TEST_CONTEXT_HEADER = "x-test-context-id";
19
+ /**
20
+ * Which side of a URL the running code is on.
21
+ *
22
+ * `client` is the test itself and the frontend it renders — what a browser
23
+ * does. `server` is a handler serving a request. The difference matters for
24
+ * cookies: a browser attaches its cookies to what it sends, a server forwards
25
+ * only what it was handed. A server call that picked up the browser's cookies
26
+ * would hide the bug of a server that forgets to forward them.
27
+ */
28
+ export type TestSide = 'client' | 'server';
29
+ export interface TestContext {
30
+ readonly id: string;
31
+ readonly side: TestSide;
32
+ }
33
+ /** The context the running code belongs to, if any. */
34
+ export declare function currentTestContext(): TestContext | undefined;
35
+ /** Run `fn` as the client side of the test `id`. */
36
+ export declare function runInTestContext<T>(id: string, fn: () => T): T;
37
+ /**
38
+ * Run `fn` as a server handling a request for the test `id` — what whatever
39
+ * serves a URL in-process wraps each request in.
40
+ */
41
+ export declare function runAsServer<T>(id: string, fn: () => T): T;
42
+ /** The test a request was made for, read off its header. */
43
+ export declare function testContextOf(request: Request): string | undefined;
44
+ /** `fetch`, adding the current test's id to every request made inside one. */
45
+ export declare function stampedFetch(inner: typeof fetch): typeof fetch;
46
+ /**
47
+ * Stamp the global `fetch` with the current test's id.
48
+ *
49
+ * Install it after anything else that patches `fetch` — MSW's interceptor in
50
+ * particular — so the stamp is added before the request is intercepted.
51
+ * Returns what puts the previous `fetch` back.
52
+ */
53
+ export declare function installContextFetch(): () => void;
54
+ //#endregion
55
+ //# sourceMappingURL=context.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.mts","names":[],"sources":["../src/context.ts"],"mappings":";;;;;;;;;;;;;;;;;qBAmBa;;;;;;;;;;YAWD;iBAEK;WACP;WACA,MAAM;;;wBAMA,sBAAsB;;wBAKtB,iBAAiB,GAAG,YAAY,UAAU,IAAI;;;;;wBAQ9C,YAAY,GAAG,YAAY,UAAU,IAAI;;wBAKzC,cAAc,SAAS;;wBAKvB,aAAa,cAAc,eAAe;;;;;;;;wBAoB1C"}
@@ -0,0 +1,73 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/context.ts
3
+ /**
4
+ * The test a request belongs to.
5
+ *
6
+ * A feature test drives an app the way it runs deployed: a browser calls the
7
+ * API, the API calls the auth server, each over a URL. In a test those URLs are
8
+ * served in-process, and every request made on behalf of one test has to reach
9
+ * *that test's* transactions — its own call to the API, and the API's call to
10
+ * the auth server made while handling it.
11
+ *
12
+ * The id travels two ways. In-process, an `AsyncLocalStorage` carries it
13
+ * through every await a test and its handlers make. On the wire it is the
14
+ * `x-test-context-id` header, which a stamped `fetch` adds to every request made
15
+ * inside a context, so whatever serves the URL can find the context again —
16
+ * without any caller, including code under test, writing the header.
17
+ */
18
+ /** The header a test context travels in between client and server. */
19
+ const TEST_CONTEXT_HEADER = "x-test-context-id";
20
+ const storage = new AsyncLocalStorage();
21
+ /** The context the running code belongs to, if any. */
22
+ function currentTestContext() {
23
+ return storage.getStore();
24
+ }
25
+ /** Run `fn` as the client side of the test `id`. */
26
+ function runInTestContext(id, fn) {
27
+ return storage.run({
28
+ id,
29
+ side: "client"
30
+ }, fn);
31
+ }
32
+ /**
33
+ * Run `fn` as a server handling a request for the test `id` — what whatever
34
+ * serves a URL in-process wraps each request in.
35
+ */
36
+ function runAsServer(id, fn) {
37
+ return storage.run({
38
+ id,
39
+ side: "server"
40
+ }, fn);
41
+ }
42
+ /** The test a request was made for, read off its header. */
43
+ function testContextOf(request) {
44
+ return request.headers.get("x-test-context-id") ?? void 0;
45
+ }
46
+ /** `fetch`, adding the current test's id to every request made inside one. */
47
+ function stampedFetch(inner) {
48
+ return (input, init) => {
49
+ const context = currentTestContext();
50
+ if (!context) return inner(input, init);
51
+ const request = new Request(input, init);
52
+ if (!request.headers.has("x-test-context-id")) request.headers.set(TEST_CONTEXT_HEADER, context.id);
53
+ return inner(request);
54
+ };
55
+ }
56
+ /**
57
+ * Stamp the global `fetch` with the current test's id.
58
+ *
59
+ * Install it after anything else that patches `fetch` — MSW's interceptor in
60
+ * particular — so the stamp is added before the request is intercepted.
61
+ * Returns what puts the previous `fetch` back.
62
+ */
63
+ function installContextFetch() {
64
+ const previous = globalThis.fetch;
65
+ globalThis.fetch = stampedFetch(previous);
66
+ return () => {
67
+ globalThis.fetch = previous;
68
+ };
69
+ }
70
+ //#endregion
71
+ export { TEST_CONTEXT_HEADER, currentTestContext, installContextFetch, runAsServer, runInTestContext, stampedFetch, testContextOf };
72
+
73
+ //# sourceMappingURL=context.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.mjs","names":[],"sources":["../src/context.ts"],"sourcesContent":["/**\n * The test a request belongs to.\n *\n * A feature test drives an app the way it runs deployed: a browser calls the\n * API, the API calls the auth server, each over a URL. In a test those URLs are\n * served in-process, and every request made on behalf of one test has to reach\n * *that test's* transactions — its own call to the API, and the API's call to\n * the auth server made while handling it.\n *\n * The id travels two ways. In-process, an `AsyncLocalStorage` carries it\n * through every await a test and its handlers make. On the wire it is the\n * `x-test-context-id` header, which a stamped `fetch` adds to every request made\n * inside a context, so whatever serves the URL can find the context again —\n * without any caller, including code under test, writing the header.\n */\n\nimport { AsyncLocalStorage } from 'node:async_hooks';\n\n/** The header a test context travels in between client and server. */\nexport const TEST_CONTEXT_HEADER = 'x-test-context-id';\n\n/**\n * Which side of a URL the running code is on.\n *\n * `client` is the test itself and the frontend it renders — what a browser\n * does. `server` is a handler serving a request. The difference matters for\n * cookies: a browser attaches its cookies to what it sends, a server forwards\n * only what it was handed. A server call that picked up the browser's cookies\n * would hide the bug of a server that forgets to forward them.\n */\nexport type TestSide = 'client' | 'server';\n\nexport interface TestContext {\n\treadonly id: string;\n\treadonly side: TestSide;\n}\n\nconst storage = new AsyncLocalStorage<TestContext>();\n\n/** The context the running code belongs to, if any. */\nexport function currentTestContext(): TestContext | undefined {\n\treturn storage.getStore();\n}\n\n/** Run `fn` as the client side of the test `id`. */\nexport function runInTestContext<T>(id: string, fn: () => T): T {\n\treturn storage.run({ id, side: 'client' }, fn);\n}\n\n/**\n * Run `fn` as a server handling a request for the test `id` — what whatever\n * serves a URL in-process wraps each request in.\n */\nexport function runAsServer<T>(id: string, fn: () => T): T {\n\treturn storage.run({ id, side: 'server' }, fn);\n}\n\n/** The test a request was made for, read off its header. */\nexport function testContextOf(request: Request): string | undefined {\n\treturn request.headers.get(TEST_CONTEXT_HEADER) ?? undefined;\n}\n\n/** `fetch`, adding the current test's id to every request made inside one. */\nexport function stampedFetch(inner: typeof fetch): typeof fetch {\n\treturn (input, init) => {\n\t\tconst context = currentTestContext();\n\t\tif (!context) return inner(input, init);\n\n\t\tconst request = new Request(input, init);\n\t\tif (!request.headers.has(TEST_CONTEXT_HEADER)) {\n\t\t\trequest.headers.set(TEST_CONTEXT_HEADER, context.id);\n\t\t}\n\t\treturn inner(request);\n\t};\n}\n\n/**\n * Stamp the global `fetch` with the current test's id.\n *\n * Install it after anything else that patches `fetch` — MSW's interceptor in\n * particular — so the stamp is added before the request is intercepted.\n * Returns what puts the previous `fetch` back.\n */\nexport function installContextFetch(): () => void {\n\tconst previous = globalThis.fetch;\n\tglobalThis.fetch = stampedFetch(previous);\n\treturn () => {\n\t\tglobalThis.fetch = previous;\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAmBA,MAAa,sBAAsB;AAkBnC,MAAM,UAAU,IAAI,kBAA+B;;AAGnD,SAAgB,qBAA8C;CAC7D,OAAO,QAAQ,SAAS;AACzB;;AAGA,SAAgB,iBAAoB,IAAY,IAAgB;CAC/D,OAAO,QAAQ,IAAI;EAAE;EAAI,MAAM;CAAS,GAAG,EAAE;AAC9C;;;;;AAMA,SAAgB,YAAe,IAAY,IAAgB;CAC1D,OAAO,QAAQ,IAAI;EAAE;EAAI,MAAM;CAAS,GAAG,EAAE;AAC9C;;AAGA,SAAgB,cAAc,SAAsC;CACnE,OAAO,QAAQ,QAAQ,IAAA,mBAAuB,KAAK,KAAA;AACpD;;AAGA,SAAgB,aAAa,OAAmC;CAC/D,QAAQ,OAAO,SAAS;EACvB,MAAM,UAAU,mBAAmB;EACnC,IAAI,CAAC,SAAS,OAAO,MAAM,OAAO,IAAI;EAEtC,MAAM,UAAU,IAAI,QAAQ,OAAO,IAAI;EACvC,IAAI,CAAC,QAAQ,QAAQ,IAAA,mBAAuB,GAC3C,QAAQ,QAAQ,IAAI,qBAAqB,QAAQ,EAAE;EAEpD,OAAO,MAAM,OAAO;CACrB;AACD;;;;;;;;AASA,SAAgB,sBAAkC;CACjD,MAAM,WAAW,WAAW;CAC5B,WAAW,QAAQ,aAAa,QAAQ;CACxC,aAAa;EACZ,WAAW,QAAQ;CACpB;AACD"}
@@ -0,0 +1,95 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/mailbox.ts
3
+ /**
4
+ * The mail a test sent, read from Mailpit.
5
+ *
6
+ * Locally, mail goes over real SMTP to Mailpit — the same client, the same
7
+ * message, only the host differs. A test reads what arrived through Mailpit's
8
+ * HTTP API, so a magic link is signed in with by opening the email that was
9
+ * actually sent rather than a token pulled from somewhere else.
10
+ *
11
+ * Mailpit has one inbox for everything, and transactions do not reach it, so a
12
+ * test keeps to its own mail by address: a unique recipient per test, read by
13
+ * `to:`, and cleared afterwards.
14
+ */
15
+ /** How long `last()` waits for mail that is still on its way. */
16
+ const DEFAULT_TIMEOUT = 5e3;
17
+ const POLL_INTERVAL = 50;
18
+ /** A way to read the mail sent to one address. */
19
+ function createMailbox(options) {
20
+ const inbox = options.inbox.replace(/\/$/, "");
21
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
22
+ const get = (path, init) => (options.fetch ?? globalThis.fetch)(`${inbox}${path}`, init);
23
+ return (address) => {
24
+ const query = `?query=${encodeURIComponent(`to:"${address}"`)}`;
25
+ const all = async () => {
26
+ const found = await json(await get(`/api/v1/search${query}`), inbox);
27
+ return Promise.all(found.messages.map(async ({ ID }) => email(await json(await get(`/api/v1/message/${ID}`), inbox))));
28
+ };
29
+ return {
30
+ all,
31
+ async last() {
32
+ const deadline = Date.now() + timeout;
33
+ for (;;) {
34
+ const [newest] = await all();
35
+ if (newest) return newest;
36
+ if (Date.now() >= deadline) throw new NoMail(address, timeout);
37
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
38
+ }
39
+ },
40
+ async clear() {
41
+ const response = await get(`/api/v1/search${query}`, { method: "DELETE" });
42
+ if (!response.ok) throw new InboxUnreachable(inbox, response.status);
43
+ }
44
+ };
45
+ };
46
+ }
47
+ async function json(response, inbox) {
48
+ if (!response.ok) throw new InboxUnreachable(inbox, response.status);
49
+ return await response.json();
50
+ }
51
+ function email(message) {
52
+ const links = [...new Set([...(message.HTML ?? "").matchAll(/href="(https?:\/\/[^"]+)"/g), ...(message.Text ?? "").matchAll(/(https?:\/\/[^\s<>"]+)/g)].map(([, url]) => decodeEntities(url)))];
53
+ return {
54
+ id: message.ID,
55
+ from: message.From.Address,
56
+ to: message.To.map(({ Address }) => Address),
57
+ subject: message.Subject,
58
+ text: message.Text ?? "",
59
+ html: message.HTML ?? "",
60
+ links,
61
+ link: links[0]
62
+ };
63
+ }
64
+ /** `&amp;` in an HTML attribute is `&` in the URL it names. */
65
+ function decodeEntities(url) {
66
+ return url.replaceAll("&amp;", "&");
67
+ }
68
+ /** No email to an address arrived in time. */
69
+ var NoMail = class extends Error {
70
+ address;
71
+ timeout;
72
+ constructor(address, timeout) {
73
+ super(`No email to ${address} arrived within ${timeout}ms. Check that the code under test sends it, and to that address.`);
74
+ this.address = address;
75
+ this.timeout = timeout;
76
+ this.name = "NoMail";
77
+ }
78
+ };
79
+ /** Mailpit did not answer. */
80
+ var InboxUnreachable = class extends Error {
81
+ inbox;
82
+ status;
83
+ constructor(inbox, status) {
84
+ super(`Mailpit at ${inbox} answered ${status}. Is it running? \`gkm test\` starts it when the app declares an Email construct.`);
85
+ this.inbox = inbox;
86
+ this.status = status;
87
+ this.name = "InboxUnreachable";
88
+ }
89
+ };
90
+ //#endregion
91
+ exports.InboxUnreachable = InboxUnreachable;
92
+ exports.NoMail = NoMail;
93
+ exports.createMailbox = createMailbox;
94
+
95
+ //# sourceMappingURL=mailbox.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mailbox.cjs","names":[],"sources":["../src/mailbox.ts"],"sourcesContent":["/**\n * The mail a test sent, read from Mailpit.\n *\n * Locally, mail goes over real SMTP to Mailpit — the same client, the same\n * message, only the host differs. A test reads what arrived through Mailpit's\n * HTTP API, so a magic link is signed in with by opening the email that was\n * actually sent rather than a token pulled from somewhere else.\n *\n * Mailpit has one inbox for everything, and transactions do not reach it, so a\n * test keeps to its own mail by address: a unique recipient per test, read by\n * `to:`, and cleared afterwards.\n */\n\n/** How long `last()` waits for mail that is still on its way. */\nconst DEFAULT_TIMEOUT = 5_000;\nconst POLL_INTERVAL = 50;\n\nexport interface MailboxOptions {\n\t/** Mailpit's HTTP address — what `gkm test` injects as `<ID>_INBOX_URL`. */\n\tinbox: string;\n\t/** The `fetch` to read it with. Defaults to the global one. */\n\tfetch?: typeof fetch;\n\t/** How long `last()` waits for mail to arrive. */\n\ttimeout?: number;\n}\n\n/** One email, as it arrived. */\nexport interface Email {\n\tid: string;\n\tfrom: string;\n\tto: string[];\n\tsubject: string;\n\ttext: string;\n\thtml: string;\n\t/** Every `http(s)` link in the email, in order. */\n\tlinks: string[];\n\t/** The first link — the one a sign-in or confirmation email is about. */\n\tlink: string | undefined;\n}\n\nexport interface Mailbox {\n\t/** The newest email to this address, waiting for one if none has arrived. */\n\tlast(): Promise<Email>;\n\t/** Every email to this address, newest first. */\n\tall(): Promise<Email[]>;\n\t/** Delete every email to this address. */\n\tclear(): Promise<void>;\n}\n\n/** A way to read the mail sent to one address. */\nexport function createMailbox(\n\toptions: MailboxOptions,\n): (address: string) => Mailbox {\n\tconst inbox = options.inbox.replace(/\\/$/, '');\n\tconst timeout = options.timeout ?? DEFAULT_TIMEOUT;\n\tconst get = (path: string, init?: RequestInit) =>\n\t\t(options.fetch ?? globalThis.fetch)(`${inbox}${path}`, init);\n\n\treturn (address) => {\n\t\tconst query = `?query=${encodeURIComponent(`to:\"${address}\"`)}`;\n\n\t\tconst all = async (): Promise<Email[]> => {\n\t\t\tconst found = await json<SearchResult>(\n\t\t\t\tawait get(`/api/v1/search${query}`),\n\t\t\t\tinbox,\n\t\t\t);\n\t\t\treturn Promise.all(\n\t\t\t\tfound.messages.map(async ({ ID }) =>\n\t\t\t\t\temail(await json<Message>(await get(`/api/v1/message/${ID}`), inbox)),\n\t\t\t\t),\n\t\t\t);\n\t\t};\n\n\t\treturn {\n\t\t\tall,\n\t\t\tasync last() {\n\t\t\t\tconst deadline = Date.now() + timeout;\n\t\t\t\tfor (;;) {\n\t\t\t\t\tconst [newest] = await all();\n\t\t\t\t\tif (newest) return newest;\n\t\t\t\t\tif (Date.now() >= deadline) throw new NoMail(address, timeout);\n\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));\n\t\t\t\t}\n\t\t\t},\n\t\t\tasync clear() {\n\t\t\t\tconst response = await get(`/api/v1/search${query}`, {\n\t\t\t\t\tmethod: 'DELETE',\n\t\t\t\t});\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tthrow new InboxUnreachable(inbox, response.status);\n\t\t\t\t}\n\t\t\t},\n\t\t};\n\t};\n}\n\ninterface SearchResult {\n\tmessages: { ID: string }[];\n}\n\ninterface Message {\n\tID: string;\n\tFrom: { Address: string };\n\tTo: { Address: string }[];\n\tSubject: string;\n\tText: string;\n\tHTML: string;\n}\n\nasync function json<T>(response: Response, inbox: string): Promise<T> {\n\tif (!response.ok) throw new InboxUnreachable(inbox, response.status);\n\treturn (await response.json()) as T;\n}\n\nfunction email(message: Message): Email {\n\tconst links = [\n\t\t...new Set(\n\t\t\t[\n\t\t\t\t...(message.HTML ?? '').matchAll(/href=\"(https?:\\/\\/[^\"]+)\"/g),\n\t\t\t\t...(message.Text ?? '').matchAll(/(https?:\\/\\/[^\\s<>\"]+)/g),\n\t\t\t].map(([, url]) => decodeEntities(url!)),\n\t\t),\n\t];\n\n\treturn {\n\t\tid: message.ID,\n\t\tfrom: message.From.Address,\n\t\tto: message.To.map(({ Address }) => Address),\n\t\tsubject: message.Subject,\n\t\ttext: message.Text ?? '',\n\t\thtml: message.HTML ?? '',\n\t\tlinks,\n\t\tlink: links[0],\n\t};\n}\n\n/** `&amp;` in an HTML attribute is `&` in the URL it names. */\nfunction decodeEntities(url: string): string {\n\treturn url.replaceAll('&amp;', '&');\n}\n\n/** No email to an address arrived in time. */\nexport class NoMail extends Error {\n\tconstructor(\n\t\treadonly address: string,\n\t\treadonly timeout: number,\n\t) {\n\t\tsuper(\n\t\t\t`No email to ${address} arrived within ${timeout}ms. Check that the ` +\n\t\t\t\t`code under test sends it, and to that address.`,\n\t\t);\n\t\tthis.name = 'NoMail';\n\t}\n}\n\n/** Mailpit did not answer. */\nexport class InboxUnreachable extends Error {\n\tconstructor(\n\t\treadonly inbox: string,\n\t\treadonly status: number,\n\t) {\n\t\tsuper(\n\t\t\t`Mailpit at ${inbox} answered ${status}. Is it running? \\`gkm test\\` ` +\n\t\t\t\t`starts it when the app declares an Email construct.`,\n\t\t);\n\t\tthis.name = 'InboxUnreachable';\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;AAcA,MAAM,kBAAkB;AACxB,MAAM,gBAAgB;;AAmCtB,SAAgB,cACf,SAC+B;CAC/B,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,EAAE;CAC7C,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,OAAO,MAAc,UACzB,QAAQ,SAAS,WAAW,MAAA,CAAO,GAAG,QAAQ,QAAQ,IAAI;CAE5D,QAAQ,YAAY;EACnB,MAAM,QAAQ,UAAU,mBAAmB,OAAO,QAAQ,EAAE;EAE5D,MAAM,MAAM,YAA8B;GACzC,MAAM,QAAQ,MAAM,KACnB,MAAM,IAAI,iBAAiB,OAAO,GAClC,KACD;GACA,OAAO,QAAQ,IACd,MAAM,SAAS,IAAI,OAAO,EAAE,SAC3B,MAAM,MAAM,KAAc,MAAM,IAAI,mBAAmB,IAAI,GAAG,KAAK,CAAC,CACrE,CACD;EACD;EAEA,OAAO;GACN;GACA,MAAM,OAAO;IACZ,MAAM,WAAW,KAAK,IAAI,IAAI;IAC9B,SAAS;KACR,MAAM,CAAC,UAAU,MAAM,IAAI;KAC3B,IAAI,QAAQ,OAAO;KACnB,IAAI,KAAK,IAAI,KAAK,UAAU,MAAM,IAAI,OAAO,SAAS,OAAO;KAC7D,MAAM,IAAI,SAAS,YAAY,WAAW,SAAS,aAAa,CAAC;IAClE;GACD;GACA,MAAM,QAAQ;IACb,MAAM,WAAW,MAAM,IAAI,iBAAiB,SAAS,EACpD,QAAQ,SACT,CAAC;IACD,IAAI,CAAC,SAAS,IACb,MAAM,IAAI,iBAAiB,OAAO,SAAS,MAAM;GAEnD;EACD;CACD;AACD;AAeA,eAAe,KAAQ,UAAoB,OAA2B;CACrE,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,iBAAiB,OAAO,SAAS,MAAM;CACnE,OAAQ,MAAM,SAAS,KAAK;AAC7B;AAEA,SAAS,MAAM,SAAyB;CACvC,MAAM,QAAQ,CACb,GAAG,IAAI,IACN,CACC,IAAI,QAAQ,QAAQ,GAAA,CAAI,SAAS,4BAA4B,GAC7D,IAAI,QAAQ,QAAQ,GAAA,CAAI,SAAS,yBAAyB,CAC3D,CAAC,CAAC,KAAK,GAAG,SAAS,eAAe,GAAI,CAAC,CACxC,CACD;CAEA,OAAO;EACN,IAAI,QAAQ;EACZ,MAAM,QAAQ,KAAK;EACnB,IAAI,QAAQ,GAAG,KAAK,EAAE,cAAc,OAAO;EAC3C,SAAS,QAAQ;EACjB,MAAM,QAAQ,QAAQ;EACtB,MAAM,QAAQ,QAAQ;EACtB;EACA,MAAM,MAAM;CACb;AACD;;AAGA,SAAS,eAAe,KAAqB;CAC5C,OAAO,IAAI,WAAW,SAAS,GAAG;AACnC;;AAGA,IAAa,SAAb,cAA4B,MAAM;CAEvB;CACA;CAFV,YACC,SACA,SACC;EACD,MACC,eAAe,QAAQ,kBAAkB,QAAQ,kEAElD;EANS,KAAA,UAAA;EACA,KAAA,UAAA;EAMT,KAAK,OAAO;CACb;AACD;;AAGA,IAAa,mBAAb,cAAsC,MAAM;CAEjC;CACA;CAFV,YACC,OACA,QACC;EACD,MACC,cAAc,MAAM,YAAY,OAAO,kFAExC;EANS,KAAA,QAAA;EACA,KAAA,SAAA;EAMT,KAAK,OAAO;CACb;AACD"}
@@ -0,0 +1,58 @@
1
+ //#region src/mailbox.d.ts
2
+ /**
3
+ * The mail a test sent, read from Mailpit.
4
+ *
5
+ * Locally, mail goes over real SMTP to Mailpit — the same client, the same
6
+ * message, only the host differs. A test reads what arrived through Mailpit's
7
+ * HTTP API, so a magic link is signed in with by opening the email that was
8
+ * actually sent rather than a token pulled from somewhere else.
9
+ *
10
+ * Mailpit has one inbox for everything, and transactions do not reach it, so a
11
+ * test keeps to its own mail by address: a unique recipient per test, read by
12
+ * `to:`, and cleared afterwards.
13
+ */
14
+ export interface MailboxOptions {
15
+ /** Mailpit's HTTP address — what `gkm test` injects as `<ID>_INBOX_URL`. */
16
+ inbox: string;
17
+ /** The `fetch` to read it with. Defaults to the global one. */
18
+ fetch?: typeof fetch;
19
+ /** How long `last()` waits for mail to arrive. */
20
+ timeout?: number;
21
+ }
22
+ /** One email, as it arrived. */
23
+ export interface Email {
24
+ id: string;
25
+ from: string;
26
+ to: string[];
27
+ subject: string;
28
+ text: string;
29
+ html: string;
30
+ /** Every `http(s)` link in the email, in order. */
31
+ links: string[];
32
+ /** The first link — the one a sign-in or confirmation email is about. */
33
+ link: string | undefined;
34
+ }
35
+ export interface Mailbox {
36
+ /** The newest email to this address, waiting for one if none has arrived. */
37
+ last(): Promise<Email>;
38
+ /** Every email to this address, newest first. */
39
+ all(): Promise<Email[]>;
40
+ /** Delete every email to this address. */
41
+ clear(): Promise<void>;
42
+ }
43
+ /** A way to read the mail sent to one address. */
44
+ export declare function createMailbox(options: MailboxOptions): (address: string) => Mailbox;
45
+ /** No email to an address arrived in time. */
46
+ export declare class NoMail extends Error {
47
+ readonly address: string;
48
+ readonly timeout: number;
49
+ constructor(address: string, timeout: number);
50
+ }
51
+ /** Mailpit did not answer. */
52
+ export declare class InboxUnreachable extends Error {
53
+ readonly inbox: string;
54
+ readonly status: number;
55
+ constructor(inbox: string, status: number);
56
+ }
57
+ //#endregion
58
+ //# sourceMappingURL=mailbox.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mailbox.d.cts","names":[],"sources":["../src/mailbox.ts"],"mappings":";;;;;;;;;;;;;iBAiBiB;;EAEhB;;EAEA,eAAe;;EAEf;;;iBAIgB;EAChB;EACA;EACA;EACA;EACA;EACA;;EAEA;;EAEA;;iBAGgB;;EAEhB,QAAQ,QAAQ;;EAEhB,OAAO,QAAQ;;EAEf,SAAS;;;wBAIM,cACf,SAAS,kBACN,oBAAoB;;qBA0FX,eAAe;WAEjB;WACA;EAFV,YACU,iBACA;;;qBAWE,yBAAyB;WAE3B;WACA;EAFV,YACU,eACA"}
@@ -0,0 +1,58 @@
1
+ //#region src/mailbox.d.ts
2
+ /**
3
+ * The mail a test sent, read from Mailpit.
4
+ *
5
+ * Locally, mail goes over real SMTP to Mailpit — the same client, the same
6
+ * message, only the host differs. A test reads what arrived through Mailpit's
7
+ * HTTP API, so a magic link is signed in with by opening the email that was
8
+ * actually sent rather than a token pulled from somewhere else.
9
+ *
10
+ * Mailpit has one inbox for everything, and transactions do not reach it, so a
11
+ * test keeps to its own mail by address: a unique recipient per test, read by
12
+ * `to:`, and cleared afterwards.
13
+ */
14
+ export interface MailboxOptions {
15
+ /** Mailpit's HTTP address — what `gkm test` injects as `<ID>_INBOX_URL`. */
16
+ inbox: string;
17
+ /** The `fetch` to read it with. Defaults to the global one. */
18
+ fetch?: typeof fetch;
19
+ /** How long `last()` waits for mail to arrive. */
20
+ timeout?: number;
21
+ }
22
+ /** One email, as it arrived. */
23
+ export interface Email {
24
+ id: string;
25
+ from: string;
26
+ to: string[];
27
+ subject: string;
28
+ text: string;
29
+ html: string;
30
+ /** Every `http(s)` link in the email, in order. */
31
+ links: string[];
32
+ /** The first link — the one a sign-in or confirmation email is about. */
33
+ link: string | undefined;
34
+ }
35
+ export interface Mailbox {
36
+ /** The newest email to this address, waiting for one if none has arrived. */
37
+ last(): Promise<Email>;
38
+ /** Every email to this address, newest first. */
39
+ all(): Promise<Email[]>;
40
+ /** Delete every email to this address. */
41
+ clear(): Promise<void>;
42
+ }
43
+ /** A way to read the mail sent to one address. */
44
+ export declare function createMailbox(options: MailboxOptions): (address: string) => Mailbox;
45
+ /** No email to an address arrived in time. */
46
+ export declare class NoMail extends Error {
47
+ readonly address: string;
48
+ readonly timeout: number;
49
+ constructor(address: string, timeout: number);
50
+ }
51
+ /** Mailpit did not answer. */
52
+ export declare class InboxUnreachable extends Error {
53
+ readonly inbox: string;
54
+ readonly status: number;
55
+ constructor(inbox: string, status: number);
56
+ }
57
+ //#endregion
58
+ //# sourceMappingURL=mailbox.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mailbox.d.mts","names":[],"sources":["../src/mailbox.ts"],"mappings":";;;;;;;;;;;;;iBAiBiB;;EAEhB;;EAEA,eAAe;;EAEf;;;iBAIgB;EAChB;EACA;EACA;EACA;EACA;EACA;;EAEA;;EAEA;;iBAGgB;;EAEhB,QAAQ,QAAQ;;EAEhB,OAAO,QAAQ;;EAEf,SAAS;;;wBAIM,cACf,SAAS,kBACN,oBAAoB;;qBA0FX,eAAe;WAEjB;WACA;EAFV,YACU,iBACA;;;qBAWE,yBAAyB;WAE3B;WACA;EAFV,YACU,eACA"}
@@ -0,0 +1,92 @@
1
+ //#region src/mailbox.ts
2
+ /**
3
+ * The mail a test sent, read from Mailpit.
4
+ *
5
+ * Locally, mail goes over real SMTP to Mailpit — the same client, the same
6
+ * message, only the host differs. A test reads what arrived through Mailpit's
7
+ * HTTP API, so a magic link is signed in with by opening the email that was
8
+ * actually sent rather than a token pulled from somewhere else.
9
+ *
10
+ * Mailpit has one inbox for everything, and transactions do not reach it, so a
11
+ * test keeps to its own mail by address: a unique recipient per test, read by
12
+ * `to:`, and cleared afterwards.
13
+ */
14
+ /** How long `last()` waits for mail that is still on its way. */
15
+ const DEFAULT_TIMEOUT = 5e3;
16
+ const POLL_INTERVAL = 50;
17
+ /** A way to read the mail sent to one address. */
18
+ function createMailbox(options) {
19
+ const inbox = options.inbox.replace(/\/$/, "");
20
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
21
+ const get = (path, init) => (options.fetch ?? globalThis.fetch)(`${inbox}${path}`, init);
22
+ return (address) => {
23
+ const query = `?query=${encodeURIComponent(`to:"${address}"`)}`;
24
+ const all = async () => {
25
+ const found = await json(await get(`/api/v1/search${query}`), inbox);
26
+ return Promise.all(found.messages.map(async ({ ID }) => email(await json(await get(`/api/v1/message/${ID}`), inbox))));
27
+ };
28
+ return {
29
+ all,
30
+ async last() {
31
+ const deadline = Date.now() + timeout;
32
+ for (;;) {
33
+ const [newest] = await all();
34
+ if (newest) return newest;
35
+ if (Date.now() >= deadline) throw new NoMail(address, timeout);
36
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
37
+ }
38
+ },
39
+ async clear() {
40
+ const response = await get(`/api/v1/search${query}`, { method: "DELETE" });
41
+ if (!response.ok) throw new InboxUnreachable(inbox, response.status);
42
+ }
43
+ };
44
+ };
45
+ }
46
+ async function json(response, inbox) {
47
+ if (!response.ok) throw new InboxUnreachable(inbox, response.status);
48
+ return await response.json();
49
+ }
50
+ function email(message) {
51
+ const links = [...new Set([...(message.HTML ?? "").matchAll(/href="(https?:\/\/[^"]+)"/g), ...(message.Text ?? "").matchAll(/(https?:\/\/[^\s<>"]+)/g)].map(([, url]) => decodeEntities(url)))];
52
+ return {
53
+ id: message.ID,
54
+ from: message.From.Address,
55
+ to: message.To.map(({ Address }) => Address),
56
+ subject: message.Subject,
57
+ text: message.Text ?? "",
58
+ html: message.HTML ?? "",
59
+ links,
60
+ link: links[0]
61
+ };
62
+ }
63
+ /** `&amp;` in an HTML attribute is `&` in the URL it names. */
64
+ function decodeEntities(url) {
65
+ return url.replaceAll("&amp;", "&");
66
+ }
67
+ /** No email to an address arrived in time. */
68
+ var NoMail = class extends Error {
69
+ address;
70
+ timeout;
71
+ constructor(address, timeout) {
72
+ super(`No email to ${address} arrived within ${timeout}ms. Check that the code under test sends it, and to that address.`);
73
+ this.address = address;
74
+ this.timeout = timeout;
75
+ this.name = "NoMail";
76
+ }
77
+ };
78
+ /** Mailpit did not answer. */
79
+ var InboxUnreachable = class extends Error {
80
+ inbox;
81
+ status;
82
+ constructor(inbox, status) {
83
+ super(`Mailpit at ${inbox} answered ${status}. Is it running? \`gkm test\` starts it when the app declares an Email construct.`);
84
+ this.inbox = inbox;
85
+ this.status = status;
86
+ this.name = "InboxUnreachable";
87
+ }
88
+ };
89
+ //#endregion
90
+ export { InboxUnreachable, NoMail, createMailbox };
91
+
92
+ //# sourceMappingURL=mailbox.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mailbox.mjs","names":[],"sources":["../src/mailbox.ts"],"sourcesContent":["/**\n * The mail a test sent, read from Mailpit.\n *\n * Locally, mail goes over real SMTP to Mailpit — the same client, the same\n * message, only the host differs. A test reads what arrived through Mailpit's\n * HTTP API, so a magic link is signed in with by opening the email that was\n * actually sent rather than a token pulled from somewhere else.\n *\n * Mailpit has one inbox for everything, and transactions do not reach it, so a\n * test keeps to its own mail by address: a unique recipient per test, read by\n * `to:`, and cleared afterwards.\n */\n\n/** How long `last()` waits for mail that is still on its way. */\nconst DEFAULT_TIMEOUT = 5_000;\nconst POLL_INTERVAL = 50;\n\nexport interface MailboxOptions {\n\t/** Mailpit's HTTP address — what `gkm test` injects as `<ID>_INBOX_URL`. */\n\tinbox: string;\n\t/** The `fetch` to read it with. Defaults to the global one. */\n\tfetch?: typeof fetch;\n\t/** How long `last()` waits for mail to arrive. */\n\ttimeout?: number;\n}\n\n/** One email, as it arrived. */\nexport interface Email {\n\tid: string;\n\tfrom: string;\n\tto: string[];\n\tsubject: string;\n\ttext: string;\n\thtml: string;\n\t/** Every `http(s)` link in the email, in order. */\n\tlinks: string[];\n\t/** The first link — the one a sign-in or confirmation email is about. */\n\tlink: string | undefined;\n}\n\nexport interface Mailbox {\n\t/** The newest email to this address, waiting for one if none has arrived. */\n\tlast(): Promise<Email>;\n\t/** Every email to this address, newest first. */\n\tall(): Promise<Email[]>;\n\t/** Delete every email to this address. */\n\tclear(): Promise<void>;\n}\n\n/** A way to read the mail sent to one address. */\nexport function createMailbox(\n\toptions: MailboxOptions,\n): (address: string) => Mailbox {\n\tconst inbox = options.inbox.replace(/\\/$/, '');\n\tconst timeout = options.timeout ?? DEFAULT_TIMEOUT;\n\tconst get = (path: string, init?: RequestInit) =>\n\t\t(options.fetch ?? globalThis.fetch)(`${inbox}${path}`, init);\n\n\treturn (address) => {\n\t\tconst query = `?query=${encodeURIComponent(`to:\"${address}\"`)}`;\n\n\t\tconst all = async (): Promise<Email[]> => {\n\t\t\tconst found = await json<SearchResult>(\n\t\t\t\tawait get(`/api/v1/search${query}`),\n\t\t\t\tinbox,\n\t\t\t);\n\t\t\treturn Promise.all(\n\t\t\t\tfound.messages.map(async ({ ID }) =>\n\t\t\t\t\temail(await json<Message>(await get(`/api/v1/message/${ID}`), inbox)),\n\t\t\t\t),\n\t\t\t);\n\t\t};\n\n\t\treturn {\n\t\t\tall,\n\t\t\tasync last() {\n\t\t\t\tconst deadline = Date.now() + timeout;\n\t\t\t\tfor (;;) {\n\t\t\t\t\tconst [newest] = await all();\n\t\t\t\t\tif (newest) return newest;\n\t\t\t\t\tif (Date.now() >= deadline) throw new NoMail(address, timeout);\n\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));\n\t\t\t\t}\n\t\t\t},\n\t\t\tasync clear() {\n\t\t\t\tconst response = await get(`/api/v1/search${query}`, {\n\t\t\t\t\tmethod: 'DELETE',\n\t\t\t\t});\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tthrow new InboxUnreachable(inbox, response.status);\n\t\t\t\t}\n\t\t\t},\n\t\t};\n\t};\n}\n\ninterface SearchResult {\n\tmessages: { ID: string }[];\n}\n\ninterface Message {\n\tID: string;\n\tFrom: { Address: string };\n\tTo: { Address: string }[];\n\tSubject: string;\n\tText: string;\n\tHTML: string;\n}\n\nasync function json<T>(response: Response, inbox: string): Promise<T> {\n\tif (!response.ok) throw new InboxUnreachable(inbox, response.status);\n\treturn (await response.json()) as T;\n}\n\nfunction email(message: Message): Email {\n\tconst links = [\n\t\t...new Set(\n\t\t\t[\n\t\t\t\t...(message.HTML ?? '').matchAll(/href=\"(https?:\\/\\/[^\"]+)\"/g),\n\t\t\t\t...(message.Text ?? '').matchAll(/(https?:\\/\\/[^\\s<>\"]+)/g),\n\t\t\t].map(([, url]) => decodeEntities(url!)),\n\t\t),\n\t];\n\n\treturn {\n\t\tid: message.ID,\n\t\tfrom: message.From.Address,\n\t\tto: message.To.map(({ Address }) => Address),\n\t\tsubject: message.Subject,\n\t\ttext: message.Text ?? '',\n\t\thtml: message.HTML ?? '',\n\t\tlinks,\n\t\tlink: links[0],\n\t};\n}\n\n/** `&amp;` in an HTML attribute is `&` in the URL it names. */\nfunction decodeEntities(url: string): string {\n\treturn url.replaceAll('&amp;', '&');\n}\n\n/** No email to an address arrived in time. */\nexport class NoMail extends Error {\n\tconstructor(\n\t\treadonly address: string,\n\t\treadonly timeout: number,\n\t) {\n\t\tsuper(\n\t\t\t`No email to ${address} arrived within ${timeout}ms. Check that the ` +\n\t\t\t\t`code under test sends it, and to that address.`,\n\t\t);\n\t\tthis.name = 'NoMail';\n\t}\n}\n\n/** Mailpit did not answer. */\nexport class InboxUnreachable extends Error {\n\tconstructor(\n\t\treadonly inbox: string,\n\t\treadonly status: number,\n\t) {\n\t\tsuper(\n\t\t\t`Mailpit at ${inbox} answered ${status}. Is it running? \\`gkm test\\` ` +\n\t\t\t\t`starts it when the app declares an Email construct.`,\n\t\t);\n\t\tthis.name = 'InboxUnreachable';\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAM,kBAAkB;AACxB,MAAM,gBAAgB;;AAmCtB,SAAgB,cACf,SAC+B;CAC/B,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,EAAE;CAC7C,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,OAAO,MAAc,UACzB,QAAQ,SAAS,WAAW,MAAA,CAAO,GAAG,QAAQ,QAAQ,IAAI;CAE5D,QAAQ,YAAY;EACnB,MAAM,QAAQ,UAAU,mBAAmB,OAAO,QAAQ,EAAE;EAE5D,MAAM,MAAM,YAA8B;GACzC,MAAM,QAAQ,MAAM,KACnB,MAAM,IAAI,iBAAiB,OAAO,GAClC,KACD;GACA,OAAO,QAAQ,IACd,MAAM,SAAS,IAAI,OAAO,EAAE,SAC3B,MAAM,MAAM,KAAc,MAAM,IAAI,mBAAmB,IAAI,GAAG,KAAK,CAAC,CACrE,CACD;EACD;EAEA,OAAO;GACN;GACA,MAAM,OAAO;IACZ,MAAM,WAAW,KAAK,IAAI,IAAI;IAC9B,SAAS;KACR,MAAM,CAAC,UAAU,MAAM,IAAI;KAC3B,IAAI,QAAQ,OAAO;KACnB,IAAI,KAAK,IAAI,KAAK,UAAU,MAAM,IAAI,OAAO,SAAS,OAAO;KAC7D,MAAM,IAAI,SAAS,YAAY,WAAW,SAAS,aAAa,CAAC;IAClE;GACD;GACA,MAAM,QAAQ;IACb,MAAM,WAAW,MAAM,IAAI,iBAAiB,SAAS,EACpD,QAAQ,SACT,CAAC;IACD,IAAI,CAAC,SAAS,IACb,MAAM,IAAI,iBAAiB,OAAO,SAAS,MAAM;GAEnD;EACD;CACD;AACD;AAeA,eAAe,KAAQ,UAAoB,OAA2B;CACrE,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,iBAAiB,OAAO,SAAS,MAAM;CACnE,OAAQ,MAAM,SAAS,KAAK;AAC7B;AAEA,SAAS,MAAM,SAAyB;CACvC,MAAM,QAAQ,CACb,GAAG,IAAI,IACN,CACC,IAAI,QAAQ,QAAQ,GAAA,CAAI,SAAS,4BAA4B,GAC7D,IAAI,QAAQ,QAAQ,GAAA,CAAI,SAAS,yBAAyB,CAC3D,CAAC,CAAC,KAAK,GAAG,SAAS,eAAe,GAAI,CAAC,CACxC,CACD;CAEA,OAAO;EACN,IAAI,QAAQ;EACZ,MAAM,QAAQ,KAAK;EACnB,IAAI,QAAQ,GAAG,KAAK,EAAE,cAAc,OAAO;EAC3C,SAAS,QAAQ;EACjB,MAAM,QAAQ,QAAQ;EACtB,MAAM,QAAQ,QAAQ;EACtB;EACA,MAAM,MAAM;CACb;AACD;;AAGA,SAAS,eAAe,KAAqB;CAC5C,OAAO,IAAI,WAAW,SAAS,GAAG;AACnC;;AAGA,IAAa,SAAb,cAA4B,MAAM;CAEvB;CACA;CAFV,YACC,SACA,SACC;EACD,MACC,eAAe,QAAQ,kBAAkB,QAAQ,kEAElD;EANS,KAAA,UAAA;EACA,KAAA,UAAA;EAMT,KAAK,OAAO;CACb;AACD;;AAGA,IAAa,mBAAb,cAAsC,MAAM;CAEjC;CACA;CAFV,YACC,OACA,QACC;EACD,MACC,cAAc,MAAM,YAAY,OAAO,kFAExC;EANS,KAAA,QAAA;EACA,KAAA,SAAA;EAMT,KAAK,OAAO;CACb;AACD"}
@@ -0,0 +1,114 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_rolldown_runtime = require("./rolldown-runtime-VH7oDXx4.cjs");
3
+ let pg = require("pg");
4
+ pg = require_rolldown_runtime.__toESM(pg, 1);
5
+ let kysely = require("kysely");
6
+ //#region src/transactions.ts
7
+ /**
8
+ * A transaction per database, per test — as each runs deployed.
9
+ *
10
+ * An app's database and a schema tenant such as the auth server's are separate
11
+ * databases to the code using them: separate connections, credentials, roles
12
+ * and search paths. A test keeps them that way. Each gets its own connection,
13
+ * opened into a transaction the first time the test touches it, and every one
14
+ * is rolled back when the test ends.
15
+ *
16
+ * Code under test must be free to use transactions itself, and better-auth
17
+ * does — it also calls `db.connection()`, which Kysely refuses on a
18
+ * `Transaction`. So what a test hands over is not a `Transaction` but a Kysely
19
+ * over the one connection already inside the test's transaction, on which a
20
+ * `BEGIN` becomes a savepoint and a `COMMIT` releases it. Nothing the code does
21
+ * can end the transaction the test will roll back.
22
+ */
23
+ /**
24
+ * The connection settings for a database URL.
25
+ *
26
+ * `?search_path=` is not a libpq parameter: a URL carrying it connects and then
27
+ * resolves every name against `public`. Postgres takes it as a startup option,
28
+ * which is what a schema tenant's URL needs to reach its own tables.
29
+ */
30
+ function connectionConfig(url) {
31
+ const parsed = new URL(url);
32
+ const searchPath = parsed.searchParams.get("search_path");
33
+ if (!searchPath) return { connectionString: url };
34
+ parsed.searchParams.delete("search_path");
35
+ return {
36
+ connectionString: parsed.toString(),
37
+ options: `-c search_path=${searchPath}`
38
+ };
39
+ }
40
+ /** Open a connection to `url` and begin the transaction a test will roll back. */
41
+ async function openBoundTransaction(url) {
42
+ const client = new pg.default.Client(connectionConfig(url));
43
+ await client.connect();
44
+ await client.query("BEGIN");
45
+ return {
46
+ db: new kysely.Kysely({ dialect: new kysely.PostgresDialect({ pool: {
47
+ connect: async () => savepointing(client),
48
+ end: async () => {}
49
+ } }) }),
50
+ async rollback() {
51
+ try {
52
+ await client.query("ROLLBACK");
53
+ } finally {
54
+ await client.end();
55
+ }
56
+ }
57
+ };
58
+ }
59
+ /**
60
+ * The client, with transaction control turned into savepoints.
61
+ *
62
+ * Kysely issues `begin`/`commit`/`rollback` as plain statements; this is the
63
+ * one place they can be caught. A depth counter names the savepoints, so
64
+ * nested transactions nest.
65
+ */
66
+ function savepointing(client) {
67
+ let depth = 0;
68
+ const query = client.query.bind(client);
69
+ const wrapped = Object.create(client);
70
+ Object.assign(wrapped, {
71
+ query: (text, ...rest) => {
72
+ const statement = typeof text === "string" ? text.trim().toLowerCase() : void 0;
73
+ if (statement === "begin" || statement?.startsWith("start transaction")) {
74
+ depth++;
75
+ return query(`SAVEPOINT test_sp_${depth}`);
76
+ }
77
+ if (statement === "commit") return query(`RELEASE SAVEPOINT test_sp_${depth--}`);
78
+ if (statement === "rollback") return query(`ROLLBACK TO SAVEPOINT test_sp_${depth--}`);
79
+ return query(text, ...rest);
80
+ },
81
+ release: () => {}
82
+ });
83
+ return wrapped;
84
+ }
85
+ /**
86
+ * The transactions one test has opened, by database.
87
+ *
88
+ * `get` opens a database's transaction the first time it is asked for and
89
+ * hands back the same one after that — including to a caller that asks while
90
+ * it is still opening. `rollbackAll` ends every one of them.
91
+ */
92
+ var TransactionRegistry = class {
93
+ open = /* @__PURE__ */ new Map();
94
+ get(key, url) {
95
+ let transaction = this.open.get(key);
96
+ if (!transaction) {
97
+ transaction = openBoundTransaction(url);
98
+ this.open.set(key, transaction);
99
+ }
100
+ return transaction.then(({ db }) => db);
101
+ }
102
+ async rollbackAll() {
103
+ const transactions = [...this.open.values()];
104
+ this.open.clear();
105
+ const failed = (await Promise.allSettled(transactions.map(async (transaction) => (await transaction).rollback()))).find((result) => result.status === "rejected");
106
+ if (failed) throw failed.reason;
107
+ }
108
+ };
109
+ //#endregion
110
+ exports.TransactionRegistry = TransactionRegistry;
111
+ exports.connectionConfig = connectionConfig;
112
+ exports.openBoundTransaction = openBoundTransaction;
113
+
114
+ //# sourceMappingURL=transactions.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transactions.cjs","names":["Kysely","PostgresDialect"],"sources":["../src/transactions.ts"],"sourcesContent":["/**\n * A transaction per database, per test — as each runs deployed.\n *\n * An app's database and a schema tenant such as the auth server's are separate\n * databases to the code using them: separate connections, credentials, roles\n * and search paths. A test keeps them that way. Each gets its own connection,\n * opened into a transaction the first time the test touches it, and every one\n * is rolled back when the test ends.\n *\n * Code under test must be free to use transactions itself, and better-auth\n * does — it also calls `db.connection()`, which Kysely refuses on a\n * `Transaction`. So what a test hands over is not a `Transaction` but a Kysely\n * over the one connection already inside the test's transaction, on which a\n * `BEGIN` becomes a savepoint and a `COMMIT` releases it. Nothing the code does\n * can end the transaction the test will roll back.\n */\n\nimport { Kysely, PostgresDialect } from 'kysely';\nimport pg from 'pg';\n\n/** How to reach one database: a URL, and its search path as a startup option. */\nexport interface ConnectionConfig {\n\tconnectionString: string;\n\toptions?: string;\n}\n\n/**\n * The connection settings for a database URL.\n *\n * `?search_path=` is not a libpq parameter: a URL carrying it connects and then\n * resolves every name against `public`. Postgres takes it as a startup option,\n * which is what a schema tenant's URL needs to reach its own tables.\n */\nexport function connectionConfig(url: string): ConnectionConfig {\n\tconst parsed = new URL(url);\n\tconst searchPath = parsed.searchParams.get('search_path');\n\tif (!searchPath) return { connectionString: url };\n\n\tparsed.searchParams.delete('search_path');\n\treturn {\n\t\tconnectionString: parsed.toString(),\n\t\toptions: `-c search_path=${searchPath}`,\n\t};\n}\n\n/** One database's connection, inside a transaction the test will roll back. */\nexport interface BoundTransaction {\n\t/** Everything queried through this runs inside the test's transaction. */\n\treadonly db: Kysely<any>;\n\t/** Undo everything, and let the connection go. */\n\trollback(): Promise<void>;\n}\n\n/** Open a connection to `url` and begin the transaction a test will roll back. */\nexport async function openBoundTransaction(\n\turl: string,\n): Promise<BoundTransaction> {\n\tconst client = new pg.Client(connectionConfig(url));\n\tawait client.connect();\n\tawait client.query('BEGIN');\n\n\treturn {\n\t\tdb: new Kysely({\n\t\t\tdialect: new PostgresDialect({\n\t\t\t\t// A \"pool\" of the one connection. Released never, ended never: the\n\t\t\t\t// test owns it, and it closes in `rollback`.\n\t\t\t\tpool: {\n\t\t\t\t\tconnect: async () => savepointing(client),\n\t\t\t\t\tend: async () => {},\n\t\t\t\t} as unknown as pg.Pool,\n\t\t\t}),\n\t\t}),\n\t\tasync rollback() {\n\t\t\ttry {\n\t\t\t\tawait client.query('ROLLBACK');\n\t\t\t} finally {\n\t\t\t\tawait client.end();\n\t\t\t}\n\t\t},\n\t};\n}\n\n/**\n * The client, with transaction control turned into savepoints.\n *\n * Kysely issues `begin`/`commit`/`rollback` as plain statements; this is the\n * one place they can be caught. A depth counter names the savepoints, so\n * nested transactions nest.\n */\nfunction savepointing(client: pg.Client): pg.PoolClient {\n\tlet depth = 0;\n\tconst query = client.query.bind(client) as (...args: unknown[]) => unknown;\n\n\tconst wrapped = Object.create(client) as pg.PoolClient;\n\tObject.assign(wrapped, {\n\t\tquery: (text: unknown, ...rest: unknown[]) => {\n\t\t\tconst statement =\n\t\t\t\ttypeof text === 'string' ? text.trim().toLowerCase() : undefined;\n\n\t\t\tif (statement === 'begin' || statement?.startsWith('start transaction')) {\n\t\t\t\tdepth++;\n\t\t\t\treturn query(`SAVEPOINT test_sp_${depth}`);\n\t\t\t}\n\t\t\tif (statement === 'commit') {\n\t\t\t\treturn query(`RELEASE SAVEPOINT test_sp_${depth--}`);\n\t\t\t}\n\t\t\tif (statement === 'rollback') {\n\t\t\t\treturn query(`ROLLBACK TO SAVEPOINT test_sp_${depth--}`);\n\t\t\t}\n\t\t\treturn query(text, ...rest);\n\t\t},\n\t\trelease: () => {},\n\t});\n\treturn wrapped;\n}\n\n/**\n * The transactions one test has opened, by database.\n *\n * `get` opens a database's transaction the first time it is asked for and\n * hands back the same one after that — including to a caller that asks while\n * it is still opening. `rollbackAll` ends every one of them.\n */\nexport class TransactionRegistry<TKey = string> {\n\tprivate readonly open = new Map<TKey, Promise<BoundTransaction>>();\n\n\tget(key: TKey, url: string): Promise<Kysely<any>> {\n\t\tlet transaction = this.open.get(key);\n\t\tif (!transaction) {\n\t\t\ttransaction = openBoundTransaction(url);\n\t\t\tthis.open.set(key, transaction);\n\t\t}\n\t\treturn transaction.then(({ db }) => db);\n\t}\n\n\tasync rollbackAll(): Promise<void> {\n\t\tconst transactions = [...this.open.values()];\n\t\tthis.open.clear();\n\n\t\tconst results = await Promise.allSettled(\n\t\t\ttransactions.map(async (transaction) => (await transaction).rollback()),\n\t\t);\n\t\tconst failed = results.find(\n\t\t\t(result): result is PromiseRejectedResult => result.status === 'rejected',\n\t\t);\n\t\tif (failed) throw failed.reason;\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,SAAgB,iBAAiB,KAA+B;CAC/D,MAAM,SAAS,IAAI,IAAI,GAAG;CAC1B,MAAM,aAAa,OAAO,aAAa,IAAI,aAAa;CACxD,IAAI,CAAC,YAAY,OAAO,EAAE,kBAAkB,IAAI;CAEhD,OAAO,aAAa,OAAO,aAAa;CACxC,OAAO;EACN,kBAAkB,OAAO,SAAS;EAClC,SAAS,kBAAkB;CAC5B;AACD;;AAWA,eAAsB,qBACrB,KAC4B;CAC5B,MAAM,SAAS,IAAI,GAAA,QAAG,OAAO,iBAAiB,GAAG,CAAC;CAClD,MAAM,OAAO,QAAQ;CACrB,MAAM,OAAO,MAAM,OAAO;CAE1B,OAAO;EACN,IAAI,IAAIA,OAAAA,OAAO,EACd,SAAS,IAAIC,OAAAA,gBAAgB,EAG5B,MAAM;GACL,SAAS,YAAY,aAAa,MAAM;GACxC,KAAK,YAAY,CAAC;EACnB,EACD,CAAC,EACF,CAAC;EACD,MAAM,WAAW;GAChB,IAAI;IACH,MAAM,OAAO,MAAM,UAAU;GAC9B,UAAU;IACT,MAAM,OAAO,IAAI;GAClB;EACD;CACD;AACD;;;;;;;;AASA,SAAS,aAAa,QAAkC;CACvD,IAAI,QAAQ;CACZ,MAAM,QAAQ,OAAO,MAAM,KAAK,MAAM;CAEtC,MAAM,UAAU,OAAO,OAAO,MAAM;CACpC,OAAO,OAAO,SAAS;EACtB,QAAQ,MAAe,GAAG,SAAoB;GAC7C,MAAM,YACL,OAAO,SAAS,WAAW,KAAK,KAAK,CAAC,CAAC,YAAY,IAAI,KAAA;GAExD,IAAI,cAAc,WAAW,WAAW,WAAW,mBAAmB,GAAG;IACxE;IACA,OAAO,MAAM,qBAAqB,OAAO;GAC1C;GACA,IAAI,cAAc,UACjB,OAAO,MAAM,6BAA6B,SAAS;GAEpD,IAAI,cAAc,YACjB,OAAO,MAAM,iCAAiC,SAAS;GAExD,OAAO,MAAM,MAAM,GAAG,IAAI;EAC3B;EACA,eAAe,CAAC;CACjB,CAAC;CACD,OAAO;AACR;;;;;;;;AASA,IAAa,sBAAb,MAAgD;CAC/C,uBAAwB,IAAI,IAAqC;CAEjE,IAAI,KAAW,KAAmC;EACjD,IAAI,cAAc,KAAK,KAAK,IAAI,GAAG;EACnC,IAAI,CAAC,aAAa;GACjB,cAAc,qBAAqB,GAAG;GACtC,KAAK,KAAK,IAAI,KAAK,WAAW;EAC/B;EACA,OAAO,YAAY,MAAM,EAAE,SAAS,EAAE;CACvC;CAEA,MAAM,cAA6B;EAClC,MAAM,eAAe,CAAC,GAAG,KAAK,KAAK,OAAO,CAAC;EAC3C,KAAK,KAAK,MAAM;EAKhB,MAAM,UAAS,MAHO,QAAQ,WAC7B,aAAa,IAAI,OAAO,iBAAiB,MAAM,YAAA,CAAa,SAAS,CAAC,CACvE,EAAA,CACuB,MACrB,WAA4C,OAAO,WAAW,UAChE;EACA,IAAI,QAAQ,MAAM,OAAO;CAC1B;AACD"}
@@ -0,0 +1,38 @@
1
+ import { Kysely } from "kysely";
2
+ //#region src/transactions.d.ts
3
+ /** How to reach one database: a URL, and its search path as a startup option. */
4
+ export interface ConnectionConfig {
5
+ connectionString: string;
6
+ options?: string;
7
+ }
8
+ /**
9
+ * The connection settings for a database URL.
10
+ *
11
+ * `?search_path=` is not a libpq parameter: a URL carrying it connects and then
12
+ * resolves every name against `public`. Postgres takes it as a startup option,
13
+ * which is what a schema tenant's URL needs to reach its own tables.
14
+ */
15
+ export declare function connectionConfig(url: string): ConnectionConfig;
16
+ /** One database's connection, inside a transaction the test will roll back. */
17
+ export interface BoundTransaction {
18
+ /** Everything queried through this runs inside the test's transaction. */
19
+ readonly db: Kysely<any>;
20
+ /** Undo everything, and let the connection go. */
21
+ rollback(): Promise<void>;
22
+ }
23
+ /** Open a connection to `url` and begin the transaction a test will roll back. */
24
+ export declare function openBoundTransaction(url: string): Promise<BoundTransaction>;
25
+ /**
26
+ * The transactions one test has opened, by database.
27
+ *
28
+ * `get` opens a database's transaction the first time it is asked for and
29
+ * hands back the same one after that — including to a caller that asks while
30
+ * it is still opening. `rollbackAll` ends every one of them.
31
+ */
32
+ export declare class TransactionRegistry<TKey = string> {
33
+ private readonly open;
34
+ get(key: TKey, url: string): Promise<Kysely<any>>;
35
+ rollbackAll(): Promise<void>;
36
+ }
37
+ //#endregion
38
+ //# sourceMappingURL=transactions.d.cts.map