@@ -68,24 +68,14 @@ export function createMockSqlOperators() {
6868 * `leftJoin`) references that table. Each chain consumes at most one queued
6969 * set (FIFO per table, `.from()` table checked before join tables); chains
7070 * against tables with no queued sets resolve the chain-fn defaults (empty
71- * array). Queues are cleared by `resetDbChainMock()`.
71+ * array). Mutation chains (`update`/`delete`/`insert`) never consume select
72+ * queues. Queues are cleared by `resetDbChainMock()`.
7273 *
7374 * The queue is keyed by table object identity, so pass the same schema-mock
7475 * table object the code under test passes to `.from()` / the join.
75- *
76- * Routing assumes each select chain is built left-to-right before the next
77- * chain starts — the norm for code under test (`Promise.all` over fully-built
78- * chains is fine; interleaving *construction* of two chains is not). Mutation
79- * chains (`update`/`delete`/`insert`) never consume select queues.
8076 */
8177const tableRowQueues = new Map < unknown , unknown [ ] [ ] > ( )
8278
83- /** Tables of the select chain currently being built: `.from()` first, then joins. */
84- let activeTables : unknown [ ] = [ ]
85-
86- /** Rows dequeued for the current chain, shared by every downstream terminal. */
87- let activeRows : unknown [ ] | null = null
88-
8979/**
9080 * Enqueues one result set for the next select chain reading `table`.
9181 */
@@ -107,9 +97,9 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
10797/**
10898 * Pre-wired chain of vi.fn()s for drizzle-style DB queries.
10999 *
110- * Each builder step is a stable, module-level `vi.fn()` — safe to reference
111- * inside hoisted `vi.mock()` factories (same pattern as `authMockFns`). Chains
112- * are wired at module load time :
100+ * Each chain step is recorded on a stable, module-level `vi.fn()` spy
101+ * (`dbChainMockFns.*`) — safe to reference inside hoisted `vi.mock()`
102+ * factories (same pattern as `authMockFns`) :
113103 *
114104 * - `select().from().where()` → returns a builder with `.limit` / `.orderBy` /
115105 * `.returning` / `.groupBy` / `.for` terminals
@@ -118,9 +108,16 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
118108 *
119109 * Results resolve, in priority order:
120110 * 1. a per-test override (`dbChainMockFns.limit.mockResolvedValueOnce([...])`)
121- * 2. rows queued for the chain's `.from()` table via `queueTableRows`
111+ * 2. rows queued for one of the chain's tables via `queueTableRows`
122112 * 3. the default empty array
123113 *
114+ * Routing state lives in per-chain closures: each `select().from(t)` captures
115+ * its own table list, so partially-built chains for different tables can be
116+ * interleaved or awaited in any order without cross-talk. The shared spies
117+ * carry only call history and per-test overrides — a spy's default
118+ * implementation returns a sentinel that the chain replaces with the
119+ * chain-local builder, while any `mock*` override on the spy wins verbatim.
120+ *
124121 * `for` mirrors drizzle's `.for('update')` — it returns a Promise with
125122 * `.limit` / `.orderBy` / `.returning` / `.groupBy` attached, so both
126123 * `await .where().for('update')` (terminal) and
@@ -132,8 +129,7 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
132129 *
133130 * @example
134131 * ```ts
135- * import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
136- * import { schemaMock } from '@sim/testing'
132+ * import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
137133 *
138134 * beforeEach(() => {
139135 * vi.clearAllMocks()
@@ -147,104 +143,95 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
147143 * })
148144 * ```
149145 */
150- const chainRows = ( ) => Promise . resolve ( ( activeRows ?? [ ] ) as unknown [ ] )
146+ const CHAIN_DEFAULT = Symbol ( 'db-chain-default' )
151147
152- const offset = vi . fn ( chainRows )
153- // `.limit()` returns a builder that is awaitable and also exposes `.offset()`
154- // for keyset/OFFSET paging (`.limit(n).offset(m)`).
155- const limitBuilder = ( ) => {
156- const thenable : any = chainRows ( )
157- thenable . offset = offset
158- return thenable
159- }
160- const limit = vi . fn ( limitBuilder )
161- const returning = vi . fn ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
162- const execute = vi . fn ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
148+ type ChainSpy = ReturnType < typeof vi . fn < ( ...args : any [ ] ) => any > >
163149
164- const terminalBuilder = ( ) => {
165- const thenable : any = chainRows ( )
166- thenable . limit = limit
167- thenable . orderBy = orderBy
168- thenable . returning = returning
169- thenable . groupBy = groupBy
170- thenable . for = forClause
171- return thenable
172- }
150+ const chainSpy = ( ) : ChainSpy => vi . fn ( ( ..._args : any [ ] ) => CHAIN_DEFAULT as any )
173151
174- const orderBy = vi . fn ( terminalBuilder )
175- const having = vi . fn ( terminalBuilder )
176- const groupBy = vi . fn ( ( ) => {
177- const builder = terminalBuilder ( )
178- builder . having = having
179- return builder
180- } )
181- const forBuilder = terminalBuilder
182- const forClause = vi . fn ( forBuilder )
152+ /**
153+ * Records the call on the shared spy, honoring any per-test override; when the
154+ * spy still has its default implementation, builds the chain-local default.
155+ */
156+ const spyOrDefault = ( spy : ChainSpy , buildDefault : ( ... args : any [ ] ) => unknown ) =>
157+ vi . fn ( ( ... args : any [ ] ) => {
158+ const result = spy ( ... args )
159+ return result === CHAIN_DEFAULT ? buildDefault ( ... args ) : result
160+ } )
183161
162+ // Shared spies: structural steps default to the sentinel (chain-local builders
163+ // take over); value terminals keep real defaults.
164+ const select = chainSpy ( )
165+ const selectDistinct = chainSpy ( )
166+ const selectDistinctOn = chainSpy ( )
167+ const from = chainSpy ( )
168+ const where = chainSpy ( )
169+ const limit = chainSpy ( )
170+ const offset = chainSpy ( )
171+ const orderBy = chainSpy ( )
172+ const groupBy = chainSpy ( )
173+ const having = chainSpy ( )
174+ const forClause = chainSpy ( )
175+ const innerJoin = chainSpy ( )
176+ const leftJoin = chainSpy ( )
177+ const insert = chainSpy ( )
178+ const update = chainSpy ( )
179+ const set = chainSpy ( )
180+ const del = chainSpy ( )
181+ const returning = vi . fn ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
182+ const execute = vi . fn ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
183+ const query = vi . fn ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
184184const onConflictDoUpdate = vi . fn ( ( ) => ( { returning } ) as unknown as Promise < void > )
185185const onConflictDoNothing = vi . fn ( ( ) => ( { returning } ) as unknown as Promise < void > )
186+ const values = vi . fn ( ( ) => ( { returning, onConflictDoUpdate, onConflictDoNothing } ) )
187+ const transaction : ReturnType < typeof vi . fn > = vi . fn (
188+ async ( cb : ( tx : any ) => unknown ) : Promise < unknown > => cb ( dbChainMock . db )
189+ )
190+
191+ const rowsPromise = ( rows : unknown [ ] | null ) => Promise . resolve ( ( rows ?? [ ] ) as unknown [ ] )
192+
193+ // `.limit()` returns a builder that is awaitable and also exposes `.offset()`
194+ // for keyset/OFFSET paging (`.limit(n).offset(m)`).
195+ const limitBuilder = ( rows : unknown [ ] | null ) => {
196+ const thenable : any = rowsPromise ( rows )
197+ thenable . offset = spyOrDefault ( offset , ( ) => rowsPromise ( rows ) )
198+ return thenable
199+ }
186200
187- const whereBuilder = ( ) => {
188- // Dequeue table-routed rows when the where clause materializes; every
189- // downstream terminal (limit/orderBy/...) then resolves the same rows.
190- activeRows = dequeueChainRows ( activeTables )
191- // Some call sites await the where directly (no limit/orderBy), so the
192- // builder is itself a thenable.
193- const thenable : any = chainRows ( )
194- thenable . limit = limit
195- thenable . orderBy = orderBy
201+ const terminalBuilder = ( rows : unknown [ ] | null ) : any => {
202+ const thenable : any = rowsPromise ( rows )
203+ thenable . limit = spyOrDefault ( limit , ( ) => limitBuilder ( rows ) )
204+ thenable . orderBy = spyOrDefault ( orderBy , ( ) => terminalBuilder ( rows ) )
196205 thenable . returning = returning
197- thenable . groupBy = groupBy
198- thenable . for = forClause
206+ thenable . groupBy = spyOrDefault ( groupBy , ( ) => {
207+ const builder = terminalBuilder ( rows )
208+ builder . having = spyOrDefault ( having , ( ) => terminalBuilder ( rows ) )
209+ return builder
210+ } )
211+ thenable . for = spyOrDefault ( forClause , ( ) => terminalBuilder ( rows ) )
199212 return thenable
200213}
201- const where = vi . fn ( whereBuilder )
202214
203215// The from/join builder is itself a thenable so `await db.select().from(t)`
204- // (no where clause) also resolves table-routed rows. Each builder closes over
205- // ITS chain's tables array, so builders constructed before an earlier one is
206- // awaited still route to their own chain. Dequeue happens lazily at await
207- // time, so a chain that continues into `.where()` never double-consumes.
208- const joinBuilder = (
209- tables : unknown [ ]
210- ) : { where : typeof where ; innerJoin : any ; leftJoin : any ; then : any } => ( {
211- where,
212- innerJoin,
213- leftJoin,
216+ // (no where clause) also resolves table-routed rows; dequeue happens lazily at
217+ // await (or where()) time, so a chain never double-consumes.
218+ const joinBuilder = ( tables : unknown [ ] ) : any => ( {
219+ where : spyOrDefault ( where , ( ) => terminalBuilder ( dequeueChainRows ( tables ) ) ) ,
220+ innerJoin : spyOrDefault ( innerJoin , ( table : unknown ) => joinBuilder ( [ ...tables , table ] ) ) ,
221+ leftJoin : spyOrDefault ( leftJoin , ( table : unknown ) => joinBuilder ( [ ...tables , table ] ) ) ,
214222 then : ( onFulfilled ?: ( rows : unknown [ ] ) => unknown , onRejected ?: ( reason : unknown ) => unknown ) =>
215- Promise . resolve ( ( dequeueChainRows ( tables ) ?? [ ] ) as unknown [ ] ) . then ( onFulfilled , onRejected ) ,
223+ rowsPromise ( dequeueChainRows ( tables ) ) . then ( onFulfilled , onRejected ) ,
216224} )
217- const joinStep = ( table ?: unknown ) => {
218- activeTables . push ( table )
219- return joinBuilder ( activeTables )
220- }
221- const innerJoin : ReturnType < typeof vi . fn > = vi . fn ( joinStep )
222- const leftJoin : ReturnType < typeof vi . fn > = vi . fn ( joinStep )
223- const from = vi . fn ( ( table ?: unknown ) => {
224- activeTables = [ table ]
225- activeRows = null
226- return joinBuilder ( activeTables )
225+
226+ const selectBuilder = ( ) => ( {
227+ from : spyOrDefault ( from , ( table : unknown ) => joinBuilder ( [ table ] ) ) ,
227228} )
228229
229- const select = vi . fn ( ( ) => ( { from } ) )
230- const selectDistinct = vi . fn ( ( ) => ( { from } ) )
231- const selectDistinctOn = vi . fn ( ( ) => ( { from } ) )
232- const values = vi . fn ( ( ) => ( { returning, onConflictDoUpdate, onConflictDoNothing } ) )
233- const insert = vi . fn ( ( ) => ( { values } ) )
234- // Mutation chains clear the routing context so their `where()` never consumes
235- // rows queued for a select.
236- const mutationStep = < T > ( next : T ) : T => {
237- activeTables = [ ]
238- activeRows = null
239- return next
240- }
241- const set = vi . fn ( ( ) => mutationStep ( { where } ) )
242- const update = vi . fn ( ( ) => mutationStep ( { set } ) )
243- const del = vi . fn ( ( ) => mutationStep ( { where } ) )
244- const query = vi . fn ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
245- const transaction : ReturnType < typeof vi . fn > = vi . fn (
246- async ( cb : ( tx : any ) => unknown ) : Promise < unknown > => cb ( dbChainMock . db )
247- )
230+ // Mutation chains route nothing: their where() resolves the plain default so a
231+ // mutation can never consume rows queued for a select.
232+ const mutationWhere = ( ) => ( {
233+ where : spyOrDefault ( where , ( ) => terminalBuilder ( null ) ) ,
234+ } )
248235
249236export const dbChainMockFns = {
250237 select,
@@ -273,8 +260,8 @@ export const dbChainMockFns = {
273260}
274261
275262/**
276- * Re-applies the default chain wiring to every `dbChainMockFns` entry and
277- * clears all table-routed row queues. Call this in `beforeEach` (after
263+ * Restores every `dbChainMockFns` entry to its default wiring and clears all
264+ * table-routed row queues. Call this in `beforeEach` (after
278265 * `vi.clearAllMocks()`) if any test uses `mockReturnValue` /
279266 * `mockResolvedValue` (permanent overrides) or `queueTableRows` — this
280267 * guarantees the next test starts with fresh defaults.
@@ -284,57 +271,51 @@ export const dbChainMockFns = {
284271 */
285272export function resetDbChainMock ( ) : void {
286273 tableRowQueues . clear ( )
287- activeTables = [ ]
288- activeRows = null
289- select . mockImplementation ( ( ) => ( { from } ) )
290- selectDistinct . mockImplementation ( ( ) => ( { from } ) )
291- selectDistinctOn . mockImplementation ( ( ) => ( { from } ) )
292- from . mockImplementation ( ( table ?: unknown ) => {
293- activeTables = [ table ]
294- activeRows = null
295- return joinBuilder ( activeTables )
296- } )
297- innerJoin . mockImplementation ( joinStep )
298- leftJoin . mockImplementation ( joinStep )
299- where . mockImplementation ( whereBuilder )
300- insert . mockImplementation ( ( ) => ( { values } ) )
301- values . mockImplementation ( ( ) => ( { returning, onConflictDoUpdate, onConflictDoNothing } ) )
302- onConflictDoUpdate . mockImplementation ( ( ) => ( { returning } ) as unknown as Promise < void > )
303- onConflictDoNothing . mockImplementation ( ( ) => ( { returning } ) as unknown as Promise < void > )
304- update . mockImplementation ( ( ) => mutationStep ( { set } ) )
305- set . mockImplementation ( ( ) => mutationStep ( { where } ) )
306- del . mockImplementation ( ( ) => mutationStep ( { where } ) )
307- limit . mockImplementation ( limitBuilder )
308- offset . mockImplementation ( chainRows )
309- orderBy . mockImplementation ( terminalBuilder )
274+ for ( const spy of [
275+ select ,
276+ selectDistinct ,
277+ selectDistinctOn ,
278+ from ,
279+ where ,
280+ limit ,
281+ offset ,
282+ orderBy ,
283+ groupBy ,
284+ having ,
285+ forClause ,
286+ innerJoin ,
287+ leftJoin ,
288+ insert ,
289+ update ,
290+ set ,
291+ del ,
292+ ] ) {
293+ spy . mockImplementation ( ( ) => CHAIN_DEFAULT )
294+ }
310295 returning . mockImplementation ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
311- having . mockImplementation ( terminalBuilder )
312- groupBy . mockImplementation ( ( ) => {
313- const builder = terminalBuilder ( )
314- builder . having = having
315- return builder
316- } )
317296 execute . mockImplementation ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
318297 query . mockImplementation ( ( ) => Promise . resolve ( [ ] as unknown [ ] ) )
319- forClause . mockImplementation ( forBuilder )
298+ onConflictDoUpdate . mockImplementation ( ( ) => ( { returning } ) as unknown as Promise < void > )
299+ onConflictDoNothing . mockImplementation ( ( ) => ( { returning } ) as unknown as Promise < void > )
300+ values . mockImplementation ( ( ) => ( { returning, onConflictDoUpdate, onConflictDoNothing } ) )
320301 transaction . mockImplementation ( async ( cb : ( tx : typeof dbChainMock . db ) => unknown ) =>
321302 cb ( dbChainMock . db )
322303 )
323304}
324305
325306/**
326307 * The single shared `@sim/db` mock instance backing BOTH `dbChainMock` and
327- * `databaseMock`. Because every binding resolves to the same chain fns , a
308+ * `databaseMock`. Because every binding resolves to the same chain spies , a
328309 * module bound to either export behaves identically — there is exactly one
329310 * db-mock state to configure and reset.
330311 */
331312const dbInstance = {
332- select,
333- selectDistinct,
334- selectDistinctOn,
335- insert,
336- update,
337- delete : del ,
313+ select : spyOrDefault ( select , selectBuilder ) ,
314+ selectDistinct : spyOrDefault ( selectDistinct , selectBuilder ) ,
315+ selectDistinctOn : spyOrDefault ( selectDistinctOn , selectBuilder ) ,
316+ insert : spyOrDefault ( insert , ( ) => ( { values } ) ) ,
317+ update : spyOrDefault ( update , ( ) => ( { set : spyOrDefault ( set , mutationWhere ) } ) ) ,
318+ delete : spyOrDefault ( del , mutationWhere ) ,
338319 execute,
339320 query,
340321 transaction,
@@ -360,7 +341,7 @@ export const dbChainMock = {
360341
361342/**
362343 * Mock module for `@sim/db` installed globally in vitest.setup.ts. Shares its
363- * `db` instance (and therefore all chain fns and table queues) with
344+ * `db` instance (and therefore all chain spies and table queues) with
364345 * `dbChainMock`; additionally exposes the `sql` template tag and operator
365346 * exports the real module provides.
366347 *
0 commit comments