@murumets-ee/db 0.9.0 → 0.11.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/dist/index.d.mts +256 -132
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +6 -6
- package/dist/index.mjs.map +1 -1
- package/dist/test-utils.d.mts +64 -1
- package/dist/test-utils.d.mts.map +1 -1
- package/dist/test-utils.mjs +1 -1
- package/dist/test-utils.mjs.map +1 -1
- package/package.json +4 -1
package/dist/test-utils.d.mts
CHANGED
|
@@ -24,6 +24,69 @@ interface TestDb {
|
|
|
24
24
|
* ```
|
|
25
25
|
*/
|
|
26
26
|
declare function createTestDb(): Promise<TestDb>;
|
|
27
|
+
/** OrderBy spec mirrors TableClient's `OrderBySpec`. */
|
|
28
|
+
interface InMemoryOrderBy {
|
|
29
|
+
column: string;
|
|
30
|
+
dir?: 'asc' | 'desc';
|
|
31
|
+
}
|
|
32
|
+
/** Subset of TableClient methods used by tests. */
|
|
33
|
+
interface InMemoryTableClient<Row extends Record<string, unknown>> {
|
|
34
|
+
rows: Row[];
|
|
35
|
+
reset(initial?: Row[]): void;
|
|
36
|
+
findOne(where: Record<string, unknown>): Promise<Row | null>;
|
|
37
|
+
findMany(opts?: {
|
|
38
|
+
where?: Record<string, unknown>;
|
|
39
|
+
orderBy?: InMemoryOrderBy[];
|
|
40
|
+
limit?: number;
|
|
41
|
+
offset?: number;
|
|
42
|
+
}): Promise<Row[]>;
|
|
43
|
+
exists(where: Record<string, unknown>): Promise<boolean>;
|
|
44
|
+
count(where?: Record<string, unknown>): Promise<number>;
|
|
45
|
+
insert(values: Partial<Row>): Promise<Row>;
|
|
46
|
+
update(where: Record<string, unknown>, patch: Partial<Row>): Promise<Row | null>;
|
|
47
|
+
upsert(values: Partial<Row>, opts: {
|
|
48
|
+
target: string[] | string;
|
|
49
|
+
set?: Partial<Row>;
|
|
50
|
+
}): Promise<Row>;
|
|
51
|
+
delete(where: Record<string, unknown>): Promise<Row | null>;
|
|
52
|
+
deleteMany(where: Record<string, unknown>): Promise<Row[]>;
|
|
53
|
+
tryClaim(values: Partial<Row>, opts: {
|
|
54
|
+
target: string | string[];
|
|
55
|
+
replaceWhen: Record<string, unknown>;
|
|
56
|
+
set?: Partial<Row>;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
acquired: true;
|
|
59
|
+
row: Row;
|
|
60
|
+
} | {
|
|
61
|
+
acquired: false;
|
|
62
|
+
row: Row;
|
|
63
|
+
}>;
|
|
64
|
+
aggregate?: (opts: {
|
|
65
|
+
select: Record<string, {
|
|
66
|
+
fn: 'max' | 'min' | 'count' | 'sum' | 'avg';
|
|
67
|
+
column?: string;
|
|
68
|
+
}>;
|
|
69
|
+
where?: Record<string, unknown>;
|
|
70
|
+
}) => Promise<Array<Record<string, unknown>>>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build an in-memory TableClient stand-in for unit tests. Pass
|
|
74
|
+
* `idGenerator` if your test wants deterministic synthetic IDs
|
|
75
|
+
* (defaults to a monotonic `row-1`, `row-2`, ... counter).
|
|
76
|
+
*
|
|
77
|
+
* Example:
|
|
78
|
+
* ```ts
|
|
79
|
+
* const drafts = createInMemoryTableClient<DraftRow>({ idField: 'id' })
|
|
80
|
+
* vi.mock('@murumets-ee/content/schema', () => ({
|
|
81
|
+
* getContentTables: () => ({ drafts: { makeClient: () => drafts } })
|
|
82
|
+
* }))
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function createInMemoryTableClient<Row extends Record<string, unknown>>(options?: {
|
|
86
|
+
idField?: keyof Row & string;
|
|
87
|
+
idGenerator?: () => string;
|
|
88
|
+
initial?: Row[];
|
|
89
|
+
}): InMemoryTableClient<Row>;
|
|
27
90
|
//#endregion
|
|
28
|
-
export { TestDb, createTestDb };
|
|
91
|
+
export { InMemoryOrderBy, InMemoryTableClient, TestDb, createInMemoryTableClient, createTestDb };
|
|
29
92
|
//# sourceMappingURL=test-utils.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-utils.d.mts","names":[],"sources":["../src/test-utils.ts"],"mappings":";;;UAIiB,MAAA;EACf,EAAA,EAAI,kBAAA;EACJ,MAAA;EAFqB;EAIrB,IAAA,GAAO,OAAA,EAAS,MAAA,sBAA4B,OAAA;EAC5C,OAAA,QAAe,OAAA;AAAA;;;;;;;;;;;;;;;;iBAkBK,YAAA,CAAA,GAAgB,OAAA,CAAQ,MAAA"}
|
|
1
|
+
{"version":3,"file":"test-utils.d.mts","names":[],"sources":["../src/test-utils.ts"],"mappings":";;;UAIiB,MAAA;EACf,EAAA,EAAI,kBAAA;EACJ,MAAA;EAFqB;EAIrB,IAAA,GAAO,OAAA,EAAS,MAAA,sBAA4B,OAAA;EAC5C,OAAA,QAAe,OAAA;AAAA;;;;;;;;;;;;;;;;iBAkBK,YAAA,CAAA,GAAgB,OAAA,CAAQ,MAAA;;UA6I7B,eAAA;EACf,MAAA;EACA,GAAA;AAAA;;UAIe,mBAAA,aAAgC,MAAA;EAC/C,IAAA,EAAM,GAAA;EACN,KAAA,CAAM,OAAA,GAAU,GAAA;EAChB,OAAA,CAAQ,KAAA,EAAO,MAAA,oBAA0B,OAAA,CAAQ,GAAA;EACjD,QAAA,CAAS,IAAA;IACP,KAAA,GAAQ,MAAA;IACR,OAAA,GAAU,eAAA;IACV,KAAA;IACA,MAAA;EAAA,IACE,OAAA,CAAQ,GAAA;EACZ,MAAA,CAAO,KAAA,EAAO,MAAA,oBAA0B,OAAA;EACxC,KAAA,CAAM,KAAA,GAAQ,MAAA,oBAA0B,OAAA;EACxC,MAAA,CAAO,MAAA,EAAQ,OAAA,CAAQ,GAAA,IAAO,OAAA,CAAQ,GAAA;EACtC,MAAA,CAAO,KAAA,EAAO,MAAA,mBAAyB,KAAA,EAAO,OAAA,CAAQ,GAAA,IAAO,OAAA,CAAQ,GAAA;EACrE,MAAA,CACE,MAAA,EAAQ,OAAA,CAAQ,GAAA,GAChB,IAAA;IAAQ,MAAA;IAA2B,GAAA,GAAM,OAAA,CAAQ,GAAA;EAAA,IAChD,OAAA,CAAQ,GAAA;EACX,MAAA,CAAO,KAAA,EAAO,MAAA,oBAA0B,OAAA,CAAQ,GAAA;EAChD,UAAA,CAAW,KAAA,EAAO,MAAA,oBAA0B,OAAA,CAAQ,GAAA;EACpD,QAAA,CACE,MAAA,EAAQ,OAAA,CAAQ,GAAA,GAChB,IAAA;IACE,MAAA;IACA,WAAA,EAAa,MAAA;IACb,GAAA,GAAM,OAAA,CAAQ,GAAA;EAAA,IAEf,OAAA;IAAU,QAAA;IAAgB,GAAA,EAAK,GAAA;EAAA;IAAU,QAAA;IAAiB,GAAA,EAAK,GAAA;EAAA;EAClE,SAAA,IAAa,IAAA;IACX,MAAA,EAAQ,MAAA;MAAiB,EAAA;MAA6C,MAAA;IAAA;IACtE,KAAA,GAAQ,MAAA;EAAA,MACJ,OAAA,CAAQ,KAAA,CAAM,MAAA;AAAA;;;;;;;;;;;;;;iBAgBN,yBAAA,aAAsC,MAAA,kBAAA,CACpD,OAAA;EAAW,OAAA,SAAgB,GAAA;EAAc,WAAA;EAA4B,OAAA,GAAU,GAAA;AAAA,IAC9E,mBAAA,CAAoB,GAAA"}
|
package/dist/test-utils.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{drizzle as e}from"drizzle-orm/postgres-js";import t from"postgres";import{sql as n}from"drizzle-orm";async function r(){let r=process.env.DATABASE_TEST_URL||process.env.DATABASE_URL;if(!r)throw Error(`DATABASE_TEST_URL (or DATABASE_URL) env var is required for integration tests`);let i=`test_${crypto.randomUUID().replaceAll(`-`,``).slice(0,12)}`,a=t(r,{max:
|
|
1
|
+
import{drizzle as e}from"drizzle-orm/postgres-js";import t from"postgres";import{sql as n}from"drizzle-orm";async function r(){if(process.env.NODE_ENV===`production`)throw Error(`createTestDb() is not allowed when NODE_ENV=production — it issues CREATE/DROP SCHEMA and is intended for tests only`);let r=process.env.DATABASE_TEST_URL||process.env.DATABASE_URL;if(!r)throw Error(`DATABASE_TEST_URL (or DATABASE_URL) env var is required for integration tests`);let i=`test_${crypto.randomUUID().replaceAll(`-`,``).slice(0,12)}`,a=t(r,{max:1});try{await a.unsafe(`CREATE SCHEMA "${i}"`)}finally{await a.end()}let o=t(r,{max:3,idle_timeout:10,connection:{search_path:`"${i}",public`}}),c=e(o);return{db:c,schema:i,async push(e){let{createRequire:t}=await import(`node:module`),{pushSchema:n}=t(import.meta.url)(`drizzle-kit/api`);await(await n(e,s(c))).apply()},async cleanup(){await c.execute(n.raw(`DROP SCHEMA IF EXISTS "${i}" CASCADE`)),await o.end()}}}function i(e,t){return`eq`in t?e===t.eq:`ne`in t?e!==t.ne:`in`in t?t.in.includes(e):`notIn`in t?!t.notIn.includes(e):`lt`in t?e<t.lt:`lte`in t?e<=t.lte:`gt`in t?e>t.gt:`gte`in t?e>=t.gte:`isNull`in t?e==null:`isNotNull`in t?e!=null:!1}function a(e,t){for(let[n,r]of Object.entries(t)){if(n===`$or`){if(!r.some(t=>a(e,t)))return!1;continue}if(n===`$and`){if(!r.every(t=>a(e,t)))return!1;continue}let t=e[n];if(r===null){if(t!=null)return!1;continue}if(typeof r==`object`&&r&&!(r instanceof Date)){if(!i(t,r))return!1;continue}if(t!==r)return!1}return!0}function o(e={}){let t=e.idField??`id`,n=1,r=e.idGenerator??(()=>`row-${n++}`),i={rows:e.initial?[...e.initial]:[]};return{get rows(){return i.rows},set rows(e){i.rows=e},reset(e){i.rows=e?[...e]:[],n=1},async findOne(e){return i.rows.find(t=>a(t,e))??null},async findMany(e){let t=e?.where?i.rows.filter(t=>a(t,e.where)):[...i.rows];e?.orderBy&&e.orderBy.length>0&&(t=[...t].sort((t,n)=>{for(let r of e.orderBy??[]){let e=t[r.column],i=n[r.column],a=r.dir===`desc`?-1:1;if(e instanceof Date&&i instanceof Date){let t=(e.getTime()-i.getTime())*a;if(t!==0)return t}else if(typeof e==`number`&&typeof i==`number`){let t=(e-i)*a;if(t!==0)return t}else if(typeof e==`string`&&typeof i==`string`){let t=e.localeCompare(i)*a;if(t!==0)return t}}return 0}));let n=e?.offset??0,r=e?.limit;return n>0&&(t=t.slice(n)),r!==void 0&&r>=0&&(t=t.slice(0,r)),t},async aggregate(e){let t=e?.where?i.rows.filter(t=>a(t,e.where)):i.rows,n={};for(let[r,i]of Object.entries(e.select))if(i.fn===`count`)n[r]=t.length;else if(i.fn===`max`&&i.column){let e=null;for(let n of t){let t=n[i.column];typeof t==`number`&&(e===null||t>e)&&(e=t)}n[r]=e}else if(i.fn===`min`&&i.column){let e=null;for(let n of t){let t=n[i.column];typeof t==`number`&&(e===null||t<e)&&(e=t)}n[r]=e}else if(i.fn===`sum`&&i.column){let e=0;for(let n of t){let t=n[i.column];typeof t==`number`&&(e+=t)}n[r]=e}return[n]},async exists(e){return i.rows.some(t=>a(t,e))},async count(e){return e?i.rows.filter(t=>a(t,e)).length:i.rows.length},async insert(e){let n={[t]:r(),...e};return i.rows.push(n),n},async update(e,t){let n=i.rows.filter(t=>a(t,e));if(n.length===0)return null;let r=n[0];return Object.assign(r,t),r},async upsert(e,n){let o=Array.isArray(n.target)?n.target:[n.target],s={};for(let t of o)s[t]=e[t];let c=i.rows.find(e=>a(e,s));if(c)return Object.assign(c,n.set??e),c;let l={[t]:r(),...e};return i.rows.push(l),l},async delete(e){let t=i.rows.findIndex(t=>a(t,e));if(t===-1)return null;let[n]=i.rows.splice(t,1);return n??null},async deleteMany(e){let t=i.rows.filter(t=>a(t,e));return i.rows=i.rows.filter(e=>!t.includes(e)),t},async tryClaim(e,n){let o=Array.isArray(n.target)?n.target:[n.target],s={};for(let t of o)s[t]=e[t];let c=i.rows.find(e=>a(e,s));if(!c){let n={[t]:r(),...e};return i.rows.push(n),{acquired:!0,row:n}}return a(c,n.replaceWhen)?(Object.assign(c,n.set??e),{acquired:!0,row:c}):{acquired:!1,row:c}}}}function s(e){return new Proxy(e,{get(e,t,n){return t===`execute`?async(...t)=>{let n=await e.execute(...t);return Array.isArray(n)&&!(`rows`in n)&&Object.defineProperty(n,`rows`,{value:[...n],enumerable:!1}),n}:Reflect.get(e,t,n)}})}export{o as createInMemoryTableClient,r as createTestDb};
|
|
2
2
|
//# sourceMappingURL=test-utils.mjs.map
|
package/dist/test-utils.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-utils.mjs","names":[],"sources":["../src/test-utils.ts"],"sourcesContent":["import { sql } from 'drizzle-orm'\nimport { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport postgres from 'postgres'\n\nexport interface TestDb {\n db: PostgresJsDatabase\n schema: string\n /** Push Drizzle table definitions to the test schema (uses drizzle-kit/api) */\n push: (schemas: Record<string, unknown>) => Promise<void>\n cleanup: () => Promise<void>\n}\n\n/**\n * Create an isolated test database using a unique PostgreSQL schema.\n * Each test suite gets its own schema so tests can run in parallel safely.\n *\n * Requires `DATABASE_TEST_URL` env var pointing to a running PostgreSQL instance.\n * Falls back to `DATABASE_URL` if test URL is not set.\n *\n * Usage:\n * ```ts\n * const testDb = await createTestDb()\n * await testDb.push({ articles: articlesTable, entity_refs: entityRefsTable })\n * // ... run tests with testDb.db ...\n * await testDb.cleanup()\n * ```\n */\nexport async function createTestDb(): Promise<TestDb> {\n const url = process.env.DATABASE_TEST_URL || process.env.DATABASE_URL\n if (!url) {\n throw new Error('DATABASE_TEST_URL (or DATABASE_URL) env var is required for integration tests')\n }\n\n const schemaName = `test_${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`\n\n const connection = postgres(url, {\n max: 3,\n idle_timeout: 10,\n })\n\n const db = drizzle(connection)\n\n // Create isolated schema\n await db.execute(sql.raw(`CREATE SCHEMA \"${schemaName}\"`))\n await db.execute(sql.raw(`SET search_path TO \"${schemaName}\", public`))\n\n return {\n db,\n schema: schemaName,\n\n async push(schemas: Record<string, unknown>) {\n // Use createRequire to load drizzle-kit/api via Node's CJS resolver.\n // drizzle-kit/api.mjs bundles CJS code with require('fs') — Vite's ESM\n // transform replaces require() with a polyfill that throws. Native CJS bypasses this.\n const { createRequire } = await import('node:module')\n const require = createRequire(import.meta.url)\n const { pushSchema } = require('drizzle-kit/api') as typeof import('drizzle-kit/api')\n\n // pushSchema internally does: res = drizzleInstance.execute(sql.raw(query)); return res.rows\n // postgres-js returns rows as a plain array — no .rows property. Patch execute to add it.\n const patchedDb = new Proxy(db, {\n get(target, prop, receiver) {\n if (prop === 'execute') {\n return async (...args: unknown[]) => {\n const result = await (target.execute as (...a: unknown[]) => Promise<unknown[]>)(\n ...args,\n )\n if (Array.isArray(result) && !('rows' in result)) {\n Object.defineProperty(result, 'rows', { value: [...result], enumerable: false })\n }\n return result\n }\n }\n return Reflect.get(target, prop, receiver)\n },\n })\n\n // Use default schemaFilters ([\"public\"]). Our tables are unqualified (= public),\n // and SET search_path ensures DDL actually creates them in the test schema.\n const result = await pushSchema(schemas, patchedDb as typeof db)\n await result.apply()\n },\n\n async cleanup() {\n await db.execute(sql.raw(`DROP SCHEMA IF EXISTS \"${schemaName}\" CASCADE`))\n await connection.end()\n },\n }\n}\n"],"mappings":"4GA2BA,eAAsB,GAAgC,CACpD,IAAM,EAAM,QAAQ,IAAI,mBAAqB,QAAQ,IAAI,aACzD,GAAI,CAAC,EACH,MAAU,MAAM,gFAAgF,CAGlG,IAAM,EAAa,QAAQ,OAAO,YAAY,CAAC,WAAW,IAAK,GAAG,CAAC,MAAM,EAAG,GAAG,GAEzE,EAAa,EAAS,EAAK,CAC/B,IAAK,EACL,aAAc,GACf,CAAC,CAEI,EAAK,EAAQ,EAAW,CAM9B,OAHA,MAAM,EAAG,QAAQ,EAAI,IAAI,kBAAkB,EAAW,GAAG,CAAC,CAC1D,MAAM,EAAG,QAAQ,EAAI,IAAI,uBAAuB,EAAW,WAAW,CAAC,CAEhE,CACL,KACA,OAAQ,EAER,MAAM,KAAK,EAAkC,CAI3C,GAAM,CAAE,iBAAkB,MAAM,OAAO,eAEjC,CAAE,cADQ,EAAc,OAAO,KAAK,IAAI,CACf,kBAAkB,CAwBjD,MADe,MAAM,EAAW,EAnBd,IAAI,MAAM,EAAI,CAC9B,IAAI,EAAQ,EAAM,EAAU,CAY1B,OAXI,IAAS,UACJ,MAAO,GAAG,IAAoB,CACnC,IAAM,EAAS,MAAO,EAAO,QAC3B,GAAG,EACJ,CAID,OAHI,MAAM,QAAQ,EAAO,EAAI,EAAE,SAAU,IACvC,OAAO,eAAe,EAAQ,OAAQ,CAAE,MAAO,CAAC,GAAG,EAAO,CAAE,WAAY,GAAO,CAAC,CAE3E,GAGJ,QAAQ,IAAI,EAAQ,EAAM,EAAS,EAE7C,CAAC,CAI8D,EACnD,OAAO,EAGtB,MAAM,SAAU,CACd,MAAM,EAAG,QAAQ,EAAI,IAAI,0BAA0B,EAAW,WAAW,CAAC,CAC1E,MAAM,EAAW,KAAK,EAEzB"}
|
|
1
|
+
{"version":3,"file":"test-utils.mjs","names":[],"sources":["../src/test-utils.ts"],"sourcesContent":["import { sql } from 'drizzle-orm'\nimport { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport postgres from 'postgres'\n\nexport interface TestDb {\n db: PostgresJsDatabase\n schema: string\n /** Push Drizzle table definitions to the test schema (uses drizzle-kit/api) */\n push: (schemas: Record<string, unknown>) => Promise<void>\n cleanup: () => Promise<void>\n}\n\n/**\n * Create an isolated test database using a unique PostgreSQL schema.\n * Each test suite gets its own schema so tests can run in parallel safely.\n *\n * Requires `DATABASE_TEST_URL` env var pointing to a running PostgreSQL instance.\n * Falls back to `DATABASE_URL` if test URL is not set.\n *\n * Usage:\n * ```ts\n * const testDb = await createTestDb()\n * await testDb.push({ articles: articlesTable, entity_refs: entityRefsTable })\n * // ... run tests with testDb.db ...\n * await testDb.cleanup()\n * ```\n */\nexport async function createTestDb(): Promise<TestDb> {\n // Refuse to ever provision a scratch schema in production. `createTestDb`\n // issues `CREATE SCHEMA` and (via `cleanup()`) `DROP SCHEMA … CASCADE`,\n // which would be catastrophic if pointed at a real database. The whole\n // function is test-only, but the npm subpath export means an app could\n // import it by accident — fail loud instead of running.\n if (process.env.NODE_ENV === 'production') {\n throw new Error(\n 'createTestDb() is not allowed when NODE_ENV=production — it issues CREATE/DROP SCHEMA and is intended for tests only',\n )\n }\n\n const url = process.env.DATABASE_TEST_URL || process.env.DATABASE_URL\n if (!url) {\n throw new Error('DATABASE_TEST_URL (or DATABASE_URL) env var is required for integration tests')\n }\n\n const schemaName = `test_${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`\n\n // First connection: create the schema. We can't pass `search_path` as a\n // connection startup parameter for this client because the schema doesn't\n // exist yet — postgres rejects connections asking to enter a missing\n // schema with `invalid value for parameter \"search_path\"`.\n const setupConnection = postgres(url, { max: 1 })\n try {\n await setupConnection.unsafe(`CREATE SCHEMA \"${schemaName}\"`)\n } finally {\n // Always close the setup connection — even on CREATE SCHEMA failure\n // (rare with a UUID-derived name, but possible if the role lacks\n // permission). Without this, a thrown error would leak a postgres\n // connection until the process exits.\n await setupConnection.end()\n }\n\n // Working connection pool: every connection in the pool starts with\n // `search_path` pointing at the test schema. Without this, a second\n // pooled connection would fall back to the default search_path and miss\n // the test tables — which is exactly the bug that surfaced when concurrent\n // queries (e.g. tryClaim's race test) bypassed connection 1.\n const connection = postgres(url, {\n max: 3,\n idle_timeout: 10,\n connection: {\n search_path: `\"${schemaName}\",public`,\n },\n })\n\n const db = drizzle(connection)\n\n return {\n db,\n schema: schemaName,\n\n async push(schemas: Record<string, unknown>) {\n // Use createRequire to load drizzle-kit/api via Node's CJS resolver.\n // drizzle-kit/api.mjs bundles CJS code with require('fs') — Vite's ESM\n // transform replaces require() with a polyfill that throws. Native CJS bypasses this.\n const { createRequire } = await import('node:module')\n const require = createRequire(import.meta.url)\n const { pushSchema } = require('drizzle-kit/api') as typeof import('drizzle-kit/api')\n\n // Use default schemaFilters ([\"public\"]). Our tables are unqualified (= public),\n // and SET search_path ensures DDL actually creates them in the test schema.\n const result = await pushSchema(schemas, withRowsShim(db))\n await result.apply()\n },\n\n async cleanup() {\n await db.execute(sql.raw(`DROP SCHEMA IF EXISTS \"${schemaName}\" CASCADE`))\n await connection.end()\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// In-memory TableClient shim — for unit tests that exercise consumer code\n// (LockService, ContentClient draft methods, etc.) without spinning up\n// Postgres. Implements the subset of TableClient that consumers actually\n// use; the matcher understands the same WhereClause shape (eq shorthand,\n// lt/gte/in/notIn operators, $or branches, jsonb-path keys are NOT\n// supported — use the real DB for those).\n//\n// Each consumer test previously hand-rolled its own ~100-LoC version of\n// this; collapsed into one place per audit-quality-content L#12.\n// ---------------------------------------------------------------------------\n\n/** Operators recognized by the in-memory matcher. Mirrors WhereClause. */\ntype MemOp =\n | { eq: unknown }\n | { ne: unknown }\n | { in: unknown[] }\n | { notIn: unknown[] }\n | { lt: unknown }\n | { lte: unknown }\n | { gt: unknown }\n | { gte: unknown }\n | { isNull: true }\n | { isNotNull: true }\n\nfunction matchesOp(rowVal: unknown, op: MemOp): boolean {\n if ('eq' in op) return rowVal === op.eq\n if ('ne' in op) return rowVal !== op.ne\n if ('in' in op) return op.in.includes(rowVal)\n if ('notIn' in op) return !op.notIn.includes(rowVal)\n if ('lt' in op) return (rowVal as Date | number) < (op.lt as Date | number)\n if ('lte' in op) return (rowVal as Date | number) <= (op.lte as Date | number)\n if ('gt' in op) return (rowVal as Date | number) > (op.gt as Date | number)\n if ('gte' in op) return (rowVal as Date | number) >= (op.gte as Date | number)\n if ('isNull' in op) return rowVal === null || rowVal === undefined\n if ('isNotNull' in op) return rowVal !== null && rowVal !== undefined\n return false\n}\n\n/** Recursive matcher: handles top-level keys, $or, value shorthand, and operator objects. */\nfunction matchesRow(row: Record<string, unknown>, where: Record<string, unknown>): boolean {\n for (const [key, val] of Object.entries(where)) {\n if (key === '$or') {\n const branches = val as Record<string, unknown>[]\n if (!branches.some((b) => matchesRow(row, b))) return false\n continue\n }\n if (key === '$and') {\n const branches = val as Record<string, unknown>[]\n if (!branches.every((b) => matchesRow(row, b))) return false\n continue\n }\n const rowVal = row[key]\n if (val === null) {\n if (rowVal !== null && rowVal !== undefined) return false\n continue\n }\n if (val !== null && typeof val === 'object' && !(val instanceof Date)) {\n if (!matchesOp(rowVal, val as MemOp)) return false\n continue\n }\n if (rowVal !== val) return false\n }\n return true\n}\n\n/** OrderBy spec mirrors TableClient's `OrderBySpec`. */\nexport interface InMemoryOrderBy {\n column: string\n dir?: 'asc' | 'desc'\n}\n\n/** Subset of TableClient methods used by tests. */\nexport interface InMemoryTableClient<Row extends Record<string, unknown>> {\n rows: Row[] // direct mutation for test seeding / inspection\n reset(initial?: Row[]): void\n findOne(where: Record<string, unknown>): Promise<Row | null>\n findMany(opts?: {\n where?: Record<string, unknown>\n orderBy?: InMemoryOrderBy[]\n limit?: number\n offset?: number\n }): Promise<Row[]>\n exists(where: Record<string, unknown>): Promise<boolean>\n count(where?: Record<string, unknown>): Promise<number>\n insert(values: Partial<Row>): Promise<Row>\n update(where: Record<string, unknown>, patch: Partial<Row>): Promise<Row | null>\n upsert(\n values: Partial<Row>,\n opts: { target: string[] | string; set?: Partial<Row> },\n ): Promise<Row>\n delete(where: Record<string, unknown>): Promise<Row | null>\n deleteMany(where: Record<string, unknown>): Promise<Row[]>\n tryClaim(\n values: Partial<Row>,\n opts: {\n target: string | string[]\n replaceWhen: Record<string, unknown>\n set?: Partial<Row>\n },\n ): Promise<{ acquired: true; row: Row } | { acquired: false; row: Row }>\n aggregate?: (opts: {\n select: Record<string, { fn: 'max' | 'min' | 'count' | 'sum' | 'avg'; column?: string }>\n where?: Record<string, unknown>\n }) => Promise<Array<Record<string, unknown>>>\n}\n\n/**\n * Build an in-memory TableClient stand-in for unit tests. Pass\n * `idGenerator` if your test wants deterministic synthetic IDs\n * (defaults to a monotonic `row-1`, `row-2`, ... counter).\n *\n * Example:\n * ```ts\n * const drafts = createInMemoryTableClient<DraftRow>({ idField: 'id' })\n * vi.mock('@murumets-ee/content/schema', () => ({\n * getContentTables: () => ({ drafts: { makeClient: () => drafts } })\n * }))\n * ```\n */\nexport function createInMemoryTableClient<Row extends Record<string, unknown>>(\n options: { idField?: keyof Row & string; idGenerator?: () => string; initial?: Row[] } = {},\n): InMemoryTableClient<Row> {\n const idField = (options.idField ?? 'id') as keyof Row & string\n let nextId = 1\n const idGen = options.idGenerator ?? (() => `row-${nextId++}`)\n\n const state: { rows: Row[] } = { rows: options.initial ? [...options.initial] : [] }\n\n const client: InMemoryTableClient<Row> = {\n get rows() {\n return state.rows\n },\n set rows(next: Row[]) {\n state.rows = next\n },\n\n reset(initial?: Row[]) {\n state.rows = initial ? [...initial] : []\n nextId = 1\n },\n\n async findOne(where) {\n return state.rows.find((r) => matchesRow(r, where)) ?? null\n },\n\n async findMany(opts) {\n let out = opts?.where\n ? state.rows.filter((r) => matchesRow(r, opts.where as Record<string, unknown>))\n : [...state.rows]\n if (opts?.orderBy && opts.orderBy.length > 0) {\n out = [...out].sort((a, b) => {\n for (const spec of opts.orderBy ?? []) {\n const av = a[spec.column]\n const bv = b[spec.column]\n const dir = spec.dir === 'desc' ? -1 : 1\n // Date and number comparisons via subtraction; string via localeCompare.\n if (av instanceof Date && bv instanceof Date) {\n const d = (av.getTime() - bv.getTime()) * dir\n if (d !== 0) return d\n } else if (typeof av === 'number' && typeof bv === 'number') {\n const d = (av - bv) * dir\n if (d !== 0) return d\n } else if (typeof av === 'string' && typeof bv === 'string') {\n const d = av.localeCompare(bv) * dir\n if (d !== 0) return d\n } else {\n // Mixed / nullable: fall through to next spec.\n }\n }\n return 0\n })\n }\n const offset = opts?.offset ?? 0\n const limit = opts?.limit\n if (offset > 0) out = out.slice(offset)\n if (limit !== undefined && limit >= 0) out = out.slice(0, limit)\n return out\n },\n\n async aggregate(opts) {\n const subset = opts?.where\n ? state.rows.filter((r) => matchesRow(r, opts.where as Record<string, unknown>))\n : state.rows\n const result: Record<string, unknown> = {}\n for (const [alias, spec] of Object.entries(opts.select)) {\n if (spec.fn === 'count') {\n result[alias] = subset.length\n } else if (spec.fn === 'max' && spec.column) {\n let max: number | null = null\n for (const r of subset) {\n const v = r[spec.column]\n if (typeof v === 'number') {\n if (max === null || v > max) max = v\n }\n }\n result[alias] = max\n } else if (spec.fn === 'min' && spec.column) {\n let min: number | null = null\n for (const r of subset) {\n const v = r[spec.column]\n if (typeof v === 'number') {\n if (min === null || v < min) min = v\n }\n }\n result[alias] = min\n } else if (spec.fn === 'sum' && spec.column) {\n let sum = 0\n for (const r of subset) {\n const v = r[spec.column]\n if (typeof v === 'number') sum += v\n }\n result[alias] = sum\n }\n }\n return [result]\n },\n\n async exists(where) {\n return state.rows.some((r) => matchesRow(r, where))\n },\n\n async count(where) {\n if (!where) return state.rows.length\n return state.rows.filter((r) => matchesRow(r, where)).length\n },\n\n async insert(values) {\n const row = { [idField]: idGen(), ...values } as Row\n state.rows.push(row)\n return row\n },\n\n async update(where, patch) {\n const matched = state.rows.filter((r) => matchesRow(r, where))\n if (matched.length === 0) return null\n // TableClient.update expects at-most-one match\n const target = matched[0] as Row\n Object.assign(target, patch)\n return target\n },\n\n async upsert(values, opts) {\n const targetKeys = Array.isArray(opts.target) ? opts.target : [opts.target]\n const targetWhere: Record<string, unknown> = {}\n for (const k of targetKeys) targetWhere[k] = (values as Record<string, unknown>)[k]\n const existing = state.rows.find((r) => matchesRow(r, targetWhere))\n if (existing) {\n Object.assign(existing, opts.set ?? values)\n return existing\n }\n const row = { [idField]: idGen(), ...values } as Row\n state.rows.push(row)\n return row\n },\n\n async delete(where) {\n const idx = state.rows.findIndex((r) => matchesRow(r, where))\n if (idx === -1) return null\n const [removed] = state.rows.splice(idx, 1)\n return (removed ?? null) as Row | null\n },\n\n async deleteMany(where) {\n const matched = state.rows.filter((r) => matchesRow(r, where))\n state.rows = state.rows.filter((r) => !matched.includes(r))\n return matched\n },\n\n async tryClaim(values, opts) {\n const targetKeys = Array.isArray(opts.target) ? opts.target : [opts.target]\n const targetWhere: Record<string, unknown> = {}\n for (const k of targetKeys) targetWhere[k] = (values as Record<string, unknown>)[k]\n const existing = state.rows.find((r) => matchesRow(r, targetWhere))\n\n if (!existing) {\n const row = { [idField]: idGen(), ...values } as Row\n state.rows.push(row)\n return { acquired: true, row }\n }\n\n if (matchesRow(existing, opts.replaceWhen)) {\n Object.assign(existing, opts.set ?? values)\n return { acquired: true, row: existing }\n }\n\n return { acquired: false, row: existing }\n },\n }\n\n return client\n}\n\n/**\n * Wrap a Drizzle handle so `.execute()` returns an array that *also* exposes\n * a `.rows` property. `drizzle-kit/api`'s `pushSchema` reads `res.rows`, but\n * postgres-js (the driver Drizzle is built on here) returns plain arrays\n * with no `.rows`. The shim is local to test-utils — production code never\n * needs it.\n */\nfunction withRowsShim(db: PostgresJsDatabase): PostgresJsDatabase {\n return new Proxy(db, {\n get(target, prop, receiver) {\n if (prop === 'execute') {\n return async (...args: unknown[]) => {\n const result = await (target.execute as (...a: unknown[]) => Promise<unknown[]>)(...args)\n if (Array.isArray(result) && !('rows' in result)) {\n Object.defineProperty(result, 'rows', { value: [...result], enumerable: false })\n }\n return result\n }\n }\n return Reflect.get(target, prop, receiver)\n },\n })\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"typescript": "^5.7.2",
|
|
27
27
|
"vitest": "^2.1.8"
|
|
28
28
|
},
|
|
29
|
+
"typeCoverage": {
|
|
30
|
+
"atLeast": 99.98
|
|
31
|
+
},
|
|
29
32
|
"scripts": {
|
|
30
33
|
"build": "tsdown",
|
|
31
34
|
"dev": "tsdown --watch",
|