@zeyolabs/kit 0.0.0-stage → 2026.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +102 -2
  2. package/auth/accounts/accounts.remote.ts +116 -0
  3. package/auth/accounts/accounts.ts +332 -0
  4. package/auth/accounts/handle.ts +102 -0
  5. package/auth/accounts/invites.ts +149 -0
  6. package/auth/accounts/permissions.ts +90 -0
  7. package/auth/accounts/schemas.ts +22 -0
  8. package/auth/config.ts +77 -0
  9. package/auth/emails.ts +57 -0
  10. package/auth/schema.ts +152 -0
  11. package/auth/sessions/handle.ts +124 -0
  12. package/auth/sessions/magic-link/magic-link.ts +71 -0
  13. package/auth/sessions/passkeys/client.ts +115 -0
  14. package/auth/sessions/passkeys/passkeys.ts +295 -0
  15. package/auth/sessions/sessions.ts +101 -0
  16. package/auth/sveltekit/loads.ts +59 -0
  17. package/auth/sveltekit/login.ts +28 -0
  18. package/auth/sveltekit/routes.ts +264 -0
  19. package/auth/users.ts +49 -0
  20. package/cloudflare/access.ts +134 -0
  21. package/cloudflare/amqp/client.ts +276 -0
  22. package/cloudflare/amqp/testing.ts +9 -0
  23. package/cloudflare/context.ts +66 -0
  24. package/cloudflare/dispatch/client.ts +30 -0
  25. package/cloudflare/email/base.ts +59 -0
  26. package/cloudflare/email/client.ts +51 -0
  27. package/cloudflare/email/dev.ts +14 -0
  28. package/cloudflare/email/smtp.ts +65 -0
  29. package/cloudflare/email/testing.ts +18 -0
  30. package/cloudflare/r2/client.ts +154 -0
  31. package/cloudflare/r2/testing.ts +43 -0
  32. package/cloudflare/rate-limiter.ts +65 -0
  33. package/cloudflare/wait-until.ts +12 -0
  34. package/cloudflare/workers/index.ts +311 -0
  35. package/cloudflare/workers/testing.ts +94 -0
  36. package/cloudflare/workers/ticking-do.ts +104 -0
  37. package/cloudflare/workers-ai/client.ts +30 -0
  38. package/cloudflare/workers-ai/testing.ts +17 -0
  39. package/cloudflare/workers-env.ts +12 -0
  40. package/eslint-config.js +115 -0
  41. package/monitoring/alerts/client.ts +48 -0
  42. package/monitoring/alerts/event-config.ts +33 -0
  43. package/monitoring/alerts/policy.ts +39 -0
  44. package/monitoring/alerts/registry.ts +44 -0
  45. package/monitoring/gates/client.ts +90 -0
  46. package/monitoring/gates/dev.ts +29 -0
  47. package/monitoring/gates/testing.ts +30 -0
  48. package/monitoring/logger/client.ts +66 -0
  49. package/monitoring/prometheus/client.ts +147 -0
  50. package/monitoring/prometheus/dev.ts +23 -0
  51. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  52. package/monitoring/prometheus/metrics.ts +180 -0
  53. package/monitoring/prometheus/testing.ts +19 -0
  54. package/monitoring/slack/api.ts +68 -0
  55. package/monitoring/slack/client.ts +33 -0
  56. package/monitoring/slack/events.ts +15 -0
  57. package/package.json +156 -5
  58. package/server/logger.ts +63 -0
  59. package/server/rate-limit-client.ts +88 -0
  60. package/server/rate-limit.ts +24 -0
  61. package/server/remote-functions.ts +139 -0
  62. package/server/result.ts +18 -0
  63. package/server/turnstile.ts +19 -0
  64. package/storage/clickhouse/client.ts +441 -0
  65. package/storage/clickhouse/compiler.ts +218 -0
  66. package/storage/clickhouse/config.ts +13 -0
  67. package/storage/clickhouse/dev.ts +18 -0
  68. package/storage/clickhouse/error.ts +61 -0
  69. package/storage/clickhouse/index.ts +4 -0
  70. package/storage/clickhouse/native-decoder.ts +192 -0
  71. package/storage/clickhouse/readers.ts +167 -0
  72. package/storage/clickhouse/retry.ts +30 -0
  73. package/storage/clickhouse/schema.ts +163 -0
  74. package/storage/clickhouse/testing.ts +81 -0
  75. package/storage/manticore/client.ts +165 -0
  76. package/storage/manticore/schema.ts +23 -0
  77. package/storage/postgres/client.ts +44 -0
  78. package/storage/postgres/testing.ts +275 -0
  79. package/storage/redis/client.ts +219 -0
  80. package/storage/redis/protocol.ts +119 -0
  81. package/storage/redis/schema.ts +17 -0
  82. package/storage/redis/testing.ts +122 -0
  83. package/utils/crypto.ts +36 -0
  84. package/utils/datetime.ts +338 -0
  85. package/utils/email.ts +173 -0
  86. package/utils/env.ts +171 -0
  87. package/utils/errors.ts +67 -0
  88. package/utils/form.ts +135 -0
  89. package/utils/http-status.ts +42 -0
  90. package/utils/money.ts +10 -0
  91. package/utils/public-host.ts +66 -0
  92. package/utils/schema.ts +176 -0
  93. package/utils/strings/format.ts +51 -0
  94. package/utils/strings/normalize.ts +129 -0
  95. package/utils/transport.ts +25 -0
@@ -0,0 +1,81 @@
1
+ import type { z } from 'zod';
2
+
3
+ import type { ClickHouseTable, IClickHouseClient, Select } from './client';
4
+ import type { ClickHouseSettings } from './schema';
5
+
6
+ export class FakeClickHouseClient implements IClickHouseClient {
7
+ readonly commands: string[] = [];
8
+ private rawSelectQueue: unknown[][] = [];
9
+ private readonly rows = new Map<string, Record<string, unknown>[]>();
10
+
11
+ async command(sql: string): Promise<void> {
12
+ this.commands.push(sql);
13
+ }
14
+
15
+ async insert<S extends z.ZodObject>(
16
+ table: string,
17
+ _schema: S,
18
+ rows: z.infer<S>[]
19
+ ): Promise<void> {
20
+ const existing = this.rows.get(table) ?? [];
21
+ this.rows.set(table, [...existing, ...(rows as Record<string, unknown>[])]);
22
+ }
23
+
24
+ async rawSelect<S extends z.ZodObject>(): Promise<z.infer<S>[]> {
25
+ return (this.rawSelectQueue.shift() ?? []) as z.infer<S>[];
26
+ }
27
+
28
+ seedRawSelect(rows: unknown[]): void {
29
+ this.rawSelectQueue.push(rows);
30
+ }
31
+
32
+ seedTable(table: string, rows: Record<string, unknown>[]): void {
33
+ this.rows.set(table, rows);
34
+ }
35
+
36
+ select<T extends z.ZodObject, K extends keyof T['shape'] & string>(
37
+ table: ClickHouseTable<T>,
38
+ columns: K[]
39
+ ): Select<T, K> {
40
+ const rows = () =>
41
+ (this.rows.get(table.name) ?? []).map(
42
+ (row) => Object.fromEntries(columns.map((c) => [c, row[c]])) as Pick<z.infer<T>, K>
43
+ );
44
+ return new FakeSelect(rows);
45
+ }
46
+ }
47
+
48
+ class FakeSelect<T extends z.ZodObject, K extends keyof T['shape'] & string> implements Select<
49
+ T,
50
+ K
51
+ > {
52
+ constructor(private readonly rows: () => Pick<z.infer<T>, K>[]) {}
53
+
54
+ distinct(): Select<T, K> {
55
+ return this;
56
+ }
57
+ limit(): Select<T, K> {
58
+ return this;
59
+ }
60
+ limitBy(): Select<T, K> {
61
+ return this;
62
+ }
63
+ offset(): Select<T, K> {
64
+ return this;
65
+ }
66
+ orderBy(): Select<T, K> {
67
+ return this;
68
+ }
69
+ settings(_settings: ClickHouseSettings): Select<T, K> {
70
+ return this;
71
+ }
72
+ then<F = Pick<z.infer<T>, K>[], J = never>(
73
+ onFulfilled?: ((value: Pick<z.infer<T>, K>[]) => F | PromiseLike<F>) | null,
74
+ onRejected?: ((reason: unknown) => J | PromiseLike<J>) | null
75
+ ): PromiseLike<F | J> {
76
+ return Promise.resolve(this.rows()).then(onFulfilled, onRejected);
77
+ }
78
+ where(): Select<T, K> {
79
+ return this;
80
+ }
81
+ }
@@ -0,0 +1,165 @@
1
+ import mysql, { type Connection, type RowDataPacket } from 'mysql2/promise';
2
+ import { Duplex } from 'node:stream';
3
+
4
+ import type { CfSocket, ManticoreConfig } from './schema';
5
+
6
+ import { logger } from '../../monitoring/logger/client';
7
+
8
+ const DEFAULT_HOST = '127.0.0.1';
9
+ const DEFAULT_PORT = 9306;
10
+ const CONNECT_TIMEOUT_MS = 10_000;
11
+
12
+ /** mysql2 sets this exact code (see its `base/connection.js`) when the underlying socket closes —
13
+ * the only case worth a same-query retry, since the connection (not the query) was at fault. */
14
+ function isConnectionLost(err: unknown): boolean {
15
+ return err instanceof Error && 'code' in err && err.code === 'PROTOCOL_CONNECTION_LOST';
16
+ }
17
+
18
+ type ManticoreClientConfig = {
19
+ connectTimeoutMs?: number;
20
+ password: string;
21
+ /** `host:port` (optionally `mysql://host:port`) — ignored when a VPC binding resolves. */
22
+ url?: string;
23
+ user: string;
24
+ /** The resolved Workers VPC Service binding (`.connect()` opens a raw socket to Manticore's
25
+ * `:9306` MySQL listener) — the caller reads it off its own `EnvStore`
26
+ * (`env.vpc('MANTICORE_VPC')`), not a binding name for this client to look up itself. Omit
27
+ * if this runtime has none (local dev). */
28
+ vpc?: Fetcher;
29
+ };
30
+
31
+ /** Parse `host:port` (optionally `mysql://host:port`) — defaults fill in whatever's missing. */
32
+ function parseUrl(url: string | undefined): { host: string; port: number } {
33
+ if (!url) return { host: DEFAULT_HOST, port: DEFAULT_PORT };
34
+ const stripped = url.replace(/^mysql:\/\//, '');
35
+ const [host, port] = stripped.split(':');
36
+ return { host: host || DEFAULT_HOST, port: port ? Number(port) : DEFAULT_PORT };
37
+ }
38
+
39
+ /** Bridge a Cloudflare socket (Web `readable`/`writable`) to a Node `Duplex` so mysql2 can speak the
40
+ * MySQL wire protocol over the VPC Service TCP socket. `Duplex.fromWeb` is available under
41
+ * `nodejs_compat`; mysql2 only reads/writes the stream, so the missing net-socket extras
42
+ * (`setNoDelay`/`setTimeout`) aren't needed. */
43
+ function toDuplex(socket: CfSocket): Duplex {
44
+ // The Workers `ReadableStream`/`WritableStream` types (from workers-types) don't line up with the
45
+ // `stream/web` types `Duplex.fromWeb` expects, though they're the same runtime objects — cast the
46
+ // pair to the parameter type.
47
+ return Duplex.fromWeb({
48
+ readable: socket.readable,
49
+ writable: socket.writable,
50
+ } as unknown as Parameters<typeof Duplex.fromWeb>[0]);
51
+ }
52
+
53
+ /** Thin Manticore client over the MySQL protocol (mysql2). One MySQL connection per instance, opened
54
+ * lazily on first `query()` and cached for the instance's life. Per-request (a Workers TCP socket
55
+ * can't cross requests), not a per-isolate singleton like ClickHouse's HTTP client.
56
+ *
57
+ * Queries use `conn.query` (client-side `?` interpolation), not `execute` (server-side prepared
58
+ * statements) — Manticore's prepared-statement support over MySQL is limited, and search SQL
59
+ * (`MATCH(...)`, faceting) is a poor ORM fit, so this stays a raw-SQL surface. */
60
+ export class ManticoreClient {
61
+ private readonly config: ManticoreConfig;
62
+ private connPromise: null | Promise<Connection> = null;
63
+
64
+ constructor(config: ManticoreClientConfig) {
65
+ const { connectTimeoutMs = CONNECT_TIMEOUT_MS, password, url, user, vpc } = config;
66
+ // An explicit `url` (the local-dev override, `MANTICORE_URL` in `.dev.vars`) always wins over
67
+ // `vpc`, even when `vpc` resolves truthy — under `vite dev`, `env.vpcNull('MANTICORE_VPC')`
68
+ // hands back a connectable-looking stub for a `vpc_services` binding marked `"remote": true"`
69
+ // regardless of the platformProxy's `remoteBindings: false`, and routing an actual `.connect()`
70
+ // through it hits a Miniflare bug ("This ReadableStream is disturbed") — every query silently
71
+ // returns no rows instead of throwing. Prod never sets a `MANTICORE_URL` var, so this is a
72
+ // pure local-dev branch there.
73
+ if (!vpc || url) {
74
+ this.config = { ...parseUrl(url), connectTimeoutMs, password, user };
75
+ return;
76
+ }
77
+ // Workers' `connect()` requires a non-empty address, but the VPC service routes to its own
78
+ // registered target (`wrangler vpc service list`) and ignores this value.
79
+ const address = url ?? `${DEFAULT_HOST}:${DEFAULT_PORT}`;
80
+ this.config = {
81
+ connectTimeoutMs,
82
+ password,
83
+ socket: async () => vpc.connect(address),
84
+ user,
85
+ };
86
+ }
87
+
88
+ /** Close the connection if one was opened. Safe when never opened (lazy client, no query ran) and
89
+ * best-effort — runs in the request-teardown `finally`, so a failed close must not throw. */
90
+ async close(): Promise<void> {
91
+ if (!this.connPromise) return;
92
+ const conn = await this.connPromise.catch(() => null);
93
+ this.connPromise = null;
94
+ if (!conn) return;
95
+ try {
96
+ await conn.end();
97
+ } catch {
98
+ // best-effort close; nothing actionable on a failed teardown
99
+ }
100
+ }
101
+
102
+ /** Run a read and return the rows. Params fill `?` placeholders (client-side interpolation). */
103
+ async query<T = RowDataPacket>(sql: string, params?: unknown[]): Promise<T[]> {
104
+ try {
105
+ return await this.runQuery<T>(sql, params);
106
+ } catch (err) {
107
+ // A dropped connection (idle timeout, socket reset) is retried once on a fresh connection —
108
+ // most callers only run one query per request, so dropping `connPromise` for "next call"
109
+ // alone (as this used to) never actually got a retry: there was no next call. Anything else
110
+ // (bad SQL, a genuine query error) isn't retried — it'll just fail the same way again.
111
+ if (!isConnectionLost(err)) {
112
+ await logger.error(err, { op: 'manticore', summary: sql });
113
+ throw err;
114
+ }
115
+ try {
116
+ return await this.runQuery<T>(sql, params);
117
+ } catch (retryErr) {
118
+ await logger.error(retryErr, { op: 'manticore', retried: true, summary: sql });
119
+ throw retryErr;
120
+ }
121
+ }
122
+ }
123
+
124
+ private getConnection(): Promise<Connection> {
125
+ return (this.connPromise ??= (async () => {
126
+ const cfg = this.config;
127
+ // Prod: tunnel through the VPC Service socket (resolved async), bridged to a Node duplex.
128
+ // Dev: hand mysql2 host/port and let it dial the local Manticore itself.
129
+ const stream = cfg.socket ? toDuplex(await cfg.socket()) : undefined;
130
+ const conn = await mysql.createConnection({
131
+ ...(stream ? { stream } : { host: cfg.host, port: cfg.port }),
132
+ connectTimeout: cfg.connectTimeoutMs,
133
+ // mysql2's row parser is JIT-compiled via `new Function` unless this is set — Workers
134
+ // has no `eval`/dynamic Function, so the first row-bearing result crashes mid-parse and
135
+ // tears down the stream (surfaces as a misleading "server closed the connection").
136
+ disableEval: true,
137
+ // Manticore has no databases and rejects a CONNECT_WITH_DB handshake with ER_NO_DB_ERROR
138
+ // (1046), so drop the capability and connect database-less, like the native `mysql` client.
139
+ flags: ['-CONNECT_WITH_DB'],
140
+ password: cfg.password,
141
+ user: cfg.user,
142
+ });
143
+ // The connection can die between queries (idle timeout, socket reset) without another
144
+ // query ever running to hit the catch in `query()` — drop the cache here too so the next
145
+ // `getConnection()` call dials fresh instead of returning the same dead object forever.
146
+ conn.on('error', () => {
147
+ this.connPromise = null;
148
+ });
149
+ return conn;
150
+ })());
151
+ }
152
+
153
+ private async runQuery<T>(sql: string, params?: unknown[]): Promise<T[]> {
154
+ try {
155
+ const conn = await this.getConnection();
156
+ const [rows] = await conn.query(sql, params);
157
+ return rows as T[];
158
+ } catch (err) {
159
+ // Leaves `connPromise` cached but unusable — drop it so the retry (or the next call, if this
160
+ // was already the retry) dials fresh instead of reusing the same dead connection.
161
+ this.connPromise = null;
162
+ throw err;
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,23 @@
1
+ /** A Cloudflare socket (from a VPC Service `.connect()` or `cloudflare:sockets`): Web streams, not a
2
+ * Node duplex — the client bridges it to a `Duplex` before handing it to mysql2. */
3
+ export type CfSocket = {
4
+ close?: () => Promise<void>;
5
+ readable: ReadableStream<Uint8Array>;
6
+ writable: WritableStream<Uint8Array>;
7
+ };
8
+
9
+ /** Resolved Manticore connection config. Exactly one transport is set:
10
+ * - `socket` (prod): a thunk that opens the VPC Service TCP socket per connect. Resolving the binding
11
+ * lazily (not capturing it) keeps a reused client pointed at the ACTIVE request's binding.
12
+ * - `host`/`port` (dev): let mysql2 open its own TCP socket to the local Docker Manticore. */
13
+ export type ManticoreConfig = {
14
+ /** Connect timeout (ms). */
15
+ connectTimeoutMs?: number;
16
+ /** Dev transport: mysql2 dials this directly. */
17
+ host?: string;
18
+ password: string;
19
+ port?: number;
20
+ /** Prod transport: open the VPC Service socket. Mutually exclusive with `host`/`port`. */
21
+ socket?: () => Promise<CfSocket>;
22
+ user: string;
23
+ };
@@ -0,0 +1,44 @@
1
+ // Generic Postgres-over-Cloudflare-Hyperdrive infra — no app domain knowledge. The schema is
2
+ // generic (TSchema): kit never imports it, just forwards whatever the caller hands it to drizzle.
3
+ // `drizzle-orm` itself is a fine kit dependency (a generic third-party ORM); the app's actual
4
+ // schema never enters kit — it stays in the app, the only place that knows what it is.
5
+ import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
6
+ import pg from 'pg';
7
+
8
+ export type PostgresClientConfig = {
9
+ connectionTimeoutMs?: number;
10
+ poolMax?: number;
11
+ queryTimeoutMs?: number;
12
+ };
13
+
14
+ /** A small `pg.Pool`, not a single `pg.Client` — Hyperdrive pools cheaply at the edge, and a lone
15
+ * `Client` only serves one in-flight query at a time. One per request/invocation: opened lazily
16
+ * (a pooled connection only dials on first query), closed via `close()` at the end. */
17
+ export class PostgresClient<TSchema extends Record<string, unknown>> {
18
+ readonly db: NodePgDatabase<TSchema>;
19
+ private readonly pool: pg.Pool;
20
+
21
+ constructor(hyperdrive: Hyperdrive, schema: TSchema, config: PostgresClientConfig = {}) {
22
+ const { connectionTimeoutMs = 10_000, poolMax = 5, queryTimeoutMs = 20_000 } = config;
23
+ this.pool = new pg.Pool({
24
+ connectionString: hyperdrive.connectionString,
25
+ connectionTimeoutMillis: connectionTimeoutMs,
26
+ max: poolMax,
27
+ query_timeout: queryTimeoutMs,
28
+ });
29
+ // An idle pooled connection dropping (e.g. a WARP blip in dev) is otherwise an unhandled
30
+ // 'error' event that crashes the process — in-flight query errors still propagate normally
31
+ // through their own query() rejection, this only guards idle drops.
32
+ this.pool.on('error', () => {});
33
+ this.db = drizzle(this.pool, { schema });
34
+ }
35
+
36
+ /** Best-effort teardown — nothing actionable on a failed close. */
37
+ async close(): Promise<void> {
38
+ try {
39
+ await this.pool.end();
40
+ } catch {
41
+ // best-effort teardown — nothing actionable on a failed close
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,275 @@
1
+ import type { PgColumn, PgTable } from 'drizzle-orm/pg-core';
2
+ import type { PgliteDatabase } from 'drizzle-orm/pglite';
3
+
4
+ import { PGlite } from '@electric-sql/pglite';
5
+ import { getTableConfig } from 'drizzle-orm/pg-core';
6
+ import { drizzle } from 'drizzle-orm/pglite';
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { vi } from 'vitest';
10
+
11
+ // ── PGlite test database ──
12
+ //
13
+ // A fresh, fully-migrated in-memory Postgres for one test file -- real Postgres (PGlite is the
14
+ // actual Postgres engine compiled to WASM, not a mock/emulator), no Docker, no DATABASE_URL,
15
+ // isolated per call so tests using this don't need to serialize with each other the way a
16
+ // DATABASE_URL-gated integration suite does.
17
+
18
+ function isAddConstraint(statement: string): boolean {
19
+ return /^ALTER TABLE\b/i.test(statement) && /\bADD CONSTRAINT\b/i.test(statement);
20
+ }
21
+
22
+ // GRANT/REVOKE reference real production Postgres roles provisioned by infra outside migrations --
23
+ // irrelevant here, PGlite's default connection already has full privileges.
24
+ function isGrantOrRevoke(statement: string): boolean {
25
+ return /^(GRANT|REVOKE)\b/i.test(statement);
26
+ }
27
+
28
+ function collectStatements(migrationsDir: string): string[] {
29
+ const files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql'));
30
+ return files
31
+ .flatMap((file) =>
32
+ readFileSync(path.join(migrationsDir, file), 'utf8')
33
+ .split('--> statement-breakpoint')
34
+ .map((s) => s.trim())
35
+ .filter(Boolean)
36
+ )
37
+ .filter((s) => !isGrantOrRevoke(s));
38
+ }
39
+
40
+ // A schema's hand-written `functions.sql` (drizzle can't model functions), as one script with its GRANTs dropped.
41
+ function collectFunctions(schemaDir: string): string | undefined {
42
+ const file = path.join(schemaDir, 'functions.sql');
43
+ if (!existsSync(file)) return undefined;
44
+ return readFileSync(file, 'utf8').replace(/^(GRANT|REVOKE)\b.*;$/gim, '');
45
+ }
46
+
47
+ // Every immediate subdirectory of `schemasDir` that ships a `migrations` folder -- so a package
48
+ // gaining a new Postgres schema doesn't also need a second, hand-maintained list of schema names
49
+ // kept in sync by hand.
50
+ function discoverSchemaNames(schemasDir: string): string[] {
51
+ return readdirSync(schemasDir, { withFileTypes: true })
52
+ .filter(
53
+ (entry) => entry.isDirectory() && existsSync(path.join(schemasDir, entry.name, 'migrations'))
54
+ )
55
+ .map((entry) => entry.name);
56
+ }
57
+
58
+ /**
59
+ * A fresh, fully-migrated in-memory Postgres for one test file, wrapped in drizzle over `schema`.
60
+ * `schemasDir` is the package's `schemas/` directory (e.g. `.../db-schemas-postgres/schemas`) --
61
+ * every schema under it gets migrated, not just the ones `schema` imports types from, since a
62
+ * cross-schema FK needs its target table to exist even when nothing here queries it through
63
+ * Drizzle directly.
64
+ *
65
+ * Migrations are applied in two passes across every schema -- everything except `ADD CONSTRAINT`
66
+ * first, then every `ADD CONSTRAINT` after -- rather than schema-by-schema in list order: a
67
+ * circular FK between two schemas (each referencing a table in the other) means no ordering of
68
+ * whole schemas can work; every table has to exist before any FK constraint is added, no matter
69
+ * which schema it's in.
70
+ *
71
+ * `frozenTime`, if given, freezes the clock for the lifetime of the returned client (restored on
72
+ * `close()`) -- PGlite's SQL `now()` is backed by the host JS Date (runs in-process WASM), so any
73
+ * fixture data computed relative to "now" needs the clock already frozen before it's inserted.
74
+ */
75
+ export async function createPgliteTestDb<TSchema extends Record<string, unknown>>(
76
+ schema: TSchema,
77
+ schemasDir: string,
78
+ frozenTime?: Date
79
+ ): Promise<{ close: () => Promise<void>; db: PgliteDatabase<TSchema> }> {
80
+ if (frozenTime) {
81
+ vi.useFakeTimers();
82
+ vi.setSystemTime(frozenTime);
83
+ }
84
+
85
+ const pg = new PGlite();
86
+ const schemaNames = discoverSchemaNames(schemasDir);
87
+
88
+ for (const name of schemaNames) {
89
+ await pg.exec(`CREATE SCHEMA IF NOT EXISTS ${name};`);
90
+ }
91
+
92
+ const allStatements = schemaNames.flatMap((name) =>
93
+ collectStatements(path.join(schemasDir, name, 'migrations'))
94
+ );
95
+ const constraints = allStatements.filter(isAddConstraint);
96
+ const everythingElse = allStatements.filter((s) => !isAddConstraint(s));
97
+
98
+ for (const stmt of everythingElse) await pg.exec(stmt);
99
+ for (const stmt of constraints) await pg.exec(stmt);
100
+ for (const name of schemaNames) {
101
+ const functions = collectFunctions(path.join(schemasDir, name));
102
+ if (functions) await pg.exec(functions);
103
+ }
104
+
105
+ return {
106
+ close: async () => {
107
+ await pg.close();
108
+ if (frozenTime) vi.useRealTimers();
109
+ },
110
+ db: drizzle(pg, { schema }),
111
+ };
112
+ }
113
+
114
+ // ── JSON fixtures ──
115
+ //
116
+ // A master fixture file (plus override files merged on top) seeds rows into a table by
117
+ // fixture-id -- a local alias, never written to any column -- so other rows can reference one
118
+ // another before either has a real DB-assigned id (e.g. a list's `accountId: "a"`), and so a test
119
+ // can look the real id back up after seeding by the same name. A row's shape (which fields are
120
+ // foreign keys, to which other collection, and whether its primary key needs the fixture-id as a
121
+ // fallback value) is read off the table's own drizzle definition via `getTableConfig`, not
122
+ // hand-maintained.
123
+
124
+ export type FixtureFile = Record<string, Record<string, FixtureRow>>;
125
+ export type FixtureIdMap = Record<string, Record<string, number | string>>;
126
+ export type FixtureRow = Record<string, unknown>;
127
+
128
+ export type FixtureCollection = {
129
+ /** Boilerplate field values a fixture row can omit -- for a NOT NULL column with no
130
+ * meaningful DB-side default (e.g. a jsonb config column), not something schema
131
+ * introspection can supply. */
132
+ defaults?: FixtureRow;
133
+ /** A field that references another collection WITHOUT a real DB foreign key constraint --
134
+ * schema introspection can only see actual FK constraints, so a denormalized reference has
135
+ * to be named here by hand. */
136
+ extraFkFields?: Record<string, string>;
137
+ /** The JSON key fixture files address this collection by. */
138
+ name: string;
139
+ table: PgTable;
140
+ };
141
+
142
+ function qualifiedName(table: PgTable): string {
143
+ const { name, schema } = getTableConfig(table);
144
+ return schema ? `${schema}.${name}` : name;
145
+ }
146
+
147
+ // Finds which JS property name (e.g. "accountId") a column is exposed under on its own table --
148
+ // foreign-key/primary-key metadata only gives us DB column names ("account_id"), not the
149
+ // camelCase ones fixture rows and `.values()` calls use. Matched by DB name, not object identity:
150
+ // a table-level `foreignKey({ columns: [t.accountId], ... })` constraint (as opposed to an inline
151
+ // `.references()`) captures a different column object than the one exposed as `table.accountId`,
152
+ // even though they're the same column -- verified empirically against a real schema.
153
+ function columnPropertyName(table: PgTable, column: PgColumn): string {
154
+ const entry = Object.entries(table).find(
155
+ ([, value]) => (value as PgColumn | undefined)?.name === column.name
156
+ );
157
+ if (!entry) throw new Error(`Column ${column.name} not found as a property of its own table`);
158
+ return entry[0];
159
+ }
160
+
161
+ type TableShape = {
162
+ fkFields: Record<string, string>;
163
+ idField: string | undefined;
164
+ idNeedsFixtureKey: boolean;
165
+ };
166
+
167
+ function introspectTable(
168
+ table: PgTable,
169
+ collectionByTableName: Map<string, string>,
170
+ extraFkFields: Record<string, string> = {}
171
+ ): TableShape {
172
+ const config = getTableConfig(table);
173
+ // A single-column `.primaryKey()` builder marks the column itself (`.primary === true`); a
174
+ // table-level `primaryKey({ columns: [...] })` constraint (e.g. auth.method) doesn't -- it only
175
+ // shows up in `primaryKeys`, verified empirically against a real schema.
176
+ const pk = config.columns.find((c) => c.primary) ?? config.primaryKeys[0]?.columns[0];
177
+ const fkFields: Record<string, string> = { ...extraFkFields };
178
+ for (const fk of config.foreignKeys) {
179
+ const ref = fk.reference();
180
+ const targetCollection = collectionByTableName.get(qualifiedName(ref.foreignTable));
181
+ if (!targetCollection) continue;
182
+ for (const column of ref.columns)
183
+ fkFields[columnPropertyName(table, column)] = targetCollection;
184
+ }
185
+ return {
186
+ fkFields,
187
+ idField: pk && columnPropertyName(table, pk),
188
+ idNeedsFixtureKey: pk ? !pk.hasDefault : false,
189
+ };
190
+ }
191
+
192
+ // A relative-time field written as data instead of code, e.g. `{ "daysAgo": 365 }` or
193
+ // `{ "minutesAgo": 5 }` -- resolved against `nowFn()` at seed time rather than baked into the JSON
194
+ // as an absolute timestamp, so it stays meaningful regardless of when the suite runs.
195
+ function isRelativeTime(value: unknown): value is { daysAgo: number } | { minutesAgo: number } {
196
+ return (
197
+ typeof value === 'object' &&
198
+ value !== null &&
199
+ ('daysAgo' in value || 'minutesAgo' in value) &&
200
+ Object.keys(value).length === 1
201
+ );
202
+ }
203
+
204
+ /** Deep-merges override files onto the master fixture, per collection per fixture-id: fields on
205
+ * an existing fixture-id are overridden, new fixture-ids are added, nothing in master is dropped. */
206
+ export function mergeFixtures(master: FixtureFile, overrides: FixtureFile[]): FixtureFile {
207
+ const merged: FixtureFile = structuredClone(master);
208
+ for (const override of overrides) {
209
+ for (const [collection, rows] of Object.entries(override)) {
210
+ merged[collection] ??= {};
211
+ for (const [fixtureId, row] of Object.entries(rows)) {
212
+ merged[collection][fixtureId] = { ...merged[collection][fixtureId], ...row };
213
+ }
214
+ }
215
+ }
216
+ return merged;
217
+ }
218
+
219
+ /**
220
+ * Seeds `master` (plus any override files, merged on top) into a PGlite instance, resolving
221
+ * cross-collection references (e.g. a job's `fromInboxId: "sender"`) to the real id Postgres
222
+ * assigned on insert. Returns the fixture-id -> real-id map so a test can look up rows by the same
223
+ * name they're defined under in the JSON.
224
+ */
225
+ export async function seedFixtures(
226
+ pglite: PgliteDatabase<Record<string, unknown>>,
227
+ collections: FixtureCollection[],
228
+ master: FixtureFile,
229
+ overrides: FixtureFile[] = [],
230
+ // A caller freezing the clock for relative fixture dates wants those dates resolved against
231
+ // its own frozen "now" -- passed in rather than imported, so this module doesn't depend on any
232
+ // one app's datetime library. Only required if a fixture actually uses `daysAgo`/`minutesAgo`.
233
+ nowFn?: () => { subtract: (unit: Record<string, number>) => unknown }
234
+ ): Promise<{ ids: FixtureIdMap }> {
235
+ const collectionByTableName = new Map(collections.map((c) => [qualifiedName(c.table), c.name]));
236
+ const fixtures = mergeFixtures(master, overrides);
237
+ const ids: FixtureIdMap = {};
238
+
239
+ for (const { defaults, extraFkFields, name, table } of collections) {
240
+ const { fkFields, idField, idNeedsFixtureKey } = introspectTable(
241
+ table,
242
+ collectionByTableName,
243
+ extraFkFields
244
+ );
245
+ ids[name] = {};
246
+ for (const [fixtureId, row] of Object.entries(fixtures[name] ?? {})) {
247
+ const resolved: FixtureRow = { ...defaults, ...row };
248
+ if (idNeedsFixtureKey && idField) resolved[idField] ??= fixtureId;
249
+ for (const [field, refCollection] of Object.entries(fkFields)) {
250
+ const ref = resolved[field];
251
+ if (typeof ref === 'string') resolved[field] = ids[refCollection][ref];
252
+ }
253
+ for (const [field, value] of Object.entries(resolved)) {
254
+ if (!isRelativeTime(value)) continue;
255
+ if (!nowFn) {
256
+ throw new Error(
257
+ `Fixture "${name}.${fixtureId}.${field}" uses a relative time but no nowFn was passed to seedFixtures()`
258
+ );
259
+ }
260
+ resolved[field] =
261
+ 'daysAgo' in value
262
+ ? nowFn().subtract({ days: value.daysAgo })
263
+ : nowFn().subtract({ minutes: value.minutesAgo });
264
+ }
265
+ const [inserted] = await pglite
266
+ .insert(table)
267
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- each table's own insert shape, fixtures are untyped JSON by design
268
+ .values(resolved as any)
269
+ .returning({ id: table[idField as keyof PgTable] as PgColumn });
270
+ ids[name][fixtureId] = inserted.id as number | string;
271
+ }
272
+ }
273
+
274
+ return { ids };
275
+ }