diff --git a/drizzle-orm/src/libsql/session.ts b/drizzle-orm/src/libsql/session.ts index b4c331068b..1d1c102657 100644 --- a/drizzle-orm/src/libsql/session.ts +++ b/drizzle-orm/src/libsql/session.ts @@ -285,7 +285,9 @@ export class LibSQLPreparedQuery { const stmt: InStatement = { sql: this.query.sql, args: params as InArgs }; - return (this.tx ? this.tx.execute(stmt) : this.client.execute(stmt)).then(({ rows }) => rows) as Promise< + return (this.tx ? this.tx.execute(stmt) : this.client.execute(stmt)).then(({ rows }) => { + return rows.map((row) => Array.prototype.slice.call(row)); + }) as Promise< T['values'] >; }); diff --git a/drizzle-orm/tests/libsql-cache-roundtrip.test.ts b/drizzle-orm/tests/libsql-cache-roundtrip.test.ts new file mode 100644 index 0000000000..c810dc679c --- /dev/null +++ b/drizzle-orm/tests/libsql-cache-roundtrip.test.ts @@ -0,0 +1,78 @@ +import type { Client, InStatement } from '@libsql/client'; +import { webcrypto } from 'node:crypto'; +import { expect, test, vi } from 'vitest'; + +import { Cache, type MutationOption } from '~/cache/core/index.ts'; +import { drizzle } from '~/libsql/index.ts'; +import { sqliteTable, text } from '~/sqlite-core/index.ts'; + +if (!globalThis.crypto) { + Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, + configurable: true, + }); +} + +// eslint-disable-next-line drizzle-internal/require-entity-kind +class JsonRoundTripCache extends Cache { + private data = new Map(); + + override strategy(): 'explicit' | 'all' { + return 'explicit'; + } + + override async get(key: string): Promise { + const stored = this.data.get(key); + return stored === undefined ? undefined : JSON.parse(stored); + } + + override async put(key: string, response: any): Promise { + this.data.set(key, JSON.stringify(response)); + } + + override async onMutate(_params: MutationOption): Promise {} +} + +function createArrayLikeLibSqlRow(payloadJson: string): Record { + const row: Record = { + payload: payloadJson, + }; + + Object.defineProperty(row, '0', { + value: payloadJson, + enumerable: false, + writable: false, + }); + Object.defineProperty(row, 'length', { + value: 1, + enumerable: false, + writable: false, + }); + + return row; +} + +test('libsql cached values survive JSON roundtrip', async () => { + const table = sqliteTable('cache_roundtrip_users', { + payload: text('payload', { mode: 'json' }).$type<{ a: number }>(), + }); + + const execute = vi.fn(async (_statement: InStatement) => { + return { + rows: [createArrayLikeLibSqlRow('{"a":1}')], + }; + }); + + const client = { + execute, + } as unknown as Client; + + const db = drizzle(client, { cache: new JsonRoundTripCache() }); + + const first = await db.select().from(table).$withCache(); + const second = await db.select().from(table).$withCache(); + + expect(first).toEqual([{ payload: { a: 1 } }]); + expect(second).toEqual(first); + expect(execute).toHaveBeenCalledTimes(1); +}); diff --git a/integration-tests/tests/sqlite/libsql.test.ts b/integration-tests/tests/sqlite/libsql.test.ts index 8d68496584..cbe3d260f8 100644 --- a/integration-tests/tests/sqlite/libsql.test.ts +++ b/integration-tests/tests/sqlite/libsql.test.ts @@ -1,7 +1,10 @@ import { type Client, createClient } from '@libsql/client'; import retry from 'async-retry'; +import type { MutationOption } from 'drizzle-orm/cache/core'; +import { Cache } from 'drizzle-orm/cache/core'; import { sql } from 'drizzle-orm'; import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql'; +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { migrate } from 'drizzle-orm/libsql/migrator'; import { afterAll, beforeAll, beforeEach, expect, test } from 'vitest'; import { skipTests } from '~/common'; @@ -14,8 +17,34 @@ const ENABLE_LOGGING = false; let db: LibSQLDatabase; let dbGlobalCached: LibSQLDatabase; let cachedDb: LibSQLDatabase; +let roundTripCachedDb: LibSQLDatabase; let client: Client; +// eslint-disable-next-line drizzle-internal/require-entity-kind +class JsonRoundTripCache extends Cache { + private data = new Map(); + + override strategy(): 'explicit' | 'all' { + return 'explicit'; + } + + override async get(key: string): Promise { + const stored = this.data.get(key); + return stored === undefined ? undefined : JSON.parse(stored); + } + + override async put(key: string, response: any): Promise { + this.data.set(key, JSON.stringify(response)); + } + + override async onMutate(_params: MutationOption): Promise {} +} + +const cacheRoundTripUsers = sqliteTable('cache_roundtrip_users', { + id: integer('id').primaryKey({ autoIncrement: true }), + payload: text('payload', { mode: 'json' }).$type<{ a: number }>().notNull(), +}); + beforeAll(async () => { const url = process.env['LIBSQL_URL']; const authToken = process.env['LIBSQL_AUTH_TOKEN']; @@ -38,6 +67,7 @@ beforeAll(async () => { db = drizzle(client, { logger: ENABLE_LOGGING }); cachedDb = drizzle(client, { logger: ENABLE_LOGGING, cache: new TestCache() }); dbGlobalCached = drizzle(client, { logger: ENABLE_LOGGING, cache: new TestGlobalCache() }); + roundTripCachedDb = drizzle(client, { logger: ENABLE_LOGGING, cache: new JsonRoundTripCache() }); }); afterAll(async () => { @@ -97,6 +127,30 @@ test('migrator : migrate with custom table', async () => { await db.run(sql`drop table ${sql.identifier(customTable)}`); }); +test('libsql cache hit should keep row values after JSON roundtrip', async () => { + await db.run(sql`drop table if exists cache_roundtrip_users`); + await db.run( + sql` + create table cache_roundtrip_users ( + id integer primary key AUTOINCREMENT, + payload text not null + ) + `, + ); + + await db.insert(cacheRoundTripUsers).values({ + payload: { a: 1 }, + }); + + const first = await roundTripCachedDb.select().from(cacheRoundTripUsers).$withCache(); + const second = await roundTripCachedDb.select().from(cacheRoundTripUsers).$withCache(); + + expect(first).toEqual([{ id: 1, payload: { a: 1 } }]); + expect(second).toEqual(first); + + await db.run(sql`drop table if exists cache_roundtrip_users`); +}); + skipTests([ 'delete with limit and order by', 'update with limit and order by',