@fitzzero/quickdraw-core 3.6.0 → 3.8.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/README.md +218 -64
- package/dist/client/index.d.ts +34 -4
- package/dist/client/index.js +57 -65
- package/dist/client/index.js.map +1 -1
- package/dist/client/testing.d.ts +1 -1
- package/dist/client/testing.js.map +1 -1
- package/dist/server/express/index.d.ts +37 -0
- package/dist/server/express/index.js +60 -0
- package/dist/server/express/index.js.map +1 -0
- package/dist/server/index.d.ts +330 -5
- package/dist/server/index.js +593 -144
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing-prisma.d.ts +101 -0
- package/dist/server/testing-prisma.js +172 -0
- package/dist/server/testing-prisma.js.map +1 -0
- package/dist/server/testing.d.ts +1 -1
- package/dist/server/testing.js +87 -22
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +77 -1
- package/dist/shared/index.js +5 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/{types-DniMxkQs.d.ts → types-ClgSr90l.d.ts} +5 -1
- package/dist/{types-B1z5aXdR.d.ts → types-DUHPIy_5.d.ts} +20 -1
- package/eslint-config/index.mjs +1 -2
- package/eslint-plugin-quickdraw/index.mjs +15 -7
- package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +31 -32
- package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +17 -18
- package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +52 -0
- package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +140 -0
- package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +20 -22
- package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +17 -17
- package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +59 -0
- package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +52 -0
- package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +3 -3
- package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +8 -8
- package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +15 -20
- package/package.json +74 -50
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma test-database helpers (dual-mode: real PostgreSQL or PGlite).
|
|
3
|
+
*
|
|
4
|
+
* Exported as `@fitzzero/quickdraw-core/server/testing/prisma`.
|
|
5
|
+
*
|
|
6
|
+
* Real-PostgreSQL mode: a template database is migrated once per run, then
|
|
7
|
+
* per-worker databases are cloned from it (`CREATE DATABASE … TEMPLATE …`) so
|
|
8
|
+
* vitest workers never share state. PGlite mode (no TEST_DATABASE_URL):
|
|
9
|
+
* migrations run once into an in-memory PGlite instance whose data dir is
|
|
10
|
+
* dumped to a gzip cache keyed by a migrations fingerprint — later test
|
|
11
|
+
* processes boot from the dump in milliseconds with no PostgreSQL at all.
|
|
12
|
+
*
|
|
13
|
+
* `pg` and `@electric-sql/pglite` are optional peers, loaded via dynamic
|
|
14
|
+
* import only inside the mode that needs them. App-specific pieces (the
|
|
15
|
+
* generated PrismaClient, adapter choice, seed helpers) stay in the app.
|
|
16
|
+
*/
|
|
17
|
+
interface RawSqlClient {
|
|
18
|
+
$executeRawUnsafe(sql: string): Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reset a test database by truncating all public tables.
|
|
22
|
+
*
|
|
23
|
+
* Dynamic TRUNCATE discovers all tables so it never falls out of sync with
|
|
24
|
+
* schema changes. Retries on deadlock (surfaced by Prisma as P2010), which can
|
|
25
|
+
* occur when parallel test files within the same fork race on TRUNCATE vs
|
|
26
|
+
* active queries.
|
|
27
|
+
*/
|
|
28
|
+
declare function resetDatabase(client: RawSqlClient): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite a database URL to point at a per-worker database
|
|
31
|
+
* (e.g. ".../app_test" → ".../test_worker_3").
|
|
32
|
+
*/
|
|
33
|
+
declare function workerDatabaseUrl(baseUrl: string, poolId: string, prefix?: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Hash of all migration.sql contents — cache key for the PGlite template.
|
|
36
|
+
*/
|
|
37
|
+
declare function computeMigrationsFingerprint(migrationsDir: string): string;
|
|
38
|
+
/** All migration.sql contents in execution order. */
|
|
39
|
+
declare function readMigrationSql(migrationsDir: string): string[];
|
|
40
|
+
interface PgliteTemplateOptions {
|
|
41
|
+
/** Absolute path to the Prisma migrations directory. */
|
|
42
|
+
migrationsDir: string;
|
|
43
|
+
/** Cache directory for the template dump (e.g. node_modules/.cache). */
|
|
44
|
+
cacheDir: string;
|
|
45
|
+
/** Base name for the cache files (e.g. "myapp-test-template"). */
|
|
46
|
+
templateName: string;
|
|
47
|
+
}
|
|
48
|
+
interface PgliteTemplatePaths {
|
|
49
|
+
templatePath: string;
|
|
50
|
+
fingerprintPath: string;
|
|
51
|
+
}
|
|
52
|
+
/** Resolve the cache file locations for a PGlite template. */
|
|
53
|
+
declare function pgliteTemplatePaths(options: PgliteTemplateOptions): PgliteTemplatePaths;
|
|
54
|
+
/**
|
|
55
|
+
* Build (or reuse) the gzip'd PGlite data-dir template with all migrations
|
|
56
|
+
* applied. Returns the template path; skips the build when the cached
|
|
57
|
+
* fingerprint matches the current migrations.
|
|
58
|
+
*/
|
|
59
|
+
declare function buildPgliteTemplate(options: PgliteTemplateOptions): Promise<{
|
|
60
|
+
templatePath: string;
|
|
61
|
+
rebuilt: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
interface PostgresWorkerOptions {
|
|
64
|
+
/** Connection URL whose database segment names the template database. */
|
|
65
|
+
testDatabaseUrl: string;
|
|
66
|
+
/** Template database name (e.g. "myapp_test"). */
|
|
67
|
+
templateDbName: string;
|
|
68
|
+
/** Absolute path to the Prisma migrations directory. */
|
|
69
|
+
migrationsDir: string;
|
|
70
|
+
/** Number of per-worker databases to clone. Default: 8. */
|
|
71
|
+
workerCount?: number;
|
|
72
|
+
/** Worker database name prefix. Default: "test_worker_". */
|
|
73
|
+
workerPrefix?: string;
|
|
74
|
+
/** Seconds to wait for PostgreSQL to accept connections. Default: 30. */
|
|
75
|
+
maxWaitSeconds?: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Prepare real-PostgreSQL test databases: wait for the server, ensure the
|
|
79
|
+
* template database exists, reset + migrate it, then clone one database per
|
|
80
|
+
* worker from the template.
|
|
81
|
+
*/
|
|
82
|
+
declare function setupPostgresWorkerDatabases(options: PostgresWorkerOptions): Promise<void>;
|
|
83
|
+
interface PrismaTestGlobalSetupOptions {
|
|
84
|
+
/** Absolute path to the Prisma migrations directory. */
|
|
85
|
+
migrationsDir: string;
|
|
86
|
+
/** Template database name for real-PostgreSQL mode. */
|
|
87
|
+
templateDbName: string;
|
|
88
|
+
/** Cache name for the PGlite template dump (e.g. "myapp-test-template"). */
|
|
89
|
+
templateName: string;
|
|
90
|
+
/** Cache directory. Default: <cwd>/node_modules/.cache. */
|
|
91
|
+
cacheDir?: string;
|
|
92
|
+
/** Per-worker database count for real-PostgreSQL mode. Default: 8. */
|
|
93
|
+
workerCount?: number;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build a vitest globalSetup function that prepares the test databases.
|
|
97
|
+
* Picks real-PostgreSQL mode when TEST_DATABASE_URL is set, PGlite otherwise.
|
|
98
|
+
*/
|
|
99
|
+
declare function createPrismaTestGlobalSetup(options: PrismaTestGlobalSetupOptions): () => Promise<void>;
|
|
100
|
+
|
|
101
|
+
export { type PgliteTemplateOptions, type PgliteTemplatePaths, type PostgresWorkerOptions, type PrismaTestGlobalSetupOptions, type RawSqlClient, buildPgliteTemplate, computeMigrationsFingerprint, createPrismaTestGlobalSetup, pgliteTemplatePaths, readMigrationSql, resetDatabase, setupPostgresWorkerDatabases, workerDatabaseUrl };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
// src/server/testing-prisma.ts
|
|
6
|
+
async function resetDatabase(client) {
|
|
7
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
8
|
+
try {
|
|
9
|
+
await client.$executeRawUnsafe(`
|
|
10
|
+
DO $$
|
|
11
|
+
DECLARE _tables TEXT;
|
|
12
|
+
BEGIN
|
|
13
|
+
SELECT string_agg('"' || tablename || '"', ', ')
|
|
14
|
+
INTO _tables
|
|
15
|
+
FROM pg_tables
|
|
16
|
+
WHERE schemaname = 'public' AND tablename != '_prisma_migrations';
|
|
17
|
+
IF _tables IS NOT NULL THEN
|
|
18
|
+
EXECUTE 'TRUNCATE TABLE ' || _tables || ' CASCADE';
|
|
19
|
+
END IF;
|
|
20
|
+
END $$
|
|
21
|
+
`);
|
|
22
|
+
return;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const isDeadlock = error instanceof Error && "code" in error && error.code === "P2010";
|
|
25
|
+
if (!isDeadlock || attempt === 2) throw error;
|
|
26
|
+
await new Promise((r) => {
|
|
27
|
+
setTimeout(r, 50 * (attempt + 1));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function workerDatabaseUrl(baseUrl, poolId, prefix = "test_worker_") {
|
|
33
|
+
return baseUrl.replace(/\/[^/?]+(\?|$)/, `/${prefix}${poolId}$1`);
|
|
34
|
+
}
|
|
35
|
+
function migrationDirNames(migrationsDir) {
|
|
36
|
+
return readdirSync(migrationsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
37
|
+
}
|
|
38
|
+
function computeMigrationsFingerprint(migrationsDir) {
|
|
39
|
+
const hash = createHash("sha256");
|
|
40
|
+
for (const dir of migrationDirNames(migrationsDir)) {
|
|
41
|
+
const sqlPath = resolve(migrationsDir, dir, "migration.sql");
|
|
42
|
+
if (existsSync(sqlPath)) {
|
|
43
|
+
hash.update(readFileSync(sqlPath));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return hash.digest("hex");
|
|
47
|
+
}
|
|
48
|
+
function readMigrationSql(migrationsDir) {
|
|
49
|
+
const sqls = [];
|
|
50
|
+
for (const dir of migrationDirNames(migrationsDir)) {
|
|
51
|
+
const sqlPath = resolve(migrationsDir, dir, "migration.sql");
|
|
52
|
+
if (existsSync(sqlPath)) {
|
|
53
|
+
sqls.push(readFileSync(sqlPath, "utf-8"));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return sqls;
|
|
57
|
+
}
|
|
58
|
+
function pgliteTemplatePaths(options) {
|
|
59
|
+
return {
|
|
60
|
+
templatePath: resolve(options.cacheDir, `${options.templateName}.tar.gz`),
|
|
61
|
+
fingerprintPath: resolve(options.cacheDir, `${options.templateName}.fingerprint`)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function buildPgliteTemplate(options) {
|
|
65
|
+
const { templatePath, fingerprintPath } = pgliteTemplatePaths(options);
|
|
66
|
+
const fingerprint = computeMigrationsFingerprint(options.migrationsDir);
|
|
67
|
+
mkdirSync(options.cacheDir, { recursive: true });
|
|
68
|
+
if (existsSync(templatePath) && existsSync(fingerprintPath)) {
|
|
69
|
+
const cached = readFileSync(fingerprintPath, "utf-8").trim();
|
|
70
|
+
if (cached === fingerprint) {
|
|
71
|
+
return { templatePath, rebuilt: false };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const { PGlite } = await import('@electric-sql/pglite');
|
|
75
|
+
const pg = new PGlite();
|
|
76
|
+
for (const sql of readMigrationSql(options.migrationsDir)) {
|
|
77
|
+
await pg.exec(sql);
|
|
78
|
+
}
|
|
79
|
+
const dump = await pg.dumpDataDir("gzip");
|
|
80
|
+
const buffer = Buffer.from(await dump.arrayBuffer());
|
|
81
|
+
writeFileSync(templatePath, buffer);
|
|
82
|
+
writeFileSync(fingerprintPath, fingerprint);
|
|
83
|
+
await pg.close();
|
|
84
|
+
return { templatePath, rebuilt: true };
|
|
85
|
+
}
|
|
86
|
+
function withDatabase(url, database) {
|
|
87
|
+
return url.replace(/\/[^/?]+(\?|$)/, `/${database}$1`);
|
|
88
|
+
}
|
|
89
|
+
async function setupPostgresWorkerDatabases(options) {
|
|
90
|
+
const { default: pg } = await import('pg');
|
|
91
|
+
const workerCount = options.workerCount ?? 8;
|
|
92
|
+
const workerPrefix = options.workerPrefix ?? "test_worker_";
|
|
93
|
+
const maxAttempts = options.maxWaitSeconds ?? 30;
|
|
94
|
+
const adminUrl = withDatabase(options.testDatabaseUrl, "postgres");
|
|
95
|
+
const templateUrl = withDatabase(options.testDatabaseUrl, options.templateDbName);
|
|
96
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
97
|
+
try {
|
|
98
|
+
const client2 = new pg.Client({ connectionString: adminUrl });
|
|
99
|
+
await client2.connect();
|
|
100
|
+
await client2.end();
|
|
101
|
+
break;
|
|
102
|
+
} catch {
|
|
103
|
+
if (i === maxAttempts - 1) {
|
|
104
|
+
throw new Error(`PostgreSQL not ready after ${maxAttempts}s`);
|
|
105
|
+
}
|
|
106
|
+
await new Promise((res) => {
|
|
107
|
+
setTimeout(res, 1e3);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
{
|
|
112
|
+
const client2 = new pg.Client({ connectionString: adminUrl });
|
|
113
|
+
await client2.connect();
|
|
114
|
+
try {
|
|
115
|
+
const res = await client2.query("SELECT 1 FROM pg_database WHERE datname = $1", [
|
|
116
|
+
options.templateDbName
|
|
117
|
+
]);
|
|
118
|
+
if (res.rowCount === 0) {
|
|
119
|
+
await client2.query(`CREATE DATABASE ${options.templateDbName}`);
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
await client2.end();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const client = new pg.Client({ connectionString: templateUrl });
|
|
126
|
+
await client.connect();
|
|
127
|
+
try {
|
|
128
|
+
await client.query(
|
|
129
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
|
130
|
+
[options.templateDbName]
|
|
131
|
+
);
|
|
132
|
+
await client.query("DROP SCHEMA public CASCADE");
|
|
133
|
+
await client.query("CREATE SCHEMA public");
|
|
134
|
+
for (const sql of readMigrationSql(options.migrationsDir)) {
|
|
135
|
+
await client.query(sql);
|
|
136
|
+
}
|
|
137
|
+
for (let i = 0; i < workerCount; i++) {
|
|
138
|
+
const workerDb = `${workerPrefix}${i}`;
|
|
139
|
+
await client.query(
|
|
140
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
|
141
|
+
[workerDb]
|
|
142
|
+
);
|
|
143
|
+
await client.query(`DROP DATABASE IF EXISTS ${workerDb}`);
|
|
144
|
+
await client.query(`CREATE DATABASE ${workerDb} TEMPLATE ${options.templateDbName}`);
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
await client.end();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function createPrismaTestGlobalSetup(options) {
|
|
151
|
+
return async () => {
|
|
152
|
+
const testDatabaseUrl = process.env.TEST_DATABASE_URL;
|
|
153
|
+
if (testDatabaseUrl) {
|
|
154
|
+
await setupPostgresWorkerDatabases({
|
|
155
|
+
testDatabaseUrl,
|
|
156
|
+
templateDbName: options.templateDbName,
|
|
157
|
+
migrationsDir: options.migrationsDir,
|
|
158
|
+
workerCount: options.workerCount
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
await buildPgliteTemplate({
|
|
162
|
+
migrationsDir: options.migrationsDir,
|
|
163
|
+
cacheDir: options.cacheDir ?? resolve(process.cwd(), "node_modules/.cache"),
|
|
164
|
+
templateName: options.templateName
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { buildPgliteTemplate, computeMigrationsFingerprint, createPrismaTestGlobalSetup, pgliteTemplatePaths, readMigrationSql, resetDatabase, setupPostgresWorkerDatabases, workerDatabaseUrl };
|
|
171
|
+
//# sourceMappingURL=testing-prisma.js.map
|
|
172
|
+
//# sourceMappingURL=testing-prisma.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/testing-prisma.ts"],"names":["client"],"mappings":"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","file":"testing-prisma.js","sourcesContent":["/**\n * Prisma test-database helpers (dual-mode: real PostgreSQL or PGlite).\n *\n * Exported as `@fitzzero/quickdraw-core/server/testing/prisma`.\n *\n * Real-PostgreSQL mode: a template database is migrated once per run, then\n * per-worker databases are cloned from it (`CREATE DATABASE … TEMPLATE …`) so\n * vitest workers never share state. PGlite mode (no TEST_DATABASE_URL):\n * migrations run once into an in-memory PGlite instance whose data dir is\n * dumped to a gzip cache keyed by a migrations fingerprint — later test\n * processes boot from the dump in milliseconds with no PostgreSQL at all.\n *\n * `pg` and `@electric-sql/pglite` are optional peers, loaded via dynamic\n * import only inside the mode that needs them. App-specific pieces (the\n * generated PrismaClient, adapter choice, seed helpers) stay in the app.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nexport interface RawSqlClient {\n $executeRawUnsafe(sql: string): Promise<unknown>;\n}\n\n/**\n * Reset a test database by truncating all public tables.\n *\n * Dynamic TRUNCATE discovers all tables so it never falls out of sync with\n * schema changes. Retries on deadlock (surfaced by Prisma as P2010), which can\n * occur when parallel test files within the same fork race on TRUNCATE vs\n * active queries.\n */\nexport async function resetDatabase(client: RawSqlClient): Promise<void> {\n for (let attempt = 0; attempt < 3; attempt++) {\n try {\n await client.$executeRawUnsafe(`\n DO $$\n DECLARE _tables TEXT;\n BEGIN\n SELECT string_agg('\"' || tablename || '\"', ', ')\n INTO _tables\n FROM pg_tables\n WHERE schemaname = 'public' AND tablename != '_prisma_migrations';\n IF _tables IS NOT NULL THEN\n EXECUTE 'TRUNCATE TABLE ' || _tables || ' CASCADE';\n END IF;\n END $$\n `);\n return;\n } catch (error: unknown) {\n const isDeadlock =\n error instanceof Error && \"code\" in error && (error as { code: string }).code === \"P2010\";\n if (!isDeadlock || attempt === 2) throw error;\n await new Promise<void>((r) => {\n setTimeout(r, 50 * (attempt + 1));\n });\n }\n }\n}\n\n/**\n * Rewrite a database URL to point at a per-worker database\n * (e.g. \".../app_test\" → \".../test_worker_3\").\n */\nexport function workerDatabaseUrl(\n baseUrl: string,\n poolId: string,\n prefix: string = \"test_worker_\",\n): string {\n return baseUrl.replace(/\\/[^/?]+(\\?|$)/, `/${prefix}${poolId}$1`);\n}\n\n/** Sorted migration directory names under a Prisma migrations dir. */\nfunction migrationDirNames(migrationsDir: string): string[] {\n return readdirSync(migrationsDir, { withFileTypes: true })\n .filter((d) => d.isDirectory())\n .map((d) => d.name)\n .sort();\n}\n\n/**\n * Hash of all migration.sql contents — cache key for the PGlite template.\n */\nexport function computeMigrationsFingerprint(migrationsDir: string): string {\n const hash = createHash(\"sha256\");\n for (const dir of migrationDirNames(migrationsDir)) {\n const sqlPath = resolve(migrationsDir, dir, \"migration.sql\");\n if (existsSync(sqlPath)) {\n hash.update(readFileSync(sqlPath));\n }\n }\n return hash.digest(\"hex\");\n}\n\n/** All migration.sql contents in execution order. */\nexport function readMigrationSql(migrationsDir: string): string[] {\n const sqls: string[] = [];\n for (const dir of migrationDirNames(migrationsDir)) {\n const sqlPath = resolve(migrationsDir, dir, \"migration.sql\");\n if (existsSync(sqlPath)) {\n sqls.push(readFileSync(sqlPath, \"utf-8\"));\n }\n }\n return sqls;\n}\n\nexport interface PgliteTemplateOptions {\n /** Absolute path to the Prisma migrations directory. */\n migrationsDir: string;\n /** Cache directory for the template dump (e.g. node_modules/.cache). */\n cacheDir: string;\n /** Base name for the cache files (e.g. \"myapp-test-template\"). */\n templateName: string;\n}\n\nexport interface PgliteTemplatePaths {\n templatePath: string;\n fingerprintPath: string;\n}\n\n/** Resolve the cache file locations for a PGlite template. */\nexport function pgliteTemplatePaths(options: PgliteTemplateOptions): PgliteTemplatePaths {\n return {\n templatePath: resolve(options.cacheDir, `${options.templateName}.tar.gz`),\n fingerprintPath: resolve(options.cacheDir, `${options.templateName}.fingerprint`),\n };\n}\n\n/**\n * Build (or reuse) the gzip'd PGlite data-dir template with all migrations\n * applied. Returns the template path; skips the build when the cached\n * fingerprint matches the current migrations.\n */\nexport async function buildPgliteTemplate(\n options: PgliteTemplateOptions,\n): Promise<{ templatePath: string; rebuilt: boolean }> {\n const { templatePath, fingerprintPath } = pgliteTemplatePaths(options);\n const fingerprint = computeMigrationsFingerprint(options.migrationsDir);\n\n mkdirSync(options.cacheDir, { recursive: true });\n\n if (existsSync(templatePath) && existsSync(fingerprintPath)) {\n const cached = readFileSync(fingerprintPath, \"utf-8\").trim();\n if (cached === fingerprint) {\n return { templatePath, rebuilt: false };\n }\n }\n\n const { PGlite } = await import(\"@electric-sql/pglite\");\n const pg = new PGlite();\n\n for (const sql of readMigrationSql(options.migrationsDir)) {\n await pg.exec(sql);\n }\n\n const dump = await pg.dumpDataDir(\"gzip\");\n const buffer = Buffer.from(await dump.arrayBuffer());\n writeFileSync(templatePath, buffer);\n writeFileSync(fingerprintPath, fingerprint);\n\n await pg.close();\n return { templatePath, rebuilt: true };\n}\n\nexport interface PostgresWorkerOptions {\n /** Connection URL whose database segment names the template database. */\n testDatabaseUrl: string;\n /** Template database name (e.g. \"myapp_test\"). */\n templateDbName: string;\n /** Absolute path to the Prisma migrations directory. */\n migrationsDir: string;\n /** Number of per-worker databases to clone. Default: 8. */\n workerCount?: number;\n /** Worker database name prefix. Default: \"test_worker_\". */\n workerPrefix?: string;\n /** Seconds to wait for PostgreSQL to accept connections. Default: 30. */\n maxWaitSeconds?: number;\n}\n\n/** Swap the database segment of a connection URL. */\nfunction withDatabase(url: string, database: string): string {\n return url.replace(/\\/[^/?]+(\\?|$)/, `/${database}$1`);\n}\n\n/**\n * Prepare real-PostgreSQL test databases: wait for the server, ensure the\n * template database exists, reset + migrate it, then clone one database per\n * worker from the template.\n */\nexport async function setupPostgresWorkerDatabases(options: PostgresWorkerOptions): Promise<void> {\n const { default: pg } = await import(\"pg\");\n const workerCount = options.workerCount ?? 8;\n const workerPrefix = options.workerPrefix ?? \"test_worker_\";\n const maxAttempts = options.maxWaitSeconds ?? 30;\n const adminUrl = withDatabase(options.testDatabaseUrl, \"postgres\");\n const templateUrl = withDatabase(options.testDatabaseUrl, options.templateDbName);\n\n // Wait for PostgreSQL to accept connections — guards against timing races\n // when tests start before a sidecar container is fully ready.\n for (let i = 0; i < maxAttempts; i++) {\n try {\n const client = new pg.Client({ connectionString: adminUrl });\n await client.connect();\n await client.end();\n break;\n } catch {\n if (i === maxAttempts - 1) {\n throw new Error(`PostgreSQL not ready after ${maxAttempts}s`);\n }\n await new Promise<void>((res) => {\n setTimeout(res, 1000);\n });\n }\n }\n\n // Ensure the template database exists (self-heals fresh environments).\n {\n const client = new pg.Client({ connectionString: adminUrl });\n await client.connect();\n try {\n const res = await client.query(\"SELECT 1 FROM pg_database WHERE datname = $1\", [\n options.templateDbName,\n ]);\n if (res.rowCount === 0) {\n await client.query(`CREATE DATABASE ${options.templateDbName}`);\n }\n } finally {\n await client.end();\n }\n }\n\n // Reset + migrate the template, then clone per-worker databases from it.\n const client = new pg.Client({ connectionString: templateUrl });\n await client.connect();\n try {\n await client.query(\n `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,\n [options.templateDbName],\n );\n\n await client.query(\"DROP SCHEMA public CASCADE\");\n await client.query(\"CREATE SCHEMA public\");\n\n for (const sql of readMigrationSql(options.migrationsDir)) {\n await client.query(sql);\n }\n\n for (let i = 0; i < workerCount; i++) {\n const workerDb = `${workerPrefix}${i}`;\n await client.query(\n `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,\n [workerDb],\n );\n await client.query(`DROP DATABASE IF EXISTS ${workerDb}`);\n await client.query(`CREATE DATABASE ${workerDb} TEMPLATE ${options.templateDbName}`);\n }\n } finally {\n await client.end();\n }\n}\n\nexport interface PrismaTestGlobalSetupOptions {\n /** Absolute path to the Prisma migrations directory. */\n migrationsDir: string;\n /** Template database name for real-PostgreSQL mode. */\n templateDbName: string;\n /** Cache name for the PGlite template dump (e.g. \"myapp-test-template\"). */\n templateName: string;\n /** Cache directory. Default: <cwd>/node_modules/.cache. */\n cacheDir?: string;\n /** Per-worker database count for real-PostgreSQL mode. Default: 8. */\n workerCount?: number;\n}\n\n/**\n * Build a vitest globalSetup function that prepares the test databases.\n * Picks real-PostgreSQL mode when TEST_DATABASE_URL is set, PGlite otherwise.\n */\nexport function createPrismaTestGlobalSetup(\n options: PrismaTestGlobalSetupOptions,\n): () => Promise<void> {\n return async () => {\n const testDatabaseUrl = process.env.TEST_DATABASE_URL;\n if (testDatabaseUrl) {\n await setupPostgresWorkerDatabases({\n testDatabaseUrl,\n templateDbName: options.templateDbName,\n migrationsDir: options.migrationsDir,\n workerCount: options.workerCount,\n });\n } else {\n await buildPgliteTemplate({\n migrationsDir: options.migrationsDir,\n cacheDir: options.cacheDir ?? resolve(process.cwd(), \"node_modules/.cache\"),\n templateName: options.templateName,\n });\n }\n };\n}\n"]}
|
package/dist/server/testing.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Socket } from 'socket.io-client';
|
|
2
|
-
import { B as BaseServiceInstance, Q as QuickdrawServerResult } from '../types-
|
|
2
|
+
import { B as BaseServiceInstance, Q as QuickdrawServerResult } from '../types-ClgSr90l.js';
|
|
3
3
|
import 'socket.io';
|
|
4
4
|
import 'http';
|
|
5
5
|
import 'zod';
|
package/dist/server/testing.js
CHANGED
|
@@ -6,6 +6,9 @@ import { Server } from 'socket.io';
|
|
|
6
6
|
// src/server/testing.ts
|
|
7
7
|
|
|
8
8
|
// src/shared/types.ts
|
|
9
|
+
function channelEventName(serviceName, channelName) {
|
|
10
|
+
return `channel:${serviceName}:${channelName}`;
|
|
11
|
+
}
|
|
9
12
|
var consoleLogger = {
|
|
10
13
|
info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? ""),
|
|
11
14
|
warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? ""),
|
|
@@ -15,6 +18,8 @@ var consoleLogger = {
|
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
// src/server/ServiceRegistry.ts
|
|
21
|
+
var CHANNEL_ABUSE_WINDOW_MS = 1e4;
|
|
22
|
+
var CHANNEL_ABUSE_MULTIPLIER = 100;
|
|
18
23
|
var ServiceRegistry = class {
|
|
19
24
|
io;
|
|
20
25
|
services = /* @__PURE__ */ new Map();
|
|
@@ -25,6 +30,8 @@ var ServiceRegistry = class {
|
|
|
25
30
|
connectionHandlerInstalled = false;
|
|
26
31
|
// Store method metadata for deferred registration
|
|
27
32
|
methodRegistry = /* @__PURE__ */ new Map();
|
|
33
|
+
// Store channel metadata for deferred registration
|
|
34
|
+
channelRegistry = /* @__PURE__ */ new Map();
|
|
28
35
|
constructor(io, options) {
|
|
29
36
|
this.io = io;
|
|
30
37
|
this.logger = options?.logger?.child({ service: "ServiceRegistry" }) ?? consoleLogger.child({ service: "ServiceRegistry" });
|
|
@@ -58,22 +65,13 @@ var ServiceRegistry = class {
|
|
|
58
65
|
for (const [eventName, { method, service }] of this.methodRegistry) {
|
|
59
66
|
this.registerMethodListener(socket, eventName, method, service);
|
|
60
67
|
}
|
|
68
|
+
for (const [eventName, { channel, service }] of this.channelRegistry) {
|
|
69
|
+
this.registerChannelListener(socket, eventName, channel, service);
|
|
70
|
+
}
|
|
61
71
|
for (const [serviceName, service] of this.services) {
|
|
62
|
-
this.registerSubscriptionListener(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
service
|
|
66
|
-
);
|
|
67
|
-
this.registerBatchSubscriptionListener(
|
|
68
|
-
socket,
|
|
69
|
-
`${serviceName}:batchSubscribe`,
|
|
70
|
-
service
|
|
71
|
-
);
|
|
72
|
-
this.registerUnsubscriptionListener(
|
|
73
|
-
socket,
|
|
74
|
-
`${serviceName}:unsubscribe`,
|
|
75
|
-
service
|
|
76
|
-
);
|
|
72
|
+
this.registerSubscriptionListener(socket, `${serviceName}:subscribe`, service);
|
|
73
|
+
this.registerBatchSubscriptionListener(socket, `${serviceName}:batchSubscribe`, service);
|
|
74
|
+
this.registerUnsubscriptionListener(socket, `${serviceName}:unsubscribe`, service);
|
|
77
75
|
}
|
|
78
76
|
}
|
|
79
77
|
/**
|
|
@@ -86,6 +84,7 @@ var ServiceRegistry = class {
|
|
|
86
84
|
}
|
|
87
85
|
this.logger.info(`Registered service: ${serviceName}`);
|
|
88
86
|
this.discoverServiceMethods(serviceName, service);
|
|
87
|
+
this.discoverServiceChannels(serviceName, service);
|
|
89
88
|
this.installConnectionHandler();
|
|
90
89
|
}
|
|
91
90
|
/**
|
|
@@ -103,6 +102,77 @@ var ServiceRegistry = class {
|
|
|
103
102
|
});
|
|
104
103
|
}
|
|
105
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Discover and store all channels from a service.
|
|
107
|
+
*/
|
|
108
|
+
discoverServiceChannels(serviceName, service) {
|
|
109
|
+
const channels = service.getPublicChannels?.() ?? [];
|
|
110
|
+
for (const channel of channels) {
|
|
111
|
+
const eventName = channelEventName(serviceName, channel.name);
|
|
112
|
+
this.logger.info(
|
|
113
|
+
`Registering channel event: ${eventName} (${channel.ratePerSecond}/s, burst ${channel.burst})`
|
|
114
|
+
);
|
|
115
|
+
this.channelRegistry.set(eventName, {
|
|
116
|
+
serviceName,
|
|
117
|
+
channel,
|
|
118
|
+
service
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Register a channel as a socket event listener.
|
|
124
|
+
*
|
|
125
|
+
* This is the hot path for high-frequency traffic: no ack, no async access
|
|
126
|
+
* checks, no per-message logging. Order of operations per message:
|
|
127
|
+
* token bucket → schema validation → in-memory access check → handler.
|
|
128
|
+
* Any failure silently drops the message; sustained extreme flooding
|
|
129
|
+
* disconnects the socket.
|
|
130
|
+
*/
|
|
131
|
+
registerChannelListener(socket, eventName, channel, service) {
|
|
132
|
+
let tokens = channel.burst;
|
|
133
|
+
let lastRefillAt = Date.now();
|
|
134
|
+
let abuseWindowStart = lastRefillAt;
|
|
135
|
+
let droppedInWindow = 0;
|
|
136
|
+
socket.on(eventName, (payload) => {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
tokens = Math.min(
|
|
139
|
+
channel.burst,
|
|
140
|
+
tokens + (now - lastRefillAt) / 1e3 * channel.ratePerSecond
|
|
141
|
+
);
|
|
142
|
+
lastRefillAt = now;
|
|
143
|
+
if (tokens < 1) {
|
|
144
|
+
if (now - abuseWindowStart > CHANNEL_ABUSE_WINDOW_MS) {
|
|
145
|
+
abuseWindowStart = now;
|
|
146
|
+
droppedInWindow = 0;
|
|
147
|
+
}
|
|
148
|
+
droppedInWindow++;
|
|
149
|
+
if (droppedInWindow > channel.ratePerSecond * CHANNEL_ABUSE_MULTIPLIER) {
|
|
150
|
+
this.logger.warn(
|
|
151
|
+
`Disconnecting socket ${socket.id} for sustained channel flooding on ${eventName}`,
|
|
152
|
+
{ userId: socket.userId, droppedInWindow }
|
|
153
|
+
);
|
|
154
|
+
socket.disconnect(true);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
tokens -= 1;
|
|
159
|
+
const parsed = channel.schema.safeParse(payload);
|
|
160
|
+
if (!parsed.success) return;
|
|
161
|
+
if (!service.checkChannelAccess?.(channel, socket, parsed.data)) return;
|
|
162
|
+
try {
|
|
163
|
+
channel.handler(parsed.data, {
|
|
164
|
+
userId: socket.userId,
|
|
165
|
+
socketId: socket.id,
|
|
166
|
+
serviceAccess: socket.serviceAccess ?? {}
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
this.logger.error(`Channel handler error for ${eventName}`, {
|
|
170
|
+
userId: socket.userId,
|
|
171
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
106
176
|
/**
|
|
107
177
|
* Register a method as a socket event listener.
|
|
108
178
|
*/
|
|
@@ -206,10 +276,7 @@ var ServiceRegistry = class {
|
|
|
206
276
|
if (this.logPayloads) {
|
|
207
277
|
logContext.payload = payload;
|
|
208
278
|
}
|
|
209
|
-
this.logger.error(
|
|
210
|
-
`User ${userIdShort} ${serviceName}.${methodName} -> fail`,
|
|
211
|
-
logContext
|
|
212
|
-
);
|
|
279
|
+
this.logger.error(`User ${userIdShort} ${serviceName}.${methodName} -> fail`, logContext);
|
|
213
280
|
const errorResponse = {
|
|
214
281
|
success: false,
|
|
215
282
|
error: error instanceof Error ? error.message : "Internal error",
|
|
@@ -407,9 +474,7 @@ function createQuickdrawServer(options) {
|
|
|
407
474
|
});
|
|
408
475
|
httpServer.listen(options.port, "0.0.0.0", () => {
|
|
409
476
|
serverLogger.info(`Server listening on port ${options.port}`);
|
|
410
|
-
serverLogger.info(
|
|
411
|
-
`Registered services: ${registry.getServices().join(", ")}`
|
|
412
|
-
);
|
|
477
|
+
serverLogger.info(`Registered services: ${registry.getServices().join(", ")}`);
|
|
413
478
|
});
|
|
414
479
|
const shutdown = async (signal) => {
|
|
415
480
|
serverLogger.info(`Received ${signal}, starting graceful shutdown...`);
|