diff --git a/.changeset/silly-lions-vanish.md b/.changeset/silly-lions-vanish.md new file mode 100644 index 000000000..a50920e25 --- /dev/null +++ b/.changeset/silly-lions-vanish.md @@ -0,0 +1,5 @@ +--- +'@powersync/drizzle-driver': patch +--- + +Initial Alpha version. diff --git a/packages/drizzle-driver/CHANGELOG.md b/packages/drizzle-driver/CHANGELOG.md new file mode 100644 index 000000000..39e359a7d --- /dev/null +++ b/packages/drizzle-driver/CHANGELOG.md @@ -0,0 +1 @@ +# @powersync/drizzle-driver diff --git a/packages/drizzle-driver/LICENSE b/packages/drizzle-driver/LICENSE new file mode 100644 index 000000000..c61b66391 --- /dev/null +++ b/packages/drizzle-driver/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/drizzle-driver/README.md b/packages/drizzle-driver/README.md new file mode 100644 index 000000000..6ab6f2c29 --- /dev/null +++ b/packages/drizzle-driver/README.md @@ -0,0 +1,80 @@ +# PowerSync Drizzle Driver + +This package (`@powersync/drizzle-driver`) brings the benefits of an ORM through our maintained [Drizzle](https://orm.drizzle.team/) driver to PowerSync. + +## Alpha Release + +The `drizzle-driver` package is currently in an Alpha release. + +## Getting Started + +Set up the PowerSync Database and wrap it with Drizzle. + +```js +import { wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver'; +import { PowerSyncDatabase } from '@powersync/web'; +import { relations } from 'drizzle-orm'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { appSchema } from './schema'; + +import { wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver'; + +export const lists = sqliteTable('lists', { + id: text('id'), + name: text('name') +}); + +export const todos = sqliteTable('todos', { + id: text('id'), + description: text('description'), + list_id: text('list_id'), + created_at: text('created_at') +}); + +export const listsRelations = relations(lists, ({ one, many }) => ({ + todos: many(todos) +})); + +export const todosRelations = relations(todos, ({ one, many }) => ({ + list: one(lists, { + fields: [todos.list_id], + references: [lists.id] + }) +})); + +export const drizzleSchema = { + lists, + todos, + listsRelations, + todosRelations +}; + +export const powerSyncDb = new PowerSyncDatabase({ + database: { + dbFilename: 'test.sqlite' + }, + schema: appSchema +}); + +export const db = wrapPowerSyncWithDrizzle(powerSyncDb, { + schema: drizzleSchema +}); +``` + +## Known limitations + +- The integration does not currently support nested transations (also known as `savepoints`). +- The Drizzle schema needs to be created manually, and it should match the table definitions of your PowerSync schema. + +### Compilable queries + +To use Drizzle queries in your hooks and composables, queries need to be converted using `toCompilableQuery`. + +```js +import { toCompilableQuery } from '@powersync/drizzle-driver'; + +const query = db.select().from(lists); +const { data: listRecords, isLoading } = useQuery(toCompilableQuery(query)); +``` + +For more information on how to use Drizzle queries in PowerSync, see [here](https://docs.powersync.com/client-sdk-references/javascript-web/javascript-orm/drizzle#usage-examples). diff --git a/packages/drizzle-driver/package.json b/packages/drizzle-driver/package.json new file mode 100644 index 000000000..83d4181dc --- /dev/null +++ b/packages/drizzle-driver/package.json @@ -0,0 +1,48 @@ +{ + "name": "@powersync/drizzle-driver", + "version": "0.0.0", + "description": "Drizzle driver for PowerSync", + "main": "lib/src/index.js", + "types": "lib/src/index.d.ts", + "author": "JOURNEYAPPS", + "license": "Apache-2.0", + "files": [ + "lib" + ], + "repository": "https://github.com/powersync-ja/powersync-js", + "bugs": { + "url": "https://github.com/powersync-ja/powersync-js/issues" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "homepage": "https://docs.powersync.com", + "scripts": { + "build": "tsc --build", + "build:prod": "tsc --build --sourceMap false", + "clean": "rm -rf lib tsconfig.tsbuildinfo", + "watch": "tsc --build -w", + "test": "vitest" + }, + "peerDependencies": { + "@powersync/common": "workspace:^1.19.0" + }, + "dependencies": { + "drizzle-orm": "0.35.2" + }, + "devDependencies": { + "@powersync/web": "workspace:*", + "@journeyapps/wa-sqlite": "^0.4.1", + "@types/node": "^20.17.6", + "@vitest/browser": "^2.1.4", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vite-plugin-top-level-await": "^1.4.4", + "vite-plugin-wasm": "^3.3.0", + "vitest": "^2.1.4", + "webdriverio": "^9.2.8" + } +} diff --git a/packages/drizzle-driver/src/index.ts b/packages/drizzle-driver/src/index.ts new file mode 100644 index 000000000..f2eed7324 --- /dev/null +++ b/packages/drizzle-driver/src/index.ts @@ -0,0 +1,4 @@ +import { wrapPowerSyncWithDrizzle } from './sqlite/db'; +import { toCompilableQuery } from './utils/compilableQuery'; + +export { wrapPowerSyncWithDrizzle, toCompilableQuery }; diff --git a/packages/drizzle-driver/src/sqlite/db.ts b/packages/drizzle-driver/src/sqlite/db.ts new file mode 100644 index 000000000..8b36570e6 --- /dev/null +++ b/packages/drizzle-driver/src/sqlite/db.ts @@ -0,0 +1,52 @@ +import { AbstractPowerSyncDatabase, QueryResult } from '@powersync/common'; +import { DefaultLogger } from 'drizzle-orm/logger'; +import { + createTableRelationsHelpers, + extractTablesRelationalConfig, + ExtractTablesWithRelations, + type RelationalSchemaConfig, + type TablesRelationalConfig +} from 'drizzle-orm/relations'; +import { SQLiteTransaction } from 'drizzle-orm/sqlite-core'; +import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core/db'; +import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core/dialect'; +import type { DrizzleConfig } from 'drizzle-orm/utils'; +import { PowerSyncSQLiteSession, PowerSyncSQLiteTransactionConfig } from './sqlite-session'; + +export interface PowerSyncSQLiteDatabase = Record> + extends BaseSQLiteDatabase<'async', QueryResult, TSchema> { + transaction( + transaction: ( + tx: SQLiteTransaction<'async', QueryResult, TSchema, ExtractTablesWithRelations> + ) => Promise, + config?: PowerSyncSQLiteTransactionConfig + ): Promise; +} + +export function wrapPowerSyncWithDrizzle = Record>( + db: AbstractPowerSyncDatabase, + config: DrizzleConfig = {} +): PowerSyncSQLiteDatabase { + const dialect = new SQLiteAsyncDialect(); + let logger; + if (config.logger === true) { + logger = new DefaultLogger(); + } else if (config.logger !== false) { + logger = config.logger; + } + + let schema: RelationalSchemaConfig | undefined; + if (config.schema) { + const tablesConfig = extractTablesRelationalConfig(config.schema, createTableRelationsHelpers); + schema = { + fullSchema: config.schema, + schema: tablesConfig.tables, + tableNamesMap: tablesConfig.tableNamesMap + }; + } + + const session = new PowerSyncSQLiteSession(db, dialect, schema, { + logger + }); + return new BaseSQLiteDatabase('async', dialect, session, schema) as PowerSyncSQLiteDatabase; +} diff --git a/packages/drizzle-driver/src/sqlite/sqlite-query.ts b/packages/drizzle-driver/src/sqlite/sqlite-query.ts new file mode 100644 index 000000000..80f31fe75 --- /dev/null +++ b/packages/drizzle-driver/src/sqlite/sqlite-query.ts @@ -0,0 +1,188 @@ +import { AbstractPowerSyncDatabase, QueryResult } from '@powersync/common'; +import { Column, DriverValueDecoder, getTableName, SQL } from 'drizzle-orm'; +import { entityKind, is } from 'drizzle-orm/entity'; +import type { Logger } from 'drizzle-orm/logger'; +import { fillPlaceholders, type Query } from 'drizzle-orm/sql/sql'; +import { SQLiteColumn } from 'drizzle-orm/sqlite-core'; +import type { SelectedFieldsOrdered } from 'drizzle-orm/sqlite-core/query-builders/select.types'; +import { + type PreparedQueryConfig as PreparedQueryConfigBase, + type SQLiteExecuteMethod, + SQLitePreparedQuery +} from 'drizzle-orm/sqlite-core/session'; + +type PreparedQueryConfig = Omit; + +export class PowerSyncSQLitePreparedQuery< + T extends PreparedQueryConfig = PreparedQueryConfig +> extends SQLitePreparedQuery<{ + type: 'async'; + run: QueryResult; + all: T['all']; + get: T['get']; + values: T['values']; + execute: T['execute']; +}> { + static readonly [entityKind]: string = 'PowerSyncSQLitePreparedQuery'; + + constructor( + private db: AbstractPowerSyncDatabase, + query: Query, + private logger: Logger, + private fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + private _isResponseInArrayMode: boolean, + private customResultMapper?: (rows: unknown[][]) => unknown + ) { + super('async', executeMethod, query); + } + + async run(placeholderValues?: Record): Promise { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); + this.logger.logQuery(this.query.sql, params); + const rs = await this.db.execute(this.query.sql, params); + return rs; + } + + async all(placeholderValues?: Record): Promise { + const { fields, query, logger, customResultMapper } = this; + if (!fields && !customResultMapper) { + const params = fillPlaceholders(query.params, placeholderValues ?? {}); + logger.logQuery(query.sql, params); + const rs = await this.db.execute(this.query.sql, params); + return rs.rows?._array ?? []; + } + + const rows = (await this.values(placeholderValues)) as unknown[][]; + const valueRows = rows.map((row) => Object.values(row)); + if (customResultMapper) { + const mapped = customResultMapper(valueRows) as T['all']; + return mapped; + } + return valueRows.map((row) => mapResultRow(fields!, row, (this as any).joinsNotNullableMap)); + } + + async get(placeholderValues?: Record): Promise { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); + this.logger.logQuery(this.query.sql, params); + + const { fields, customResultMapper } = this; + const joinsNotNullableMap = (this as any).joinsNotNullableMap; + if (!fields && !customResultMapper) { + return this.db.get(this.query.sql, params); + } + + const rows = (await this.values(placeholderValues)) as unknown[][]; + const row = rows[0]; + + if (!row) { + return undefined; + } + + if (customResultMapper) { + const valueRows = rows.map((row) => Object.values(row)); + return customResultMapper(valueRows) as T['get']; + } + + return mapResultRow(fields!, Object.values(row), joinsNotNullableMap); + } + + async values(placeholderValues?: Record): Promise { + const params = fillPlaceholders(this.query.params, placeholderValues ?? {}); + this.logger.logQuery(this.query.sql, params); + const rs = await this.db.execute(this.query.sql, params); + return rs.rows?._array ?? []; + } + + isResponseInArrayMode(): boolean { + return this._isResponseInArrayMode; + } +} + +/** + * Maps a flat array of database row values to a result object based on the provided column definitions. + * It reconstructs the hierarchical structure of the result by following the specified paths for each field. + * It also handles nullification of nested objects when joined tables are nullable. + */ +export function mapResultRow( + columns: SelectedFieldsOrdered, + row: unknown[], + joinsNotNullableMap: Record | undefined +): TResult { + // Key -> nested object key, value -> table name if all fields in the nested object are from the same table, false otherwise + const nullifyMap: Record = {}; + + const result = columns.reduce>((result, { path, field }, columnIndex) => { + const decoder = getDecoder(field); + let node = result; + for (const [pathChunkIndex, pathChunk] of path.entries()) { + if (pathChunkIndex < path.length - 1) { + if (!(pathChunk in node)) { + node[pathChunk] = {}; + } + node = node[pathChunk]; + } else { + const rawValue = row[columnIndex]!; + const value = (node[pathChunk] = rawValue === null ? null : decoder.mapFromDriverValue(rawValue)); + + updateNullifyMap(nullifyMap, field, path, value, joinsNotNullableMap); + } + } + return result; + }, {}); + + applyNullifyMap(result, nullifyMap, joinsNotNullableMap); + + return result as TResult; +} + +/** + * Determines the appropriate decoder for a given field. + */ +function getDecoder(field: SQLiteColumn | SQL | SQL.Aliased): DriverValueDecoder { + if (is(field, Column)) { + return field; + } else if (is(field, SQL)) { + return (field as any).decoder; + } else { + return (field.sql as any).decoder; + } +} + +function updateNullifyMap( + nullifyMap: Record, + field: any, + path: string[], + value: any, + joinsNotNullableMap: Record | undefined +): void { + if (!joinsNotNullableMap || !is(field, Column) || path.length !== 2) { + return; + } + + const objectName = path[0]!; + if (!(objectName in nullifyMap)) { + nullifyMap[objectName] = value === null ? getTableName(field.table) : false; + } else if (typeof nullifyMap[objectName] === 'string' && nullifyMap[objectName] !== getTableName(field.table)) { + nullifyMap[objectName] = false; + } +} + +/** + * Nullify all nested objects from nullifyMap that are nullable + */ +function applyNullifyMap( + result: Record, + nullifyMap: Record, + joinsNotNullableMap: Record | undefined +): void { + if (!joinsNotNullableMap || Object.keys(nullifyMap).length === 0) { + return; + } + + for (const [objectName, tableName] of Object.entries(nullifyMap)) { + if (typeof tableName === 'string' && !joinsNotNullableMap[tableName]) { + result[objectName] = null; + } + } +} diff --git a/packages/drizzle-driver/src/sqlite/sqlite-session.ts b/packages/drizzle-driver/src/sqlite/sqlite-session.ts new file mode 100644 index 000000000..49b5a241c --- /dev/null +++ b/packages/drizzle-driver/src/sqlite/sqlite-session.ts @@ -0,0 +1,98 @@ +import { AbstractPowerSyncDatabase, QueryResult } from '@powersync/common'; +import { entityKind } from 'drizzle-orm/entity'; +import type { Logger } from 'drizzle-orm/logger'; +import { NoopLogger } from 'drizzle-orm/logger'; +import type { RelationalSchemaConfig, TablesRelationalConfig } from 'drizzle-orm/relations'; +import { type Query, sql } from 'drizzle-orm/sql/sql'; +import type { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core/dialect'; +import type { SelectedFieldsOrdered } from 'drizzle-orm/sqlite-core/query-builders/select.types'; +import { + type PreparedQueryConfig as PreparedQueryConfigBase, + type SQLiteExecuteMethod, + SQLiteSession, + SQLiteTransaction, + type SQLiteTransactionConfig +} from 'drizzle-orm/sqlite-core/session'; +import { PowerSyncSQLitePreparedQuery } from './sqlite-query'; + +export interface PowerSyncSQLiteSessionOptions { + logger?: Logger; +} + +export type PowerSyncSQLiteTransactionConfig = SQLiteTransactionConfig & { + accessMode?: 'read only' | 'read write'; +}; + +export class PowerSyncSQLiteTransaction< + TFullSchema extends Record, + TSchema extends TablesRelationalConfig +> extends SQLiteTransaction<'async', QueryResult, TFullSchema, TSchema> { + static readonly [entityKind]: string = 'PowerSyncSQLiteTransaction'; +} + +export class PowerSyncSQLiteSession< + TFullSchema extends Record, + TSchema extends TablesRelationalConfig +> extends SQLiteSession<'async', QueryResult, TFullSchema, TSchema> { + static readonly [entityKind]: string = 'PowerSyncSQLiteSession'; + + private logger: Logger; + + constructor( + private db: AbstractPowerSyncDatabase, + dialect: SQLiteAsyncDialect, + private schema: RelationalSchemaConfig | undefined, + options: PowerSyncSQLiteSessionOptions = {} + ) { + super(dialect); + this.logger = options.logger ?? new NoopLogger(); + } + + prepareQuery( + query: Query, + fields: SelectedFieldsOrdered | undefined, + executeMethod: SQLiteExecuteMethod, + isResponseInArrayMode: boolean, + customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown + ): PowerSyncSQLitePreparedQuery { + return new PowerSyncSQLitePreparedQuery( + this.db, + query, + this.logger, + fields, + executeMethod, + isResponseInArrayMode, + customResultMapper + ); + } + + override transaction( + transaction: (tx: PowerSyncSQLiteTransaction) => T, + config: PowerSyncSQLiteTransactionConfig = {} + ): T { + const { accessMode = 'read write' } = config; + + if (accessMode === 'read only') { + return this.db.readLock(async () => this.internalTransaction(transaction, config)) as T; + } + + return this.db.writeLock(async () => this.internalTransaction(transaction, config)) as T; + } + + async internalTransaction( + transaction: (tx: PowerSyncSQLiteTransaction) => T, + config: PowerSyncSQLiteTransactionConfig = {} + ): Promise { + const tx = new PowerSyncSQLiteTransaction('async', (this as any).dialect, this, this.schema); + + await this.run(sql.raw(`begin${config?.behavior ? ' ' + config.behavior : ''}`)); + try { + const result = await transaction(tx); + await this.run(sql`commit`); + return result; + } catch (err) { + await this.run(sql`rollback`); + throw err; + } + } +} diff --git a/packages/drizzle-driver/src/utils/compilableQuery.ts b/packages/drizzle-driver/src/utils/compilableQuery.ts new file mode 100644 index 000000000..6520c71f7 --- /dev/null +++ b/packages/drizzle-driver/src/utils/compilableQuery.ts @@ -0,0 +1,21 @@ +import { CompilableQuery } from '@powersync/common'; +import { Query } from 'drizzle-orm'; + +export function toCompilableQuery(query: { + execute: () => Promise; + toSQL: () => Query; +}): CompilableQuery { + return { + compile: () => { + const sql = query.toSQL(); + return { + sql: sql.sql, + parameters: sql.params + }; + }, + execute: async () => { + const result = await query.execute(); + return Array.isArray(result) ? result : [result]; + } + }; +} diff --git a/packages/drizzle-driver/tests/setup/db.ts b/packages/drizzle-driver/tests/setup/db.ts new file mode 100644 index 000000000..b3807a7e0 --- /dev/null +++ b/packages/drizzle-driver/tests/setup/db.ts @@ -0,0 +1,32 @@ +import { Schema, PowerSyncDatabase, column, Table, AbstractPowerSyncDatabase } from '@powersync/web'; +import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { wrapPowerSyncWithDrizzle, PowerSyncSQLiteDatabase } from '../../src/sqlite/db'; + +const users = new Table({ + name: column.text +}); + +export const drizzleUsers = sqliteTable('users', { + id: text('id').primaryKey().notNull(), + name: text('name').notNull() +}); + +export const TestSchema = new Schema({ users }); +export const DrizzleSchema = { users: drizzleUsers }; + +export const getPowerSyncDb = () => { + const database = new PowerSyncDatabase({ + database: { + dbFilename: 'test.db' + }, + schema: TestSchema + }); + + return database; +}; + +export const getDrizzleDb = (db: AbstractPowerSyncDatabase) => { + const database = wrapPowerSyncWithDrizzle(db, { schema: DrizzleSchema, logger: { logQuery: () => {} } }); + + return database; +}; diff --git a/packages/drizzle-driver/tests/setup/types.ts b/packages/drizzle-driver/tests/setup/types.ts new file mode 100644 index 000000000..5760bb721 --- /dev/null +++ b/packages/drizzle-driver/tests/setup/types.ts @@ -0,0 +1,5 @@ +import { TestSchema } from './db'; + +export type Database = (typeof TestSchema)['types']; + +export type UsersTable = Database['users']; diff --git a/packages/drizzle-driver/tests/sqlite/db.test.ts b/packages/drizzle-driver/tests/sqlite/db.test.ts new file mode 100644 index 000000000..a7b3f79e9 --- /dev/null +++ b/packages/drizzle-driver/tests/sqlite/db.test.ts @@ -0,0 +1,63 @@ +import { AbstractPowerSyncDatabase } from '@powersync/common'; +import { eq, sql } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as SUT from '../../src/sqlite/db'; +import { DrizzleSchema, drizzleUsers, getDrizzleDb, getPowerSyncDb } from '../setup/db'; + +describe('Database operations', () => { + let powerSyncDb: AbstractPowerSyncDatabase; + let db: SUT.PowerSyncSQLiteDatabase; + + beforeEach(() => { + powerSyncDb = getPowerSyncDb(); + db = getDrizzleDb(powerSyncDb); + }); + + afterEach(async () => { + await powerSyncDb.disconnectAndClear(); + }); + + it('should insert a user and select that user', async () => { + await db.insert(drizzleUsers).values({ id: '1', name: 'John' }); + const result = await db.select().from(drizzleUsers); + + expect(result.length).toEqual(1); + }); + + it('should insert a user and delete that user', async () => { + await db.insert(drizzleUsers).values({ id: '2', name: 'Ben' }); + await db.delete(drizzleUsers).where(eq(drizzleUsers.name, 'Ben')); + const result = await db.select().from(drizzleUsers); + + expect(result.length).toEqual(0); + }); + + it('should insert a user and update that user', async () => { + await db.insert(drizzleUsers).values({ id: '3', name: 'Lucy' }); + await db.update(drizzleUsers).set({ name: 'Lucy Smith' }).where(eq(drizzleUsers.name, 'Lucy')); + const result = await db.select().from(drizzleUsers).get(); + + expect(result!.name).toEqual('Lucy Smith'); + }); + + it('should insert a user and update that user within a transaction', async () => { + await db.transaction(async (transaction) => { + await transaction.insert(drizzleUsers).values({ id: '4', name: 'James' }); + await transaction.update(drizzleUsers).set({ name: 'James Smith' }).where(eq(drizzleUsers.name, 'James')); + }); + const result = await db.select().from(drizzleUsers).get(); + + expect(result!.name).toEqual('James Smith'); + }); + + it('should insert a user and update that user within a transaction when raw sql is used', async () => { + await db.transaction(async (transaction) => { + await transaction.run(sql`INSERT INTO users (id, name) VALUES ('4', 'James');`); + await transaction.update(drizzleUsers).set({ name: 'James Smith' }).where(eq(drizzleUsers.name, 'James')); + }); + + const result = await db.select().from(drizzleUsers).get(); + + expect(result!.name).toEqual('James Smith'); + }); +}); diff --git a/packages/drizzle-driver/tests/sqlite/sqlite-query.test.ts b/packages/drizzle-driver/tests/sqlite/sqlite-query.test.ts new file mode 100644 index 000000000..9a45db0f5 --- /dev/null +++ b/packages/drizzle-driver/tests/sqlite/sqlite-query.test.ts @@ -0,0 +1,64 @@ +import { AbstractPowerSyncDatabase } from '@powersync/web'; +import { Query } from 'drizzle-orm/sql/sql'; +import { PowerSyncSQLiteDatabase } from '../../src/sqlite/db'; +import { PowerSyncSQLitePreparedQuery } from '../../src/sqlite/sqlite-query'; +import { DrizzleSchema, drizzleUsers, getDrizzleDb, getPowerSyncDb } from '../setup/db'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('PowerSyncSQLitePreparedQuery', () => { + let powerSyncDb: AbstractPowerSyncDatabase; + let db: PowerSyncSQLiteDatabase; + const loggerMock = { logQuery: () => {} }; + + beforeEach(async () => { + powerSyncDb = getPowerSyncDb(); + db = getDrizzleDb(powerSyncDb); + + await db.insert(drizzleUsers).values({ id: '1', name: 'Alice' }); + await db.insert(drizzleUsers).values({ id: '2', name: 'Bob' }); + }); + + afterEach(async () => { + await powerSyncDb.disconnectAndClear(); + }); + + it('should execute a query in run()', async () => { + const query: Query = { sql: `SELECT * FROM users WHERE id = ?`, params: [1] }; + const preparedQuery = new PowerSyncSQLitePreparedQuery(powerSyncDb, query, loggerMock, undefined, 'run', false); + + const result = await preparedQuery.run(); + expect(result.rows?._array).toEqual([{ id: '1', name: 'Alice' }]); + }); + + it('should retrieve all rows in all()', async () => { + const query: Query = { sql: `SELECT * FROM users`, params: [] } as Query; + const preparedQuery = new PowerSyncSQLitePreparedQuery(powerSyncDb, query, loggerMock, undefined, 'all', false); + + const rows = await preparedQuery.all(); + expect(rows).toEqual([ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' } + ]); + }); + + it('should retrieve a single row in get()', async () => { + const query: Query = { sql: `SELECT * FROM users WHERE id = ?`, params: [1] }; + + const preparedQuery = new PowerSyncSQLitePreparedQuery(powerSyncDb, query, loggerMock, undefined, 'get', false); + + const row = await preparedQuery.get(); + expect(row).toEqual({ id: '1', name: 'Alice' }); + }); + + it('should retrieve values in values()', async () => { + const query: Query = { sql: `SELECT * FROM users`, params: [] } as Query; + + const preparedQuery = new PowerSyncSQLitePreparedQuery(powerSyncDb, query, loggerMock, undefined, 'all', false); + + const values = await preparedQuery.values(); + expect(values).toEqual([ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' } + ]); + }); +}); diff --git a/packages/drizzle-driver/tsconfig.json b/packages/drizzle-driver/tsconfig.json new file mode 100644 index 000000000..e626ebecb --- /dev/null +++ b/packages/drizzle-driver/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "baseUrl": "./", + "declaration": true /* Generates corresponding '.d.ts' file. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + "lib": ["DOM", "ES2020", "WebWorker"] /* Specify library files to be included in the compilation. */, + "module": "es2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "outDir": "./lib" /* Redirect output structure to the directory. */, + "strict": true /* Enable all strict type-checking options. */, + "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + }, + "include": ["src/**/*"], + "references": [ + { + "path": "../common" + } + ] +} diff --git a/packages/drizzle-driver/vitest.config.ts b/packages/drizzle-driver/vitest.config.ts new file mode 100644 index 000000000..cbbefe1c5 --- /dev/null +++ b/packages/drizzle-driver/vitest.config.ts @@ -0,0 +1,29 @@ +import wasm from 'vite-plugin-wasm'; +import topLevelAwait from 'vite-plugin-top-level-await'; +import { defineConfig, UserConfigExport } from 'vitest/config'; + +const config: UserConfigExport = { + worker: { + format: 'es', + plugins: () => [wasm(), topLevelAwait()] + }, + optimizeDeps: { + // Don't optimise these packages as they contain web workers and WASM files. + // https://github.com/vitejs/vite/issues/11672#issuecomment-1415820673 + exclude: ['@journeyapps/wa-sqlite', '@powersync/web'] + }, + plugins: [wasm(), topLevelAwait()], + test: { + isolate: false, + globals: true, + include: ['tests/**/*.test.ts'], + browser: { + enabled: true, + headless: true, + provider: 'webdriverio', + name: 'chrome' // browser name is required + } + } +}; + +export default defineConfig(config); diff --git a/packages/kysely-driver/tests/sqlite/db.test.ts b/packages/kysely-driver/tests/sqlite/db.test.ts index a7fc0c6c7..1bed90d09 100644 --- a/packages/kysely-driver/tests/sqlite/db.test.ts +++ b/packages/kysely-driver/tests/sqlite/db.test.ts @@ -51,13 +51,11 @@ describe('CRUD operations', () => { expect(result.name).toEqual('James Smith'); }); - it('should insert a user and update that user within a transaction when raw sql is used', async () => { await db.transaction().execute(async (transaction) => { - await sql`INSERT INTO users (id, name) VALUES ('4', 'James');`.execute(transaction) + await sql`INSERT INTO users (id, name) VALUES ('4', 'James');`.execute(transaction); await transaction.updateTable('users').where('name', '=', 'James').set('name', 'James Smith').execute(); }); - console.log(await db.selectFrom('users').selectAll().execute()) const result = await db.selectFrom('users').select('name').executeTakeFirstOrThrow(); expect(result.name).toEqual('James Smith'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 337e7ee9d..870db4587 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1595,6 +1595,52 @@ importers: specifier: 3.2.1 version: 3.2.1 + packages/drizzle-driver: + dependencies: + '@powersync/common': + specifier: workspace:^1.19.0 + version: link:../common + drizzle-orm: + specifier: 0.35.2 + version: 0.35.2(@op-engineering/op-sqlite@9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1))(@types/react@18.3.11)(kysely@0.27.4)(react@18.3.1) + devDependencies: + '@journeyapps/wa-sqlite': + specifier: ^0.4.1 + version: 0.4.1 + '@powersync/web': + specifier: workspace:* + version: link:../web + '@types/node': + specifier: ^20.17.6 + version: 20.17.6 + '@vitest/browser': + specifier: ^2.1.4 + version: 2.1.4(@types/node@20.17.6)(typescript@5.6.3)(vite@5.4.10(@types/node@20.17.6)(less@4.2.0)(sass@1.79.4)(terser@5.34.1))(vitest@2.1.4)(webdriverio@9.2.8) + ts-loader: + specifier: ^9.5.1 + version: 9.5.1(typescript@5.6.3)(webpack@5.95.0(@swc/core@1.7.26(@swc/helpers@0.5.5))) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.5))(@types/node@20.17.6)(typescript@5.6.3) + typescript: + specifier: ^5.6.3 + version: 5.6.3 + vite: + specifier: ^5.4.10 + version: 5.4.10(@types/node@20.17.6)(less@4.2.0)(sass@1.79.4)(terser@5.34.1) + vite-plugin-top-level-await: + specifier: ^1.4.4 + version: 1.4.4(@swc/helpers@0.5.5)(rollup@4.24.0)(vite@5.4.10(@types/node@20.17.6)(less@4.2.0)(sass@1.79.4)(terser@5.34.1)) + vite-plugin-wasm: + specifier: ^3.3.0 + version: 3.3.0(vite@5.4.10(@types/node@20.17.6)(less@4.2.0)(sass@1.79.4)(terser@5.34.1)) + vitest: + specifier: ^2.1.4 + version: 2.1.4(@types/node@20.17.6)(@vitest/browser@2.1.4)(jsdom@24.1.3)(less@4.2.0)(msw@2.6.0(@types/node@20.17.6)(typescript@5.6.3))(sass@1.79.4)(terser@5.34.1) + webdriverio: + specifier: ^9.2.8 + version: 9.2.8 + packages/kysely-driver: dependencies: '@powersync/common': @@ -4517,6 +4563,9 @@ packages: react: '*' react-native: '*' + '@journeyapps/wa-sqlite@0.4.1': + resolution: {integrity: sha512-a964h8f+6PSVfg3kxhLF2FwAqPdlY4gaWYIV6nnwJbdUhfjUcHDdL5njkw7egwmjEIG9rbIuxRsEqANcQ/bTwQ==} + '@journeyapps/wa-sqlite@0.4.2': resolution: {integrity: sha512-xdpDLbyC/DHkNcnXCfgBXUgfy+ff1w/sxVY6mjdGP8F4bgxnSQfUyN8+PNE2nTgYUx4y5ar57MEnSty4zjIm7Q==} @@ -10088,6 +10137,95 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + drizzle-orm@0.35.2: + resolution: {integrity: sha512-bLQtRchl8QvRo2MyG6kcZC90UDzR7Ubir4YwOHV3cZPdJbF+4jU/Yt0QOczsoXe25wLRt6CtCWLXtSDQKft3yg==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=3' + '@electric-sql/pglite': '>=0.1.1' + '@libsql/client': '>=0.10.0' + '@neondatabase/serverless': '>=0.1' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/react': '>=18' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=13.2.0' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + react: '>=18' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/react': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dtrace-provider@0.8.8: resolution: {integrity: sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==} engines: {node: '>=0.10'} @@ -18932,9 +19070,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1)': + '@babel/eslint-parser@7.25.8(@babel/core@7.25.7)(eslint@8.57.1)': dependencies: - '@babel/core': 7.24.5 + '@babel/core': 7.25.7 '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 eslint: 8.57.1 eslint-visitor-keys: 2.1.0 @@ -24379,6 +24517,8 @@ snapshots: react: 18.2.0 react-native: 0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0) + '@journeyapps/wa-sqlite@0.4.1': {} + '@journeyapps/wa-sqlite@0.4.2': {} '@jridgewell/gen-mapping@0.3.5': @@ -25239,6 +25379,12 @@ snapshots: react: 18.3.1 react-native: 0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.5.4) + '@op-engineering/op-sqlite@9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1)': + dependencies: + react: 18.3.1 + react-native: 0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3) + optional: true + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -25675,6 +25821,18 @@ snapshots: transitivePeerDependencies: - typescript + '@react-native-community/cli-config@14.1.0(typescript@5.6.3)': + dependencies: + '@react-native-community/cli-tools': 14.1.0 + chalk: 4.1.2 + cosmiconfig: 9.0.0(typescript@5.6.3) + deepmerge: 4.3.1 + fast-glob: 3.3.2 + joi: 17.13.3 + transitivePeerDependencies: + - typescript + optional: true + '@react-native-community/cli-debugger-ui@11.3.6': dependencies: serve-static: 1.16.2 @@ -25787,6 +25945,28 @@ snapshots: transitivePeerDependencies: - typescript + '@react-native-community/cli-doctor@14.1.0(typescript@5.6.3)': + dependencies: + '@react-native-community/cli-config': 14.1.0(typescript@5.6.3) + '@react-native-community/cli-platform-android': 14.1.0 + '@react-native-community/cli-platform-apple': 14.1.0 + '@react-native-community/cli-platform-ios': 14.1.0 + '@react-native-community/cli-tools': 14.1.0 + chalk: 4.1.2 + command-exists: 1.2.9 + deepmerge: 4.3.1 + envinfo: 7.14.0 + execa: 5.1.1 + node-stream-zip: 1.15.0 + ora: 5.4.1 + semver: 7.6.3 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + yaml: 2.5.1 + transitivePeerDependencies: + - typescript + optional: true + '@react-native-community/cli-hermes@11.3.6(encoding@0.1.13)': dependencies: '@react-native-community/cli-platform-android': 11.3.6(encoding@0.1.13) @@ -26176,6 +26356,31 @@ snapshots: - typescript - utf-8-validate + '@react-native-community/cli@14.1.0(typescript@5.6.3)': + dependencies: + '@react-native-community/cli-clean': 14.1.0 + '@react-native-community/cli-config': 14.1.0(typescript@5.6.3) + '@react-native-community/cli-debugger-ui': 14.1.0 + '@react-native-community/cli-doctor': 14.1.0(typescript@5.6.3) + '@react-native-community/cli-server-api': 14.1.0 + '@react-native-community/cli-tools': 14.1.0 + '@react-native-community/cli-types': 14.1.0 + chalk: 4.1.2 + commander: 9.5.0 + deepmerge: 4.3.1 + execa: 5.1.1 + find-up: 5.0.0 + fs-extra: 8.1.0 + graceful-fs: 4.2.11 + prompts: 2.4.2 + semver: 7.6.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + optional: true + '@react-native-community/masked-view@0.1.11(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: react: 18.2.0 @@ -26667,7 +26872,7 @@ snapshots: '@react-native/eslint-config@0.73.2(eslint@8.57.1)(prettier@3.3.3)(typescript@5.5.4)': dependencies: '@babel/core': 7.24.5 - '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1) + '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1) '@react-native/eslint-plugin': 0.73.1 '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4) '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.5.4) @@ -26811,6 +27016,16 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 + '@react-native/virtualized-lists@0.75.3(@types/react@18.3.11)(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 18.3.1 + react-native: 0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3) + optionalDependencies: + '@types/react': 18.3.11 + optional: true + '@react-navigation/bottom-tabs@6.5.20(@react-navigation/native@6.1.18(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native-safe-area-context@4.10.1(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native-screens@3.31.1(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: '@react-navigation/elements': 1.3.31(@react-navigation/native@6.1.18(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native-safe-area-context@4.10.1(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) @@ -27895,7 +28110,7 @@ snapshots: fs-extra: 11.2.0 get-tsconfig: 4.8.1 lodash.debounce: 4.0.8 - typescript: 5.5.4 + typescript: 5.6.3 '@tamagui/button@1.79.6(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.3.11)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)': dependencies: @@ -31683,6 +31898,16 @@ snapshots: optionalDependencies: typescript: 5.5.4 + cosmiconfig@9.0.0(typescript@5.6.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.6.3 + optional: true + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -32486,6 +32711,13 @@ snapshots: dotenv@16.4.5: {} + drizzle-orm@0.35.2(@op-engineering/op-sqlite@9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1))(@types/react@18.3.11)(kysely@0.27.4)(react@18.3.1): + optionalDependencies: + '@op-engineering/op-sqlite': 9.2.1(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1) + '@types/react': 18.3.11 + kysely: 0.27.4 + react: 18.3.1 + dtrace-provider@0.8.8: dependencies: nan: 2.20.0 @@ -33101,7 +33333,7 @@ snapshots: eslint-plugin-ft-flow@2.0.3(@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1))(eslint@8.57.1): dependencies: - '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1) + '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1) eslint: 8.57.1 lodash: 4.17.21 string-natural-compare: 3.0.1 @@ -40045,6 +40277,60 @@ snapshots: - typescript - utf-8-validate + react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native-community/cli': 14.1.0(typescript@5.6.3) + '@react-native-community/cli-platform-android': 14.1.0 + '@react-native-community/cli-platform-ios': 14.1.0 + '@react-native/assets-registry': 0.75.3 + '@react-native/codegen': 0.75.3(@babel/preset-env@7.25.7(@babel/core@7.25.7)) + '@react-native/community-cli-plugin': 0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(encoding@0.1.13) + '@react-native/gradle-plugin': 0.75.3 + '@react-native/js-polyfills': 0.75.3 + '@react-native/normalize-colors': 0.75.3 + '@react-native/virtualized-lists': 0.75.3(@types/react@18.3.11)(react-native@0.75.3(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)(typescript@5.6.3))(react@18.3.1) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + base64-js: 1.5.1 + chalk: 4.1.2 + commander: 9.5.0 + event-target-shim: 5.0.1 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + jsc-android: 250231.0.0 + memoize-one: 5.2.1 + metro-runtime: 0.80.12 + metro-source-map: 0.80.12 + mkdirp: 0.5.6 + nullthrows: 1.1.1 + pretty-format: 26.6.2 + promise: 8.3.0 + react: 18.3.1 + react-devtools-core: 5.3.1 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.24.0-canary-efb381bbf-20230505 + semver: 7.6.3 + stacktrace-parser: 0.1.10 + whatwg-fetch: 3.6.20 + ws: 6.2.3 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 18.3.11 + transitivePeerDependencies: + - '@babel/core' + - '@babel/preset-env' + - bufferutil + - encoding + - supports-color + - typescript + - utf-8-validate + optional: true + react-navigation-stack@2.10.4(b23yjknfeew5kcy4o5zrlfz5ae): dependencies: '@react-native-community/masked-view': 0.1.11(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)