diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 330deb02..6eb77c85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: - uses: ./.github/actions/setup - run: npm run build - run: npm run attw + - run: npm run check:surface --workspace=@ydbjs/drizzle-adapter test: name: Test diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 37acf142..580d93a4 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -18,6 +18,7 @@ export default defineConfig({ nav: [ { text: 'Getting Started', link: '/guide/core' }, { text: 'Query', link: '/guide/query/' }, + { text: 'Drizzle', link: '/guide/drizzle-adapter/' }, { text: 'Topic', link: '/guide/topic/' }, { text: 'Coordination', link: '/guide/coordination/' }, { text: 'Advanced', link: '/advanced/' }, @@ -100,6 +101,86 @@ export default defineConfig({ }, ], }, + { + text: 'Drizzle', + items: [ + { text: 'Overview', link: '/guide/drizzle-adapter/' }, + { + text: 'Options & API', + link: '/guide/drizzle-adapter/options', + }, + { + text: 'Getting Started', + link: '/guide/drizzle-adapter/getting-started', + }, + { + text: 'Examples', + link: '/guide/drizzle-adapter/examples', + }, + { text: 'Schema', link: '/guide/drizzle-adapter/schema' }, + { + text: 'Database API', + link: '/guide/drizzle-adapter/database-api', + }, + { + text: 'Query Builders', + link: '/guide/drizzle-adapter/query-builders', + }, + { + text: 'Migrations and DDL', + link: '/guide/drizzle-adapter/migrations-ddl', + }, + { + text: 'YQL Helpers', + link: '/guide/drizzle-adapter/yql-helpers', + }, + { + text: 'Driver, Session, Dialect', + link: '/guide/drizzle-adapter/internals', + }, + { + text: 'Public API', + link: '/guide/drizzle-adapter/api-index', + }, + { + text: 'Examples', + items: [ + { + text: 'Runnable examples', + link: '/guide/drizzle-adapter/examples', + }, + { + text: 'Schema with YDB options', + link: '/guide/drizzle-adapter/#examples-schema', + }, + { + text: 'CRUD', + link: '/guide/drizzle-adapter/#examples-crud', + }, + { + text: 'Relations', + link: '/guide/drizzle-adapter/#examples-relations', + }, + { + text: 'Transactions', + link: '/guide/drizzle-adapter/#examples-transactions', + }, + { + text: 'Migrations', + link: '/guide/drizzle-adapter/#examples-migrations', + }, + { + text: 'YQL helpers', + link: '/guide/drizzle-adapter/#examples-yql-helpers', + }, + { + text: 'Vector search', + link: '/guide/drizzle-adapter/#examples-vector-search', + }, + ], + }, + ], + }, { text: 'Topic', items: [ @@ -229,6 +310,7 @@ export default defineConfig({ nav: [ { text: 'Начало работы', link: '/ru/guide/core' }, { text: 'Query', link: '/ru/guide/query/' }, + { text: 'Drizzle', link: '/ru/guide/drizzle-adapter/' }, { text: 'Topic', link: '/ru/guide/topic/' }, { text: 'Coordination', link: '/ru/guide/coordination/' }, { text: 'Расширенные темы', link: '/ru/advanced/' }, @@ -317,6 +399,89 @@ export default defineConfig({ }, ], }, + { + text: 'Drizzle', + items: [ + { text: 'Обзор', link: '/ru/guide/drizzle-adapter/' }, + { + text: 'Опции и API', + link: '/ru/guide/drizzle-adapter/options', + }, + { + text: 'Быстрый старт', + link: '/ru/guide/drizzle-adapter/getting-started', + }, + { + text: 'Примеры', + link: '/ru/guide/drizzle-adapter/examples', + }, + { + text: 'Схема данных', + link: '/ru/guide/drizzle-adapter/schema', + }, + { + text: 'Database API', + link: '/ru/guide/drizzle-adapter/database-api', + }, + { + text: 'Построители запросов', + link: '/ru/guide/drizzle-adapter/query-builders', + }, + { + text: 'Миграции и DDL', + link: '/ru/guide/drizzle-adapter/migrations-ddl', + }, + { + text: 'YQL Helpers', + link: '/ru/guide/drizzle-adapter/yql-helpers', + }, + { + text: 'Driver, Session, Dialect', + link: '/ru/guide/drizzle-adapter/internals', + }, + { + text: 'Публичный API', + link: '/ru/guide/drizzle-adapter/api-index', + }, + { + text: 'Примеры', + items: [ + { + text: 'Runnable-примеры', + link: '/ru/guide/drizzle-adapter/examples', + }, + { + text: 'Схема с YDB options', + link: '/ru/guide/drizzle-adapter/#examples-schema', + }, + { + text: 'CRUD', + link: '/ru/guide/drizzle-adapter/#examples-crud', + }, + { + text: 'Relations', + link: '/ru/guide/drizzle-adapter/#examples-relations', + }, + { + text: 'Транзакции', + link: '/ru/guide/drizzle-adapter/#examples-transactions', + }, + { + text: 'Миграции', + link: '/ru/guide/drizzle-adapter/#examples-migrations', + }, + { + text: 'YQL helpers', + link: '/ru/guide/drizzle-adapter/#examples-yql-helpers', + }, + { + text: 'Vector search', + link: '/ru/guide/drizzle-adapter/#examples-vector-search', + }, + ], + }, + ], + }, { text: 'Topic', items: [ diff --git a/docs/guide/drizzle-adapter/api-index.md b/docs/guide/drizzle-adapter/api-index.md new file mode 100644 index 00000000..ff001338 --- /dev/null +++ b/docs/guide/drizzle-adapter/api-index.md @@ -0,0 +1,80 @@ +--- +title: Drizzle Adapter — Public API +--- + +# Public API Index + +This page lists the primary public exports of `@ydbjs/drizzle-adapter`. + +## Connection and Database + +Root runtime names: + +- `createDrizzle(input, config?)` and `drizzle(input, config?)` create the database object. +- `YdbDriver` is the default executor backed by `@ydbjs/core` and `@ydbjs/query`. +- Error classes: `YdbUniqueConstraintViolationError`, `YdbAuthenticationError`, `YdbCancelledQueryError`, `YdbTimeoutQueryError`, `YdbUnavailableQueryError`, `YdbOverloadedQueryError`, and `YdbRetryableQueryError`. +- Type exports cover `YdbDrizzleDatabase`, `YdbDrizzleOptions`, `YdbExecutor`, `YdbTransactionalExecutor`, and transaction configuration. + +Implementation classes such as the dialect, session, transaction, and concrete builders are intentionally not root runtime exports. The stable runtime surface is the database object returned by `createDrizzle()`. + +Mapped YDB query errors expose `kind`, `retryable`, `statusCode`, and original diagnostic fields such as `code`, `status`, and `issues` when the driver provides them. + +## Tables and Schema + +- `ydbTable`, `ydbTableCreator` +- `primaryKey` +- `unique` +- `relations`, `one`, `many` +- `index`, `uniqueIndex`, `vectorIndex` +- `indexView`, `vectorIndexView` +- `tableOptions`, `rawTableOption` +- `ttl` +- `partitionByHash` +- `columnFamily` + +## Column Builders + +`integer`, `int`, `text`, `bigint`, `boolean`, `bytes`, `binary`, `date`, `date32`, `datetime`, `datetime64`, `decimal`, `double`, `dyNumber`, `float`, `int8`, `int16`, `interval`, `interval64`, `json`, `jsonDocument`, `timestamp`, `timestamp64`, `uint8`, `uint16`, `uint32`, `uint64`, `uuid`, `yson`, and `customType`. + +## Query Builders + +Database entry points: + +- SELECT: `db.select`, `db.selectDistinct`, `db.selectDistinctOn`, `db.with`, `db.$with` +- Mutations: `db.insert`, `db.upsert`, `db.replace`, `db.update`, `db.batchUpdate`, `db.delete`, `db.batchDelete` +- Utilities: `db.execute`, `db.all`, `db.get`, `db.values`, `db.$count`, `db.transaction` + +Builder methods: + +- SELECT sources: `.from()`, `.fromAsTable()`, `.fromValues()` +- SELECT clauses: `.where()`, `.having()`, `.groupBy()`, `.groupCompactBy()`, `.orderBy()`, `.assumeOrderBy()`, `.limit()`, `.offset()` +- YDB SELECT extensions: `.without()`, `.flattenBy()`, `.flattenListBy()`, `.flattenDictBy()`, `.flattenOptionalBy()`, `.flattenColumns()`, `.sample()`, `.tableSample()`, `.matchRecognize()`, `.window()`, `.intoResult()`, `.uniqueDistinct()`, `.distinct()`, `.distinctOn()` +- Joins: `.innerJoin()`, `.leftJoin()`, `.rightJoin()`, `.fullJoin()`, `.crossJoin()`, `.leftSemiJoin()`, `.rightSemiJoin()`, `.leftOnlyJoin()`, `.rightOnlyJoin()`, `.exclusionJoin()` +- Set operators: `.union()`, `.unionAll()`, `.intersect()`, `.except()`, `union()`, `unionAll()`, `intersect()`, `except()` +- Mutations: `.values()`, `.select()`, `.set()`, `.where()`, `.using()`, `.on()`, `.returning()`, `.onDuplicateKeyUpdate()` +- Execution and rendering: `.getSQL()`, `.toSQL()`, `.prepare()`, `.execute()`, `.all()`, `.get()`, `.values()` +- Utilities are exposed through database methods such as `db.$count()` and callback builders passed to `.select()`, `.on()`, and mutation helpers. + +## YQL Helpers + +- Sources: `asTable`, `values`, `valuesTable` +- Scripts: `yqlScript`, `pragma`, `declareParam`, `commit`, `defineAction`, `doAction`, `doBlock`, `intoResult` +- Grouping: `rollup`, `cube`, `groupingSets`, `grouping`, `groupKey` +- Windows: `windowDefinition`, `sessionWindow`, `sessionStart`, `hop`, `hopStart`, `hopEnd` +- Vector search: `knnDistance`, `knnSimilarity`, `knnCosineDistance`, `knnEuclideanDistance`, `knnManhattanDistance`, `knnCosineSimilarity`, `knnInnerProductSimilarity`, `kMeansTreeSearchTopSize` +- Pattern matching and hints: `matchRecognize`, `uniqueHint`, `distinctHint` +- Set operators: `union`, `unionAll`, `intersect`, `except` + +## Migrations and DDL + +- `migrate` +- Migration metadata: `buildMigrationSql`, `buildMigrationLockTableBootstrapSql` +- Tables: `buildCreateTableSql`, `buildDropTableSql`, `buildAlterTableSql`, `buildRenameTableSql`, `buildAnalyzeSql`, `buildShowCreateSql` +- Columns and families: `buildAddColumnsSql`, `buildDropColumnsSql`, `buildAddColumnFamilySql`, `buildAlterColumnFamilySql`, `buildAlterColumnSetFamilySql` +- Table options: `buildAlterTableSetOptionsSql`, `buildAlterTableResetOptionsSql` +- Indexes and CDC: `buildAddIndexSql`, `buildDropIndexSql`, `buildAddChangefeedSql`, `buildDropChangefeedSql` +- Topics: `buildCreateTopicSql`, `buildAlterTopicSql`, `buildDropTopicSql` +- Users and groups: `buildCreateUserSql`, `buildAlterUserSql`, `buildDropUserSql`, `buildCreateGroupSql`, `buildAlterGroupSql`, `buildDropGroupSql` +- ACL: `buildGrantSql`, `buildRevokeSql` +- Views: `buildCreateViewSql`, `buildDropViewSql` +- Services: `buildCreateAsyncReplicationSql`, `buildAlterAsyncReplicationSql`, `buildDropAsyncReplicationSql`, `buildCreateTransferSql`, `buildAlterTransferSql`, `buildDropTransferSql`, `buildCreateSecretSql` diff --git a/docs/guide/drizzle-adapter/database-api.md b/docs/guide/drizzle-adapter/database-api.md new file mode 100644 index 00000000..af7151fb --- /dev/null +++ b/docs/guide/drizzle-adapter/database-api.md @@ -0,0 +1,169 @@ +--- +title: Drizzle Adapter — Database API +--- + +# Database API + +The database object returned by `createDrizzle()` is the main runtime surface. It combines Drizzle-compatible query builders with YDB-specific helpers. + +For a runnable app that exercises most methods on this page, see [Drizzle Adapter Examples](/guide/drizzle-adapter/examples). + +## Initialization + +The `createDrizzle` function (aliased as `drizzle`) is the entry point for creating a database instance. + +### Configuration Methods + +- `createDrizzle(options)` creates a database from a connection string or an existing client. +- `createDrizzle(callback, config?)` creates a database from a custom execution callback (useful for Remote/Proxy setups). + +### Options + +- `connectionString`: YDB connection string (e.g., `grpc://localhost:2136/local`). The adapter automatically creates and manages a `Driver` instance. +- `client`: existing `YdbExecutor` or `YdbTransactionalExecutor`. +- `schema`: table and relations object for typed Relational Query API (`db.query.*`). +- `logger`: `true` for default logger, `false` to disable, or a custom Drizzle `Logger`. +- `casing`: Drizzle casing mode (`'snake_case'` or `'camelCase'`) passed to the dialect. + +Example: + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, + schema, + logger: true, +}) +``` + +### Lifecycle Management + +When a `connectionString` is used, the adapter owns the underlying driver. Use `$client` to manage its lifecycle: + +- `await db.$client.ready()`: ensures the driver is initialized and the database is accessible. +- `await db.$client.close()`: closes the session pool and releases driver resources. **Always call this when the application shuts down.** + +```ts +await db.$client.ready() +// ... application logic ... +await db.$client.close() +``` + +## Execution Methods + +Methods for executing raw YQL or query builder objects: + +| Method | Description | +| :---------------- | :------------------------------------------------ | +| `.execute(query)` | Executes a query and returns the typed result. | +| `.all(query)` | Returns all rows as an array of objects. | +| `.get(query)` | Returns the first row or `undefined`. | +| `.values(query)` | Returns rows as arrays of values in column order. | + +Example: + +```ts +import { sql } from 'drizzle-orm' + +await db.execute(sql`DELETE FROM users WHERE id = ${1}`) + +const rows = await db.all(sql`SELECT * FROM users`) +const first = await db.get(sql`SELECT * FROM users LIMIT 1`) +const values = await db.values<[number, string]>(sql`SELECT id, name FROM users`) + +const firstBuilt = await db.get(db.select({ id: users.id, name: users.name }).from(users).limit(1)) +``` + +## Query Builders + +### SELECT Builders + +- `db.select(fields?)`, `db.selectDistinct(fields?)`, `db.selectDistinctOn(on, fields?)`: create SELECT builders. +- `db.with(...queries)`, `db.$with(alias)`: create YDB CTE (Common Table Expressions) bindings. + +### Mutation Builders + +| Method | YQL Operation | Description | +| :---------------------- | :------------- | :------------------------------- | +| `db.insert(table)` | `INSERT INTO` | Standard row insertion. | +| `db.upsert(table)` | `UPSERT INTO` | Insert or update by Primary Key. | +| `db.replace(table)` | `REPLACE INTO` | Full row replacement. | +| `db.update(table)` | `UPDATE` | Partial update. | +| `db.delete(table)` | `DELETE FROM` | Row deletion. | +| `db.batchUpdate(table)` | (optimized) | Mass update of multiple rows. | +| `db.batchDelete(table)` | (optimized) | Mass deletion of multiple rows. | + +### Utilities + +- `db.$count(source, filters?)`: returns an awaitable count expression for efficient row counting. + +```ts +import { eq } from 'drizzle-orm' + +const activeCount = await db.$count(users, eq(users.active, true)) + +const rows = await db.select({ id: users.id, name: users.name }).from(users).limit(10).execute() +``` + +## Transactions + +The `db.transaction()` method ensures atomicity for a group of operations. + +```ts +await db.transaction( + async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Alice' }).execute() + await tx + .update(stats) + .set({ count: sql`count + 1` }) + .execute() + + // Manual rollback + if (someCondition) { + tx.rollback() + } + }, + { + accessMode: 'read write', + isolationLevel: 'serializableReadWrite', + idempotent: true, + } +) +``` + +### Options + +- `accessMode`: `'read write'` (default) or `'read only'`. Use `'read only'` for optimized read-heavy transactions. +- `isolationLevel`: `'serializableReadWrite'` (recommended for YDB) or `'snapshotReadOnly'`. +- `idempotent`: if `true`, the adapter can automatically retry the transaction on network errors. + +## Relational Query API + +Typed relations are enabled by passing a `schema` during initialization. Use `db.query.*` for declarative data fetching without manual JOINs. + +```ts +const user = await db.query.users.findFirst({ + where: (u, { eq }) => eq(u.id, 1), + with: { + posts: { + columns: { + id: true, + title: true, + }, + limit: 5, + orderBy: (p, { desc }) => [desc(p.id)], + }, + profile: true, + }, +}) +``` + +### Options + +- `columns`: include or exclude specific table columns. +- `where`: filter logic using table columns and Drizzle operators. +- `orderBy`: sort expressions. +- `limit`, `offset`: pagination controls. +- `with`: nested relation loading. +- `extras`: additional SQL expressions. diff --git a/docs/guide/drizzle-adapter/examples.md b/docs/guide/drizzle-adapter/examples.md new file mode 100644 index 00000000..3f04eba2 --- /dev/null +++ b/docs/guide/drizzle-adapter/examples.md @@ -0,0 +1,507 @@ +--- +title: Drizzle Adapter — Examples +--- + +# Examples + +The repository contains two runnable examples: + +- `examples/drizzle-adapter`: compact TypeScript CLI example in the same style as other SDK examples. +- `examples/drizzle-adapter-lab`: interactive advanced TypeScript lab with UI, generated YQL, results, and recent query traces. + +```bash +cd examples/drizzle-adapter +npm install +npm start +``` + +```bash +cd examples/drizzle-adapter-lab +npm install +npm run db:up +npm start +``` + +Open `http://localhost:3000` for the lab. + +## Shared Setup + +Most snippets below assume this database object and schema shape. + +```ts +import { asc, desc, eq, sql } from 'drizzle-orm' +import { + createDrizzle, + index, + integer, + many, + one, + relations, + text, + timestamp, + ydbTable, +} from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('example_users', { + id: integer('id').primaryKey(), + email: text('email').notNull().unique('example_users_email_unique'), + name: text('name').notNull(), + status: text('status').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}) + +export const projects = ydbTable( + 'example_projects', + { + id: integer('id').primaryKey(), + ownerId: integer('owner_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => [index('example_projects_owner_idx').on(table.ownerId).global().sync()] +) + +export const tasks = ydbTable( + 'example_tasks', + { + id: integer('id').primaryKey(), + projectId: integer('project_id').notNull(), + assigneeId: integer('assignee_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + priority: text('priority').notNull(), + estimateHours: integer('estimate_hours').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => [ + index('example_tasks_project_idx').on(table.projectId).global().sync(), + index('example_tasks_assignee_idx').on(table.assigneeId).global().sync(), + ] +) + +export const taskSnapshots = ydbTable('example_task_snapshots', { + id: integer('id').primaryKey(), + projectId: integer('project_id').notNull(), + assigneeId: integer('assignee_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + priority: text('priority').notNull(), + estimateHours: integer('estimate_hours').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}) + +export const usersRelations = relations(users, ({ many }) => ({ + ownedProjects: many(projects), + assignedTasks: many(tasks), +})) + +export const projectsRelations = relations(projects, ({ one, many }) => ({ + owner: one(users, { fields: [projects.ownerId], references: [users.id] }), + tasks: many(tasks), +})) + +export const tasksRelations = relations(tasks, ({ one }) => ({ + project: one(projects, { fields: [tasks.projectId], references: [projects.id] }), + assignee: one(users, { fields: [tasks.assigneeId], references: [users.id] }), +})) + +export const db = createDrizzle({ + connectionString: process.env.YDB_CONNECTION_STRING!, + schema: { + users, + projects, + tasks, + taskSnapshots, + usersRelations, + projectsRelations, + tasksRelations, + }, +}) +``` + +## CRUD And Returning + +```ts +const now = new Date() + +const inserted = await db + .insert(users) + .values({ + id: 1, + email: 'ada@example.com', + name: 'Ada', + status: 'active', + createdAt: now, + updatedAt: now, + }) + .returning({ id: users.id, email: users.email }) + .execute() + +await db + .insert(users) + .values({ + id: 1, + email: 'ada@new.example.com', + name: 'Ada', + status: 'active', + createdAt: now, + updatedAt: now, + }) + .onDuplicateKeyUpdate({ set: { email: 'ada@new.example.com', updatedAt: now } }) + .execute() + +await db + .upsert(users) + .values({ + id: 2, + email: 'grace@example.com', + name: 'Grace', + status: 'active', + createdAt: now, + updatedAt: now, + }) + .execute() +await db + .replace(users) + .values({ + id: 2, + email: 'grace@example.com', + name: 'Grace Hopper', + status: 'review', + createdAt: now, + updatedAt: now, + }) + .execute() + +await db.update(users).set({ status: 'paused', updatedAt: now }).where(eq(users.id, 2)).execute() +await db + .delete(users) + .where(eq(users.id, 2)) + .returning({ id: users.id, email: users.email }) + .execute() +``` + +## Batch Mutations + +```ts +await db + .batchUpdate(tasks) + .set({ status: 'review', updatedAt: new Date() }) + .where(eq(tasks.status, 'blocked')) + .execute() + +await db.batchDelete(tasks).where(eq(tasks.status, 'done')).execute() +``` + +## Prepared Reads + +```ts +const preparedUser = db + .select({ id: users.id, email: users.email, name: users.name }) + .from(users) + .where(eq(users.id, 1)) + .prepare('get_user_by_id') + +const row = await preparedUser.get() +const rows = await preparedUser.all() +const values = await preparedUser.values() +``` + +You can also pass builders into database execution helpers. + +```ts +const firstTask = await db.get( + db.select({ id: tasks.id, title: tasks.title }).from(tasks).orderBy(asc(tasks.id)).limit(1) +) +``` + +## Raw Execution + +```ts +const allRows = await db.all(sql`SELECT id, email FROM ${users} ORDER BY id`) +const oneRow = await db.get(sql`SELECT id, title FROM ${tasks} WHERE id = ${3001} LIMIT 1`) +const rawValues = await db.values<[number, string]>(sql`SELECT id, name FROM ${users} ORDER BY id`) +await db.execute(sql`DELETE FROM ${tasks} WHERE status = ${'archived'}`) +``` + +## Relations + +```ts +const result = await db.query.projects.findMany({ + columns: { id: true, title: true, status: true }, + orderBy: (project, { asc }) => [asc(project.id)], + with: { + owner: { columns: { id: true, email: true, name: true } }, + tasks: { + columns: { id: true, title: true, status: true, priority: true }, + orderBy: (task, { desc }) => [desc(task.estimateHours)], + with: { + assignee: { columns: { id: true, name: true } }, + }, + }, + }, +}) +``` + +## Joins, CTEs, And Set Operators + +```ts +const joinedRows = await db + .select({ + projectId: projects.id, + projectTitle: projects.title, + ownerName: users.name, + taskTitle: tasks.title, + }) + .from(projects) + .innerJoin(users, eq(projects.ownerId, users.id)) + .leftJoin(tasks, eq(tasks.projectId, projects.id)) + .orderBy(asc(projects.id), asc(tasks.id)) + .execute() + +const usersWithProjects = await db + .select({ id: users.id, name: users.name }) + .from(users) + .leftSemiJoin(projects, eq(projects.ownerId, users.id)) + .execute() + +const backlog = db + .$with('backlog') + .as( + db + .select({ projectId: tasks.projectId, taskId: tasks.id, title: tasks.title }) + .from(tasks) + .where(eq(tasks.status, 'todo')) + ) + +const backlogRows = await db.with(backlog).select().from(backlog).execute() + +const projectOwners = db.selectDistinct({ userId: projects.ownerId }).from(projects) +const taskAssignees = db.selectDistinct({ userId: tasks.assigneeId }).from(tasks) + +const activePeople = await projectOwners.union(taskAssignees).execute() +const ownerAndAssignee = await projectOwners.intersect(taskAssignees).execute() +const ownersWithoutTasks = await projectOwners.except(taskAssignees).execute() +``` + +## Distinct, Grouping, And Windows + +```ts +const statuses = await db + .selectDistinct({ status: tasks.status }) + .from(tasks) + .orderBy(asc(tasks.status)) + .execute() + +const newestProjectPerOwner = await db + .selectDistinctOn([projects.ownerId], { + ownerId: projects.ownerId, + projectId: projects.id, + title: projects.title, + updatedAt: projects.updatedAt, + }) + .from(projects) + .orderBy(asc(projects.ownerId), desc(projects.updatedAt)) + .execute() + +const totals = await db + .select({ + status: tasks.status, + total: sql`count(*)`, + totalEstimate: sql`sum(${tasks.estimateHours})`, + }) + .from(tasks) + .groupCompactBy(tasks.status) + .assumeOrderBy(tasks.status) + .orderBy(asc(tasks.status)) + .execute() +``` + +## Inline Sources + +```ts +import { asTable, values, valuesTable } from '@ydbjs/drizzle-adapter' + +const lanes = await db + .select({ lane: sql`lanes.lane`, weight: sql`lanes.weight` }) + .fromValues( + [ + { lane: 'backlog', weight: 1 }, + { lane: 'review', weight: 2 }, + ], + { alias: 'lanes', columns: ['lane', 'weight'] } + ) + .execute() + +const priorityMap = valuesTable( + [ + { priority: 'high', band: 'P1' }, + { priority: 'medium', band: 'P2' }, + ], + { alias: 'priority_map', columns: ['priority', 'band'] } +) + +const mappedTasks = await db + .select({ taskId: tasks.id, band: sql`priority_map.band` }) + .from(tasks) + .innerJoin(priorityMap, eq(tasks.priority, sql`priority_map.priority`)) + .execute() + +const source = values([{ id: 1, name: 'Ada' }]) +const valuesPreview = db + .select({ id: sql`v.id`, name: sql`v.name` }) + .from(sql`${source} AS v`) + .toSQL() +const asTablePreview = db + .select({ id: sql`r.id`, name: sql`r.name` }) + .from(asTable('$rows', 'r')) + .toSQL() +``` + +## Insert From Select + +```ts +await db + .insert(taskSnapshots) + .select( + db + .select({ + id: tasks.id, + projectId: tasks.projectId, + assigneeId: tasks.assigneeId, + title: tasks.title, + status: tasks.status, + priority: tasks.priority, + estimateHours: tasks.estimateHours, + createdAt: tasks.createdAt, + updatedAt: tasks.updatedAt, + }) + .from(tasks) + .where(eq(tasks.status, 'blocked')) + ) + .execute() +``` + +## Transactions + +```ts +await db.transaction( + async (tx) => { + const newTask = { + id: 100, + projectId: 10, + assigneeId: 1, + title: 'Transactional task', + status: 'todo', + priority: 'high', + estimateHours: 4, + createdAt: new Date(), + updatedAt: new Date(), + } + + await tx.insert(tasks).values(newTask).execute() + return tx + .select({ id: tasks.id, title: tasks.title }) + .from(tasks) + .where(eq(tasks.id, newTask.id)) + .prepare() + .get() + }, + { + accessMode: 'read write', + isolationLevel: 'serializableReadWrite', + idempotent: true, + } +) + +await db.transaction(async (tx) => { + const newTask = { + id: 101, + projectId: 10, + assigneeId: 1, + title: 'Rolled back task', + status: 'todo', + priority: 'medium', + estimateHours: 2, + createdAt: new Date(), + updatedAt: new Date(), + } + + await tx.insert(tasks).values(newTask).execute() + tx.rollback() +}) +``` + +## DDL Builders And Migrations + +```ts +import { + buildAddColumnsSql, + buildAddIndexSql, + buildAlterTableSetOptionsSql, + buildCreateTableSql, + buildDropTableSql, + buildMigrationSql, + buildRenameTableSql, + migrate, +} from '@ydbjs/drizzle-adapter' + +await migrate(db, { + migrationsTable: '__example_migrations', + migrationLock: true, + migrations: [ + { + name: '001_create_tables', + operations: [ + { kind: 'create_table', table: users, ifNotExists: true }, + { kind: 'create_table', table: projects, ifNotExists: true }, + { kind: 'create_table', table: tasks, ifNotExists: true }, + ], + }, + ], +}) + +const createSql = buildCreateTableSql(tasks, { ifNotExists: true }) +const dropSql = buildDropTableSql(tasks, { ifExists: true }) +const migrationSql = buildMigrationSql([{ kind: 'create_table', table: tasks, ifNotExists: true }]) +const renameSql = buildRenameTableSql(tasks, 'example_tasks_archive') +const alterSql = buildAlterTableSetOptionsSql(tasks, { auto_partitioning_by_size: true }) + +const tasksExpanded = ydbTable('example_tasks', { + id: integer('id').primaryKey(), + stage: text('stage').notNull(), +}) +const tasksStatusIndex = index('example_tasks_status_idx').on(tasks.status).build(tasks) + +const addColumnsSql = buildAddColumnsSql(tasksExpanded, [tasksExpanded.stage]) +const addIndexSql = buildAddIndexSql(tasks, tasksStatusIndex) +``` + +## YQL Scripts + +```ts +import { + commit, + declareParam, + doBlock, + intoResult, + pragma, + yqlScript, +} from '@ydbjs/drizzle-adapter' + +await db.execute( + yqlScript( + pragma('TablePathPrefix', '/local'), + declareParam('$taskId', 'Int32'), + doBlock([ + intoResult(sql`SELECT id, title FROM ${tasks} WHERE id = $taskId`, 'picked_task'), + commit(), + ]) + ) +) +``` diff --git a/docs/guide/drizzle-adapter/getting-started.md b/docs/guide/drizzle-adapter/getting-started.md new file mode 100644 index 00000000..8afad4ef --- /dev/null +++ b/docs/guide/drizzle-adapter/getting-started.md @@ -0,0 +1,189 @@ +--- +title: Drizzle Adapter — Getting Started +--- + +# Getting Started + +This guide covers installation, connection setup, schema declaration, basic CRUD, transactions, and shutdown. + +## Requirements + +- Node.js 20.19 or newer. +- `drizzle-orm` installed next to the adapter. +- A YDB database, either local (`grpc://localhost:2136/local`) or remote. + +## Install + +```sh +npm install @ydbjs/drizzle-adapter drizzle-orm +``` + +## Connect + +`createDrizzle()` is the main entry point. `drizzle` is an alias. + +### Connection String + +Use this when the adapter should create and own the SDK driver. + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, +}) +``` + +### Existing SDK Driver + +If your application already owns a `Driver` from `@ydbjs/core`, wrap it with `YdbDriver`. + +```ts +import { Driver } from '@ydbjs/core' +import { YdbDriver, createDrizzle } from '@ydbjs/drizzle-adapter' + +const driver = new Driver('grpc://localhost:2136/local') +const db = createDrizzle({ + client: new YdbDriver(driver), +}) +``` + +### Callback Executor + +Use callback mode for tests, RPC proxies, serverless environments, or any transport that is not direct gRPC to YDB. + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle(async (sql, params, method, options) => { + const rows = await myTransport.execute({ + sql, + params, + arrayMode: options?.arrayMode === true, + }) + + return { + rows, + rowCount: rows.length, + command: method, + meta: { + arrayMode: options?.arrayMode === true, + typings: options?.typings, + }, + } +}) +``` + +## Define a Schema + +YDB tables must have a primary key. Use `ydbTable()` and YDB column builders exported by the adapter. + +```ts +import { integer, text, timestamp, ydbTable } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull().unique(), + name: text('name'), + createdAt: timestamp('created_at') + .notNull() + .$defaultFn(() => new Date()), +}) +``` + +## CRUD + +```ts +import { and, eq, like } from 'drizzle-orm' + +await db.insert(users).values({ id: 1, email: 'alice@example.com', name: 'Alice' }).execute() + +await db + .insert(users) + .values([ + { id: 2, email: 'bob@example.com', name: 'Bob' }, + { id: 3, email: 'charlie@example.com', name: 'Charlie' }, + ]) + .execute() + +await db + .insert(users) + .values({ id: 1, email: 'alice_new@example.com', name: 'Alice Updated' }) + .onDuplicateKeyUpdate({ set: { name: 'Alice Updated' } }) + .execute() + +const filteredUsers = await db + .select({ + userId: users.id, + userName: users.name, + }) + .from(users) + .where(and(eq(users.id, 1), like(users.email, '%@example.com'))) + .execute() + +await db.update(users).set({ name: 'Alice Cooper' }).where(eq(users.id, 1)).execute() +await db.delete(users).where(eq(users.id, 1)).execute() +``` + +## Transactions + +```ts +import { TransactionRollbackError } from 'drizzle-orm/errors' + +try { + await db.transaction( + async (tx) => { + await tx.insert(users).values({ id: 4, email: 'delta@example.com' }).execute() + + const user = await tx.select().from(users).where(eq(users.id, 4)).execute() + if (user.length === 0) { + tx.rollback() + } + }, + { + accessMode: 'read write', + isolationLevel: 'serializableReadWrite', + idempotent: true, + } + ) +} catch (error) { + if (error instanceof TransactionRollbackError) { + console.log('transaction was rolled back') + } +} +``` + +## Smoke Test + +```ts +import { sql } from 'drizzle-orm' + +await db.execute(sql`SELECT 1`) +``` + +## Shutdown + +If the adapter created the driver from `connectionString`, close the owned client on application shutdown. + +```ts +db.$client.close?.() +``` + +## Runnable Examples + +The SDK repository includes a compact TypeScript CLI example and a larger interactive TypeScript lab. + +```bash +cd examples/drizzle-adapter +npm install +npm start +``` + +```bash +cd examples/drizzle-adapter-lab +npm install +npm run db:up +npm start +``` + +See [Drizzle Adapter Examples](/guide/drizzle-adapter/examples) for the matching code snippets. diff --git a/docs/guide/drizzle-adapter/index.md b/docs/guide/drizzle-adapter/index.md new file mode 100644 index 00000000..a3543316 --- /dev/null +++ b/docs/guide/drizzle-adapter/index.md @@ -0,0 +1,185 @@ +--- +title: Drizzle Adapter — Overview +--- + +# Drizzle Adapter `@ydbjs/drizzle-adapter` + +Drizzle-compatible database API, schema DSL, YDB/YQL query extensions, DDL builders, and migrations for YDB. + +This is the high-level overview of the YDB Drizzle adapter. For details, continue with: + +- [Options & API](/guide/drizzle-adapter/options) +- [Database API](/guide/drizzle-adapter/database-api) +- [Schema Definition](/guide/drizzle-adapter/schema) +- [Query Builders](/guide/drizzle-adapter/query-builders) +- [Migrations and DDL](/guide/drizzle-adapter/migrations-ddl) +- [YQL Helpers](/guide/drizzle-adapter/yql-helpers) +- [Driver, Session, Dialect](/guide/drizzle-adapter/internals) +- [Public API](/guide/drizzle-adapter/api-index) +- [Examples](/guide/drizzle-adapter/examples) + +## Quick Start + +```ts +import { eq } from 'drizzle-orm' +import { createDrizzle, integer, text, ydbTable } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) + +const db = createDrizzle({ + connectionString: process.env.YDB_CONNECTION_STRING!, + schema: { users }, +}) + +await db.insert(users).values({ id: 1, name: 'Alice' }).execute() + +const row = await db.query.users.findFirst({ + where: (user, { eq }) => eq(user.id, 1), +}) + +const selected = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(eq(users.id, 1)) + .prepare() + .get() +``` + +## Examples {#examples} + +For a larger runnable app that covers CRUD, relations, joins, CTE, raw execution, transactions, scripts, and DDL builders, see [Examples](/guide/drizzle-adapter/examples). + +### Schema with YDB options {#examples-schema} + +```ts +import { + columnFamily, + index, + integer, + partitionByHash, + tableOptions, + text, + timestamp, + ttl, + ydbTable, +} from '@ydbjs/drizzle-adapter' + +export const events = ydbTable( + 'events', + { + id: integer('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + payload: text('payload').notNull(), + createdAt: timestamp('created_at').notNull(), + }, + (table) => [ + index('events_tenant_idx').on(table.tenantId), + partitionByHash(table.tenantId), + ttl(table.createdAt, 'P30D'), + columnFamily('hot', { data: 'ssd' }).columns(table.payload), + tableOptions({ AUTO_PARTITIONING_BY_LOAD: 'ENABLED' }), + ] +) +``` + +### CRUD {#examples-crud} + +```ts +await db.insert(users).values({ id: 1, name: 'Alice' }).execute() +await db.upsert(users).values({ id: 1, name: 'Alice Updated' }).execute() + +const rows = await db.select().from(users).where(eq(users.id, 1)).execute() + +await db.update(users).set({ name: 'Alice' }).where(eq(users.id, 1)).execute() +await db.delete(users).where(eq(users.id, 1)).execute() +``` + +### Relations {#examples-relations} + +```ts +const user = await db.query.users.findFirst({ + where: (u, { eq }) => eq(u.id, 1), + with: { + posts: { + limit: 5, + orderBy: (p, { desc }) => [desc(p.id)], + }, + }, +}) +``` + +### Transactions {#examples-transactions} + +```ts +import { sql } from 'drizzle-orm' + +await db.transaction( + async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Alice' }).execute() + await tx + .update(stats) + .set({ count: sql`count + 1` }) + .execute() + }, + { accessMode: 'read write', isolationLevel: 'serializableReadWrite' } +) +``` + +### Migrations {#examples-migrations} + +```ts +import { migrate } from '@ydbjs/drizzle-adapter' + +await migrate(db, { + migrationsFolder: './drizzle', + migrationLock: true, +}) +``` + +### YQL helpers {#examples-yql-helpers} + +```ts +import { sql } from 'drizzle-orm' +import { asTable, valuesTable } from '@ydbjs/drizzle-adapter' + +await db + .select({ id: sql`r.id`, name: sql`r.name` }) + .from(asTable('$rows', 'r')) + .execute() + +const valueSource = valuesTable([{ id: 1, name: 'Alice' }], { + alias: 'v', + columns: ['id', 'name'], +}) +await db + .select({ id: sql`v.id`, name: sql`v.name` }) + .from(valueSource) + .execute() +``` + +### Vector search {#examples-vector-search} + +```ts +import { sql } from 'drizzle-orm' +import { knnCosineDistance, vectorIndexView } from '@ydbjs/drizzle-adapter' + +const nearest = await db + .select() + .from(vectorIndexView(images, 'images_vector_idx', 'images')) + .orderBy(knnCosineDistance(images.embedding, sql`$target`)) + .limit(10) + .execute() +``` + +## Release Gates {#release-gates} + +Adapter readiness is gated by the SDK CI workflow: + +- root `npm run build`, `npm run attw`, and `npm run test`; +- Docker-backed YDB integration tests through the Vitest `int` project; +- `npm run check:surface --workspace=@ydbjs/drizzle-adapter`, which validates the root public API, closed deep imports, and npm pack contents. + +The repository release workflow stays shared with the SDK and is not customized by the adapter package. diff --git a/docs/guide/drizzle-adapter/internals.md b/docs/guide/drizzle-adapter/internals.md new file mode 100644 index 00000000..4fb1bd6c --- /dev/null +++ b/docs/guide/drizzle-adapter/internals.md @@ -0,0 +1,83 @@ +--- +title: Drizzle Adapter — Driver, Session, Dialect +--- + +# Internals: Driver, Session, Dialect + +Understanding the low-level components of the YDB Drizzle adapter: `YdbDriver`, `YdbSession`, and `YdbDialect`. + +Only `YdbDriver` is a root runtime export. `YdbSession` and `YdbDialect` are implementation details used by the adapter and are documented here to explain behavior, not as stable application-level constructors. + +## YdbDriver + +`YdbDriver` is the low-level component that facilitates communication between Drizzle ORM and the `@ydbjs/core` driver. + +### Initialization + +```ts +import { Driver } from '@ydbjs/core' +import { YdbDriver } from '@ydbjs/drizzle-adapter' + +// 1. From connection string +const fromString = new YdbDriver('grpc://localhost:2136/local') + +// 2. From an existing SDK Driver instance +const sdkDriver = new Driver({ + /* ... */ +}) +const driver = new YdbDriver(sdkDriver) +``` + +**Note:** If `YdbDriver` creates the SDK driver (via connection string), it owns it and will close it when `driver.close()` is called. + +### Key Methods + +- `await driver.ready(signal?)`: Ensures the driver is connected and ready. +- `await driver.execute(yql, params, method, options?)`: Low-level YQL execution. +- `await driver.transaction(callback, config?)`: Low-level transactional execution. +- `await driver.close()`: Releases driver resources. + +## YdbSession + +`YdbSession` represents a single database session context. It acts as the bridge between the driver, dialect, and logger. + +### Key Methods + +- `.all()`, `.get()`, `.values()`, `.execute()`: Execute single queries in different formats. +- `.prepareQuery(sql, fields, name, arrayMode)`: Creates a `YdbPreparedQuery` for reuse. +- `.batch([queries])`: Executes multiple queries sequentially in one session. +- `.count(query)`: Efficiently returns the row count. +- `.transaction(callback)`: Starts a transactional session. + +### YdbPreparedQuery + +Returned by `.prepare()`, this object allows executing the same query with different parameters without re-parsing the SQL. + +```ts +const prepared = session.prepareQuery( + sql`SELECT * FROM users WHERE id = ${sql.placeholder('id')}`, + undefined, + 'select_user' +) + +await prepared.execute({ id: 1 }) +``` + +## YdbDialect + +`YdbDialect` is responsible for transforming Drizzle's abstract query structures into YQL syntax and handling type mapping. + +### Features + +- **Escaping:** Methods like `escapeName()`, `escapeParam()`, and `escapeString()` for safe SQL generation. +- **Rendering:** Low-level methods (`buildSelectQuery`, `buildInsertQuery`, etc.) used by query builders. +- **Migrations:** Handles low-level migration logic, including history table management and hash validation. + +### sqlToQuery + +Transforms a Drizzle `sql` template into YQL with parameter placeholders. + +```ts +const { sql, params } = dialect.sqlToQuery(sql`SELECT * FROM users WHERE id = ${1}`) +// Result -> sql: "SELECT * FROM users WHERE id = $p0", params: [1] +``` diff --git a/docs/guide/drizzle-adapter/migrations-ddl.md b/docs/guide/drizzle-adapter/migrations-ddl.md new file mode 100644 index 00000000..9ebea0ba --- /dev/null +++ b/docs/guide/drizzle-adapter/migrations-ddl.md @@ -0,0 +1,145 @@ +--- +title: Drizzle Adapter — Migrations and DDL +--- + +# Migrations and DDL + +The adapter provides a robust migrator and a set of DDL builders to manage your YDB schema programmatically. + +The runnable lab in [Drizzle Adapter Examples](/guide/drizzle-adapter/examples) shows `migrate()` plus live and preview-only DDL builders. + +## Migrator (`migrate`) + +The `migrate` function applies schema changes while ensuring consistency through distributed locks. + +### Basic Usage + +```ts +import { migrate } from '@ydbjs/drizzle-adapter' + +await migrate(db, { + migrationsFolder: './drizzle', // Path to drizzle-kit generated files + migrationLock: true, // Enable distributed locking +}) +``` + +### Inline Migrations + +Define migrations directly in your code for dynamic schema management. + +```ts +await migrate(db, { + migrations: [ + { + name: '0000_init', + sql: ['CREATE TABLE `users` (id Int32, name Utf8, PRIMARY KEY (id))'], + }, + ], +}) +``` + +## Migration Lock + +To prevent concurrent migrations in multi-instance deployments, the adapter uses a dedicated lock table in YDB. + +```ts +await migrate(db, { + migrationLock: { + key: 'my_app_deploy', + leaseMs: 600000, // 10 minutes + acquireTimeoutMs: 60000, // 1 minute wait + }, +}) +``` + +## CI/CD Pattern + +Run migrations as one deployment step before rolling out application instances. Keep `migrationLock` enabled so parallel deploy jobs, retries, and blue/green releases do not apply the same migration twice. + +```ts +import { createDrizzle, migrate } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, +}) + +try { + await db.$client.ready?.() + await migrate(db, { + migrationsFolder: './drizzle', + migrationLock: { + key: process.env['GITHUB_SHA'] ?? 'deploy', + leaseMs: 10 * 60 * 1000, + acquireTimeoutMs: 60 * 1000, + retryIntervalMs: 1000, + }, + migrationRecovery: { + mode: 'retry', + staleRunningAfterMs: 60 * 60 * 1000, + }, + }) +} finally { + await db.$client.close?.() +} +``` + +Recommended release checks: + +```bash +npm run build -- --filter=@ydbjs/drizzle-adapter +npm run attw -- --filter=@ydbjs/drizzle-adapter +npm run test --workspace=@ydbjs/drizzle-adapter -- --project uni +npm run test:live --workspace=@ydbjs/drizzle-adapter +``` + +## Recovery Strategies + +If a migration is interrupted (e.g., process crash), the history record might get stuck in `running` status. + +- `mode: 'error'` (Default): Fails if a stuck migration is detected. +- `mode: 'retry'`: Resets and retries the migration if it has been stuck for longer than `staleRunningAfterMs`. + +```ts +await migrate(db, { + migrationRecovery: { + mode: 'retry', + staleRunningAfterMs: 3600000, // 1 hour + }, +}) +``` + +## DDL Builders + +The adapter exports low-level functions to generate YQL for schema management. + +### Table Operations + +- `buildCreateTableSql(table, options?)`: Full `CREATE TABLE` command. +- `buildDropTableSql(table, options?)`: `DROP TABLE`. +- `buildRenameTableSql(table, newName)`: `RENAME TABLE`. + +### Column Operations + +- `buildAddColumnsSql(table, columns)`: Add new columns. +- `buildDropColumnsSql(table, names)`: Remove columns by name. + +### Index and CDC + +- `buildAddIndexSql(table, index)`: Add secondary index. +- `buildDropIndexSql(table, indexName)`: Remove index. +- `buildAddChangefeedSql(table, name, options)`: Configure CDC (Change Data Capture). + +### Service Objects + +Manage YDB infrastructure via code: + +- **Topics**: `buildCreateTopicSql`, `buildAlterTopicSql`, `buildDropTopicSql`. +- **RBAC**: `buildCreateUserSql`, `buildGrantSql`, `buildRevokeSql`. +- **Views**: `buildCreateViewSql`, `buildDropViewSql`. +- **Secrets**: `buildCreateSecretSql`. + +## DDL Safety + +DDL builders escape table, column, index, family, topic, user, group, and changefeed identifiers. Option names are validated as simple identifiers before rendering. + +Raw YQL surfaces remain caller-controlled by design: inline migration `sql`, `rawTableOption()`, view query text, raw ACL permissions, and transfer `using` text. Treat those values as trusted code, not user input. diff --git a/docs/guide/drizzle-adapter/options.md b/docs/guide/drizzle-adapter/options.md new file mode 100644 index 00000000..4bba0abb --- /dev/null +++ b/docs/guide/drizzle-adapter/options.md @@ -0,0 +1,141 @@ +--- +title: Drizzle Adapter — Options and API +--- + +# Options and API `@ydbjs/drizzle-adapter` + +Below is a comprehensive overview of the adapter configuration, runtime methods, and chainable options. + +## Client and Basic Syntax + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +let db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, + schema, + logger: true, +}) +``` + +Client options: + +- `connectionString`: creates an owned `YdbDriver` from a connection string. +- `client`: existing `YdbExecutor` or `YdbTransactionalExecutor`. +- `schema`: enables typed Relational Query API (`db.query.*`). +- `logger`: `true`, `false`, or a custom Drizzle `Logger`. +- `casing`: Drizzle casing mode (`'snake_case'` or `'camelCase'`) passed to the dialect. + +Alternative entry points: + +- `createDrizzle(executor, config?)`: use an existing executor. +- `createDrizzle(callback, config?)`: use a remote callback executor (Proxy mode). +- `drizzle(...)`: an alias for `createDrizzle(...)`. + +## Database Methods + +- `execute(query)`: executes a query and returns the typed result. +- `all(query)`: returns all rows as objects. +- `get(query)`: returns the first row or `undefined`. +- `values(query)`: returns rows as arrays of values. +- `transaction(callback, config?)`: runs the callback in a YDB transaction. +- `$count(source, filters?)`: awaitable helper for efficient row counting. +- `$client`: underlying executor; use `ready?.()` and `close?.()` for lifecycle management. + +Transaction options: + +- `accessMode`: `'read write' | 'read only'`. +- `isolationLevel`: `'serializableReadWrite' | 'snapshotReadOnly'`. +- `idempotent`: if `true`, allows automatic retries for the transaction on network errors. + +## Schema Options + +- `ydbTable(name, columns, extraConfig?)`: declares a table. +- `ydbTableCreator(customizeTableName)`: table factory with name mapping. +- `primaryKey({ columns })`: composite primary key. +- `unique(name?).on(...columns)`: unique constraint. +- `index(name?).on(...columns)`: secondary index. +- `uniqueIndex(name?).on(...columns)`: unique secondary index. +- `vectorIndex(name, options).on(column)`: vector index. +- `tableOptions(options)`: raw YDB table options. +- `ttl(column, intervalOrActions, options?)`: TTL (Time to Live) configuration. +- `partitionByHash(...columns)`: hash partitioning. +- `columnFamily(name, options?).columns(...columns)`: column families. + +Index builder options: + +- `.global()` / `.local()`: locality. +- `.sync()` / `.async()`: write synchronization. +- `.using(indexType)`: custom index type. +- `.vectorKMeansTree(options)`: vector k-means tree index type. +- `.cover(...columns)`: covering columns. +- `.with(options)`: raw index options. + +Vector index options: + +- `vectorDimension`, `vectorType`, `distance` or `similarity`, `clusters`, `levels`. + +## Query Builder Options + +SELECT: + +- Sources: `.from()`, `.fromAsTable()`, `.fromValues()`. +- Filtering and Grouping: `.where()`, `.having()`, `.groupBy()`, `.groupCompactBy()`. +- Sorting and Limits: `.orderBy()`, `.assumeOrderBy()`, `.limit()`, `.offset()`. +- YDB Extensions: `.without()`, `.flattenBy()`, `.flattenListBy()`, `.flattenDictBy()`, `.flattenOptionalBy()`, `.flattenColumns()`, `.sample()`, `.tableSample()`, `.matchRecognize()`, `.window()`, `.intoResult()`. +- Distinct and Set Operations: `.distinct()`, `.distinctOn()`, `.uniqueDistinct()`, `.union()`, `.unionAll()`, `.intersect()`, `.except()`. +- Execution and rendering: `.getSQL()`, `.toSQL()`, `.execute()`, `.prepare()`. Prepared queries expose `.all()`, `.get()`, and `.values()`. + +Mutations: + +- Insert-like builders: `.values()`, `.select()`, `.onDuplicateKeyUpdate()`, `.returning()`. +- Update builders: `.set()`, `.where()`, `.on()`, `.returning()`. +- Delete builders: `.where()`, `.using()`, `.on()`, `.returning()`. +- Batch builders: `batchUpdate` and `batchDelete` support `.where()` but reject `returning()` and `on()`. + +## Relational Query Options + +Use `db.query..findFirst(config?)` and `db.query.
.findMany(config?)` when `schema` is provided. + +- `columns`: include or exclude specific table columns. +- `where`: filter logic callback. +- `orderBy`: sort expressions callback. +- `limit` and `offset`: pagination controls. +- `with`: nested relation loading. +- `extras`: additional SQL selections. + +## Migration Options + +- `migrationsFolder`: Drizzle migrations folder path. +- `migrations`: inline migration objects. +- `migrationsTable`: history table name. +- `migrationsSchema`: schema or folder prefix for history tables. +- `migrationsLockTable`: lock table name. +- `migrationLock`: `true`, `false`, or lock options. +- `migrationRecovery`: recovery options. + +Lock options: + +- `key`, `ownerId`, `leaseMs`, `acquireTimeoutMs`, `retryIntervalMs`. + +Recovery options: + +- `mode`: `'fail' | 'retry'`. +- `staleRunningAfterMs`: threshold for stale running migration. + +## YQL Helper Options + +- `valuesTable(rows, { alias?, columns? })`: inline source. +- `windowDefinition({ partitionBy?, orderBy?, frame? })`: window configuration. +- `matchRecognize({ partitionBy?, orderBy?, measures?, rowsPerMatch?, afterMatchSkip?, pattern, define? })`: event pattern matching. +- `pragma(name, value?)`, `declareParam(name, dataType)`, `defineAction(name, params, statements)`, `doAction(name, args?)`: YQL script helpers. +- `kMeansTreeSearchTopSize(value)`: vector search pragma helper. + +## Limitations + +- The adapter is ESM-only, matching the rest of the YDB JavaScript SDK packages. +- Nested YDB transactions are not supported. Create one transaction boundary and pass the transaction object to lower-level functions. +- YDB supports the adapter transaction modes `serializableReadWrite` and `snapshotReadOnly`; unsupported isolation levels are not emulated. +- `references()` is relation metadata only. YDB does not enforce foreign keys. +- Unique indexes must be created with table DDL; adding a unique index to an existing table is rejected. +- Raw helpers (`sql.raw`, inline migration `sql`, `rawTableOption`, view query text, raw ACL permissions, transfer `using`) intentionally bypass escaping and must not receive untrusted input. diff --git a/docs/guide/drizzle-adapter/query-builders.md b/docs/guide/drizzle-adapter/query-builders.md new file mode 100644 index 00000000..a769f05e --- /dev/null +++ b/docs/guide/drizzle-adapter/query-builders.md @@ -0,0 +1,164 @@ +--- +title: Drizzle Adapter — Query Builders +--- + +# Query Builders + +The YDB Drizzle adapter extends the standard Drizzle syntax to support YDB-specific features and YQL-native operators. + +For end-to-end examples that combine these builders, see [Drizzle Adapter Examples](/guide/drizzle-adapter/examples). + +## SELECT Builder + +The `YdbSelectBuilder` is the primary tool for data fetching. + +### Entry Points + +```ts +db.select() // SELECT * +db.select({ id: users.id, name: users.name }) // Specific columns +db.selectDistinct({ name: users.name }) // SELECT DISTINCT +db.selectDistinctOn([users.name], { id: users.id }) // SELECT DISTINCT ON (...) +``` + +### Fluent API & YDB Extensions + +In addition to standard Drizzle methods (`where`, `orderBy`, `limit`, etc.), the adapter provides: + +- `.fromAsTable(binding, alias?)`: Use `AS_TABLE` source. +- `.fromValues(rows, options?)`: Use inline values as a source. +- `.without(...columns)`: Exclude specific columns from `SELECT *`. +- `.groupCompactBy(...columns)`: Optimized grouping for pre-sorted inputs. +- `.assumeOrderBy(...columns)`: Optimizer hint that the input is already sorted. +- `.sample(ratio)` / `.tableSample(method, size)`: Random row sampling. +- `.window(name, definition)`: Named window definitions. +- `.intoResult(name)`: Direct output to a named YDB result block. +- `.flattenBy()`, `.flattenListBy()`: YQL `FLATTEN` operators. + +Example: + +```ts +const rows = await db + .select() + .from(users) + .without(users.password) + .where(eq(users.active, true)) + .limit(10) + .execute() +``` + +## JOINs + +The adapter supports 10 join types, including YDB-specific semi-joins: + +| Standard JOINs | YDB Semi-JOINs | +| :------------- | :------------------------------------------------------------- | +| `.innerJoin()` | `.leftSemiJoin()`: Left rows with matches in right. | +| `.leftJoin()` | `.rightSemiJoin()`: Right rows with matches in left. | +| `.rightJoin()` | `.leftOnlyJoin()`: Left rows **without** matches (NOT EXISTS). | +| `.fullJoin()` | `.rightOnlyJoin()`: Right rows **without** matches. | +| `.crossJoin()` | `.exclusionJoin()`: Rows without a match in the other table. | + +```ts +const inactiveUsers = await db + .select() + .from(users) + .leftOnlyJoin(posts, eq(users.id, posts.authorId)) + .execute() +``` + +## Mutations + +### INSERT, UPSERT, and REPLACE + +YDB provides efficient ways to manage data by primary key. + +- `.insert(table)`: Standard `INSERT INTO`. +- `.upsert(table)`: `UPSERT INTO` — the most efficient "insert or update" by PK. +- `.replace(table)`: `REPLACE INTO` — fully replaces the row by PK; columns omitted from `.values()` are rendered as `DEFAULT`. + +```ts +await db.upsert(users).values({ id: 1, name: 'Alice' }).execute() +await db.replace(users).values({ id: 1, name: 'Replaced' }).execute() +``` + +Use `upsert()` when a retryable writer should create a row or update only the provided columns: + +```ts +await db + .upsert(users) + .values({ + id: 42, + name: 'Ada', + updatedAt: new Date(), + }) + .execute() +``` + +Use `replace()` when the row is a complete snapshot and defaults for omitted columns are intentional: + +```ts +await db + .replace(users) + .values({ + id: 42, + name: 'Ada Lovelace', + updatedAt: new Date(), + }) + .execute() +``` + +### UPDATE and DELETE + +- `.update(table)`: Partial column updates. +- `.delete(table)`: Row deletion. + +The adapter also supports **Update with Subquery** (`.on()`) and **Delete with USING**: + +```ts +await db + .update(users) + .on((qb) => + qb + .select({ id: users.id, name: sql`'New Name'`.as('name') }) + .from(users) + .where(eq(users.status, 'active')) + ) + .execute() +``` + +## CTE ($with) + +Common Table Expressions in YDB are rendered as variable bindings. + +```ts +const activeUsers = db + .$with('active_users') + .as(db.select().from(users).where(eq(users.active, true))) + +const rows = await db.with(activeUsers).select().from(activeUsers).execute() +``` + +## Prepared Queries + +Use `.prepare()` for frequently executed queries to save on parsing and planning overhead. + +```ts +const selectUser = db + .select() + .from(users) + .where(eq(users.id, sql.placeholder('id'))) + .prepare() + +const user = await selectUser.execute({ id: 1 }) +``` + +Builders execute with `.execute()`. Prepared queries also expose `.all()`, `.get()`, and `.values()`. + +```ts +const prepared = db.select({ id: users.id, name: users.name }).from(users).prepare('users') + +const rows = await prepared.all() +const first = await prepared.get() +const values = await prepared.values() +``` diff --git a/docs/guide/drizzle-adapter/schema.md b/docs/guide/drizzle-adapter/schema.md new file mode 100644 index 00000000..5517f5f2 --- /dev/null +++ b/docs/guide/drizzle-adapter/schema.md @@ -0,0 +1,240 @@ +--- +title: Drizzle Adapter — Schema +--- + +# Schema Definition + +The YDB Drizzle adapter provides a specialized DSL for describing YDB tables, supporting all YDB-specific features like TTL, partitioning, and specialized indexes. + +## Table Definition + +Use `ydbTable` to define your tables. In YDB, a **Primary Key is mandatory**. + +```ts +import { integer, text, ydbTable } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) +``` + +### ydbTable Structure + +1. **Table Name** (`string`): The name of the table in the database. +2. **Columns** (`object`): Column definitions. +3. **Extra Config** (`callback`, optional): Function to define indexes, constraints, and YDB-specific options. + +```ts +export const memberships = ydbTable( + 'memberships', + { + userId: integer('user_id').notNull(), + orgId: integer('org_id').notNull(), + }, + (table) => [primaryKey({ columns: [table.userId, table.orgId] })] +) +``` + +### Table Name Customization + +Use `ydbTableCreator` to apply a global prefix or transformation to all table names. + +```ts +import { ydbTableCreator } from '@ydbjs/drizzle-adapter' + +const appTable = ydbTableCreator((name) => `myapp/${name}`) + +export const users = appTable('users', { + /* ... */ +}) +``` + +## Data Types + +The adapter exports builders for all YDB primitive and composite types. + +### Primitive Types + +| Function | YDB Type | TS Type | Description | +| :-------------- | :------- | :-------- | :---------------------- | +| `boolean(name)` | `Bool` | `boolean` | Logical value | +| `int8(name)` | `Int8` | `number` | 8-bit signed integer | +| `uint8(name)` | `Uint8` | `number` | 8-bit unsigned integer | +| `int16(name)` | `Int16` | `number` | 16-bit signed integer | +| `uint16(name)` | `Uint16` | `number` | 16-bit unsigned integer | +| `integer(name)` | `Int32` | `number` | 32-bit signed integer | +| `uint32(name)` | `Uint32` | `number` | 32-bit unsigned integer | +| `bigint(name)` | `Int64` | `bigint` | 64-bit signed integer | +| `uint64(name)` | `Uint64` | `bigint` | 64-bit unsigned integer | +| `float(name)` | `Float` | `number` | 32-bit floating point | +| `double(name)` | `Double` | `number` | 64-bit floating point | + +### Strings and Binary + +| Function | YDB Type | TS Type | Description | +| :---------------------- | :------------- | :----------- | :---------------------- | +| `text(name)` | `Utf8` | `string` | Unicode string | +| `bytes(name)` | `String` | `Uint8Array` | Binary data | +| `uuid(name)` | `Uuid` | `string` | UUID string | +| `yson(name)` | `Yson` | `Uint8Array` | YSON format | +| `json(name)` | `Json` | `T` | JSON text | +| `jsonDocument(name)` | `JsonDocument` | `T` | Binary JSON (optimized) | + +### Date and Time + +| Function | YDB Type | TS Type | Description | +| :------------------ | :------------ | :------- | :--------------------------------- | +| `date(name)` | `Date` | `Date` | Date only (until 2038) | +| `date32(name)` | `Date32` | `Date` | Date with extended range | +| `datetime(name)` | `Datetime` | `Date` | Date and time (until 2038) | +| `datetime64(name)` | `Datetime64` | `Date` | Date and time (extended) | +| `timestamp(name)` | `Timestamp` | `Date` | Microsecond precision (until 2038) | +| `timestamp64(name)` | `Timestamp64` | `Date` | Microsecond precision (extended) | +| `interval(name)` | `Interval` | `number` | Interval in microseconds | + +### Mapping Notes + +- YDB `String` is binary data in the adapter and maps to `Uint8Array` via `bytes()` or `binary()`. +- Human-readable text should use YDB `Utf8` via `text()`. +- `Int64` and `Uint64` map to `bigint` to avoid precision loss. +- Date/time builders map to JavaScript `Date`; the adapter handles conversion at the driver boundary. +- `json()` and `jsonDocument()` return typed JSON values. Use the generic to document the expected shape. + +```ts +import { bigint, bytes, json, text, timestamp, ydbTable } from '@ydbjs/drizzle-adapter' + +type Profile = { timezone: string; flags: string[] } + +export const profiles = ydbTable('profiles', { + id: bigint('id').primaryKey(), + displayName: text('display_name').notNull(), + avatar: bytes('avatar'), + settings: json('settings'), + updatedAt: timestamp('updated_at').notNull(), +}) +``` + +## Column Modifiers + +- `.primaryKey()`: Part of the primary key. +- `.notNull()`: Disallows `NULL`. +- `.default(value)`: Static default value. +- `.$defaultFn(() => value)`: Dynamic default value. +- `.unique(name?)`: Creates a secondary unique index. +- `.references(() => column)`: Metadata for Relational Query API (YDB doesn't support native Foreign Keys). + +## Primary Keys + +YDB requires a primary key for every table. + +### Single Column + +```ts +id: integer('id').primaryKey() +``` + +### Composite (Table-level) + +```ts +export const details = ydbTable( + 'details', + { + orderId: integer('order_id').notNull(), + lineNum: integer('line_num').notNull(), + }, + (table) => [primaryKey({ columns: [table.orderId, table.lineNum] })] +) +``` + +## Secondary Indexes + +Secondary indexes in YDB are stored as separate internal tables. + +```ts +import { index, uniqueIndex } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable( + 'users', + { + /* ... */ + }, + (table) => [ + index('users_tenant_idx').on(table.tenantId), + index('users_email_idx').on(table.email).global().sync().cover(table.name), + uniqueIndex('users_login_idx').on(table.login), + ] +) +``` + +- `.global()` / `.local()`: Index locality (Global is default). +- `.sync()` / `.async()`: Write synchronization mode. +- `.cover(...columns)`: Add columns to the index (Covering Index). + +## Vector Indexes + +YDB supports specialized indexes for AI-driven vector search. + +```ts +import { vectorIndex } from '@ydbjs/drizzle-adapter' + +export const embeddings = ydbTable( + 'embeddings', + { + id: integer('id').primaryKey(), + vector: bytes('vector').notNull(), + }, + (table) => [ + vectorIndex('vector_idx', { + vectorDimension: 1536, + vectorType: 'float', + distance: 'cosine', + clusters: 128, + levels: 2, + }).on(table.vector), + ] +) +``` + +## Table Options + +### Physical Parameters + +```ts +import { tableOptions } from '@ydbjs/drizzle-adapter' + +tableOptions({ + AUTO_PARTITIONING_BY_LOAD: 'ENABLED', + KEY_BLOOM_FILTER: 'ENABLED', +}) +``` + +### Time to Live (TTL) + +Automatic data expiration. + +```ts +import { ttl } from '@ydbjs/drizzle-adapter' + +ttl(table.createdAt, 'P30D') // Delete after 30 days +ttl(table.expireAt, '3600', { unit: 'SECONDS' }) +``` + +### Partitioning + +```ts +import { partitionByHash } from '@ydbjs/drizzle-adapter' + +partitionByHash(table.tenantId) +``` + +### Column Families + +```ts +import { columnFamily } from '@ydbjs/drizzle-adapter' + +columnFamily('cold_data', { + data: 'rot', + compression: 'zstd', +}).columns(table.bio) +``` diff --git a/docs/guide/drizzle-adapter/yql-helpers.md b/docs/guide/drizzle-adapter/yql-helpers.md new file mode 100644 index 00000000..52d6f905 --- /dev/null +++ b/docs/guide/drizzle-adapter/yql-helpers.md @@ -0,0 +1,114 @@ +--- +title: Drizzle Adapter — YQL Helpers +--- + +# YQL Helpers + +The adapter includes specialized tools for building advanced analytical queries and scripts directly in YQL. + +The runnable lab in [Drizzle Adapter Examples](/guide/drizzle-adapter/examples) includes live and preview-only scenarios for these helpers. + +## SELECT Sources + +### `asTable(binding, alias?)` + +Use a YQL variable (like `List`) as a table source in `FROM`. + +```ts +import { sql } from 'drizzle-orm' +import { asTable } from '@ydbjs/drizzle-adapter' + +await db + .select({ id: sql`t.id`, name: sql`t.name` }) + .from(asTable('$my_list', 't')) + .execute() +``` + +### `valuesTable(rows, options?)` + +Create a temporary data source from an array of objects (SQL `VALUES` equivalent). + +```ts +import { sql } from 'drizzle-orm' +import { valuesTable } from '@ydbjs/drizzle-adapter' + +const v = valuesTable([{ id: 1, name: 'Alice' }], { + alias: 'v', + columns: ['id', 'name'], +}) + +await db + .select({ id: sql`v.id`, name: sql`v.name` }) + .from(v) + .execute() +``` + +## Analytical Functions (OLAP) + +Use these helpers inside `.groupBy()` for advanced aggregation. + +- `rollup(...columns)`: Hierarchical sub-totals. +- `cube(...columns)`: Sub-totals for all combinations. +- `groupingSets(...sets)`: Custom grouping sets. +- `grouping(column)`: Detects if a row is a sub-total for the column. + +```ts +import { rollup } from '@ydbjs/drizzle-adapter' + +await db + .select({ city: sales.city, total: sql`sum(amount)` }) + .from(sales) + .groupBy(rollup(sales.country, sales.city)) + .execute() +``` + +## Time Windows + +Streaming and time-series aggregation helpers: + +- `sessionWindow(column, timeout)`: Group events into sessions. +- `hop(column, hop, window)`: Sliding window aggregation. + +## Vector Search (KNN) + +Distance and similarity functions for AI-driven search, typically used in `orderBy`. + +| Function | Description | +| :---------------------------------- | :------------------------ | +| `knnCosineDistance(v1, v2)` | Cosine distance. | +| `knnEuclideanDistance(v1, v2)` | Euclidean distance. | +| `knnInnerProductSimilarity(v1, v2)` | Inner product similarity. | + +```ts +import { sql } from 'drizzle-orm' +import { knnCosineDistance } from '@ydbjs/drizzle-adapter' + +const nearest = await db + .select() + .from(images) + .orderBy(knnCosineDistance(images.embedding, sql`$target`)) + .limit(10) + .execute() +``` + +## YQL Scripts + +The `yqlScript` helper allows combining multiple commands, pragmas, and parameters into a single atomic execution block. + +```ts +import { sql } from 'drizzle-orm' +import { declareParam, pragma, yqlScript } from '@ydbjs/drizzle-adapter' + +await db.execute( + yqlScript( + pragma('TablePathPrefix', '/local'), + declareParam('$userId', 'Int32'), + sql`UPSERT INTO users (id) VALUES ($userId);` + ) +) +``` + +- `pragma(name, value)`: Set execution settings. +- `declareParam(name, type)`: Explicitly declare YQL parameter types. +- `defineAction(name, params, statements)`: Create reusable macros. +- `doAction(name, args)`: Call defined actions. diff --git a/docs/ru/guide/drizzle-adapter/api-index.md b/docs/ru/guide/drizzle-adapter/api-index.md new file mode 100644 index 00000000..03168967 --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/api-index.md @@ -0,0 +1,80 @@ +--- +title: Публичный API +description: Полный список корневых экспортов и публичных методов @ydbjs/drizzle-adapter. +--- + +Этот файл содержит исчерпывающий список всех публичных имен, экспортируемых пакетом `@ydbjs/drizzle-adapter`. + +## Подключение и база данных + +- [`createDrizzle`](./database-api#createdrizzle) — инициализация адаптера. +- [`drizzle`](./database-api#createdrizzle) — алиас `createDrizzle`. +- Типы `YdbDrizzleDatabase`, `YdbDrizzleOptions`, `YdbExecutor`, `YdbTransactionalExecutor` и конфигурация транзакций. + +## Драйвер и Транспорт + +- [`YdbDriver`](./database-api) — основной драйвер. +- Ошибки выполнения: `YdbUniqueConstraintViolationError`, `YdbAuthenticationError`, `YdbCancelledQueryError`, `YdbTimeoutQueryError`, `YdbUnavailableQueryError`, `YdbOverloadedQueryError`, `YdbRetryableQueryError`. + +Внутренние классы диалекта, сессии, транзакции и конкретных builders намеренно не являются корневыми runtime-экспортами. Стабильная runtime-поверхность — объект БД, возвращаемый `createDrizzle()`. + +Смаппленные ошибки YDB содержат `kind`, `retryable`, `statusCode` и исходные диагностические поля вроде `code`, `status`, `issues`, если драйвер их передал. + +## Таблицы и Схема + +- [`ydbTable`](./schema) — объявление таблицы. +- [`ydbTableCreator`](./schema#кастомизация-имен-таблиц) — фабрика таблиц с префиксами. +- [`primaryKey`](./schema#первичные-ключи) — составной ПК. +- [`unique`](./schema#вторичные-индексы) — ограничение уникальности. +- [`relations`, `one`, `many`](./database-api#relational-query-api) — описание связей. + +## Колонки и Типы (25+ типов) + +Подробное описание всех типов: [Колонки и типы](./schema#типы-данных). + +- `integer`, `int`, `text`, `bigint`, `boolean`, `bytes`, `binary`, `date`, `date32`, `datetime`, `datetime64`, `decimal`, `double`, `dyNumber`, `float`, `int8`, `int16`, `interval`, `interval64`, `json`, `jsonDocument`, `timestamp`, `timestamp64`, `uint8`, `uint16`, `uint32`, `uint64`, `uuid`, `yson`, `customType`. + +## Опции таблиц и TTL + +- [`tableOptions`](./schema#настройки-таблицы) — физические параметры. +- [`ttl`](./schema#время-жизни-данных-ttl) — Time To Live. +- [`partitionByHash`](./schema#партиционирование) — шардирование. +- [`columnFamily`](./schema#семейства-колонок) — семейства колонок. +- [`rawTableOption`](./schema#физические-параметры) — сырые опции. + +## Индексы и Поиск + +- [`index`](./schema#вторичные-индексы) — вторичный индекс. +- [`uniqueIndex`](./schema#вторичные-индексы) — уникальный индекс. +- [`vectorIndex`](./schema#векторные-индексы) — векторный поиск. +- [`indexView`](./yql-helpers) — чтение через индекс. +- [`vectorIndexView`](./yql-helpers) — чтение через векторный индекс. + +## Построители запросов (Query Builders) + +Подробный гид: [SELECT builder](./query-builders), [Мутации](./query-builders#мутации-изменение-данных). + +- **SELECT**: `db.select`, `db.selectDistinct`, `db.selectDistinctOn`, `db.with`, `db.$with`. +- **Мутации**: `db.insert`, `db.upsert`, `db.replace`, `db.update`, `db.batchUpdate`, `db.delete`, `db.batchDelete`. +- **Утилиты**: `db.$count` и callback-builders, передаваемые в `.select()`, `.on()` и mutation helpers. + +## Хелперы YQL и Аналитика + +Подробная справка: [YQL-хелперы](./yql-helpers). + +- **Скрипты**: `yqlScript`, `pragma`, `declareParam`, `commit`, `defineAction`, `doAction`, `doBlock`, `intoResult`. +- **Аналитика**: `rollup`, `cube`, `groupingSets`, `grouping`, `groupKey`, `sessionWindow`, `sessionStart`, `hop`, `hopStart`, `hopEnd`. +- **Векторный поиск (KNN)**: `knnDistance`, `knnSimilarity`, `knnCosineDistance`, `knnEuclideanDistance`, `knnManhattanDistance`, `knnCosineSimilarity`, `knnInnerProductSimilarity`, `kMeansTreeSearchTopSize`. +- **Источники и хинты**: `asTable`, `values`, `valuesTable`, `matchRecognize`, `uniqueHint`, `distinctHint`, `windowDefinition`. + +## Миграции и DDL (50+ методов) + +Подробно: [Migrator](./migrations-ddl), [DDL-построители](./migrations-ddl#ddl-построители). + +- **Core**: `migrate`, `buildMigrationSql`, `buildMigrationLockTableBootstrapSql`. +- **Таблицы**: `buildCreateTableSql`, `buildDropTableSql`, `buildAlterTableSql`, `buildRenameTableSql`. +- **Колонки и CF**: `buildAddColumnsSql`, `buildDropColumnsSql`, `buildAddColumnFamilySql`, `buildAlterColumnFamilySql`, `buildAlterColumnSetFamilySql`. +- **Индексы и CDC**: `buildAddIndexSql`, `buildDropIndexSql`, `buildAddChangefeedSql`, `buildDropChangefeedSql`. +- **Топики**: `buildCreateTopicSql`, `buildAlterTopicSql`, `buildDropTopicSql`. +- **Пользователи и права**: `buildCreateUserSql`, `buildAlterUserSql`, `buildDropUserSql`, `buildGrantSql`, `buildRevokeSql`, `buildCreateGroupSql`, `buildAlterGroupSql`, `buildDropGroupSql`. +- **Сервисы**: `buildCreateAsyncReplicationSql`, `buildAlterAsyncReplicationSql`, `buildDropAsyncReplicationSql`, `buildCreateTransferSql`, `buildAlterTransferSql`, `buildDropTransferSql`, `buildCreateSecretSql`, `buildCreateViewSql`, `buildDropViewSql`, `buildAnalyzeSql`, `buildShowCreateSql`. diff --git a/docs/ru/guide/drizzle-adapter/database-api.md b/docs/ru/guide/drizzle-adapter/database-api.md new file mode 100644 index 00000000..9c9590cf --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/database-api.md @@ -0,0 +1,175 @@ +--- +title: Database API +description: Создание клиента, выполнение YQL и транзакции. +--- + +# Database API + +Объект базы данных, возвращаемый `createDrizzle()`, является основным интерфейсом взаимодействия. Он объединяет возможности построителей запросов Drizzle с функциональностью YDB. + +Runnable-приложение, которое покрывает большинство методов этой страницы, находится в разделе [Примеры Drizzle Adapter](/ru/guide/drizzle-adapter/examples). + +## Инициализация + +Функция `createDrizzle` (алиас `drizzle`) — точка входа для создания экземпляра базы данных. + +### Способы конфигурации + +- `createDrizzle(options)`: создает экземпляр по строке подключения или существующему клиенту. +- `createDrizzle(callback, config?)`: создает экземпляр поверх кастомного callback-исполнителя (полезно для Remote/Proxy конфигураций). + +### Опции + +- `connectionString`: строка подключения YDB (например, `grpc://localhost:2136/local`). Адаптер автоматически создает и управляет экземпляром `Driver`. +- `client`: существующий `YdbExecutor` или `YdbTransactionalExecutor`. +- `schema`: объект со схемами таблиц и связей для работы Relational Query API (`db.query.*`). +- `logger`: `true` для стандартного логгера, `false` для отключения или кастомный `Logger` Drizzle. +- `casing`: режим преобразования имен Drizzle (`'snake_case'` или `'camelCase'`). + +Пример: + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, + schema, + logger: true, +}) +``` + +### Управление жизненным циклом + +При использовании `connectionString` адаптер владеет базовым драйвером. Используйте `$client` для управления его состоянием: + +- `await db.$client.ready()`: позволяет убедиться, что драйвер инициализирован и база доступна. +- `await db.$client.close()`: закрывает пул сессий и освобождает ресурсы драйвера. **Обязательно вызывайте этот метод при завершении работы приложения.** + +```ts +await db.$client.ready() +// ... логика приложения ... +await db.$client.close() +``` + +## Выполнение запросов + +Методы для выполнения сырого YQL или объектов-построителей: + +| Метод | Описание | +| :---------------- | :------------------------------------------------------ | +| `.execute(query)` | Выполняет запрос и возвращает типизированный результат. | +| `.all(query)` | Возвращает все строки в виде массива объектов. | +| `.get(query)` | Возвращает первую найденную строку или `undefined`. | +| `.values(query)` | Возвращает данные в виде массива массивов значений. | + +Пример: + +```ts +import { sql } from 'drizzle-orm' + +await db.execute(sql`DELETE FROM users WHERE id = ${1}`) + +const rows = await db.all(sql`SELECT * FROM users`) +const user = await db.get(sql`SELECT * FROM users LIMIT 1`) +const ids = await db.values<[number]>(sql`SELECT id FROM users`) + +const firstBuilt = await db.get(db.select({ id: users.id, name: users.name }).from(users).limit(1)) +``` + +## Построители запросов + +### Выборка (SELECT) + +- `db.select(fields?)`, `db.selectDistinct(fields?)`, `db.selectDistinctOn(on, fields?)`: инициализация построителей SELECT. +- `db.with(...queries)`, `db.$with(alias)`: объявление CTE (Common Table Expressions). + +### Мутации + +| Метод | Операция YQL | Описание | +| :---------------------- | :--------------- | :------------------------------------- | +| `db.insert(table)` | `INSERT INTO` | Обычная вставка. | +| `db.upsert(table)` | `UPSERT INTO` | Вставка или обновление по Primary Key. | +| `db.replace(table)` | `REPLACE INTO` | Полная замена строки. | +| `db.update(table)` | `UPDATE` | Частичное обновление. | +| `db.delete(table)` | `DELETE FROM` | Удаление строк. | +| `db.batchUpdate(table)` | (оптимизировано) | Массовое обновление нескольких строк. | +| `db.batchDelete(table)` | (оптимизировано) | Массовое удаление нескольких строк. | + +### Утилиты + +- `db.$count(source, filters?)`: возвращает awaitable count-выражение для эффективного подсчета строк. + +```ts +import { eq } from 'drizzle-orm' + +const activeCount = await db.$count(users, eq(users.active, true)) + +const rows = await db.select({ id: users.id, name: users.name }).from(users).limit(10).execute() +``` + +## Транзакции + +Метод `db.transaction()` обеспечивает атомарность выполнения группы операций. + +```ts +await db.transaction( + async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Alice' }).execute() + await tx + .update(stats) + .set({ count: sql`count + 1` }) + .execute() + + // Ручной откат + if (someCondition) { + tx.rollback() + } + }, + { + accessMode: 'read write', + isolationLevel: 'serializableReadWrite', + idempotent: true, + } +) +``` + +### Опции + +- `accessMode`: `'read write'` (по умолчанию) или `'read only'`. Используйте `'read only'` для оптимизации читающих транзакций. +- `isolationLevel`: `'serializableReadWrite'` (рекомендуется для YDB) или `'snapshotReadOnly'`. +- `idempotent`: если `true`, адаптер сможет автоматически перезапустить транзакцию при сетевых ошибках. + +## Relational Query API + +Если при инициализации была передана `schema`, доступен высокоуровневый API для работы со связями через `db.query.*`. + +```ts +const user = await db.query.users.findFirst({ + where: (u, { eq }) => eq(u.id, 1), + with: { + posts: { + columns: { + id: true, + title: true, + }, + limit: 5, + orderBy: (p, { desc }) => [desc(p.id)], + }, + profile: true, + }, +}) +``` + +### Опции + +- `columns`: включение или исключение конкретных колонок. +- `where`: логика фильтрации. +- `orderBy`: выражения сортировки. +- `limit`, `offset`: пагинация. +- `with`: вложенная загрузка связей. +- `extras`: дополнительные SQL-выражения. + +## Служебные свойства + +- `$client`: базовый исполнитель. Используется для вызова `ready()` и `close()`. +- `_`: внутренние метаданные Drizzle/адаптера. Не используйте это поле как стабильный API приложения. diff --git a/docs/ru/guide/drizzle-adapter/examples.md b/docs/ru/guide/drizzle-adapter/examples.md new file mode 100644 index 00000000..07670bc4 --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/examples.md @@ -0,0 +1,507 @@ +--- +title: Drizzle Adapter — Примеры +--- + +# Примеры + +В репозитории есть два runnable-примера: + +- `examples/drizzle-adapter`: компактный TypeScript CLI-пример в стиле остальных SDK examples. +- `examples/drizzle-adapter-lab`: расширенная интерактивная TypeScript-лаборатория с UI, generated YQL, результатами и trace последних запросов. + +```bash +cd examples/drizzle-adapter +npm install +npm start +``` + +```bash +cd examples/drizzle-adapter-lab +npm install +npm run db:up +npm start +``` + +Для lab откройте `http://localhost:3000`. + +## Общая подготовка + +Большинство примеров ниже предполагают такой объект `db` и схему. + +```ts +import { asc, desc, eq, sql } from 'drizzle-orm' +import { + createDrizzle, + index, + integer, + many, + one, + relations, + text, + timestamp, + ydbTable, +} from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('example_users', { + id: integer('id').primaryKey(), + email: text('email').notNull().unique('example_users_email_unique'), + name: text('name').notNull(), + status: text('status').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}) + +export const projects = ydbTable( + 'example_projects', + { + id: integer('id').primaryKey(), + ownerId: integer('owner_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => [index('example_projects_owner_idx').on(table.ownerId).global().sync()] +) + +export const tasks = ydbTable( + 'example_tasks', + { + id: integer('id').primaryKey(), + projectId: integer('project_id').notNull(), + assigneeId: integer('assignee_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + priority: text('priority').notNull(), + estimateHours: integer('estimate_hours').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => [ + index('example_tasks_project_idx').on(table.projectId).global().sync(), + index('example_tasks_assignee_idx').on(table.assigneeId).global().sync(), + ] +) + +export const taskSnapshots = ydbTable('example_task_snapshots', { + id: integer('id').primaryKey(), + projectId: integer('project_id').notNull(), + assigneeId: integer('assignee_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + priority: text('priority').notNull(), + estimateHours: integer('estimate_hours').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}) + +export const usersRelations = relations(users, ({ many }) => ({ + ownedProjects: many(projects), + assignedTasks: many(tasks), +})) + +export const projectsRelations = relations(projects, ({ one, many }) => ({ + owner: one(users, { fields: [projects.ownerId], references: [users.id] }), + tasks: many(tasks), +})) + +export const tasksRelations = relations(tasks, ({ one }) => ({ + project: one(projects, { fields: [tasks.projectId], references: [projects.id] }), + assignee: one(users, { fields: [tasks.assigneeId], references: [users.id] }), +})) + +export const db = createDrizzle({ + connectionString: process.env.YDB_CONNECTION_STRING!, + schema: { + users, + projects, + tasks, + taskSnapshots, + usersRelations, + projectsRelations, + tasksRelations, + }, +}) +``` + +## CRUD и returning + +```ts +const now = new Date() + +const inserted = await db + .insert(users) + .values({ + id: 1, + email: 'ada@example.com', + name: 'Ada', + status: 'active', + createdAt: now, + updatedAt: now, + }) + .returning({ id: users.id, email: users.email }) + .execute() + +await db + .insert(users) + .values({ + id: 1, + email: 'ada@new.example.com', + name: 'Ada', + status: 'active', + createdAt: now, + updatedAt: now, + }) + .onDuplicateKeyUpdate({ set: { email: 'ada@new.example.com', updatedAt: now } }) + .execute() + +await db + .upsert(users) + .values({ + id: 2, + email: 'grace@example.com', + name: 'Grace', + status: 'active', + createdAt: now, + updatedAt: now, + }) + .execute() +await db + .replace(users) + .values({ + id: 2, + email: 'grace@example.com', + name: 'Grace Hopper', + status: 'review', + createdAt: now, + updatedAt: now, + }) + .execute() + +await db.update(users).set({ status: 'paused', updatedAt: now }).where(eq(users.id, 2)).execute() +await db + .delete(users) + .where(eq(users.id, 2)) + .returning({ id: users.id, email: users.email }) + .execute() +``` + +## Batch-мутации + +```ts +await db + .batchUpdate(tasks) + .set({ status: 'review', updatedAt: new Date() }) + .where(eq(tasks.status, 'blocked')) + .execute() + +await db.batchDelete(tasks).where(eq(tasks.status, 'done')).execute() +``` + +## Prepared reads + +```ts +const preparedUser = db + .select({ id: users.id, email: users.email, name: users.name }) + .from(users) + .where(eq(users.id, 1)) + .prepare('get_user_by_id') + +const row = await preparedUser.get() +const rows = await preparedUser.all() +const values = await preparedUser.values() +``` + +Построители также можно передавать в методы выполнения на `db`. + +```ts +const firstTask = await db.get( + db.select({ id: tasks.id, title: tasks.title }).from(tasks).orderBy(asc(tasks.id)).limit(1) +) +``` + +## Raw execution + +```ts +const allRows = await db.all(sql`SELECT id, email FROM ${users} ORDER BY id`) +const oneRow = await db.get(sql`SELECT id, title FROM ${tasks} WHERE id = ${3001} LIMIT 1`) +const rawValues = await db.values<[number, string]>(sql`SELECT id, name FROM ${users} ORDER BY id`) +await db.execute(sql`DELETE FROM ${tasks} WHERE status = ${'archived'}`) +``` + +## Relations + +```ts +const result = await db.query.projects.findMany({ + columns: { id: true, title: true, status: true }, + orderBy: (project, { asc }) => [asc(project.id)], + with: { + owner: { columns: { id: true, email: true, name: true } }, + tasks: { + columns: { id: true, title: true, status: true, priority: true }, + orderBy: (task, { desc }) => [desc(task.estimateHours)], + with: { + assignee: { columns: { id: true, name: true } }, + }, + }, + }, +}) +``` + +## Joins, CTE и set operators + +```ts +const joinedRows = await db + .select({ + projectId: projects.id, + projectTitle: projects.title, + ownerName: users.name, + taskTitle: tasks.title, + }) + .from(projects) + .innerJoin(users, eq(projects.ownerId, users.id)) + .leftJoin(tasks, eq(tasks.projectId, projects.id)) + .orderBy(asc(projects.id), asc(tasks.id)) + .execute() + +const usersWithProjects = await db + .select({ id: users.id, name: users.name }) + .from(users) + .leftSemiJoin(projects, eq(projects.ownerId, users.id)) + .execute() + +const backlog = db + .$with('backlog') + .as( + db + .select({ projectId: tasks.projectId, taskId: tasks.id, title: tasks.title }) + .from(tasks) + .where(eq(tasks.status, 'todo')) + ) + +const backlogRows = await db.with(backlog).select().from(backlog).execute() + +const projectOwners = db.selectDistinct({ userId: projects.ownerId }).from(projects) +const taskAssignees = db.selectDistinct({ userId: tasks.assigneeId }).from(tasks) + +const activePeople = await projectOwners.union(taskAssignees).execute() +const ownerAndAssignee = await projectOwners.intersect(taskAssignees).execute() +const ownersWithoutTasks = await projectOwners.except(taskAssignees).execute() +``` + +## Distinct, grouping и windows + +```ts +const statuses = await db + .selectDistinct({ status: tasks.status }) + .from(tasks) + .orderBy(asc(tasks.status)) + .execute() + +const newestProjectPerOwner = await db + .selectDistinctOn([projects.ownerId], { + ownerId: projects.ownerId, + projectId: projects.id, + title: projects.title, + updatedAt: projects.updatedAt, + }) + .from(projects) + .orderBy(asc(projects.ownerId), desc(projects.updatedAt)) + .execute() + +const totals = await db + .select({ + status: tasks.status, + total: sql`count(*)`, + totalEstimate: sql`sum(${tasks.estimateHours})`, + }) + .from(tasks) + .groupCompactBy(tasks.status) + .assumeOrderBy(tasks.status) + .orderBy(asc(tasks.status)) + .execute() +``` + +## Inline sources + +```ts +import { asTable, values, valuesTable } from '@ydbjs/drizzle-adapter' + +const lanes = await db + .select({ lane: sql`lanes.lane`, weight: sql`lanes.weight` }) + .fromValues( + [ + { lane: 'backlog', weight: 1 }, + { lane: 'review', weight: 2 }, + ], + { alias: 'lanes', columns: ['lane', 'weight'] } + ) + .execute() + +const priorityMap = valuesTable( + [ + { priority: 'high', band: 'P1' }, + { priority: 'medium', band: 'P2' }, + ], + { alias: 'priority_map', columns: ['priority', 'band'] } +) + +const mappedTasks = await db + .select({ taskId: tasks.id, band: sql`priority_map.band` }) + .from(tasks) + .innerJoin(priorityMap, eq(tasks.priority, sql`priority_map.priority`)) + .execute() + +const source = values([{ id: 1, name: 'Ada' }]) +const valuesPreview = db + .select({ id: sql`v.id`, name: sql`v.name` }) + .from(sql`${source} AS v`) + .toSQL() +const asTablePreview = db + .select({ id: sql`r.id`, name: sql`r.name` }) + .from(asTable('$rows', 'r')) + .toSQL() +``` + +## Insert from select + +```ts +await db + .insert(taskSnapshots) + .select( + db + .select({ + id: tasks.id, + projectId: tasks.projectId, + assigneeId: tasks.assigneeId, + title: tasks.title, + status: tasks.status, + priority: tasks.priority, + estimateHours: tasks.estimateHours, + createdAt: tasks.createdAt, + updatedAt: tasks.updatedAt, + }) + .from(tasks) + .where(eq(tasks.status, 'blocked')) + ) + .execute() +``` + +## Транзакции + +```ts +await db.transaction( + async (tx) => { + const newTask = { + id: 100, + projectId: 10, + assigneeId: 1, + title: 'Transactional task', + status: 'todo', + priority: 'high', + estimateHours: 4, + createdAt: new Date(), + updatedAt: new Date(), + } + + await tx.insert(tasks).values(newTask).execute() + return tx + .select({ id: tasks.id, title: tasks.title }) + .from(tasks) + .where(eq(tasks.id, newTask.id)) + .prepare() + .get() + }, + { + accessMode: 'read write', + isolationLevel: 'serializableReadWrite', + idempotent: true, + } +) + +await db.transaction(async (tx) => { + const newTask = { + id: 101, + projectId: 10, + assigneeId: 1, + title: 'Rolled back task', + status: 'todo', + priority: 'medium', + estimateHours: 2, + createdAt: new Date(), + updatedAt: new Date(), + } + + await tx.insert(tasks).values(newTask).execute() + tx.rollback() +}) +``` + +## DDL builders и миграции + +```ts +import { + buildAddColumnsSql, + buildAddIndexSql, + buildAlterTableSetOptionsSql, + buildCreateTableSql, + buildDropTableSql, + buildMigrationSql, + buildRenameTableSql, + migrate, +} from '@ydbjs/drizzle-adapter' + +await migrate(db, { + migrationsTable: '__example_migrations', + migrationLock: true, + migrations: [ + { + name: '001_create_tables', + operations: [ + { kind: 'create_table', table: users, ifNotExists: true }, + { kind: 'create_table', table: projects, ifNotExists: true }, + { kind: 'create_table', table: tasks, ifNotExists: true }, + ], + }, + ], +}) + +const createSql = buildCreateTableSql(tasks, { ifNotExists: true }) +const dropSql = buildDropTableSql(tasks, { ifExists: true }) +const migrationSql = buildMigrationSql([{ kind: 'create_table', table: tasks, ifNotExists: true }]) +const renameSql = buildRenameTableSql(tasks, 'example_tasks_archive') +const alterSql = buildAlterTableSetOptionsSql(tasks, { auto_partitioning_by_size: true }) + +const tasksExpanded = ydbTable('example_tasks', { + id: integer('id').primaryKey(), + stage: text('stage').notNull(), +}) +const tasksStatusIndex = index('example_tasks_status_idx').on(tasks.status).build(tasks) + +const addColumnsSql = buildAddColumnsSql(tasksExpanded, [tasksExpanded.stage]) +const addIndexSql = buildAddIndexSql(tasks, tasksStatusIndex) +``` + +## YQL scripts + +```ts +import { + commit, + declareParam, + doBlock, + intoResult, + pragma, + yqlScript, +} from '@ydbjs/drizzle-adapter' + +await db.execute( + yqlScript( + pragma('TablePathPrefix', '/local'), + declareParam('$taskId', 'Int32'), + doBlock([ + intoResult(sql`SELECT id, title FROM ${tasks} WHERE id = $taskId`, 'picked_task'), + commit(), + ]) + ) +) +``` diff --git a/docs/ru/guide/drizzle-adapter/getting-started.md b/docs/ru/guide/drizzle-adapter/getting-started.md new file mode 100644 index 00000000..aec5defc --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/getting-started.md @@ -0,0 +1,219 @@ +--- +title: Быстрый старт +description: Установка, настройка подключения и базовые операции с YDB. +--- + +В этом разделе описаны шаги по установке адаптера, настройке подключения к YDB и выполнению первых операций. + +## Требования + +- **Node.js**: версия 20 или новее. +- **Drizzle ORM**: должен быть установлен параллельно с адаптером. +- **YDB**: доступная база данных (локальная через Docker или облачная в Yandex Cloud). + +## Установка + +Установите адаптер и `drizzle-orm`: + +```sh +npm install @ydbjs/drizzle-adapter drizzle-orm +``` + +## Подключение к базе данных + +Адаптер поддерживает три основных способа инициализации через функцию `createDrizzle` (или её алиас `drizzle`). + +### 1. По строке подключения (рекомендуется) + +Самый простой способ, при котором адаптер сам создаёт и управляет `Driver` из `@ydbjs/core`. + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle({ + connectionString: process.env.YDB_CONNECTION_STRING!, // Например, grpc://localhost:2136/local +}) +``` + +### 2. Через существующий клиент YDB + +Если в вашем приложении уже инициализирован `Driver` из `@ydbjs/core`, оберните его в `YdbDriver` +и передайте как `client`. + +```ts +import { Driver } from '@ydbjs/core' +import { YdbDriver, createDrizzle } from '@ydbjs/drizzle-adapter' + +const driver = new Driver('grpc://localhost:2136/local') + +const db = createDrizzle({ + client: new YdbDriver(driver), +}) +``` + +### 3. Через callback (для тестов или прокси) + +Позволяет полностью переопределить транспортный слой. Полезно для написания моков или использования специфичных прокси-серверов. + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle(async (sql, params, method, options) => { + // sql — строка YQL + // params — массив параметров + // method — тип операции: execute или all + const rows = await myCustomExecute(sql, params, method, options) + return { + rows, + rowCount: rows.length, + command: method, + meta: { + arrayMode: options?.arrayMode === true, + typings: options?.typings, + }, + } +}) +``` + +## Определение схемы + +В YDB таблицы описываются с помощью `ydbTable`. **Важно:** каждая таблица YDB обязана иметь первичный ключ (Primary Key). + +```ts +import { integer, text, timestamp, ydbTable } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull().unique(), + name: text('name'), + createdAt: timestamp('created_at') + .notNull() + .$defaultFn(() => new Date()), +}) +``` + +## Базовый CRUD + +Для выполнения запросов используйте созданный объект `db`. + +### Вставка данных (Insert / Upsert) + +```ts +// Одиночная вставка +await db.insert(users).values({ id: 1, email: 'alice@example.com', name: 'Alice' }).execute() + +// Batch insert (вставка нескольких строк за один запрос) +await db + .insert(users) + .values([ + { id: 2, email: 'bob@example.com', name: 'Bob' }, + { id: 3, email: 'charlie@example.com', name: 'Charlie' }, + ]) + .execute() + +// Upsert (вставка или обновление при конфликте ключей) +await db + .insert(users) + .values({ id: 1, email: 'alice_new@example.com', name: 'Alice Updated' }) + .onDuplicateKeyUpdate({ set: { name: 'Alice Updated' } }) + .execute() +``` + +### Выборка данных (Select) + +```ts +import { eq, like, and } from 'drizzle-orm' + +// Простая выборка всех полей +const allUsers = await db.select().from(users).execute() + +// Выборка с фильтрацией и выбором конкретных полей +const filteredUsers = await db + .select({ + userId: users.id, + userName: users.name, + }) + .from(users) + .where(and(eq(users.id, 1), like(users.email, '%@example.com'))) + .execute() +``` + +### Обновление данных (Update) + +```ts +await db.update(users).set({ name: 'Alice Cooper' }).where(eq(users.id, 1)).execute() +``` + +### Удаление данных (Delete) + +```ts +await db.delete(users).where(eq(users.id, 1)).execute() +``` + +## Транзакции + +Транзакции в YDB поддерживают различные уровни изоляции и режимы доступа. + +```ts +import { TransactionRollbackError } from 'drizzle-orm/errors' + +try { + await db.transaction( + async (tx) => { + await tx.insert(users).values({ id: 4, email: 'delta@example.com' }).execute() + + // Вложенная логика + const user = await tx.select().from(users).where(eq(users.id, 4)).execute() + + if (user.length === 0) { + tx.rollback() // Ручной откат транзакции + } + }, + { + accessMode: 'read write', // Важно для операций записи + isolationLevel: 'serializableReadWrite', + } + ) +} catch (e) { + if (e instanceof TransactionRollbackError) { + console.log('Транзакция была отменена') + } +} +``` + +## Проверка соединения + +Для smoke-тестирования доступности базы данных выполните простой запрос: + +```ts +import { sql } from 'drizzle-orm' + +await db.execute(sql`SELECT 1`) +``` + +## Завершение работы + +Если адаптер создавал драйвер самостоятельно (через `connectionString`), рекомендуется закрыть соединение при завершении работы приложения: + +```ts +db.$client.close?.() +``` + +## Runnable-примеры + +В репозитории SDK есть компактный TypeScript CLI-пример и более крупная интерактивная TypeScript-лаборатория. + +```bash +cd examples/drizzle-adapter +npm install +npm start +``` + +```bash +cd examples/drizzle-adapter-lab +npm install +npm run db:up +npm start +``` + +См. [примеры Drizzle Adapter](/ru/guide/drizzle-adapter/examples) с теми же паттернами кода. diff --git a/docs/ru/guide/drizzle-adapter/index.md b/docs/ru/guide/drizzle-adapter/index.md new file mode 100644 index 00000000..5e2d1c7a --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/index.md @@ -0,0 +1,186 @@ +--- +title: Drizzle Adapter — Обзор +description: 'Адаптер YDB для Drizzle ORM: схема, запросы, расширения YQL и миграции.' +--- + +# Drizzle Adapter `@ydbjs/drizzle-adapter` + +Drizzle-совместимый API для YDB: DSL описания схемы, построители запросов, расширения YDB/YQL, DDL-хелперы и миграции. + +Это высокоуровневый обзор адаптера YDB для Drizzle. Для получения подробной информации перейдите в соответствующие разделы: + +- [Опции и API](/ru/guide/drizzle-adapter/options) +- [Database API](/ru/guide/drizzle-adapter/database-api) +- [Схема данных](/ru/guide/drizzle-adapter/schema) +- [Построители запросов](/ru/guide/drizzle-adapter/query-builders) +- [Миграции и DDL](/ru/guide/drizzle-adapter/migrations-ddl) +- [YQL-хелперы](/ru/guide/drizzle-adapter/yql-helpers) +- [Driver, Session, Dialect](/ru/guide/drizzle-adapter/internals) +- [Публичный API](/ru/guide/drizzle-adapter/api-index) +- [Примеры](/ru/guide/drizzle-adapter/examples) + +## Быстрый старт + +```ts +import { eq } from 'drizzle-orm' +import { createDrizzle, integer, text, ydbTable } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) + +const db = createDrizzle({ + connectionString: process.env.YDB_CONNECTION_STRING!, + schema: { users }, +}) + +await db.insert(users).values({ id: 1, name: 'Alice' }).execute() + +const row = await db.query.users.findFirst({ + where: (user, { eq }) => eq(user.id, 1), +}) + +const selected = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(eq(users.id, 1)) + .prepare() + .get() +``` + +## Примеры {#examples} + +Более крупное runnable-приложение с CRUD, relations, joins, CTE, raw execution, транзакциями, scripts и DDL builders находится в разделе [Примеры](/ru/guide/drizzle-adapter/examples). + +### Схема с опциями YDB {#examples-schema} + +```ts +import { + columnFamily, + index, + integer, + partitionByHash, + tableOptions, + text, + timestamp, + ttl, + ydbTable, +} from '@ydbjs/drizzle-adapter' + +export const events = ydbTable( + 'events', + { + id: integer('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + payload: text('payload').notNull(), + createdAt: timestamp('created_at').notNull(), + }, + (table) => [ + index('events_tenant_idx').on(table.tenantId), + partitionByHash(table.tenantId), + ttl(table.createdAt, 'P30D'), + columnFamily('hot', { data: 'ssd' }).columns(table.payload), + tableOptions({ AUTO_PARTITIONING_BY_LOAD: 'ENABLED' }), + ] +) +``` + +### CRUD-операции {#examples-crud} + +```ts +await db.insert(users).values({ id: 1, name: 'Alice' }).execute() +await db.upsert(users).values({ id: 1, name: 'Alice Updated' }).execute() + +const rows = await db.select().from(users).where(eq(users.id, 1)).execute() + +await db.update(users).set({ name: 'Alice' }).where(eq(users.id, 1)).execute() +await db.delete(users).where(eq(users.id, 1)).execute() +``` + +### Отношения (Relations) {#examples-relations} + +```ts +const user = await db.query.users.findFirst({ + where: (u, { eq }) => eq(u.id, 1), + with: { + posts: { + limit: 5, + orderBy: (p, { desc }) => [desc(p.id)], + }, + }, +}) +``` + +### Транзакции {#examples-transactions} + +```ts +import { sql } from 'drizzle-orm' + +await db.transaction( + async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Alice' }).execute() + await tx + .update(stats) + .set({ count: sql`count + 1` }) + .execute() + }, + { accessMode: 'read write', isolationLevel: 'serializableReadWrite' } +) +``` + +### Миграции {#examples-migrations} + +```ts +import { migrate } from '@ydbjs/drizzle-adapter' + +await migrate(db, { + migrationsFolder: './drizzle', + migrationLock: true, +}) +``` + +### YQL-хелперы {#examples-yql-helpers} + +```ts +import { sql } from 'drizzle-orm' +import { asTable, valuesTable } from '@ydbjs/drizzle-adapter' + +await db + .select({ id: sql`r.id`, name: sql`r.name` }) + .from(asTable('$rows', 'r')) + .execute() + +const valueSource = valuesTable([{ id: 1, name: 'Alice' }], { + alias: 'v', + columns: ['id', 'name'], +}) +await db + .select({ id: sql`v.id`, name: sql`v.name` }) + .from(valueSource) + .execute() +``` + +### Векторный поиск {#examples-vector-search} + +```ts +import { sql } from 'drizzle-orm' +import { knnCosineDistance, vectorIndexView } from '@ydbjs/drizzle-adapter' + +const nearest = await db + .select() + .from(vectorIndexView(images, 'images_vector_idx', 'images')) + .orderBy(knnCosineDistance(images.embedding, sql`$target`)) + .limit(10) + .execute() +``` + +## Release gates {#release-gates} + +Готовность адаптера закрывается проверками SDK CI: + +- корневые `npm run build`, `npm run attw` и `npm run test`; +- интеграционные тесты с Docker-backed YDB через Vitest-проект `int`; +- `npm run check:surface --workspace=@ydbjs/drizzle-adapter`, который проверяет root public API, закрытые deep imports и содержимое npm pack. + +Release workflow остается общим для SDK и не кастомизируется на уровне пакета адаптера. diff --git a/docs/ru/guide/drizzle-adapter/internals.md b/docs/ru/guide/drizzle-adapter/internals.md new file mode 100644 index 00000000..5e989f5e --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/internals.md @@ -0,0 +1,83 @@ +--- +title: Drizzle Adapter — Драйвер, Сессия, Диалект +--- + +# Внутренние компоненты: Driver, Session, Dialect + +Понимание низкоуровневых компонентов адаптера YDB для Drizzle: `YdbDriver`, `YdbSession` и `YdbDialect`. + +Корневым runtime-экспортом является только `YdbDriver`. `YdbSession` и `YdbDialect` — детали реализации адаптера; они описаны здесь для понимания поведения, а не как стабильные конструкторы для кода приложения. + +## YdbDriver + +`YdbDriver` — это низкоуровневый компонент, обеспечивающий взаимодействие между Drizzle ORM и основным драйвером `@ydbjs/core`. + +### Инициализация + +```ts +import { Driver } from '@ydbjs/core' +import { YdbDriver } from '@ydbjs/drizzle-adapter' + +// 1. Из строки подключения +const fromString = new YdbDriver('grpc://localhost:2136/local') + +// 2. Из существующего экземпляра Driver SDK +const sdkDriver = new Driver({ + /* ... */ +}) +const driver = new YdbDriver(sdkDriver) +``` + +**Примечание:** Если `YdbDriver` создает SDK-драйвер самостоятельно (через строку подключения), он владеет им и закроет его при вызове `driver.close()`. + +### Основные методы + +- `await driver.ready(signal?)`: Проверяет готовность драйвера и наличие соединения. +- `await driver.execute(yql, params, method, options?)`: Низкоуровневое выполнение YQL. +- `await driver.transaction(callback, config?)`: Низкоуровневое выполнение транзакции. +- `await driver.close()`: Освобождает ресурсы драйвера. + +## YdbSession + +`YdbSession` представляет контекст одной сессии базы данных. Она служит мостом между драйвером, диалектом и логгером. + +### Основные методы + +- `.all()`, `.get()`, `.values()`, `.execute()`: Выполнение одиночных запросов в разных форматах. +- `.prepareQuery(sql, fields, name, arrayMode)`: Создает `YdbPreparedQuery` для повторного использования. +- `.batch([queries])`: Последовательное выполнение нескольких запросов в рамках одной сессии. +- `.count(query)`: Эффективное получение количества строк. +- `.transaction(callback)`: Запуск транзакционной сессии. + +### YdbPreparedQuery + +Объект, возвращаемый методом `.prepare()`, позволяет выполнять один и тот же запрос с разными параметрами без повторного парсинга YQL. + +```ts +const prepared = session.prepareQuery( + sql`SELECT * FROM users WHERE id = ${sql.placeholder('id')}`, + undefined, + 'select_user' +) + +await prepared.execute({ id: 1 }) +``` + +## YdbDialect + +`YdbDialect` отвечает за преобразование абстрактных структур запросов Drizzle в синтаксис YQL и маппинг типов. + +### Особенности + +- **Экранирование:** Методы `escapeName()`, `escapeParam()` и `escapeString()` для безопасной генерации SQL. +- **Рендеринг:** Низкоуровневые методы (`buildSelectQuery`, `buildInsertQuery` и др.), используемые построителями запросов. +- **Миграции:** Управляет низкоуровневой логикой миграций, включая работу с таблицей истории и проверку хешей. + +### sqlToQuery + +Преобразует шаблон `sql` Drizzle в YQL с плейсхолдерами для параметров. + +```ts +const { sql, params } = dialect.sqlToQuery(sql`SELECT * FROM users WHERE id = ${1}`) +// Результат -> sql: "SELECT * FROM users WHERE id = $p0", params: [1] +``` diff --git a/docs/ru/guide/drizzle-adapter/migrations-ddl.md b/docs/ru/guide/drizzle-adapter/migrations-ddl.md new file mode 100644 index 00000000..42f0c8eb --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/migrations-ddl.md @@ -0,0 +1,145 @@ +--- +title: Drizzle Adapter — Миграции и DDL +--- + +# Миграции и DDL + +Адаптер предоставляет надежный мигратор и набор DDL-построителей для программного управления схемой YDB. + +Runnable-приложение из раздела [Примеры Drizzle Adapter](/ru/guide/drizzle-adapter/examples) показывает `migrate()` и live/preview-only DDL builders. + +## Мигратор (`migrate`) + +Функция `migrate` применяет изменения схемы, гарантируя консистентность через распределенные блокировки. + +### Основное использование + +```ts +import { migrate } from '@ydbjs/drizzle-adapter' + +await migrate(db, { + migrationsFolder: './drizzle', // Путь к файлам, сгенерированным drizzle-kit + migrationLock: true, // Включить распределенную блокировку +}) +``` + +### Inline миграции + +Описывайте миграции прямо в коде для динамического управления схемой. + +```ts +await migrate(db, { + migrations: [ + { + name: '0000_init', + sql: ['CREATE TABLE `users` (id Int32, name Utf8, PRIMARY KEY (id))'], + }, + ], +}) +``` + +## Блокировки миграций (Migration Lock) + +Чтобы предотвратить одновременное выполнение миграций при развертывании в несколько инстансов, адаптер использует специальную таблицу блокировок в YDB. + +```ts +await migrate(db, { + migrationLock: { + key: 'my_app_deploy', + leaseMs: 600000, // 10 минут удержания + acquireTimeoutMs: 60000, // 1 минута ожидания + }, +}) +``` + +## Паттерн CI/CD + +Запускайте миграции отдельным шагом деплоя до старта новых инстансов приложения. Оставляйте `migrationLock` включенным, чтобы параллельные jobs, retry и blue/green deploy не применили одну миграцию дважды. + +```ts +import { createDrizzle, migrate } from '@ydbjs/drizzle-adapter' + +const db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, +}) + +try { + await db.$client.ready?.() + await migrate(db, { + migrationsFolder: './drizzle', + migrationLock: { + key: process.env['GITHUB_SHA'] ?? 'deploy', + leaseMs: 10 * 60 * 1000, + acquireTimeoutMs: 60 * 1000, + retryIntervalMs: 1000, + }, + migrationRecovery: { + mode: 'retry', + staleRunningAfterMs: 60 * 60 * 1000, + }, + }) +} finally { + await db.$client.close?.() +} +``` + +Рекомендуемые release-проверки: + +```bash +npm run build -- --filter=@ydbjs/drizzle-adapter +npm run attw -- --filter=@ydbjs/drizzle-adapter +npm run test --workspace=@ydbjs/drizzle-adapter -- --project uni +npm run test:live --workspace=@ydbjs/drizzle-adapter +``` + +## Стратегии восстановления + +Если миграция была прервана (например, при падении процесса), запись в истории может зависнуть в статусе `running`. + +- `mode: 'error'` (по умолчанию): Выдает ошибку при обнаружении зависшей миграции. +- `mode: 'retry'`: Сбрасывает и перезапускает миграцию, если она висит дольше, чем `staleRunningAfterMs`. + +```ts +await migrate(db, { + migrationRecovery: { + mode: 'retry', + staleRunningAfterMs: 3600000, // 1 час + }, +}) +``` + +## DDL-построители + +Адаптер экспортирует низкоуровневые функции для генерации YQL управления схемой. + +### Операции над таблицами + +- `buildCreateTableSql(table, options?)`: Полная команда `CREATE TABLE`. +- `buildDropTableSql(table, options?)`: `DROP TABLE`. +- `buildRenameTableSql(table, newName)`: `RENAME TABLE`. + +### Операции над колонками + +- `buildAddColumnsSql(table, columns)`: Добавление новых колонок. +- `buildDropColumnsSql(table, names)`: Удаление колонок по именам. + +### Индексы и CDC + +- `buildAddIndexSql(table, index)`: Добавление вторичного индекса. +- `buildDropIndexSql(table, indexName)`: Удаление индекса. +- `buildAddChangefeedSql(table, name, options)`: Настройка потока изменений (CDC). + +### Системные объекты + +Управляйте инфраструктурой YDB через код: + +- **Топики**: `buildCreateTopicSql`, `buildAlterTopicSql`, `buildDropTopicSql`. +- **RBAC**: `buildCreateUserSql`, `buildGrantSql`, `buildRevokeSql`. +- **Вью**: `buildCreateViewSql`, `buildDropViewSql`. +- **Секреты**: `buildCreateSecretSql`. + +## Безопасность DDL + +DDL builders экранируют имена таблиц, колонок, индексов, семейств колонок, топиков, пользователей, групп и changefeed. Имена опций проверяются как простые идентификаторы до рендера. + +Raw YQL поверхности остаются под ответственностью вызывающего кода: inline migration `sql`, `rawTableOption()`, текст view query, raw ACL permissions и transfer `using`. Считайте эти значения trusted code, а не пользовательским вводом. diff --git a/docs/ru/guide/drizzle-adapter/options.md b/docs/ru/guide/drizzle-adapter/options.md new file mode 100644 index 00000000..925560d2 --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/options.md @@ -0,0 +1,141 @@ +--- +title: Drizzle Adapter — Опции и API +--- + +# Опции и API `@ydbjs/drizzle-adapter` + +Ниже представлен полный обзор конфигурации адаптера, методов выполнения и доступных опций. + +## Клиент и базовый синтаксис + +```ts +import { createDrizzle } from '@ydbjs/drizzle-adapter' + +let db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, + schema, + logger: true, +}) +``` + +Опции клиента: + +- `connectionString`: создает собственный `YdbDriver` на основе строки подключения. +- `client`: существующий `YdbExecutor` или `YdbTransactionalExecutor`. +- `schema`: включает типизированный Relational Query API (`db.query.*`). +- `logger`: `true`, `false` или кастомный логгер Drizzle. +- `casing`: режим именования Drizzle (`'snake_case'` или `'camelCase'`) для диалекта. + +Альтернативные способы инициализации: + +- `createDrizzle(executor, config?)`: использование существующего исполнителя. +- `createDrizzle(callback, config?)`: использование удаленного callback-исполнителя (режим Proxy). +- `drizzle(...)`: алиас для `createDrizzle(...)`. + +## Методы базы данных + +- `execute(query)`: выполняет запрос и возвращает типизированный результат. +- `all(query)`: возвращает все строки в виде массива объектов. +- `get(query)`: возвращает первую строку или `undefined`. +- `values(query)`: возвращает строки в виде массивов значений. +- `transaction(callback, config?)`: выполняет callback внутри транзакции YDB. +- `$count(source, filters?)`: вспомогательный метод для эффективного подсчета строк. +- `$client`: базовый исполнитель; используйте `ready?.()` и `close?.()` для управления жизненным циклом. + +Опции транзакции: + +- `accessMode`: `'read write' | 'read only'`. +- `isolationLevel`: `'serializableReadWrite' | 'snapshotReadOnly'`. +- `idempotent`: если `true`, адаптер сможет автоматически перезапустить транзакцию при сетевых ошибках. + +## Опции схемы + +- `ydbTable(name, columns, extraConfig?)`: объявление таблицы. +- `ydbTableCreator(customizeTableName)`: фабрика таблиц с маппингом имен. +- `primaryKey({ columns })`: составной первичный ключ. +- `unique(name?).on(...columns)`: ограничение уникальности. +- `index(name?).on(...columns)`: вторичный индекс. +- `uniqueIndex(name?).on(...columns)`: уникальный вторичный индекс. +- `vectorIndex(name, options).on(column)`: векторный индекс. +- `tableOptions(options)`: сырые опции таблицы YDB. +- `ttl(column, intervalOrActions, options?)`: конфигурация TTL (время жизни данных). +- `partitionByHash(...columns)`: партиционирование по хешу. +- `columnFamily(name, options?).columns(...columns)`: семейства колонок. + +Опции построителя индексов: + +- `.global()` / `.local()`: область видимости индекса. +- `.sync()` / `.async()`: синхронность записи. +- `.using(indexType)`: пользовательский тип индекса. +- `.vectorKMeansTree(options)`: тип индекса vector k-means tree. +- `.cover(...columns)`: покрывающие колонки. +- `.with(options)`: сырые опции индекса. + +Опции векторного индекса: + +- `vectorDimension`, `vectorType`, `distance` или `similarity`, `clusters`, `levels`. + +## Опции построителя запросов + +SELECT: + +- Источники: `.from()`, `.fromAsTable()`, `.fromValues()`. +- Фильтрация и группировка: `.where()`, `.having()`, `.groupBy()`, `.groupCompactBy()`. +- Сортировка и лимиты: `.orderBy()`, `.assumeOrderBy()`, `.limit()`, `.offset()`. +- Расширения YDB: `.without()`, `.flattenBy()`, `.flattenListBy()`, `.flattenDictBy()`, `.flattenOptionalBy()`, `.flattenColumns()`, `.sample()`, `.tableSample()`, `.matchRecognize()`, `.window()`, `.intoResult()`. +- Distinct и операции над множествами: `.distinct()`, `.distinctOn()`, `.uniqueDistinct()`, `.union()`, `.unionAll()`, `.intersect()`, `.except()`. +- Выполнение и рендеринг: `.getSQL()`, `.toSQL()`, `.execute()`, `.prepare()`. Prepared queries дают `.all()`, `.get()` и `.values()`. + +Мутации: + +- Insert-построители: `.values()`, `.select()`, `.onDuplicateKeyUpdate()`, `.returning()`. +- Update-построители: `.set()`, `.where()`, `.on()`, `.returning()`. +- Delete-построители: `.where()`, `.using()`, `.on()`, `.returning()`. +- Batch-построители: `batchUpdate` и `batchDelete` поддерживают `.where()`, но не принимают `returning()` и `on()`. + +## Опции Relational Query + +Используйте `db.query.
.findFirst(config?)` и `db.query.
.findMany(config?)`, если передана `schema`. + +- `columns`: включение или исключение конкретных колонок. +- `where`: callback с логикой фильтрации. +- `orderBy`: callback с выражениями сортировки. +- `limit` и `offset`: управление пагинацией. +- `with`: загрузка вложенных связей. +- `extras`: дополнительные SQL-выборки. + +## Опции миграций + +- `migrationsFolder`: путь к папке с миграциями Drizzle. +- `migrations`: инлайновые объекты миграций. +- `migrationsTable`: имя таблицы истории миграций. +- `migrationsSchema`: схема или префикс папки для таблиц истории. +- `migrationsLockTable`: имя таблицы блокировок. +- `migrationLock`: `true`, `false` или опции блокировки. +- `migrationRecovery`: опции восстановления. + +Опции блокировки: + +- `key`, `ownerId`, `leaseMs`, `acquireTimeoutMs`, `retryIntervalMs`. + +Опции восстановления: + +- `mode`: `'fail' | 'retry'`. +- `staleRunningAfterMs`: порог времени для признания миграции зависшей. + +## Опции YQL-хелперов + +- `valuesTable(rows, { alias?, columns? })`: инлайновый источник данных. +- `windowDefinition({ partitionBy?, orderBy?, frame? })`: конфигурация окна. +- `matchRecognize({ partitionBy?, orderBy?, measures?, rowsPerMatch?, afterMatchSkip?, pattern, define? })`: поиск паттернов в событиях. +- `pragma(name, value?)`, `declareParam(name, dataType)`, `defineAction(name, params, statements)`, `doAction(name, args?)`: хелперы для YQL-скриптов. +- `kMeansTreeSearchTopSize(value)`: хелпер для прагмы векторного поиска. + +## Ограничения + +- Адаптер ESM-only, как и остальные пакеты YDB JavaScript SDK. +- Вложенные YDB-транзакции не поддерживаются. Создавайте одну границу транзакции и передавайте объект `tx` в нижние уровни. +- Поддерживаются режимы транзакций `serializableReadWrite` и `snapshotReadOnly`; неподдерживаемые уровни изоляции не эмулируются. +- `references()` является только metadata для relations. YDB не enforcing foreign keys. +- Уникальные индексы нужно создавать через DDL таблицы; добавление unique index к существующей таблице отклоняется. +- Raw helpers (`sql.raw`, inline migration `sql`, `rawTableOption`, текст view query, raw ACL permissions, transfer `using`) намеренно обходят экранирование и не должны получать недоверенный ввод. diff --git a/docs/ru/guide/drizzle-adapter/query-builders.md b/docs/ru/guide/drizzle-adapter/query-builders.md new file mode 100644 index 00000000..6e3fa26b --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/query-builders.md @@ -0,0 +1,164 @@ +--- +title: Drizzle Adapter — Построители запросов +--- + +# Построители запросов + +Адаптер YDB для Drizzle расширяет стандартный синтаксис Drizzle для поддержки специфичных возможностей YDB и нативных операторов YQL. + +Сквозные примеры, где эти построители используются вместе, собраны в разделе [Примеры Drizzle Adapter](/ru/guide/drizzle-adapter/examples). + +## Построитель SELECT + +`YdbSelectBuilder` — основной инструмент для выборки данных. + +### Точки входа + +```ts +db.select() // SELECT * +db.select({ id: users.id, name: users.name }) // Выборочные колонки +db.selectDistinct({ name: users.name }) // SELECT DISTINCT +db.selectDistinctOn([users.name], { id: users.id }) // SELECT DISTINCT ON (...) +``` + +### Fluent API и расширения YDB + +Помимо стандартных методов Drizzle (`where`, `orderBy`, `limit` и др.), адаптер предоставляет: + +- `.fromAsTable(binding, alias?)`: использование источника `AS_TABLE`. +- `.fromValues(rows, options?)`: использование инлайновых значений как источника. +- `.without(...columns)`: исключение конкретных колонок из `SELECT *`. +- `.groupCompactBy(...columns)`: оптимизированная группировка для отсортированных данных. +- `.assumeOrderBy(...columns)`: подсказка оптимизатору, что вход уже отсортирован. +- `.sample(ratio)` / `.tableSample(method, size)`: выборка случайной доли данных. +- `.window(name, definition)`: объявление именованных окон. +- `.intoResult(name)`: вывод результата в именованный блок YDB. +- `.flattenBy()`, `.flattenListBy()`: операторы YQL `FLATTEN`. + +Пример: + +```ts +const rows = await db + .select() + .from(users) + .without(users.password) + .where(eq(users.active, true)) + .limit(10) + .execute() +``` + +## JOIN (Объединение таблиц) + +Адаптер поддерживает 10 типов объединений, включая специфичные для YDB semi-joins: + +| Стандартные JOIN | Полу-соединения (Semi-JOINs) YDB | +| :--------------- | :------------------------------------------------------------------------ | +| `.innerJoin()` | `.leftSemiJoin()`: строки слева, имеющие соответствие справа. | +| `.leftJoin()` | `.rightSemiJoin()`: строки справа, имеющие соответствие слева. | +| `.rightJoin()` | `.leftOnlyJoin()`: строки слева **без** соответствия (аналог NOT EXISTS). | +| `.fullJoin()` | `.rightOnlyJoin()`: строки справа **без** соответствия. | +| `.crossJoin()` | `.exclusionJoin()`: строки без соответствия в противоположной таблице. | + +```ts +const inactiveUsers = await db + .select() + .from(users) + .leftOnlyJoin(posts, eq(users.id, posts.authorId)) + .execute() +``` + +## Мутации (Изменение данных) + +### INSERT, UPSERT и REPLACE + +YDB предлагает эффективные способы управления данными по первичному ключу. + +- `.insert(table)`: обычный `INSERT INTO`. +- `.upsert(table)`: `UPSERT INTO` — самый эффективный способ «вставить или обновить» по PK. +- `.replace(table)`: `REPLACE INTO` — полная замена строки по PK; колонки, которых нет в `.values()`, рендерятся как `DEFAULT`. + +```ts +await db.upsert(users).values({ id: 1, name: 'Alice' }).execute() +await db.replace(users).values({ id: 1, name: 'Replaced' }).execute() +``` + +Используйте `upsert()`, когда retryable writer должен создать строку или обновить только переданные колонки: + +```ts +await db + .upsert(users) + .values({ + id: 42, + name: 'Ada', + updatedAt: new Date(), + }) + .execute() +``` + +Используйте `replace()`, когда строка является полным snapshot и дефолты для пропущенных колонок ожидаемы: + +```ts +await db + .replace(users) + .values({ + id: 42, + name: 'Ada Lovelace', + updatedAt: new Date(), + }) + .execute() +``` + +### UPDATE и DELETE + +- `.update(table)`: частичное обновление колонок. +- `.delete(table)`: удаление строк. + +Адаптер также поддерживает **Update с подзапросом** (`.on()`) и **Delete с USING**: + +```ts +await db + .update(users) + .on((qb) => + qb + .select({ id: users.id, name: sql`'New Name'`.as('name') }) + .from(users) + .where(eq(users.status, 'active')) + ) + .execute() +``` + +## CTE ($with) + +Common Table Expressions в YDB рендерятся как переменные-биндинги. + +```ts +const activeUsers = db + .$with('active_users') + .as(db.select().from(users).where(eq(users.active, true))) + +const rows = await db.with(activeUsers).select().from(activeUsers).execute() +``` + +## Подготовленные запросы (Prepared Queries) + +Используйте `.prepare()` для часто выполняемых запросов, чтобы сэкономить на парсинге и планировании. + +```ts +const selectUser = db + .select() + .from(users) + .where(eq(users.id, sql.placeholder('id'))) + .prepare() + +const user = await selectUser.execute({ id: 1 }) +``` + +Построители выполняются через `.execute()`. Prepared queries дополнительно дают `.all()`, `.get()` и `.values()`. + +```ts +const prepared = db.select({ id: users.id, name: users.name }).from(users).prepare('users') + +const rows = await prepared.all() +const first = await prepared.get() +const values = await prepared.values() +``` diff --git a/docs/ru/guide/drizzle-adapter/schema.md b/docs/ru/guide/drizzle-adapter/schema.md new file mode 100644 index 00000000..adbbcf36 --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/schema.md @@ -0,0 +1,240 @@ +--- +title: Drizzle Adapter — Схема данных +--- + +# Описание схемы + +Адаптер YDB для Drizzle предоставляет специализированный DSL для описания таблиц, поддерживающий все специфичные возможности YDB, такие как TTL, партиционирование и специализированные индексы. + +## Описание таблицы + +Для создания таблицы используйте функцию `ydbTable`. В YDB наличие **первичного ключа (Primary Key) обязательно**. + +```ts +import { integer, text, ydbTable } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}) +``` + +### Структура ydbTable + +1. **Имя таблицы** (`string`): Имя таблицы в базе данных. +2. **Колонки** (`object`): Описание структуры колонок. +3. **Extra Config** (`callback`, опционально): Функция для настройки индексов, ограничений и специфичных опций YDB. + +```ts +export const memberships = ydbTable( + 'memberships', + { + userId: integer('user_id').notNull(), + orgId: integer('org_id').notNull(), + }, + (table) => [primaryKey({ columns: [table.userId, table.orgId] })] +) +``` + +### Кастомизация имен таблиц + +Используйте `ydbTableCreator` для добавления глобального префикса или трансформации имен всех таблиц. + +```ts +import { ydbTableCreator } from '@ydbjs/drizzle-adapter' + +const appTable = ydbTableCreator((name) => `myapp/${name}`) + +export const users = appTable('users', { + /* ... */ +}) +``` + +## Типы данных + +Адаптер экспортирует строители для всех примитивных и составных типов YDB. + +### Примитивные типы + +| Функция | Тип YDB | Тип TS | Описание | +| :-------------- | :------- | :-------- | :------------------------ | +| `boolean(name)` | `Bool` | `boolean` | Логическое значение | +| `int8(name)` | `Int8` | `number` | 8-бит со знаком | +| `uint8(name)` | `Uint8` | `number` | 8-бит без знака | +| `int16(name)` | `Int16` | `number` | 16-бит со знаком | +| `uint16(name)` | `Uint16` | `number` | 16-бит без знака | +| `integer(name)` | `Int32` | `number` | 32-бит со знаком | +| `uint32(name)` | `Uint32` | `number` | 32-бит без знака | +| `bigint(name)` | `Int64` | `bigint` | 64-бит со знаком | +| `uint64(name)` | `Uint64` | `bigint` | 64-бит без знака | +| `float(name)` | `Float` | `number` | 32-бит с плавающей точкой | +| `double(name)` | `Double` | `number` | 64-бит с плавающей точкой | + +### Строки и бинарные данные + +| Функция | Тип YDB | Тип TS | Описание | +| :---------------------- | :------------- | :----------- | :------------------------------- | +| `text(name)` | `Utf8` | `string` | Unicode строка | +| `bytes(name)` | `String` | `Uint8Array` | Бинарные данные | +| `uuid(name)` | `Uuid` | `string` | Строка UUID | +| `yson(name)` | `Yson` | `Uint8Array` | Формат YSON | +| `json(name)` | `Json` | `T` | JSON текст | +| `jsonDocument(name)` | `JsonDocument` | `T` | Бинарный JSON (оптимизированный) | + +### Дата и время + +| Функция | Тип YDB | Тип TS | Описание | +| :------------------ | :------------ | :------- | :------------------------------------ | +| `date(name)` | `Date` | `Date` | Только дата (до 2038 г.) | +| `date32(name)` | `Date32` | `Date` | Дата с расширенным диапазоном | +| `datetime(name)` | `Datetime` | `Date` | Дата и время (до 2038 г.) | +| `datetime64(name)` | `Datetime64` | `Date` | Дата и время (расширенный диапазон) | +| `timestamp(name)` | `Timestamp` | `Date` | Микросекундная точность (до 2038 г.) | +| `timestamp64(name)` | `Timestamp64` | `Date` | Микросекундная точность (расширенный) | +| `interval(name)` | `Interval` | `number` | Интервал в микросекундах | + +### Замечания по маппингу + +- YDB `String` в адаптере означает бинарные данные и маппится в `Uint8Array` через `bytes()` или `binary()`. +- Для человекочитаемого текста используйте YDB `Utf8` через `text()`. +- `Int64` и `Uint64` маппятся в `bigint`, чтобы не терять точность. +- Date/time builders маппятся в JavaScript `Date`; адаптер конвертирует значения на границе драйвера. +- `json()` и `jsonDocument()` возвращают типизированные JSON-значения. Generic нужен для фиксации ожидаемой формы. + +```ts +import { bigint, bytes, json, text, timestamp, ydbTable } from '@ydbjs/drizzle-adapter' + +type Profile = { timezone: string; flags: string[] } + +export const profiles = ydbTable('profiles', { + id: bigint('id').primaryKey(), + displayName: text('display_name').notNull(), + avatar: bytes('avatar'), + settings: json('settings'), + updatedAt: timestamp('updated_at').notNull(), +}) +``` + +## Модификаторы колонок + +- `.primaryKey()`: Делает колонку частью первичного ключа. +- `.notNull()`: Запрещает `NULL`. +- `.default(value)`: Статическое значение по умолчанию. +- `.$defaultFn(() => value)`: Динамическое значение по умолчанию. +- `.unique(name?)`: Создает вторичный уникальный индекс. +- `.references(() => column)`: Метаданные для Relational Query API (YDB не поддерживает нативные Foreign Keys). + +## Первичные ключи (Primary Keys) + +YDB требует наличия первичного ключа для каждой таблицы. + +### Одиночная колонка + +```ts +id: integer('id').primaryKey() +``` + +### Составной ключ (Уровень таблицы) + +```ts +export const details = ydbTable( + 'details', + { + orderId: integer('order_id').notNull(), + lineNum: integer('line_num').notNull(), + }, + (table) => [primaryKey({ columns: [table.orderId, table.lineNum] })] +) +``` + +## Вторичные индексы + +Вторичные индексы в YDB хранятся как отдельные внутренние таблицы. + +```ts +import { index, uniqueIndex } from '@ydbjs/drizzle-adapter' + +export const users = ydbTable( + 'users', + { + /* ... */ + }, + (table) => [ + index('users_tenant_idx').on(table.tenantId), + index('users_email_idx').on(table.email).global().sync().cover(table.name), + uniqueIndex('users_login_idx').on(table.login), + ] +) +``` + +- `.global()` / `.local()`: Область видимости индекса (Global по умолчанию). +- `.sync()` / `.async()`: Режим синхронизации записи. +- `.cover(...columns)`: Добавление колонок в индекс (Covering Index). + +## Векторные индексы + +YDB поддерживает специализированные индексы для векторного поиска (AI/ML). + +```ts +import { vectorIndex } from '@ydbjs/drizzle-adapter' + +export const embeddings = ydbTable( + 'embeddings', + { + id: integer('id').primaryKey(), + vector: bytes('vector').notNull(), + }, + (table) => [ + vectorIndex('vector_idx', { + vectorDimension: 1536, + vectorType: 'float', + distance: 'cosine', + clusters: 128, + levels: 2, + }).on(table.vector), + ] +) +``` + +## Настройки таблицы + +### Физические параметры + +```ts +import { tableOptions } from '@ydbjs/drizzle-adapter' + +tableOptions({ + AUTO_PARTITIONING_BY_LOAD: 'ENABLED', + KEY_BLOOM_FILTER: 'ENABLED', +}) +``` + +### Время жизни данных (TTL) + +Автоматическое удаление устаревших данных. + +```ts +import { ttl } from '@ydbjs/drizzle-adapter' + +ttl(table.createdAt, 'P30D') // Удалить через 30 дней +ttl(table.expireAt, '3600', { unit: 'SECONDS' }) +``` + +### Партиционирование + +```ts +import { partitionByHash } from '@ydbjs/drizzle-adapter' + +partitionByHash(table.tenantId) +``` + +### Семейства колонок + +```ts +import { columnFamily } from '@ydbjs/drizzle-adapter' + +columnFamily('cold_data', { + data: 'rot', + compression: 'zstd', +}).columns(table.bio) +``` diff --git a/docs/ru/guide/drizzle-adapter/yql-helpers.md b/docs/ru/guide/drizzle-adapter/yql-helpers.md new file mode 100644 index 00000000..3d8fcbb5 --- /dev/null +++ b/docs/ru/guide/drizzle-adapter/yql-helpers.md @@ -0,0 +1,114 @@ +--- +title: Drizzle Adapter — YQL-хелперы +--- + +# YQL-хелперы + +Адаптер включает специализированные инструменты для построения сложных аналитических запросов и выполнения скриптов напрямую в YQL. + +В runnable-приложении из раздела [Примеры Drizzle Adapter](/ru/guide/drizzle-adapter/examples) есть live и preview-only сценарии для этих хелперов. + +## Источники данных для SELECT + +### `asTable(binding, alias?)` + +Использование переменной YQL (например, `List`) как источника таблицы в `FROM`. + +```ts +import { sql } from 'drizzle-orm' +import { asTable } from '@ydbjs/drizzle-adapter' + +await db + .select({ id: sql`t.id`, name: sql`t.name` }) + .from(asTable('$my_list', 't')) + .execute() +``` + +### `valuesTable(rows, options?)` + +Создание временного источника данных из массива объектов (аналог `VALUES` в SQL). + +```ts +import { sql } from 'drizzle-orm' +import { valuesTable } from '@ydbjs/drizzle-adapter' + +const v = valuesTable([{ id: 1, name: 'Alice' }], { + alias: 'v', + columns: ['id', 'name'], +}) + +await db + .select({ id: sql`v.id`, name: sql`v.name` }) + .from(v) + .execute() +``` + +## Аналитические функции (OLAP) + +Используйте эти хелперы внутри `.groupBy()` для продвинутой агрегации. + +- `rollup(...columns)`: иерархические итоги. +- `cube(...columns)`: итоги для всех комбинаций. +- `groupingSets(...sets)`: произвольные наборы группировок. +- `grouping(column)`: позволяет определить, является ли строка итоговой. + +```ts +import { rollup } from '@ydbjs/drizzle-adapter' + +await db + .select({ city: sales.city, total: sql`sum(amount)` }) + .from(sales) + .groupBy(rollup(sales.country, sales.city)) + .execute() +``` + +## Временные окна (Time Windows) + +Хелперы для потоковой агрегации и обработки временных рядов: + +- `sessionWindow(column, timeout)`: группировка событий в сессии. +- `hop(column, hop, window)`: скользящие окна агрегации. + +## Векторный поиск (KNN) + +Функции расстояния и близости для AI-поиска, обычно используемые в `orderBy`. + +| Функция | Описание | +| :---------------------------------- | :--------------------------------- | +| `knnCosineDistance(v1, v2)` | Косинусное расстояние. | +| `knnEuclideanDistance(v1, v2)` | Евклидово расстояние. | +| `knnInnerProductSimilarity(v1, v2)` | Скалярное произведение (близость). | + +```ts +import { sql } from 'drizzle-orm' +import { knnCosineDistance } from '@ydbjs/drizzle-adapter' + +const nearest = await db + .select() + .from(images) + .orderBy(knnCosineDistance(images.embedding, sql`$target`)) + .limit(10) + .execute() +``` + +## YQL-скрипты + +Хелпер `yqlScript` позволяет объединять несколько команд, прагм и параметров в один атомарный блок выполнения. + +```ts +import { sql } from 'drizzle-orm' +import { declareParam, pragma, yqlScript } from '@ydbjs/drizzle-adapter' + +await db.execute( + yqlScript( + pragma('TablePathPrefix', '/local'), + declareParam('$userId', 'Int32'), + sql`UPSERT INTO users (id) VALUES ($userId);` + ) +) +``` + +- `pragma(name, value)`: установка настроек выполнения. +- `declareParam(name, type)`: явное объявление типов параметров YQL. +- `defineAction(name, params, statements)`: создание переиспользуемых макросов. +- `doAction(name, args)`: вызов определенных действий. diff --git a/examples/README.MD b/examples/README.MD index 616e69dd..94879d1f 100644 --- a/examples/README.MD +++ b/examples/README.MD @@ -32,6 +32,17 @@ cd examples/query npm install && npm start ``` +### 📁 [drizzle-adapter/](./drizzle-adapter/) + +EN: Compact TypeScript CLI example for the Drizzle ORM adapter: schema, migrations, CRUD, relations, joins, raw helpers, and transactions. + +RU: Компактный TypeScript CLI-пример Drizzle ORM адаптера: схема, миграции, CRUD, relations, joins, raw helpers и транзакции. + +```bash +cd examples/drizzle-adapter +npm install && npm start +``` + ### 📁 [sls/](./sls/) EN: Serverless considerations (HTTP/2, driver lifecycle inside handler). @@ -137,6 +148,8 @@ export YDB_CONNECTION_STRING="grpc://localhost:2136/local" ```bash cd examples/api && npm start cd examples/query && npm run dev +cd examples/drizzle-adapter && npm start +cd examples/drizzle-adapter-lab && npm start cd examples/sls && npm start cd examples/tls && npm start cd examples/topic && npm start diff --git a/examples/drizzle-adapter/README.md b/examples/drizzle-adapter/README.md new file mode 100644 index 00000000..c2a9eff0 --- /dev/null +++ b/examples/drizzle-adapter/README.md @@ -0,0 +1,33 @@ +# YDB Drizzle Adapter Example + +Small TypeScript CLI example for `@ydbjs/drizzle-adapter`. + +For the larger interactive UI with generated YQL, action catalog, traces, and DDL previews, use [`examples/drizzle-adapter-lab`](../drizzle-adapter-lab/). + +## What It Demonstrates + +- Schema declarations with `ydbTable()`. +- Relations with `relations()`, `one()`, and `many()`. +- Inline migrations with `migrate()`. +- CRUD through `insert`, `upsert`, `update`, and `delete`. +- Joins, expanded `db.query.*` relational queries, raw execution helpers, `$count`, and transactions. +- Relation entry points from projects, tasks, and users with nested `with`, `where`, and `orderBy`. +- Correct driver shutdown through `db.$client.close()`. + +## Run + +Start a local YDB instance first. From the repository root you can use the manual setup from [`examples/README.MD`](../README.MD), then run: + +```bash +cd examples/drizzle-adapter +npm install +npm start +``` + +By default the example uses: + +```text +grpc://localhost:2136/local +``` + +Override it with `YDB_CONNECTION_STRING` if needed. diff --git a/examples/drizzle-adapter/index.ts b/examples/drizzle-adapter/index.ts new file mode 100644 index 00000000..f8c3f8cc --- /dev/null +++ b/examples/drizzle-adapter/index.ts @@ -0,0 +1,341 @@ +/** + * YDB Drizzle Adapter Example + * + * A compact TypeScript CLI example that follows the same style as the other SDK examples. + * For an interactive showcase, see examples/drizzle-adapter-lab. + */ + +import { asc, eq, sql } from 'drizzle-orm' +import { + createDrizzle, + index, + integer, + migrate, + relations, + text, + timestamp, + ydbTable, +} from '@ydbjs/drizzle-adapter' + +const connectionString = process.env.YDB_CONNECTION_STRING || 'grpc://localhost:2136/local' + +const users = ydbTable( + 'drizzle_example_users', + { + id: integer('id').primaryKey(), + email: text('email').notNull(), + name: text('name').notNull(), + status: text('status').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => [index('drizzle_example_users_status_idx').on(table.status).global().sync()] +) + +const projects = ydbTable( + 'drizzle_example_projects', + { + id: integer('id').primaryKey(), + ownerId: integer('owner_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => [index('drizzle_example_projects_owner_idx').on(table.ownerId).global().sync()] +) + +const tasks = ydbTable( + 'drizzle_example_tasks', + { + id: integer('id').primaryKey(), + projectId: integer('project_id').notNull(), + assigneeId: integer('assignee_id').notNull(), + title: text('title').notNull(), + status: text('status').notNull(), + priority: text('priority').notNull(), + estimateHours: integer('estimate_hours').notNull(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + }, + (table) => [ + index('drizzle_example_tasks_project_idx').on(table.projectId).global().sync(), + index('drizzle_example_tasks_assignee_idx').on(table.assigneeId).global().sync(), + ] +) + +const usersRelations = relations(users, ({ many }) => ({ + ownedProjects: many(projects), + assignedTasks: many(tasks), +})) + +const projectsRelations = relations(projects, ({ one, many }) => ({ + owner: one(users, { + fields: [projects.ownerId], + references: [users.id], + }), + tasks: many(tasks), +})) + +const tasksRelations = relations(tasks, ({ one }) => ({ + project: one(projects, { + fields: [tasks.projectId], + references: [projects.id], + }), + assignee: one(users, { + fields: [tasks.assigneeId], + references: [users.id], + }), +})) + +const schema = { + users, + projects, + tasks, + usersRelations, + projectsRelations, + tasksRelations, +} + +const db = createDrizzle({ + connectionString, + schema, +}) + +async function bootstrapSchema() { + await migrate(db, { + migrationsTable: '__drizzle_example_migrations', + migrationLock: { + table: '__drizzle_example_migrations_lock', + key: 'drizzle_example', + leaseMs: 60_000, + acquireTimeoutMs: 10_000, + retryIntervalMs: 300, + }, + migrations: [ + { + name: '001_create_drizzle_example_tables', + operations: [ + { kind: 'create_table', table: users, ifNotExists: true }, + { kind: 'create_table', table: projects, ifNotExists: true }, + { kind: 'create_table', table: tasks, ifNotExists: true }, + ], + }, + ], + }) +} + +async function resetData() { + await db.delete(tasks).execute() + await db.delete(projects).execute() + await db.delete(users).execute() +} + +async function seedData() { + const now = new Date() + + await db.insert(users).values([ + { + id: 1, + email: 'ada@example.com', + name: 'Ada Lovelace', + status: 'active', + createdAt: now, + updatedAt: now, + }, + { + id: 2, + email: 'grace@example.com', + name: 'Grace Hopper', + status: 'active', + createdAt: now, + updatedAt: now, + }, + ]) + + await db.insert(projects).values({ + id: 10, + ownerId: 1, + title: 'Drizzle Adapter Demo', + status: 'active', + createdAt: now, + updatedAt: now, + }) + + await db.insert(tasks).values([ + { + id: 100, + projectId: 10, + assigneeId: 1, + title: 'Define schema', + status: 'done', + priority: 'high', + estimateHours: 4, + createdAt: now, + updatedAt: now, + }, + { + id: 101, + projectId: 10, + assigneeId: 2, + title: 'Run relation query', + status: 'todo', + priority: 'medium', + estimateHours: 2, + createdAt: now, + updatedAt: now, + }, + ]) +} + +try { + await db.$client.ready?.() + await bootstrapSchema() + await resetData() + await seedData() + + await db + .upsert(users) + .values({ + id: 2, + email: 'grace@example.com', + name: 'Grace Hopper', + status: 'review', + createdAt: new Date(), + updatedAt: new Date(), + }) + .execute() + + await db.update(tasks).set({ status: 'review' }).where(eq(tasks.id, 101)).execute() + + const joinedRows = await db + .select({ + projectTitle: projects.title, + taskTitle: tasks.title, + assigneeName: users.name, + taskStatus: tasks.status, + }) + .from(tasks) + .innerJoin(projects, eq(tasks.projectId, projects.id)) + .innerJoin(users, eq(tasks.assigneeId, users.id)) + .orderBy(asc(tasks.id)) + .execute() + + const projectDashboard = await db.query.projects.findMany({ + columns: { id: true, title: true, status: true }, + where: (table, { eq }) => eq(table.status, 'active'), + orderBy: (table, { asc }) => [asc(table.id)], + with: { + owner: { + columns: { id: true, name: true, email: true }, + }, + tasks: { + columns: { id: true, title: true, status: true, priority: true }, + orderBy: (table, { asc }) => [asc(table.id)], + with: { + assignee: { + columns: { id: true, name: true }, + }, + }, + }, + }, + }) + + const reviewTask = await db.query.tasks.findFirst({ + columns: { + id: true, + title: true, + status: true, + priority: true, + estimateHours: true, + }, + where: (table, { eq }) => eq(table.status, 'review'), + orderBy: (table, { asc }) => [asc(table.id)], + with: { + project: { + columns: { id: true, title: true, status: true }, + with: { + owner: { + columns: { id: true, name: true, email: true }, + }, + }, + }, + assignee: { + columns: { id: true, name: true, status: true }, + }, + }, + }) + + const userWorkloads = await db.query.users.findMany({ + columns: { id: true, name: true, email: true, status: true }, + orderBy: (table, { asc }) => [asc(table.id)], + with: { + ownedProjects: { + columns: { id: true, title: true, status: true }, + orderBy: (table, { asc }) => [asc(table.id)], + with: { + tasks: { + columns: { + id: true, + title: true, + status: true, + priority: true, + estimateHours: true, + }, + orderBy: (table, { asc }) => [asc(table.id)], + }, + }, + }, + assignedTasks: { + columns: { id: true, title: true, status: true, priority: true }, + orderBy: (table, { asc }) => [asc(table.id)], + with: { + project: { + columns: { id: true, title: true }, + }, + }, + }, + }, + }) + + const taskCount = await db.$count(tasks) + const rawValues = await db.values(sql`SELECT id, name FROM ${users} ORDER BY id`) + + await db.transaction( + async (tx) => { + await tx + .insert(tasks) + .values({ + id: 102, + projectId: 10, + assigneeId: 1, + title: 'Commit transaction', + status: 'done', + priority: 'low', + estimateHours: 1, + createdAt: new Date(), + updatedAt: new Date(), + }) + .execute() + }, + { + accessMode: 'read write', + isolationLevel: 'serializableReadWrite', + idempotent: true, + } + ) + + console.log('Connection:', connectionString) + console.log('Joined rows:', joinedRows) + console.log('Project dashboard:', JSON.stringify(projectDashboard, null, 2)) + console.log('Review task:', JSON.stringify(reviewTask, null, 2)) + console.log('User workloads:', JSON.stringify(userWorkloads, null, 2)) + console.log('Task count before transaction insert:', Number(taskCount)) + console.log('Raw values:', rawValues) + console.log('Final task count:', Number(await db.$count(tasks))) +} catch (error) { + console.error('Drizzle adapter example failed:', error) + process.exitCode = 1 +} finally { + await db.$client.close?.() +} diff --git a/examples/drizzle-adapter/package.json b/examples/drizzle-adapter/package.json new file mode 100644 index 00000000..82b54e61 --- /dev/null +++ b/examples/drizzle-adapter/package.json @@ -0,0 +1,29 @@ +{ + "name": "@ydbjs/examples-drizzle-adapter", + "version": "6.0.0", + "private": true, + "type": "module", + "publishConfig": { + "access": "restricted" + }, + "scripts": { + "start": "tsx index.ts", + "dev": "DEBUG=ydbjs:* tsx index.ts", + "check": "tsc --noEmit", + "test": "npm run check" + }, + "dependencies": { + "@ydbjs/drizzle-adapter": "*", + "drizzle-orm": "^0.45.2" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsx": "^4.8.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.19.0", + "npm": ">=10" + }, + "engineStrict": true +} diff --git a/examples/drizzle-adapter/tsconfig.json b/examples/drizzle-adapter/tsconfig.json new file mode 100644 index 00000000..a95dcd7a --- /dev/null +++ b/examples/drizzle-adapter/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["index.ts"], + "exclude": ["node_modules"] +} diff --git a/package-lock.json b/package-lock.json index 08a72d9b..32c2ce17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,23 @@ "npm": ">=10" } }, + "examples/drizzle-adapter": { + "name": "@ydbjs/examples-drizzle-adapter", + "version": "6.0.0", + "dependencies": { + "@ydbjs/drizzle-adapter": "*", + "drizzle-orm": "^0.45.2" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsx": "^4.8.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.19.0", + "npm": ">=10" + } + }, "examples/environ": { "name": "@ydbjs/examples-environ", "version": "6.0.0", @@ -233,21 +250,6 @@ "@bufbuild/buf-win32-x64": "1.66.1" } }, - "node_modules/@bufbuild/buf-darwin-arm64": { - "version": "1.66.1", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", "license": "(Apache-2.0 AND BSD-3-Clause)" @@ -303,22 +305,16 @@ }, "node_modules/@docsearch/css": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.2.tgz", - "integrity": "sha512-fH/cn8BjEEdM2nJdjNMHIvOVYupG6AIDtFVDgIZrNzdCSj4KXr9kd+hsehqsNGYjpUjObeKYKvgy/IwCb1jZYQ==", "dev": true, "license": "MIT" }, "node_modules/@docsearch/js": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.2.tgz", - "integrity": "sha512-qj1yoxl3y4GKoK7+VM6fq/rQqPnvUmg3IKzJ9x0VzN14QVzdB/SG/J6VfV1BWT5RcPUFxIcVwoY1fwHM2fSRRw==", "dev": true, "license": "MIT" }, "node_modules/@docsearch/sidepanel-js": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.2.tgz", - "integrity": "sha512-Pni85AP/GwRj7fFg8cBJp0U04tzbueBvWSd3gysgnOsVnQVSZwSYncfErUScLE1CAtR+qocPDFjmYR9AMRNJtQ==", "dev": true, "license": "MIT" }, @@ -356,332 +352,314 @@ "tslib": "^2.4.0" } }, - "node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.8.0", - "@js-sdsl/ordered-map": "^4.4.2" - }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=12.10.0" + "node": ">=18" } }, - "node_modules/@grpc/proto-loader": { - "name": "dry-uninstall", - "version": "0.3.0", - "license": "MIT" - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.74", + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "CC0-1.0", - "dependencies": { - "@iconify/types": "*" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@iconify/types": { - "version": "2.0.0", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@js-sdsl/ordered-map": { - "version": "4.4.2", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "license": "Apache-2.0", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8.0.0" + "node": ">=18" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.215.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.215.0.tgz", - "integrity": "sha512-xrFlqhdhUyO8wSRn6DjE0145/HPWSJ5Nm0C7vWua6TdL/FSEAZvEyvdsa9CRXuxo9ebb7j/NEPhEcO62IJ0qUA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8.0.0" + "node": ">=18" } }, - "node_modules/@opentelemetry/core": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", - "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "node": ">=18" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.215.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.215.0.tgz", - "integrity": "sha512-FRydO5j7MWnXK9ghfykKxiSM8I5UeiicK/UNl3/mv86xoEKkb+LKz1I3WXgkuYVOQf22VNqbPO58s2W1mVWtEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/otlp-exporter-base": "0.215.0", - "@opentelemetry/otlp-transformer": "0.215.0", - "@opentelemetry/resources": "2.7.0", - "@opentelemetry/sdk-metrics": "2.7.0" - }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=18" } }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.215.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.215.0.tgz", - "integrity": "sha512-lHrfbmeLSmesGSkkHiqDwOzfaEMSWXdc7q6UoLfbW8byONCb+bE/zkAr0kapN4US1baT/2nbpNT7Cn9XoB96Vg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/otlp-transformer": "0.215.0" - }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=18" } }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.215.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.215.0.tgz", - "integrity": "sha512-cWwBvaV+vkXHkSoTYR8hGw+AW03UlgTr6xtrUKOMeum3T+8vffYXIfXu6KY5MLu8O9QtoBKqaKWw9I5xoOepng==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.215.0", - "@opentelemetry/core": "2.7.0", - "@opentelemetry/resources": "2.7.0", - "@opentelemetry/sdk-logs": "0.215.0", - "@opentelemetry/sdk-metrics": "2.7.0", - "@opentelemetry/sdk-trace-base": "2.7.0", - "protobufjs": "^8.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", - "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.215.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.215.0.tgz", - "integrity": "sha512-y3ucOmphzc4vgBTyIGchs+N/1rkACmoka8QalT2z1LBNM232Z17zMYayHcMl+dgMoOadZ0b72UZv7mDtqy1cFA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.215.0", - "@opentelemetry/core": "2.7.0", - "@opentelemetry/resources": "2.7.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.0.tgz", - "integrity": "sha512-Vd7h95av/LYRsAVN7wbprvvJnHkq7swMXAo7Uad0Uxf9jl6NSReLa0JNivrcc5BVIx/vl2t+cgdVQQbnVhsR9w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/resources": "2.7.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.0.tgz", - "integrity": "sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/resources": "2.7.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", - "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.46.0.tgz", - "integrity": "sha512-b1doV4WRcJU+BESSlCvCjV+5CEr/T6h0frArAdV26Nir+gGNFNaylvDiiMPfF1pxeV0txZEs38ojzJaxBYg+ng==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ - "arm" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.46.0.tgz", - "integrity": "sha512-v6+HhjsoV3GO0u2u9jLSAZrvWfTraDxKofUIQ7/ktS7tzS+epVsxdHmeM+XxuNcAY/nWxxU1Sg4JcGTNRXraBA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ - "arm64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.46.0.tgz", - "integrity": "sha512-3eeooJGrqGIlI5MyryDZsAcKXSmKIgAD4yYtfRrRJzXZ0UTFZtiSveIur56YPrGMYZwT4XyVhHsMqrNwr1XeFA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.46.0.tgz", - "integrity": "sha512-QG8BDM0CXWbu84k2SKmCqfEddPQPFiBicwtYnLqHRWZZl57HbtOLRMac/KTq2NO4AEc4ICCBpFxJIV9zcqYfkQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.46.0.tgz", - "integrity": "sha512-9DdCqS/n2ncu/Chazvt3cpgAjAmIGQDz7hFKSrNItMApyV/Ja9mz3hD4JakIE3nS8PW9smEbPWnb389QLBY4nw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -689,50 +667,50 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "netbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.46.0.tgz", - "integrity": "sha512-Dgs7VeE2jT0LHMhw6tPEt0xQYe54kBqHEovmWsv4FVQlegCOvlIJNx0S8n4vj8WUtpT+Z6BD2HhKJPLglLxvZg==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.46.0.tgz", - "integrity": "sha512-Zxn3adhTH13JKnU4xXJj8FeEfF680XjXh3gSShKl57HCMBRde2tUJTgogV/1MSHA80PJEVrDa7r66TLVq3Ia7Q==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ - "arm" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.46.0.tgz", - "integrity": "sha512-+TWipjrgVM8D7aIdDD0tlr3teLTTvQTn7QTE5BpT10H1Fj82gfdn9X6nn2sDgx/MepuSCfSnzFNJq2paLL0OiA==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -740,203 +718,316 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.46.0.tgz", - "integrity": "sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.46.0.tgz", - "integrity": "sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.46.0.tgz", - "integrity": "sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ - "riscv64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.46.0.tgz", - "integrity": "sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ - "riscv64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "name": "dry-uninstall", + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.74", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.215.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.215.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/otlp-exporter-base": "0.215.0", + "@opentelemetry/otlp-transformer": "0.215.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/sdk-metrics": "2.7.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.215.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/otlp-transformer": "0.215.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.46.0.tgz", - "integrity": "sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.215.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.215.0", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/sdk-logs": "0.215.0", + "@opentelemetry/sdk-metrics": "2.7.0", + "@opentelemetry/sdk-trace-base": "2.7.0", + "protobufjs": "^8.0.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.46.0.tgz", - "integrity": "sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/resources": { + "version": "2.7.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.46.0.tgz", - "integrity": "sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.215.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.215.0", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.46.0.tgz", - "integrity": "sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.46.0.tgz", - "integrity": "sha512-/5ktYUliP89RhgC37DBH1x20U5zPSZMy3cMEcO0j3793rbHP9MWsknBwQB6eozRzWmYrh0IFM/p20EbPvDlYlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.46.0.tgz", - "integrity": "sha512-3WTnoiuIr8XvV0DIY7SN+1uJSwKf4sPpcbHfobcRT9JutGcLaef/miyBB87jxd3aqH+mS0+G5lsgHuXLUwjjpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=14" } }, - "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.46.0.tgz", - "integrity": "sha512-IXxiQpkYnOwNfP23vzwSfhdpxJzyiPTY7eTn6dn3DsriKddESzM8i6kfq9R7CD/PUJwCvQT22NgtygBeug3KoA==", - "cpu": [ - "x64" - ], + "node_modules/@oxc-project/types": { + "version": "0.126.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", - "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.46.0.tgz", + "integrity": "sha512-b1doV4WRcJU+BESSlCvCjV+5CEr/T6h0frArAdV26Nir+gGNFNaylvDiiMPfF1pxeV0txZEs38ojzJaxBYg+ng==", "cpu": [ "arm" ], @@ -950,10 +1041,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-android-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", - "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.46.0.tgz", + "integrity": "sha512-v6+HhjsoV3GO0u2u9jLSAZrvWfTraDxKofUIQ7/ktS7tzS+epVsxdHmeM+XxuNcAY/nWxxU1Sg4JcGTNRXraBA==", "cpu": [ "arm64" ], @@ -967,10 +1058,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", - "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.46.0.tgz", + "integrity": "sha512-3eeooJGrqGIlI5MyryDZsAcKXSmKIgAD4yYtfRrRJzXZ0UTFZtiSveIur56YPrGMYZwT4XyVhHsMqrNwr1XeFA==", "cpu": [ "arm64" ], @@ -984,10 +1075,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", - "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.46.0.tgz", + "integrity": "sha512-QG8BDM0CXWbu84k2SKmCqfEddPQPFiBicwtYnLqHRWZZl57HbtOLRMac/KTq2NO4AEc4ICCBpFxJIV9zcqYfkQ==", "cpu": [ "x64" ], @@ -1001,10 +1092,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", - "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.46.0.tgz", + "integrity": "sha512-9DdCqS/n2ncu/Chazvt3cpgAjAmIGQDz7hFKSrNItMApyV/Ja9mz3hD4JakIE3nS8PW9smEbPWnb389QLBY4nw==", "cpu": [ "x64" ], @@ -1018,10 +1109,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", - "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.46.0.tgz", + "integrity": "sha512-Dgs7VeE2jT0LHMhw6tPEt0xQYe54kBqHEovmWsv4FVQlegCOvlIJNx0S8n4vj8WUtpT+Z6BD2HhKJPLglLxvZg==", "cpu": [ "arm" ], @@ -1035,10 +1126,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", - "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.46.0.tgz", + "integrity": "sha512-Zxn3adhTH13JKnU4xXJj8FeEfF680XjXh3gSShKl57HCMBRde2tUJTgogV/1MSHA80PJEVrDa7r66TLVq3Ia7Q==", "cpu": [ "arm" ], @@ -1052,14 +1143,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", - "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.46.0.tgz", + "integrity": "sha512-+TWipjrgVM8D7aIdDD0tlr3teLTTvQTn7QTE5BpT10H1Fj82gfdn9X6nn2sDgx/MepuSCfSnzFNJq2paLL0OiA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1069,14 +1163,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", - "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.46.0.tgz", + "integrity": "sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1084,16 +1181,19 @@ ], "engines": { "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", - "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.46.0.tgz", + "integrity": "sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1103,14 +1203,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", - "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.46.0.tgz", + "integrity": "sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1120,14 +1223,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", - "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.46.0.tgz", + "integrity": "sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1137,14 +1243,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", - "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.46.0.tgz", + "integrity": "sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1154,14 +1263,15 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.61.0.tgz", - "integrity": "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==", + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.46.0", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1171,14 +1281,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", - "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.46.0.tgz", + "integrity": "sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1188,10 +1301,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", - "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.46.0.tgz", + "integrity": "sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==", "cpu": [ "arm64" ], @@ -1205,10 +1318,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", - "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.46.0.tgz", + "integrity": "sha512-/5ktYUliP89RhgC37DBH1x20U5zPSZMy3cMEcO0j3793rbHP9MWsknBwQB6eozRzWmYrh0IFM/p20EbPvDlYlg==", "cpu": [ "arm64" ], @@ -1222,10 +1335,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", - "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.46.0.tgz", + "integrity": "sha512-3WTnoiuIr8XvV0DIY7SN+1uJSwKf4sPpcbHfobcRT9JutGcLaef/miyBB87jxd3aqH+mS0+G5lsgHuXLUwjjpQ==", "cpu": [ "ia32" ], @@ -1239,10 +1352,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", - "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.46.0.tgz", + "integrity": "sha512-IXxiQpkYnOwNfP23vzwSfhdpxJzyiPTY7eTn6dn3DsriKddESzM8i6kfq9R7CD/PUJwCvQT22NgtygBeug3KoA==", "cpu": [ "x64" ], @@ -1256,81 +1369,27 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", + "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", + "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", "cpu": [ "arm64" ], @@ -1344,10 +1403,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", + "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", "cpu": [ "arm64" ], @@ -1361,10 +1420,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", + "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", "cpu": [ "x64" ], @@ -1378,10 +1437,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", + "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", "cpu": [ "x64" ], @@ -1395,10 +1454,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", - "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", + "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", "cpu": [ "arm" ], @@ -1412,14 +1471,34 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", + "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", + "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1429,14 +1508,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", + "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1446,14 +1528,57 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", + "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", + "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", + "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", "cpu": [ - "ppc64" + "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1463,14 +1588,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", + "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1480,14 +1608,15 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.61.0", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1497,14 +1626,17 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", + "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1514,10 +1646,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", + "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", "cpu": [ "arm64" ], @@ -1531,31 +1663,29 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", - "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", + "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", "cpu": [ - "wasm32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.4" - }, + "os": [ + "win32" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", + "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", @@ -1567,10 +1697,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", + "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", "cpu": [ "x64" ], @@ -1584,31 +1714,59 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", - "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", + "node_modules/@polka/url": { + "version": "1.0.0-next.29", "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "license": "BSD-3-Clause" }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", "cpu": [ "arm64" ], @@ -1617,12 +1775,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", "cpu": [ "arm64" ], @@ -1631,12 +1792,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", "cpu": [ "x64" ], @@ -1645,26 +1809,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", "cpu": [ "x64" ], @@ -1673,26 +1826,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", "cpu": [ "arm" ], @@ -1701,194 +1843,169 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", "cpu": [ - "riscv64" + "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.16", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", "cpu": [ "arm64" ], @@ -1897,54 +2014,65 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.16", + "dev": true, + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-x64-gnu": { + "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { + "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, "node_modules/@shikijs/core": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", - "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", "dev": true, "license": "MIT", "dependencies": { @@ -1956,8 +2084,6 @@ }, "node_modules/@shikijs/engine-javascript": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", "dev": true, "license": "MIT", "dependencies": { @@ -1968,8 +2094,6 @@ }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", "dev": true, "license": "MIT", "dependencies": { @@ -1979,8 +2103,6 @@ }, "node_modules/@shikijs/langs": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", "dev": true, "license": "MIT", "dependencies": { @@ -1989,8 +2111,6 @@ }, "node_modules/@shikijs/themes": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", "dev": true, "license": "MIT", "dependencies": { @@ -1999,8 +2119,6 @@ }, "node_modules/@shikijs/transformers": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz", - "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2010,8 +2128,6 @@ }, "node_modules/@shikijs/types": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2021,15 +2137,11 @@ }, "node_modules/@shikijs/vscode-textmate": { "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -2063,8 +2175,6 @@ }, "node_modules/@turbo/linux-64": { "version": "2.9.6", - "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.6.tgz", - "integrity": "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA==", "cpu": [ "x64" ], @@ -2118,9 +2228,9 @@ ] }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -2130,8 +2240,6 @@ }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -2149,8 +2257,6 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, @@ -2161,8 +2267,6 @@ }, "node_modules/@types/hast": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2185,8 +2289,6 @@ }, "node_modules/@types/mdast": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "dev": true, "license": "MIT", "dependencies": { @@ -2205,8 +2307,6 @@ }, "node_modules/@types/node": { "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", "dependencies": { "undici-types": "~7.19.0" @@ -2214,15 +2314,11 @@ }, "node_modules/@types/unist": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "dev": true, "license": "MIT" }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "dev": true, "license": "MIT" }, @@ -2239,15 +2335,11 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", - "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", "dev": true, "license": "MIT", "dependencies": { @@ -2263,15 +2355,11 @@ }, "node_modules/@vitejs/plugin-vue/node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, "node_modules/@vitest/coverage-v8": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", "dependencies": { @@ -2301,8 +2389,6 @@ }, "node_modules/@vitest/expect": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { @@ -2319,8 +2405,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { @@ -2346,8 +2430,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -2359,8 +2441,6 @@ }, "node_modules/@vitest/runner": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2373,8 +2453,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2389,8 +2467,6 @@ }, "node_modules/@vitest/spy": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -2399,8 +2475,6 @@ }, "node_modules/@vitest/ui": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", - "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", "dev": true, "license": "MIT", "dependencies": { @@ -2421,8 +2495,6 @@ }, "node_modules/@vitest/utils": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { @@ -2436,8 +2508,6 @@ }, "node_modules/@vue/compiler-core": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz", - "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2450,8 +2520,6 @@ }, "node_modules/@vue/compiler-core/node_modules/@babel/parser": { "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -2466,15 +2534,11 @@ }, "node_modules/@vue/compiler-core/node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT" }, "node_modules/@vue/compiler-dom": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", - "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", "dev": true, "license": "MIT", "dependencies": { @@ -2484,8 +2548,6 @@ }, "node_modules/@vue/compiler-sfc": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", - "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2502,8 +2564,6 @@ }, "node_modules/@vue/compiler-sfc/node_modules/@babel/parser": { "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -2518,15 +2578,11 @@ }, "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT" }, "node_modules/@vue/compiler-ssr": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", - "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", "dev": true, "license": "MIT", "dependencies": { @@ -2536,8 +2592,6 @@ }, "node_modules/@vue/devtools-api": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", - "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", "dev": true, "license": "MIT", "dependencies": { @@ -2546,8 +2600,6 @@ }, "node_modules/@vue/devtools-kit": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", - "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", "dev": true, "license": "MIT", "dependencies": { @@ -2559,15 +2611,11 @@ }, "node_modules/@vue/devtools-shared": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", - "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", "dev": true, "license": "MIT" }, "node_modules/@vue/reactivity": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz", - "integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==", "dev": true, "license": "MIT", "dependencies": { @@ -2576,8 +2624,6 @@ }, "node_modules/@vue/runtime-core": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz", - "integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2587,8 +2633,6 @@ }, "node_modules/@vue/runtime-dom": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz", - "integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==", "dev": true, "license": "MIT", "dependencies": { @@ -2600,8 +2644,6 @@ }, "node_modules/@vue/server-renderer": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz", - "integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==", "dev": true, "license": "MIT", "dependencies": { @@ -2614,15 +2656,11 @@ }, "node_modules/@vue/shared": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz", - "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", "dev": true, "license": "MIT" }, "node_modules/@vueuse/core": { "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", - "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2639,8 +2677,6 @@ }, "node_modules/@vueuse/integrations": { "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.1.tgz", - "integrity": "sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==", "dev": true, "license": "MIT", "dependencies": { @@ -2706,8 +2742,6 @@ }, "node_modules/@vueuse/metadata": { "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", - "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", "dev": true, "license": "MIT", "funding": { @@ -2716,8 +2750,6 @@ }, "node_modules/@vueuse/shared": { "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", - "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", "dev": true, "license": "MIT", "funding": { @@ -2759,6 +2791,10 @@ "resolved": "docs", "link": true }, + "node_modules/@ydbjs/drizzle-adapter": { + "resolved": "third-parties/drizzle-adapter", + "link": true + }, "node_modules/@ydbjs/error": { "resolved": "packages/error", "link": true @@ -2779,6 +2815,10 @@ "resolved": "examples/diagnostics", "link": true }, + "node_modules/@ydbjs/examples-drizzle-adapter": { + "resolved": "examples/drizzle-adapter", + "link": true + }, "node_modules/@ydbjs/examples-environ": { "resolved": "examples/environ", "link": true @@ -2833,8 +2873,6 @@ }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -2871,8 +2909,6 @@ }, "node_modules/birpc": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "dev": true, "license": "MIT", "funding": { @@ -2881,8 +2917,6 @@ }, "node_modules/ccount": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "dev": true, "license": "MIT", "funding": { @@ -2892,8 +2926,6 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -2902,8 +2934,6 @@ }, "node_modules/character-entities-html4": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "dev": true, "license": "MIT", "funding": { @@ -2913,8 +2943,6 @@ }, "node_modules/character-entities-legacy": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "dev": true, "license": "MIT", "funding": { @@ -2924,8 +2952,6 @@ }, "node_modules/comma-separated-tokens": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "dev": true, "license": "MIT", "funding": { @@ -2935,15 +2961,11 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true, "license": "MIT" }, @@ -2972,8 +2994,6 @@ }, "node_modules/dequal": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", "engines": { @@ -2990,8 +3010,6 @@ }, "node_modules/devlop": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "dev": true, "license": "MIT", "dependencies": { @@ -3002,10 +3020,131 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "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 + }, + "@libsql/client-wasm": { + "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/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/entities": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3017,11 +3156,49 @@ }, "node_modules/es-module-lexer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "dev": true, @@ -3032,8 +3209,6 @@ }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3058,22 +3233,16 @@ }, "node_modules/fflate": { "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "dev": true, "license": "MIT" }, "node_modules/flatted": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/focus-trap": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.0.1.tgz", - "integrity": "sha512-9ptSG6z51YQOstI/oN4XuVGP/03u2nh0g//qz7L6zX0i6PZiPnkcf3GenXq7N2hZnASXaMxTPpbKwdI+PFvxlw==", "dev": true, "license": "MIT", "dependencies": { @@ -3082,7 +3251,10 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -3092,6 +3264,17 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -3102,8 +3285,6 @@ }, "node_modules/hast-util-to-html": { "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", "dev": true, "license": "MIT", "dependencies": { @@ -3126,8 +3307,6 @@ }, "node_modules/hast-util-whitespace": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", "dev": true, "license": "MIT", "dependencies": { @@ -3152,8 +3331,6 @@ }, "node_modules/hookable": { "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "dev": true, "license": "MIT" }, @@ -3164,8 +3341,6 @@ }, "node_modules/html-void-elements": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", "dev": true, "license": "MIT", "funding": { @@ -3262,6 +3437,8 @@ }, "node_modules/lightningcss-darwin-arm64": { "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -3350,6 +3527,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3371,6 +3551,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3386,12 +3569,13 @@ }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3407,12 +3591,13 @@ }, "node_modules/lightningcss-linux-x64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3470,8 +3655,6 @@ }, "node_modules/long": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/magic-string": { @@ -3513,8 +3696,6 @@ }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "dev": true, "license": "MIT", "dependencies": { @@ -3535,8 +3716,6 @@ }, "node_modules/micromark-util-character": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "dev": true, "funding": [ { @@ -3556,8 +3735,6 @@ }, "node_modules/micromark-util-encode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "dev": true, "funding": [ { @@ -3573,8 +3750,6 @@ }, "node_modules/micromark-util-sanitize-uri": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "dev": true, "funding": [ { @@ -3595,8 +3770,6 @@ }, "node_modules/micromark-util-symbol": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "dev": true, "funding": [ { @@ -3612,8 +3785,6 @@ }, "node_modules/micromark-util-types": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "dev": true, "funding": [ { @@ -3634,8 +3805,6 @@ }, "node_modules/mrmime": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", "engines": { @@ -3681,8 +3850,6 @@ }, "node_modules/obug": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -3692,15 +3859,11 @@ }, "node_modules/oniguruma-parser": { "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", "dev": true, "license": "MIT" }, "node_modules/oniguruma-to-es": { "version": "4.3.5", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", - "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3711,8 +3874,6 @@ }, "node_modules/oxfmt": { "version": "0.46.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.46.0.tgz", - "integrity": "sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==", "dev": true, "license": "MIT", "dependencies": { @@ -3751,8 +3912,6 @@ }, "node_modules/oxlint": { "version": "1.61.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.61.0.tgz", - "integrity": "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==", "dev": true, "license": "MIT", "bin": { @@ -3800,15 +3959,11 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", - "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "dev": true, "license": "MIT" }, @@ -3830,8 +3985,6 @@ }, "node_modules/postcss": { "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -3859,8 +4012,6 @@ }, "node_modules/property-information": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "dev": true, "license": "MIT", "funding": { @@ -3874,8 +4025,6 @@ }, "node_modules/protobufjs": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", - "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -3898,8 +4047,6 @@ }, "node_modules/regex": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "dev": true, "license": "MIT", "dependencies": { @@ -3908,8 +4055,6 @@ }, "node_modules/regex-recursion": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", "dev": true, "license": "MIT", "dependencies": { @@ -3918,15 +4063,19 @@ }, "node_modules/regex-utilities": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "dev": true, "license": "MIT" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", - "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", "dev": true, "license": "MIT", "dependencies": { @@ -3959,8 +4108,6 @@ }, "node_modules/rollup": { "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { @@ -4015,8 +4162,6 @@ }, "node_modules/shiki": { "version": "3.23.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", - "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", "dev": true, "license": "MIT", "dependencies": { @@ -4032,15 +4177,11 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/sirv": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", "dependencies": { @@ -4062,8 +4203,6 @@ }, "node_modules/space-separated-tokens": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "dev": true, "license": "MIT", "funding": { @@ -4073,22 +4212,16 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, "node_modules/stringify-entities": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "dev": true, "license": "MIT", "dependencies": { @@ -4113,22 +4246,16 @@ }, "node_modules/tabbable": { "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "dev": true, "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -4137,8 +4264,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { @@ -4162,8 +4287,6 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4172,8 +4295,6 @@ }, "node_modules/totalist": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", "engines": { @@ -4182,8 +4303,6 @@ }, "node_modules/trim-lines": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", "dev": true, "license": "MIT", "funding": { @@ -4203,10 +4322,26 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/turbo": { "version": "2.9.6", - "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.6.tgz", - "integrity": "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==", "dev": true, "license": "MIT", "bin": { @@ -4235,14 +4370,10 @@ }, "node_modules/undici-types": { "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/unist-util-is": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4255,8 +4386,6 @@ }, "node_modules/unist-util-position": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", "dev": true, "license": "MIT", "dependencies": { @@ -4269,8 +4398,6 @@ }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4283,8 +4410,6 @@ }, "node_modules/unist-util-visit": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "dev": true, "license": "MIT", "dependencies": { @@ -4299,8 +4424,6 @@ }, "node_modules/unist-util-visit-parents": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4314,8 +4437,6 @@ }, "node_modules/vfile": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4329,8 +4450,6 @@ }, "node_modules/vfile-message": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "dev": true, "license": "MIT", "dependencies": { @@ -4344,8 +4463,6 @@ }, "node_modules/vite": { "version": "8.0.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", - "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", "dependencies": { @@ -4422,8 +4539,6 @@ }, "node_modules/vitepress": { "version": "2.0.0-alpha.17", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.17.tgz", - "integrity": "sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4741,8 +4856,6 @@ }, "node_modules/vitepress/node_modules/@esbuild/linux-x64": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -4911,8 +5024,6 @@ }, "node_modules/vitepress/node_modules/esbuild": { "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4953,8 +5064,6 @@ }, "node_modules/vitepress/node_modules/vite": { "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { @@ -5028,8 +5137,6 @@ }, "node_modules/vitest": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { @@ -5118,8 +5225,6 @@ }, "node_modules/vue": { "version": "3.5.31", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", - "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5140,8 +5245,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -5165,8 +5268,6 @@ }, "node_modules/zwitch": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", "dev": true, "license": "MIT", "funding": { @@ -5466,6 +5567,27 @@ "node": ">=20.19.0", "npm": ">=10" } + }, + "third-parties/drizzle-adapter": { + "name": "@ydbjs/drizzle-adapter", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@ydbjs/api": "^6.0.6", + "@ydbjs/core": "^6.1.1", + "@ydbjs/query": "^6.1.0", + "@ydbjs/value": "^6.0.7" + }, + "devDependencies": { + "drizzle-orm": "^0.45.2" + }, + "engines": { + "node": ">=20.19.0", + "npm": ">=10" + }, + "peerDependencies": { + "drizzle-orm": "^0.45.2" + } } } } diff --git a/packages/query/src/session-pool.test.ts b/packages/query/src/session-pool.test.ts index 165b3b06..66213838 100644 --- a/packages/query/src/session-pool.test.ts +++ b/packages/query/src/session-pool.test.ts @@ -304,8 +304,12 @@ test('claim() releases on dispose so the next caller succeeds', async () => { } // previous claim disposed — a fresh claim must succeed - let again = session.claim() - again[Symbol.dispose]() + let again: Disposable | undefined + expect(() => { + again = session.claim() + }).not.toThrow() + expect(typeof again?.[Symbol.dispose]).toBe('function') + again?.[Symbol.dispose]() session.close() }) @@ -315,10 +319,14 @@ test('claim() dispose is idempotent', async () => { let held = session.claim() held[Symbol.dispose]() - held[Symbol.dispose]() // second dispose is a no-op + expect(() => held[Symbol.dispose]()).not.toThrow() // the slot must still be free - let again = session.claim() - again[Symbol.dispose]() + let again: Disposable | undefined + expect(() => { + again = session.claim() + }).not.toThrow() + expect(typeof again?.[Symbol.dispose]).toBe('function') + again?.[Symbol.dispose]() session.close() }) diff --git a/third-parties/drizzle-adapter/.gitignore b/third-parties/drizzle-adapter/.gitignore new file mode 100644 index 00000000..eea99aa9 --- /dev/null +++ b/third-parties/drizzle-adapter/.gitignore @@ -0,0 +1,2 @@ +.turbo +dist diff --git a/third-parties/drizzle-adapter/.npmignore b/third-parties/drizzle-adapter/.npmignore new file mode 100644 index 00000000..42f5f09a --- /dev/null +++ b/third-parties/drizzle-adapter/.npmignore @@ -0,0 +1,10 @@ +/.turbo + +/src +/tests +/coverage + +/*.yaml +/*.json +*.tsbuildinfo +vitest.config.ts diff --git a/third-parties/drizzle-adapter/CHANGELOG.md b/third-parties/drizzle-adapter/CHANGELOG.md new file mode 100644 index 00000000..1ef8bb68 --- /dev/null +++ b/third-parties/drizzle-adapter/CHANGELOG.md @@ -0,0 +1,5 @@ +# @ydbjs/drizzle-adapter + +## 1.0.0 + +Initial version. diff --git a/third-parties/drizzle-adapter/README.md b/third-parties/drizzle-adapter/README.md new file mode 100644 index 00000000..346bc451 --- /dev/null +++ b/third-parties/drizzle-adapter/README.md @@ -0,0 +1,162 @@ +# @ydbjs/drizzle-adapter + +YDB adapter for Drizzle ORM. The package provides typed schema declarations, YDB-aware query builders, direct YQL execution helpers, DDL helpers, and a migration runner with history and optional locking. + +## Install + +```sh +npm install @ydbjs/drizzle-adapter drizzle-orm +``` + +Requires Node.js 20.19 or newer. + +## Quick Start + +```ts +import { eq } from 'drizzle-orm' +import { createDrizzle, integer, text, timestamp, ydbTable } from '@ydbjs/drizzle-adapter' + +export let users = ydbTable('users', { + id: integer('id').primaryKey(), + email: text('email').notNull(), + createdAt: timestamp('created_at').notNull(), +}) + +let db = createDrizzle({ + connectionString: process.env['YDB_CONNECTION_STRING']!, + schema: { users }, +}) + +await db + .insert(users) + .values({ + id: 1, + email: 'ada@example.com', + createdAt: new Date(), + }) + .execute() + +await db + .upsert(users) + .values({ + id: 1, + email: 'ada@new.example.com', + createdAt: new Date(), + }) + .execute() + +let row = await db + .select({ id: users.id, email: users.email }) + .from(users) + .where(eq(users.id, 1)) + .prepare() + .get() + +db.$client.close?.() +``` + +## Main Capabilities + +- Schema declarations with YDB column helpers, primary keys, unique constraints, secondary and vector indexes, table options, TTL, and column families. +- SELECT builders with joins, CTEs, set operators, `WITHOUT`, `FLATTEN`, `SAMPLE`, `TABLESAMPLE`, `MATCH_RECOGNIZE`, window helpers, and YDB optimizer hints. +- Mutation helpers for `insert`, `upsert`, `replace`, `update`, `batchUpdate`, `delete`, and `batchDelete`. +- `db.query.*` relations API using Drizzle relation metadata. +- `YdbDriver`, prepared queries, raw YQL helpers, and transaction support through the database object. +- DDL helpers and `migrate()` with migration history, lock table, and recovery options. +- Typed YDB query errors for unique constraints, authentication, cancellation, timeouts, unavailable/overloaded services, and retryable failures. + +## Type Mapping Notes + +Most scalar YDB values map directly to JavaScript primitives: + +| YDB family | JavaScript / TypeScript value | +| ----------------------------------------------------- | ----------------------------- | +| `Bool` | `boolean` | +| `Int8`..`Int32`, `Uint8`..`Uint32`, `Float`, `Double` | `number` | +| `Int64`, `Uint64` | `bigint` | +| `Utf8`, `Uuid` | `string` | +| `String`, `Yson` | `Uint8Array` | +| `Date`, `Datetime`, `Timestamp` and 64-bit variants | `Date` | +| `Json`, `JsonDocument` | typed JSON value | + +Use `bytes()` for binary YDB `String`; use `text()` for human-readable UTF-8 text. + +## Migrations In CI/CD + +Run migrations as a single deployment step before application instances start serving traffic. Enable the YDB lock table so parallel deploy jobs do not apply the same migration concurrently. + +```ts +await migrate(db, { + migrationsFolder: './drizzle', + migrationLock: { + key: 'production', + leaseMs: 10 * 60 * 1000, + acquireTimeoutMs: 60 * 1000, + }, + migrationRecovery: { + mode: 'retry', + staleRunningAfterMs: 60 * 60 * 1000, + }, +}) +``` + +In CI, run the live test project against a real YDB service: + +```sh +npm run build -- --filter=@ydbjs/drizzle-adapter +npm run attw -- --filter=@ydbjs/drizzle-adapter +npm run test:live --workspace=@ydbjs/drizzle-adapter +``` + +## Limitations + +- The package follows the SDK runtime baseline: ESM-only, Node.js 20.19+, and no CommonJS build. +- YDB transactions are not nestable through the adapter. Use one `db.transaction()` boundary and pass `tx` down. +- Supported isolation options are YDB `serializableReadWrite` and `snapshotReadOnly`. +- `references()` metadata is for Drizzle relations. YDB does not enforce native foreign keys. +- Unique indexes must be created with `CREATE TABLE`; adding a unique index to an existing table is rejected by the DDL builder. +- `replace()` is a full-row replacement by primary key. Prefer `upsert()` or `update()` for partial changes. +- Query builders and DDL helpers escape identifiers and bind values. Raw surfaces such as `sql.raw()`, inline migration `sql`, `rawTableOption()`, view query text, ACL raw permissions, and transfer `using` text intentionally trust caller-provided YQL. + +## Development Checks + +From the SDK repository root: + +```sh +npm run build -- --filter=@ydbjs/drizzle-adapter +npm run attw -- --filter=@ydbjs/drizzle-adapter +npm run check:surface --workspace=@ydbjs/drizzle-adapter +npm run test --workspace=@ydbjs/drizzle-adapter -- --project uni +``` + +Integration tests require the SDK YDB test setup: + +```sh +npm run test:live --workspace=@ydbjs/drizzle-adapter +``` + +The root CI workflow runs the SDK test suite on pull requests with a Docker YDB service, so the adapter integration project is exercised together with the rest of the SDK. + +## Error Handling + +Execution failures are wrapped in Drizzle query errors with YDB-specific subclasses when the status can be classified: + +- `YdbUniqueConstraintViolationError` +- `YdbAuthenticationError` +- `YdbCancelledQueryError` +- `YdbTimeoutQueryError` +- `YdbUnavailableQueryError` +- `YdbOverloadedQueryError` +- `YdbRetryableQueryError` + +Mapped errors expose non-enumerable `kind`, `retryable`, `statusCode`, and original YDB diagnostic fields such as `code`, `status`, and `issues` when present. + +## Release Gates + +Before a release PR is accepted, the SDK CI workflow must pass: + +- root build and type packaging checks: `npm run build` and `npm run attw`; +- adapter package surface smoke test: `npm run check:surface --workspace=@ydbjs/drizzle-adapter`; +- adapter unit and live tests through the root test suite, including Docker-backed YDB integration tests. + +The repository release workflow stays shared with the SDK and is not customized by the adapter package. diff --git a/third-parties/drizzle-adapter/package.json b/third-parties/drizzle-adapter/package.json new file mode 100644 index 00000000..e30ceb23 --- /dev/null +++ b/third-parties/drizzle-adapter/package.json @@ -0,0 +1,68 @@ +{ + "name": "@ydbjs/drizzle-adapter", + "version": "0.0.0", + "description": "YDB adapter for Drizzle ORM.", + "keywords": [ + "database", + "drizzle", + "drizzle-orm", + "ydb", + "yql" + ], + "homepage": "https://github.com/ydb-platform/ydb-js-sdk/tree/main/third-parties/drizzle-adapter#readme", + "bugs": { + "url": "https://github.com/ydb-platform/ydb-js-sdk/issues" + }, + "license": "Apache-2.0", + "author": "YDB Team (https://ydb.tech)", + "repository": { + "type": "git", + "url": "git+https://github.com/ydb-platform/ydb-js-sdk.git", + "directory": "third-parties/drizzle-adapter" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "clean": "rm -rf dist", + "build": "tsc", + "test": "vitest --run", + "test:unit": "vitest --run --project uni", + "test:live": "vitest --run --project int", + "attw": "attw --pack --profile esm-only", + "check:surface": "node scripts/check-package-surface.mjs" + }, + "dependencies": { + "@ydbjs/api": "^6.0.6", + "@ydbjs/core": "^6.1.1", + "@ydbjs/query": "^6.1.0", + "@ydbjs/value": "^6.0.7" + }, + "peerDependencies": { + "drizzle-orm": "^0.45.2" + }, + "devDependencies": { + "drizzle-orm": "^0.45.2" + }, + "engines": { + "node": ">=20.19.0", + "npm": ">=10" + }, + "engineStrict": true +} diff --git a/third-parties/drizzle-adapter/scripts/check-package-surface.mjs b/third-parties/drizzle-adapter/scripts/check-package-surface.mjs new file mode 100644 index 00000000..57c4ed31 --- /dev/null +++ b/third-parties/drizzle-adapter/scripts/check-package-surface.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const packageDir = resolve(scriptDir, '..') +const packageJsonPath = resolve(packageDir, 'package.json') +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + +assert.equal(packageJson.type, 'module') +assert.equal(packageJson.sideEffects, false) +assert.equal(packageJson.main, 'dist/index.js') +assert.equal(packageJson.types, 'dist/index.d.ts') +assert.deepEqual(packageJson.exports, { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + }, +}) + +const publicApi = await import('@ydbjs/drizzle-adapter') +const expectedRuntimeExports = [ + 'YdbAuthenticationError', + 'YdbCancelledQueryError', + 'YdbDriver', + 'YdbOverloadedQueryError', + 'YdbQueryExecutionError', + 'YdbRetryableQueryError', + 'YdbTimeoutQueryError', + 'YdbUnavailableQueryError', + 'YdbUniqueConstraintViolationError', + 'asTable', + 'buildCreateTableSql', + 'createDrizzle', + 'drizzle', + 'index', + 'integer', + 'many', + 'migrate', + 'one', + 'primaryKey', + 'relations', + 'text', + 'unique', + 'ydbTable', +] + +for (const name of expectedRuntimeExports) { + assert.equal(Object.hasOwn(publicApi, name), true, `Missing root runtime export: ${name}`) +} + +const internalRuntimeExports = [ + 'YdbColumn', + 'YdbColumnBuilder', + 'YdbCountBuilder', + 'YdbDatabase', + 'YdbDialect', + 'YdbQueryBuilder', + 'YdbSession', + 'YdbTransaction', +] + +for (const name of internalRuntimeExports) { + assert.equal( + Object.hasOwn(publicApi, name), + false, + `Root public API exposes implementation detail: ${name}` + ) +} + +const forbiddenSubpaths = [ + '@ydbjs/drizzle-adapter/dist/index.js', + '@ydbjs/drizzle-adapter/ydb/dialect.js', + '@ydbjs/drizzle-adapter/ydb-core/session.js', +] + +await Promise.all( + forbiddenSubpaths.map(async (subpath) => { + try { + await import(subpath) + assert.fail(`Deep import unexpectedly resolved: ${subpath}`) + } catch (error) { + assert.equal( + error?.code, + 'ERR_PACKAGE_PATH_NOT_EXPORTED', + `Unexpected error for ${subpath}: ${error?.code ?? error}` + ) + } + }) +) + +const pack = spawnSync('npm', ['pack', '--dry-run', '--json'], { + cwd: packageDir, + encoding: 'utf8', +}) + +assert.equal(pack.status, 0, pack.stderr || pack.stdout) + +const packManifest = JSON.parse(pack.stdout)[0] +const packedFiles = new Set(packManifest.files.map((file) => file.path)) + +for (const file of [ + 'package.json', + 'README.md', + 'CHANGELOG.md', + 'dist/index.js', + 'dist/index.d.ts', +]) { + assert.equal(packedFiles.has(file), true, `Packed package is missing ${file}`) +} + +for (const file of packedFiles) { + assert.equal(file.startsWith('src/'), false, `Source file leaked into package: ${file}`) + assert.equal(file.startsWith('tests/'), false, `Test file leaked into package: ${file}`) +} + +console.log('drizzle-adapter package surface is stable') diff --git a/third-parties/drizzle-adapter/src/index.ts b/third-parties/drizzle-adapter/src/index.ts new file mode 100644 index 00000000..b0a50c44 --- /dev/null +++ b/third-parties/drizzle-adapter/src/index.ts @@ -0,0 +1,253 @@ +export type { + YdbDeleteConfig, + YdbDialectMigration, + YdbDialectMigrationConfig, + YdbFlatRelationalQueryConfig, + YdbInsertConfig, + YdbJoinConfig, + YdbJoinType, + YdbRelationalQueryConfig, + YdbRelationalQueryResult, + YdbSelectConfig, + YdbSetOperatorConfig, + YdbSetOperatorSource, + YdbUpdateConfig, +} from './ydb/dialect.js' +export { + YdbDriver, + type YdbDriverOptions, + type YdbExecuteOptions, + type YdbExecutor, + type YdbQueryMeta, + type YdbQueryResult, + type YdbRemoteCallback, + type YdbTransactionalExecutor, + type YdbTransactionConfig, +} from './ydb/driver.js' +export { + YdbAuthenticationError, + YdbCancelledQueryError, + YdbOverloadedQueryError, + YdbQueryExecutionError, + YdbRetryableQueryError, + YdbTimeoutQueryError, + YdbUnavailableQueryError, + YdbUniqueConstraintViolationError, + type YdbQueryErrorDetails, + type YdbQueryErrorKind, +} from './ydb/errors.js' +export type { YdbTransactionScope } from './ydb-core/db.js' +export { + type YdbSchemaDefinition, + type YdbSchemaRelations, + type YdbSchemaWithoutTables, +} from './ydb-core/schema.types.js' +export { + asTable, + commit, + cube, + declareParam, + defineAction, + distinctHint, + doAction, + doBlock, + except, + groupKey, + grouping, + groupingSets, + hop, + hopEnd, + hopStart, + intersect, + intoResult, + kMeansTreeSearchTopSize, + knnCosineDistance, + knnCosineSimilarity, + knnDistance, + knnEuclideanDistance, + knnInnerProductSimilarity, + knnManhattanDistance, + knnSimilarity, + matchRecognize, + pragma, + rollup, + sessionStart, + sessionWindow, + uniqueHint, + union, + unionAll, + values, + valuesTable, + windowDefinition, + yqlScript, + type YdbActionParameter, + type YdbFlattenConfig, + type YdbFlattenMode, + type YdbGroupingSet, + type YdbKnnDistanceFunction, + type YdbKnnSimilarityFunction, + type YdbMatchRecognizeConfig, + type YdbSampleConfig, + type YdbScriptExpression, + type YdbScriptPrimitive, + type YdbUniqueDistinctHint, + type YdbValuesOptions, + type YdbValuesPrimitive, + type YdbValuesRow, + type YdbWindowClause, + type YdbWindowDefinitionConfig, +} from './ydb-core/query-builders/select.js' +export { + index, + indexView, + uniqueIndex, + vectorIndexView, + vectorIndex, + type YdbVectorDistance, + type YdbVectorKMeansTreeOptions, + type YdbVectorSimilarity, + type YdbVectorType, +} from './ydb-core/indexes.js' +export { migrate, type YdbMigrateConfig, type YdbMigratorConfig } from './ydb/migrator.js' +export { + buildAddColumnsSql, + buildAddChangefeedSql, + buildAddColumnFamilySql, + buildAddIndexSql, + buildAlterAsyncReplicationSql, + buildAlterGroupSql, + buildAlterTableSql, + buildAlterColumnFamilySql, + buildAlterColumnSetFamilySql, + buildAlterTableResetOptionsSql, + buildAlterTableSetOptionsSql, + buildAlterTopicSql, + buildAlterTransferSql, + buildAlterUserSql, + buildAnalyzeSql, + buildCreateAsyncReplicationSql, + buildCreateGroupSql, + buildCreateSecretSql, + buildCreateTableSql, + buildCreateTopicSql, + buildCreateTransferSql, + buildCreateUserSql, + buildCreateViewSql, + buildDropAsyncReplicationSql, + buildDropColumnsSql, + buildDropChangefeedSql, + buildDropGroupSql, + buildDropIndexSql, + buildDropTableSql, + buildDropTopicSql, + buildDropTransferSql, + buildDropUserSql, + buildDropViewSql, + buildGrantSql, + buildMigrationLockTableBootstrapSql, + buildMigrationSql, + buildRevokeSql, + buildRenameTableSql, + buildShowCreateSql, + type YdbAccessPermission, + type YdbAccessPermissions, + type YdbAlterAsyncReplicationOperation, + type YdbAlterAsyncReplicationOptions, + type YdbAlterGroupOperation, + type YdbAlterTableAction, + type YdbAlterTableOperation, + type YdbAlterTopicAction, + type YdbAlterTopicOperation, + type YdbAlterTransferOperation, + type YdbAlterTransferOptions, + type YdbAlterUserOperation, + type YdbAnalyzeOperation, + type YdbAsyncReplicationConsistencyLevel, + type YdbAsyncReplicationOptions, + type YdbAsyncReplicationTarget, + type YdbChangefeedFormat, + type YdbChangefeedMode, + type YdbChangefeedOptions, + type YdbCreateAsyncReplicationOperation, + type YdbCreateGroupOperation, + type YdbCreateSecretOperation, + type YdbCreateTopicOptions, + type YdbCreateTopicOperation, + type YdbCreateTransferOperation, + type YdbCreateUserOperation, + type YdbCreateViewOperation, + type YdbCreateViewOptions, + type YdbDropAsyncReplicationOperation, + type YdbDropChangefeedOperation, + type YdbDropGroupOperation, + type YdbDropTopicOperation, + type YdbDropTransferOperation, + type YdbDropUserOperation, + type YdbDropViewOperation, + type YdbGrantOperation, + type YdbRenameTableOperation, + type YdbRevokeOperation, + type YdbShowCreateObjectType, + type YdbShowCreateOperation, + type YdbTopicConsumer, + type YdbTransferOptions, + type YdbUserOptions, + type YdbMigrationLockConfig, + type YdbInlineMigration, + type YdbMigrationOperation, + type YdbMigrationRecoveryConfig, + type YdbMigrationStatus, +} from './ydb/migration-ddl.js' +export { primaryKey } from './ydb-core/primary-keys.js' +export { unique } from './ydb-core/unique-constraint.js' +export { + createDrizzle, + drizzle, + type YdbDrizzleConfig, + type YdbDrizzleDatabase, + type YdbDrizzleOptions, +} from './ydb/createDrizzle.js' +export { createMany as many, createOne as one, relations } from 'drizzle-orm' +export { ydbTable, ydbTableCreator, type YdbTable, type YdbTableFn } from './ydb-core/table.js' +export { + columnFamily, + partitionByHash, + rawTableOption, + tableOptions, + ttl, + type YdbColumnFamilyOptions, + type YdbTableOptionValue, + type YdbTtlAction, + type YdbTtlUnit, +} from './ydb-core/table-options.js' +export { customType } from './ydb-core/columns/custom.js' +export { integer, int } from './ydb-core/columns/integer.js' +export { text } from './ydb-core/columns/text.js' +export { + bigint, + binary, + boolean, + bytes, + date, + date32, + datetime, + datetime64, + decimal, + double, + dyNumber, + float, + int8, + int16, + interval, + interval64, + json, + jsonDocument, + timestamp, + timestamp64, + uint8, + uint16, + uint32, + uint64, + uuid, + yson, +} from './ydb-core/columns/types.js' diff --git a/third-parties/drizzle-adapter/src/ydb-core/columns/all.ts b/third-parties/drizzle-adapter/src/ydb-core/columns/all.ts new file mode 100644 index 00000000..21e3f376 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/columns/all.ts @@ -0,0 +1,70 @@ +import { + bigint, + binary, + boolean, + bytes, + customType, + date, + date32, + datetime, + datetime64, + decimal, + double, + dyNumber, + float, + int16, + int8, + interval, + interval64, + json, + jsonDocument, + timestamp, + timestamp64, + uint16, + uint32, + uint64, + uint8, + uuid, + yson, +} from './types.js' +import { integer } from './integer.js' +import { text } from './text.js' + +export const ydbColumnBuilders = { + bigint, + binary, + boolean, + bytes, + customType, + date, + date32, + datetime, + datetime64, + decimal, + double, + dyNumber, + float, + int8, + int16, + integer, + interval, + interval64, + json, + jsonDocument, + text, + timestamp, + timestamp64, + int: integer, + uint8, + uint16, + uint32, + uint64, + uuid, + yson, +} + +export function getYdbColumnBuilders() { + return ydbColumnBuilders +} + +export type YdbColumnBuilders = typeof ydbColumnBuilders diff --git a/third-parties/drizzle-adapter/src/ydb-core/columns/common.ts b/third-parties/drizzle-adapter/src/ydb-core/columns/common.ts new file mode 100644 index 00000000..585fac64 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/columns/common.ts @@ -0,0 +1,86 @@ +import type { + ColumnBuilderBase, + ColumnBuilderBaseConfig, + ColumnBuilderExtraConfig, + ColumnBuilderRuntimeConfig, + ColumnDataType, +} from 'drizzle-orm/column-builder' +import { ColumnBuilder } from 'drizzle-orm/column-builder' +import type { ColumnBaseConfig, ColumnRuntimeConfig } from 'drizzle-orm/column' +import { Column } from 'drizzle-orm/column' +import { entityKind } from 'drizzle-orm/entity' +import type { SQL } from 'drizzle-orm/sql/sql' +import type { Table } from 'drizzle-orm/table' +import type { Update } from 'drizzle-orm/utils' +import { uniqueKeyName } from '../unique-constraint.js' + +export interface YdbColumnBuilderBase< + T extends ColumnBuilderBaseConfig = ColumnBuilderBaseConfig< + ColumnDataType, + string + >, + TTypeConfig extends object = object, +> extends ColumnBuilderBase {} + +export class YdbColumnBuilder< + T extends ColumnBuilderBaseConfig = ColumnBuilderBaseConfig< + ColumnDataType, + string + >, + TRuntimeConfig extends object = object, + TTypeConfig extends object = object, + TExtraConfig extends ColumnBuilderExtraConfig = ColumnBuilderExtraConfig, +> + extends ColumnBuilder + implements YdbColumnBuilderBase +{ + static override readonly [entityKind]: string = 'YdbColumnBuilder' + + unique(name?: string): this { + this.config.isUnique = true + this.config.uniqueName = name + return this + } + + override generatedAlwaysAs( + _as: SQL | this['_']['data'] | (() => SQL), + _config?: { mode?: 'virtual' | 'stored' } + ): any { + throw new Error('YDB generatedAlwaysAs() is not supported') + } + + build(table: TTable): YdbColumn { + return new YdbColumn(table, this.config as ColumnBuilderRuntimeConfig) + } +} + +export class YdbColumn< + T extends ColumnBaseConfig = ColumnBaseConfig, + TRuntimeConfig extends object = object, + TTypeConfig extends object = object, +> extends Column { + static override readonly [entityKind]: string = 'YdbColumn' + + constructor(table: Table, config: ColumnRuntimeConfig) { + if (config.isUnique && !config.uniqueName) { + config.uniqueName = uniqueKeyName(table as any, [config.name]) + } + + super(table, config) + } + + override getSQLType(): string { + return 'unknown' + } + + override mapFromDriverValue(value: T['driverParam']): T['data'] { + return super.mapFromDriverValue(value) as T['data'] + } + + override mapToDriverValue(value: T['data']): T['driverParam'] { + return super.mapToDriverValue(value) as T['driverParam'] + } +} + +export type AnyYdbColumn> = {}> = + YdbColumn, TPartial>>> diff --git a/third-parties/drizzle-adapter/src/ydb-core/columns/custom.ts b/third-parties/drizzle-adapter/src/ydb-core/columns/custom.ts new file mode 100644 index 00000000..ec88b6e2 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/columns/custom.ts @@ -0,0 +1,141 @@ +import type { + ColumnBuilderBaseConfig, + ColumnBuilderRuntimeConfig, + MakeColumnConfig, +} from 'drizzle-orm/column-builder' +import type { ColumnBaseConfig } from 'drizzle-orm/column' +import { entityKind } from 'drizzle-orm/entity' +import type { SQL } from 'drizzle-orm/sql/sql' +import type { Table } from 'drizzle-orm/table' +import { type Equal } from 'drizzle-orm/utils' +import { YdbColumn, YdbColumnBuilder } from './common.js' + +function getColumnNameAndConfig | undefined>( + a: string | TConfig | undefined, + b: TConfig | undefined +) { + return { + name: typeof a === 'string' && a.length > 0 ? a : '', + config: typeof a === 'object' ? a : b, + } +} + +export type ConvertCustomConfig> = { + name: TName + dataType: 'custom' + columnType: 'YdbCustomColumn' + data: T['data'] + driverParam: T['driverData'] + enumValues: undefined +} & (T['notNull'] extends true ? { notNull: true } : {}) & + (T['default'] extends true ? { hasDefault: true } : {}) + +export interface CustomTypeValues { + data: unknown + driverData?: unknown + config?: Record + configRequired?: boolean + notNull?: boolean + default?: boolean +} + +export interface CustomTypeParams { + dataType: ( + config: T['config'] | (Equal extends true ? never : undefined) + ) => string + toDriver?: (value: T['data']) => T['driverData'] | SQL + fromDriver?: (value: T['driverData']) => T['data'] +} + +export class YdbCustomColumnBuilder< + T extends ColumnBuilderBaseConfig<'custom', 'YdbCustomColumn'>, +> extends YdbColumnBuilder< + T, + { + fieldConfig: CustomTypeValues['config'] + customTypeParams: CustomTypeParams + }, + { + ydbColumnBuilderBrand: 'YdbCustomColumnBuilderBrand' + } +> { + static override readonly [entityKind]: string = 'YdbCustomColumnBuilder' + + constructor( + name: T['name'], + fieldConfig: CustomTypeValues['config'], + customTypeParams: CustomTypeParams + ) { + super(name, 'custom', 'YdbCustomColumn') + this.config.fieldConfig = fieldConfig + this.config.customTypeParams = customTypeParams + } + + override build( + table: TTable + ): YdbCustomColumn> { + return new YdbCustomColumn>( + table, + this.config as ColumnBuilderRuntimeConfig + ) + } +} + +export class YdbCustomColumn< + T extends ColumnBaseConfig<'custom', 'YdbCustomColumn'>, +> extends YdbColumn { + static override readonly [entityKind]: string = 'YdbCustomColumn' + + private readonly sqlName: string + private readonly mapTo: ((value: T['data']) => T['driverParam'] | SQL) | undefined + private readonly mapFrom: ((value: T['driverParam']) => T['data']) | undefined + + constructor(table: Table, config: YdbCustomColumnBuilder['config']) { + super(table, config) + this.sqlName = config.customTypeParams.dataType(config.fieldConfig) + this.mapTo = config.customTypeParams.toDriver + this.mapFrom = config.customTypeParams.fromDriver + } + + override getSQLType(): string { + return this.sqlName + } + + override mapFromDriverValue(value: T['driverParam']): T['data'] { + return typeof this.mapFrom === 'function' ? this.mapFrom(value) : (value as T['data']) + } + + override mapToDriverValue(value: T['data']): T['driverParam'] { + return typeof this.mapTo === 'function' + ? (this.mapTo(value) as T['driverParam']) + : (value as T['driverParam']) + } +} + +export function customType( + customTypeParams: CustomTypeParams +): Equal extends true + ? { + & T['config']>( + fieldConfig: TConfig + ): YdbCustomColumnBuilder> + ( + dbName: TName, + fieldConfig: T['config'] + ): YdbCustomColumnBuilder> + } + : { + (): YdbCustomColumnBuilder> + & T['config']>( + fieldConfig?: TConfig + ): YdbCustomColumnBuilder> + ( + dbName: TName, + fieldConfig?: T['config'] + ): YdbCustomColumnBuilder> + } { + return ((a?: string | T['config'], b?: T['config']) => { + const { name, config } = getColumnNameAndConfig(a, b) + return new YdbCustomColumnBuilder(name as any, config, customTypeParams) + }) as any +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/columns/integer.ts b/third-parties/drizzle-adapter/src/ydb-core/columns/integer.ts new file mode 100644 index 00000000..bd7d1042 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/columns/integer.ts @@ -0,0 +1,29 @@ +import { YdbColumn, YdbColumnBuilder } from './common.js' + +export interface YdbIntegerConfig { + mode?: 'number' +} + +export class YdbIntegerBuilder extends YdbColumnBuilder { + constructor(name: string) { + super(name, 'number', 'YdbInteger') + } + + override build(table: TTable): YdbInteger { + return new YdbInteger(table as any, this.config) + } +} + +export class YdbInteger extends YdbColumn { + override getSQLType(): string { + return 'Int32' + } +} + +export function integer(name?: string): YdbIntegerBuilder +export function integer(name?: string, _config?: YdbIntegerConfig): YdbIntegerBuilder +export function integer(a?: string, _b?: YdbIntegerConfig): YdbIntegerBuilder { + return new YdbIntegerBuilder(a ?? '') +} + +export const int = integer diff --git a/third-parties/drizzle-adapter/src/ydb-core/columns/text.ts b/third-parties/drizzle-adapter/src/ydb-core/columns/text.ts new file mode 100644 index 00000000..06a889f7 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/columns/text.ts @@ -0,0 +1,22 @@ +import { YdbColumn, YdbColumnBuilder } from './common.js' + +export class YdbTextBuilder extends YdbColumnBuilder { + constructor(name: string) { + super(name, 'string', 'YdbText') + } + + override build(table: TTable): YdbText { + return new YdbText(table as any, this.config) + } +} + +export class YdbText extends YdbColumn { + override getSQLType(): string { + return 'Utf8' + } +} + +export function text(name?: string): YdbTextBuilder +export function text(a?: string): YdbTextBuilder { + return new YdbTextBuilder(a ?? '') +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/columns/types.ts b/third-parties/drizzle-adapter/src/ydb-core/columns/types.ts new file mode 100644 index 00000000..0fdc4dfa --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/columns/types.ts @@ -0,0 +1,453 @@ +import * as Ydb from '@ydbjs/api/value' +import { + Bool, + Primitive, + PrimitiveType, + Date as YdbDate, + Datetime as YdbDatetime, + Double as YdbDouble, + Float as YdbFloat, + Int16 as YdbInt16, + Int64 as YdbInt64, + Int8 as YdbInt8, + Interval as YdbInterval, + Json as YdbJson, + JsonDocument as YdbJsonDocument, + Timestamp as YdbTimestamp, + Uint16 as YdbUint16, + Uint32 as YdbUint32, + Uint64 as YdbUint64, + Uint8 as YdbUint8, + Uuid as YdbUuid, + Yson as YdbYson, +} from '@ydbjs/value/primitive' +import { sql as yql } from 'drizzle-orm/sql/sql' +import { customType } from './custom.js' + +function escapeYqlString(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +function toUint8Array(value: unknown): Uint8Array { + if (value instanceof Uint8Array) { + return value + } + + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + + if (Array.isArray(value)) { + return Uint8Array.from(value) + } + + if (typeof value === 'string') { + return Uint8Array.from(Buffer.from(value, 'latin1')) + } + + throw new Error(`Cannot decode YDB binary value: ${Object.prototype.toString.call(value)}`) +} + +export interface YdbDecimalConfig { + precision: number + scale: number +} + +const booleanBase = customType<{ data: boolean; driverData: boolean | Bool }>({ + dataType() { + return 'Bool' + }, + toDriver(value) { + return new Bool(value) + }, +}) + +const int8Base = customType<{ data: number; driverData: YdbInt8 }>({ + dataType() { + return 'Int8' + }, + toDriver(value) { + return new YdbInt8(value) + }, +}) + +const int16Base = customType<{ data: number; driverData: YdbInt16 }>({ + dataType() { + return 'Int16' + }, + toDriver(value) { + return new YdbInt16(value) + }, +}) + +const bigintBase = customType<{ data: bigint; driverData: bigint | YdbInt64 }>({ + dataType() { + return 'Int64' + }, + toDriver(value) { + return new YdbInt64(value) + }, +}) + +const uint8Base = customType<{ data: number; driverData: YdbUint8 }>({ + dataType() { + return 'Uint8' + }, + toDriver(value) { + return new YdbUint8(value) + }, +}) + +const uint16Base = customType<{ data: number; driverData: YdbUint16 }>({ + dataType() { + return 'Uint16' + }, + toDriver(value) { + return new YdbUint16(value) + }, +}) + +const uint32Base = customType<{ data: number; driverData: YdbUint32 }>({ + dataType() { + return 'Uint32' + }, + toDriver(value) { + return new YdbUint32(value) + }, +}) + +const uint64Base = customType<{ data: bigint; driverData: YdbUint64 }>({ + dataType() { + return 'Uint64' + }, + toDriver(value) { + return new YdbUint64(value) + }, +}) + +const floatBase = customType<{ data: number; driverData: YdbFloat }>({ + dataType() { + return 'Float' + }, + toDriver(value) { + return new YdbFloat(value) + }, +}) + +const doubleBase = customType<{ data: number; driverData: YdbDouble }>({ + dataType() { + return 'Double' + }, + toDriver(value) { + return new YdbDouble(value) + }, +}) + +const dyNumberBase = customType<{ data: string; driverData: Primitive }>({ + dataType() { + return 'DyNumber' + }, + toDriver(value) { + return new Primitive( + { value: { case: 'textValue', value } }, + new PrimitiveType(Ydb.Type_PrimitiveTypeId.DYNUMBER) + ) + }, + fromDriver(value) { + return String(value) + }, +}) + +const bytesBase = customType<{ data: Uint8Array; driverData: unknown }>({ + dataType() { + return 'String' + }, + fromDriver(value) { + return toUint8Array(value) + }, +}) + +const dateBase = customType<{ data: Date; driverData: YdbDate }>({ + dataType() { + return 'Date' + }, + toDriver(value) { + return new YdbDate(value) + }, +}) + +const date32Base = customType<{ data: Date; driverData: Primitive }>({ + dataType() { + return 'Date32' + }, + toDriver(value) { + return new Primitive( + { value: { case: 'int32Value', value: Math.floor(value.getTime() / 86400000) } }, + new PrimitiveType(Ydb.Type_PrimitiveTypeId.DATE32) + ) + }, +}) + +const datetimeBase = customType<{ data: Date; driverData: YdbDatetime }>({ + dataType() { + return 'Datetime' + }, + toDriver(value) { + return new YdbDatetime(value) + }, +}) + +const datetime64Base = customType<{ data: Date; driverData: Primitive }>({ + dataType() { + return 'Datetime64' + }, + toDriver(value) { + return new Primitive( + { value: { case: 'int64Value', value: BigInt(Math.floor(value.getTime() / 1000)) } }, + new PrimitiveType(Ydb.Type_PrimitiveTypeId.DATETIME64) + ) + }, +}) + +const timestampBase = customType<{ data: Date; driverData: YdbTimestamp }>({ + dataType() { + return 'Timestamp' + }, + toDriver(value) { + return new YdbTimestamp(value) + }, +}) + +const timestamp64Base = customType<{ data: Date; driverData: Primitive }>({ + dataType() { + return 'Timestamp64' + }, + toDriver(value) { + return new Primitive( + { value: { case: 'int64Value', value: BigInt(value.getTime()) * 1000n } }, + new PrimitiveType(Ydb.Type_PrimitiveTypeId.TIMESTAMP64) + ) + }, +}) + +const intervalBase = customType<{ data: number; driverData: YdbInterval }>({ + dataType() { + return 'Interval' + }, + toDriver(value) { + return new YdbInterval(value) + }, +}) + +const interval64Base = customType<{ data: bigint | number; driverData: Primitive }>({ + dataType() { + return 'Interval64' + }, + toDriver(value) { + return new Primitive( + { value: { case: 'int64Value', value: BigInt(value) } }, + new PrimitiveType(Ydb.Type_PrimitiveTypeId.INTERVAL64) + ) + }, +}) + +const uuidBase = customType<{ data: string; driverData: YdbUuid }>({ + dataType() { + return 'Uuid' + }, + toDriver(value) { + return new YdbUuid(value) + }, +}) + +const ysonBase = customType<{ data: Uint8Array; driverData: unknown }>({ + dataType() { + return 'Yson' + }, + toDriver(value) { + return new YdbYson(value instanceof Uint8Array ? value : new Uint8Array(value)) + }, + fromDriver(value) { + return toUint8Array(value) + }, +}) + +export function boolean(name?: string) { + return booleanBase(name as any) +} + +export function int8(name?: string) { + return int8Base(name as any) +} + +export function int16(name?: string) { + return int16Base(name as any) +} + +export function bigint(name?: string) { + return bigintBase(name as any) +} + +export function uint8(name?: string) { + return uint8Base(name as any) +} + +export function uint16(name?: string) { + return uint16Base(name as any) +} + +export function uint32(name?: string) { + return uint32Base(name as any) +} + +export function uint64(name?: string) { + return uint64Base(name as any) +} + +export function float(name?: string) { + return floatBase(name as any) +} + +export function double(name?: string) { + return doubleBase(name as any) +} + +export function dyNumber(name?: string) { + return dyNumberBase(name as any) +} + +export function bytes(name?: string) { + return bytesBase(name as any) +} + +export const binary = bytes + +export function date(name?: string) { + return dateBase(name as any) +} + +export function date32(name?: string) { + return date32Base(name as any) +} + +export function datetime(name?: string) { + return datetimeBase(name as any) +} + +export function datetime64(name?: string) { + return datetime64Base(name as any) +} + +export function timestamp(name?: string) { + return timestampBase(name as any) +} + +export function timestamp64(name?: string) { + return timestamp64Base(name as any) +} + +export function interval(name?: string) { + return intervalBase(name as any) +} + +export function interval64(name?: string) { + return interval64Base(name as any) +} + +export function json(name?: string) { + return customType<{ data: T; driverData: YdbJson }>({ + dataType() { + return 'Json' + }, + toDriver(value) { + return new YdbJson(JSON.stringify(value)) + }, + fromDriver(value) { + return typeof value === 'string' ? JSON.parse(value) : (value as T) + }, + })(name as any) +} + +export function jsonDocument(name?: string) { + return customType<{ data: T; driverData: YdbJsonDocument }>({ + dataType() { + return 'JsonDocument' + }, + toDriver(value) { + return new YdbJsonDocument(JSON.stringify(value)) + }, + fromDriver(value) { + return typeof value === 'string' ? JSON.parse(value) : (value as T) + }, + })(name as any) +} + +export function uuid(name?: string) { + return uuidBase(name as any) +} + +export function yson(name?: string) { + return ysonBase(name as any) +} + +export function decimal( + precision: number, + scale: number +): ReturnType< + ReturnType< + typeof customType<{ + data: string + driverData: string + }> + > +> +export function decimal( + name: string, + precision: number, + scale: number +): ReturnType< + ReturnType< + typeof customType<{ + data: string + driverData: string + }> + > +> +export function decimal( + nameOrPrecision: string | number, + precisionOrScale: number, + scaleOrUndefined?: number +) { + const name = typeof nameOrPrecision === 'string' ? nameOrPrecision : '' + const precision = typeof nameOrPrecision === 'string' ? precisionOrScale : nameOrPrecision + const scale = typeof nameOrPrecision === 'string' ? scaleOrUndefined : precisionOrScale + + if (scale === undefined) { + throw new Error('YDB decimal() requires precision and scale') + } + + return customType<{ + data: string + driverData: string + }>({ + dataType() { + return `Decimal(${precision}, ${scale})` + }, + toDriver(value) { + if (!/^-?\d+(?:\.\d+)?$/.test(value)) { + throw new Error(`Invalid decimal value: ${value}`) + } + + return yql.raw(`Decimal(${escapeYqlString(value)}, ${precision}, ${scale})`) + }, + fromDriver(value) { + return String(value) + }, + })(name) +} + +export { customType } diff --git a/third-parties/drizzle-adapter/src/ydb-core/db.ts b/third-parties/drizzle-adapter/src/ydb-core/db.ts new file mode 100644 index 00000000..179a0773 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/db.ts @@ -0,0 +1,302 @@ +import { entityKind } from 'drizzle-orm/entity' +import type { RelationalSchemaConfig, TablesRelationalConfig } from 'drizzle-orm/relations' +import { type SQL, type SQLWrapper } from 'drizzle-orm/sql/sql' +import type { WithSubquery } from 'drizzle-orm/subquery' +import type { DrizzleTypeError } from 'drizzle-orm/utils' +import type { YdbTransactionConfig } from '../ydb/driver.js' +import type { YdbDialect } from '../ydb/dialect.js' +import type { YdbSession } from './session.js' +import type { YdbQuerySource } from './session.js' +import type { + YdbSchemaDefinition, + YdbSchemaRelations, + YdbSchemaWithoutTables, +} from './schema.types.js' +import type { YdbTable } from './table.js' +import { + YdbBatchDeleteBuilder, + YdbBatchUpdateBuilder, + YdbCountBuilder, + YdbDeleteBuilder, + YdbInsertBuilder, + YdbQueryBuilder, + YdbRelationalQueryBuilder, + YdbReplaceBuilder, + YdbSelectBuilder, + YdbUpdateBuilder, + YdbUpsertBuilder, +} from './query-builders/index.js' + +export type YdbTransactionScope< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, + TSchemaRelations extends TablesRelationalConfig = YdbSchemaRelations, +> = Omit, 'transaction'> & { + rollback(): never +} + +export class YdbDatabase< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, + TSchemaRelations extends TablesRelationalConfig = YdbSchemaRelations, +> { + static readonly [entityKind] = 'YdbDatabase' + + readonly _: { + readonly schema: TSchemaRelations | undefined + readonly fullSchema: TSchemaDefinition + readonly tableNamesMap: Record + readonly session: YdbSession + } + + query: TSchemaDefinition extends YdbSchemaWithoutTables + ? DrizzleTypeError<'Seems like the schema generic is missing - did you forget to add it to your DB type?'> + : { + [K in keyof TSchemaRelations]: YdbRelationalQueryBuilder< + TSchemaRelations, + TSchemaRelations[K] + > + } + + constructor( + protected readonly dialect: YdbDialect, + protected readonly session: YdbSession, + schema?: RelationalSchemaConfig + ) { + this._ = schema + ? { + schema: schema.schema, + fullSchema: schema.fullSchema as TSchemaDefinition, + tableNamesMap: schema.tableNamesMap, + session, + } + : { + schema: undefined, + fullSchema: {} as TSchemaDefinition, + tableNamesMap: {}, + session, + } + + const queryBuilders = new Map>() + const getRelationalTableConfig = (tableKey: string) => { + const schemaByKey = this._.schema as + | Record + | undefined + return schemaByKey?.[tableKey] + } + const getRelationalQueryBuilder = (tableKey: string) => { + const tableConfig = getRelationalTableConfig(tableKey) + + if (!tableConfig) { + return undefined + } + + const cached = queryBuilders.get(tableKey) + if (cached) { + return cached + } + + const table = (this._.fullSchema as Record)[tableConfig.tsName] as + | YdbTable + | undefined + + if (!table) { + throw new Error(`Table ${tableConfig.tsName} not found in schema`) + } + + const builder = new YdbRelationalQueryBuilder( + this._.fullSchema, + this._.schema!, + this._.tableNamesMap, + table, + tableConfig, + this.dialect, + this.session + ) + queryBuilders.set(tableKey, builder) + + return builder + } + + if (this._.schema) { + this.query = new Proxy(Object.create(null), { + get: (_target, property) => { + if (typeof property !== 'string') { + return undefined + } + + return getRelationalQueryBuilder(property) + }, + getOwnPropertyDescriptor: (_target, property) => { + if (typeof property !== 'string' || !getRelationalTableConfig(property)) { + return undefined + } + + return { + configurable: true, + enumerable: true, + get: () => getRelationalQueryBuilder(property), + } + }, + has: (_target, property) => { + return typeof property === 'string' && !!getRelationalTableConfig(property) + }, + ownKeys: () => Object.keys(this._.schema!), + }) as typeof this.query + } else { + this.query = Object.create(null) as typeof this.query + } + } + + execute(query: YdbQuerySource): Promise { + return this.session.execute(query) + } + + all(query: YdbQuerySource): Promise { + return this.session.all(query) + } + + get(query: YdbQuerySource): Promise { + return this.session.get(query) + } + + values(query: YdbQuerySource): Promise { + return this.session.values(query) + } + + $with(alias: TAlias) { + return new YdbQueryBuilder(this.dialect).$with(alias) + } + + with(...queries: WithSubquery[]) { + const { session, dialect } = this + + function select | undefined = undefined>( + fields?: TFields + ) { + return new YdbSelectBuilder(session, dialect, fields as any, {}, queries) + } + + function selectDistinct | undefined = undefined>( + fields?: TFields + ) { + return new YdbSelectBuilder( + session, + dialect, + fields as any, + { distinct: true }, + queries + ) + } + + function selectDistinctOn | undefined = undefined>( + on: SQLWrapper | SQLWrapper[], + fields?: TFields + ) { + return new YdbSelectBuilder( + session, + dialect, + fields as any, + { + distinctOn: Array.isArray(on) ? on : [on], + }, + queries + ) + } + + function insert(table: YdbTable) { + return new YdbInsertBuilder(table, session, dialect, queries) + } + + function upsert(table: YdbTable) { + return new YdbUpsertBuilder(table, session, dialect, queries) + } + + function replace(table: YdbTable) { + return new YdbReplaceBuilder(table, session, dialect, queries) + } + + function update(table: YdbTable) { + return new YdbUpdateBuilder(table, session, dialect, queries) + } + + function delete_(table: YdbTable) { + return new YdbDeleteBuilder(table, session, dialect, queries) + } + + return { + select, + selectDistinct, + selectDistinctOn, + insert, + upsert, + replace, + update, + delete: delete_, + } + } + + $count(source: YdbTable | SQLWrapper, filters?: SQL) { + return new YdbCountBuilder({ source, filters, session: this.session }) + } + + select | undefined = undefined>(fields?: TFields) { + return new YdbSelectBuilder(this.session, this.dialect, fields as any) + } + + selectDistinct | undefined = undefined>( + fields?: TFields + ) { + return new YdbSelectBuilder(this.session, this.dialect, fields as any, { distinct: true }) + } + + selectDistinctOn | undefined = undefined>( + on: SQLWrapper | SQLWrapper[], + fields?: TFields + ) { + return new YdbSelectBuilder(this.session, this.dialect, fields as any, { + distinctOn: Array.isArray(on) ? on : [on], + }) + } + + insert(table: YdbTable) { + return new YdbInsertBuilder(table, this.session, this.dialect) + } + + upsert(table: YdbTable) { + return new YdbUpsertBuilder(table, this.session, this.dialect) + } + + replace(table: YdbTable) { + return new YdbReplaceBuilder(table, this.session, this.dialect) + } + + update(table: YdbTable) { + return new YdbUpdateBuilder(table, this.session, this.dialect) + } + + batchUpdate(table: YdbTable) { + return new YdbBatchUpdateBuilder(table, this.session, this.dialect) + } + + delete(table: YdbTable) { + return new YdbDeleteBuilder(table, this.session, this.dialect) + } + + batchDelete(table: YdbTable) { + return new YdbBatchDeleteBuilder(table, this.session, this.dialect) + } + + transaction( + transaction: (tx: YdbTransactionScope) => Promise, + config?: YdbTransactionConfig + ) { + const schema = this._.schema + ? ({ + fullSchema: this._.fullSchema, + schema: this._.schema, + tableNamesMap: this._.tableNamesMap, + } as RelationalSchemaConfig) + : undefined + + return this.session.transaction(transaction, config, schema) + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/indexes.ts b/third-parties/drizzle-adapter/src/ydb-core/indexes.ts new file mode 100644 index 00000000..d69f3eb8 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/indexes.ts @@ -0,0 +1,267 @@ +import { entityKind } from 'drizzle-orm/entity' +import { type SQL, sql as yql } from 'drizzle-orm/sql/sql' +import { getTableName } from 'drizzle-orm/table' +import type { YdbColumn } from './columns/common.js' +import type { YdbTable } from './table.js' + +export type YdbIndexLocality = 'GLOBAL' | 'LOCAL' +export type YdbIndexSyncMode = 'SYNC' | 'ASYNC' +export type YdbVectorType = 'float' | 'uint8' | 'int8' +export type YdbVectorDistance = 'cosine' | 'manhattan' | 'euclidean' +export type YdbVectorSimilarity = 'inner_product' | 'cosine' + +export interface YdbIndexWithOptions { + [key: string]: string | number | boolean +} + +export interface YdbVectorKMeansTreeOptions { + vectorDimension: number + vectorType: YdbVectorType + distance?: YdbVectorDistance + similarity?: YdbVectorSimilarity + clusters: number + levels: number +} + +export interface YdbIndexConfig { + readonly name?: string | undefined + readonly table: YdbTable + readonly columns: readonly YdbColumn[] + readonly unique: boolean + readonly locality: YdbIndexLocality + readonly sync: YdbIndexSyncMode + readonly indexType?: string | undefined + readonly cover: readonly YdbColumn[] + readonly withOptions: Readonly> +} + +interface YdbIndexBuilderDefaults { + readonly indexType?: string | undefined + readonly withOptions?: YdbIndexWithOptions | undefined +} + +const vectorKMeansTreeIndexType = 'vector_kmeans_tree' +const vectorTypes: readonly YdbVectorType[] = ['float', 'uint8', 'int8'] +const vectorDistances: readonly YdbVectorDistance[] = ['cosine', 'manhattan', 'euclidean'] +const vectorSimilarities: readonly YdbVectorSimilarity[] = ['inner_product', 'cosine'] + +function assertColumnsBelongToTable( + table: YdbTable, + columns: readonly YdbColumn[], + kind: string +): void { + const tableName = getTableName(table) + + for (const column of columns) { + if (column.table !== table) { + throw new Error( + `${kind} column "${column.name}" does not belong to table "${tableName}"` + ) + } + } +} + +function assertIntegerRange(name: string, value: number, min: number, max: number): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`YDB vector index ${name} must be an integer between ${min} and ${max}`) + } +} + +function assertOneOf( + name: string, + value: string, + allowed: readonly T[] +): asserts value is T { + if (!(allowed as readonly string[]).includes(value)) { + throw new Error(`YDB vector index ${name} must be one of: ${allowed.join(', ')}`) + } +} + +function normalizeVectorKMeansTreeOptions( + options: YdbVectorKMeansTreeOptions +): YdbIndexWithOptions { + assertIntegerRange('vectorDimension', options.vectorDimension, 1, 16_384) + assertIntegerRange('clusters', options.clusters, 2, 2_048) + assertIntegerRange('levels', options.levels, 1, 16) + assertOneOf('vectorType', options.vectorType, vectorTypes) + + if ((options.distance === undefined) === (options.similarity === undefined)) { + throw new Error('YDB vector index requires exactly one of distance or similarity') + } + + const result: YdbIndexWithOptions = {} + if (options.distance !== undefined) { + assertOneOf('distance', options.distance, vectorDistances) + result['distance'] = options.distance + } else { + assertOneOf('similarity', options.similarity!, vectorSimilarities) + result['similarity'] = options.similarity! + } + + if (options.clusters ** options.levels > 1_073_741_824) { + throw new Error('YDB vector index clusters ** levels must be no more than 1073741824') + } + + if (options.vectorDimension * options.clusters > 4_194_304) { + throw new Error('YDB vector index vectorDimension * clusters must be no more than 4194304') + } + + result['vector_type'] = options.vectorType + result['vector_dimension'] = options.vectorDimension + result['clusters'] = options.clusters + result['levels'] = options.levels + return result +} + +export class YdbIndexBuilderOn { + static readonly [entityKind] = 'YdbIndexBuilderOn' + + constructor( + private readonly name: string | undefined, + private readonly unique: boolean, + private readonly defaults: YdbIndexBuilderDefaults = {} + ) {} + + on(...columns: [YdbColumn, ...YdbColumn[]]): YdbIndexBuilder { + const builder = new YdbIndexBuilder(this.name, columns, this.unique) + + if (this.defaults.indexType) { + builder.using(this.defaults.indexType) + } + + if (this.defaults.withOptions) { + builder.with(this.defaults.withOptions) + } + + return builder + } +} + +export class YdbIndexBuilder { + static readonly [entityKind] = 'YdbIndexBuilder' + + private locality: YdbIndexLocality = 'GLOBAL' + private syncMode: YdbIndexSyncMode = 'SYNC' + private indexType?: string + private coverColumns: YdbColumn[] = [] + private withOptions: YdbIndexWithOptions = {} + + constructor( + private readonly name: string | undefined, + private readonly columns: [YdbColumn, ...YdbColumn[]], + private readonly unique: boolean + ) {} + + global(): this { + this.locality = 'GLOBAL' + return this + } + + local(): this { + this.locality = 'LOCAL' + return this + } + + sync(): this { + this.syncMode = 'SYNC' + return this + } + + async(): this { + this.syncMode = 'ASYNC' + return this + } + + using(indexType: string): this { + this.indexType = indexType + return this + } + + vectorKMeansTree(options: YdbVectorKMeansTreeOptions): this { + return this.using(vectorKMeansTreeIndexType).with(normalizeVectorKMeansTreeOptions(options)) + } + + cover(...columns: YdbColumn[]): this { + this.coverColumns = [...columns] + return this + } + + with(options: YdbIndexWithOptions): this { + this.withOptions = { ...this.withOptions, ...options } + return this + } + + build(table: YdbTable): YdbIndex { + assertColumnsBelongToTable(table, this.columns, 'Index') + assertColumnsBelongToTable(table, this.coverColumns, 'Index cover') + + if (this.indexType === vectorKMeansTreeIndexType) { + if (this.unique) { + throw new Error('YDB vector indexes cannot be UNIQUE') + } + + if (this.locality !== 'GLOBAL') { + throw new Error('YDB vector indexes support only GLOBAL locality') + } + + if (this.syncMode !== 'SYNC') { + throw new Error('YDB vector indexes support only SYNC mode') + } + } + + return new YdbIndex({ + name: this.name, + table, + columns: [...this.columns], + unique: this.unique, + locality: this.locality, + sync: this.syncMode, + indexType: this.indexType, + cover: [...this.coverColumns], + withOptions: { ...this.withOptions }, + }) + } +} + +export class YdbIndex { + static readonly [entityKind] = 'YdbIndex' + + constructor(readonly config: YdbIndexConfig) {} +} + +export function index(name?: string): YdbIndexBuilderOn { + return new YdbIndexBuilderOn(name, false) +} + +export function uniqueIndex(name?: string): YdbIndexBuilderOn { + return new YdbIndexBuilderOn(name, true) +} + +export function vectorIndex(name: string, options: YdbVectorKMeansTreeOptions): YdbIndexBuilderOn +export function vectorIndex(options: YdbVectorKMeansTreeOptions): YdbIndexBuilderOn +export function vectorIndex( + nameOrOptions: string | YdbVectorKMeansTreeOptions, + options?: YdbVectorKMeansTreeOptions +): YdbIndexBuilderOn { + const name = typeof nameOrOptions === 'string' ? nameOrOptions : undefined + const vectorOptions = typeof nameOrOptions === 'string' ? options : nameOrOptions + + if (!vectorOptions) { + throw new Error('YDB vectorIndex() requires vector index options') + } + + return new YdbIndexBuilderOn(name, false, { + indexType: vectorKMeansTreeIndexType, + withOptions: normalizeVectorKMeansTreeOptions(vectorOptions), + }) +} + +export function indexView(table: YdbTable | string, indexName: string, alias?: string): SQL { + const tableSql = typeof table === 'string' ? yql.identifier(table) : yql`${table}` + const aliasSql = alias ? yql` as ${yql.identifier(alias)}` : undefined + return yql`${tableSql} view ${yql.identifier(indexName)}${aliasSql}` +} + +export function vectorIndexView(table: YdbTable | string, indexName: string, alias?: string): SQL { + return indexView(table, indexName, alias) +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/primary-keys.ts b/third-parties/drizzle-adapter/src/ydb-core/primary-keys.ts new file mode 100644 index 00000000..6eede2fa --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/primary-keys.ts @@ -0,0 +1,45 @@ +import { entityKind } from 'drizzle-orm/entity' +import { getTableName } from 'drizzle-orm/table' +import type { YdbColumn } from './columns/common.js' +import type { YdbTable } from './table.js' + +function assertColumnsBelongToTable(table: YdbTable, columns: readonly YdbColumn[]): void { + const tableName = getTableName(table) + + for (const column of columns) { + if (column.table !== table) { + throw new Error( + `Primary key column "${column.name}" does not belong to table "${tableName}"` + ) + } + } +} + +export interface YdbPrimaryKeyConfig { + readonly table: YdbTable + readonly columns: readonly YdbColumn[] +} + +export class YdbPrimaryKeyBuilder { + static readonly [entityKind] = 'YdbPrimaryKeyBuilder' + + constructor(private readonly columns: [YdbColumn, ...YdbColumn[]]) {} + + build(table: YdbTable): YdbPrimaryKey { + assertColumnsBelongToTable(table, this.columns) + return new YdbPrimaryKey({ + table, + columns: [...this.columns], + }) + } +} + +export class YdbPrimaryKey { + static readonly [entityKind] = 'YdbPrimaryKey' + + constructor(readonly config: YdbPrimaryKeyConfig) {} +} + +export function primaryKey(...columns: [YdbColumn, ...YdbColumn[]]): YdbPrimaryKeyBuilder { + return new YdbPrimaryKeyBuilder(columns) +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/count.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/count.ts new file mode 100644 index 00000000..f7770fb4 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/count.ts @@ -0,0 +1,59 @@ +import { entityKind } from 'drizzle-orm/entity' +import { SQL, type SQLWrapper, sql as yql } from 'drizzle-orm/sql/sql' +import type { YdbSession } from '../session.js' + +export interface YdbCountBuilderParams { + source: SQLWrapper + filters?: SQL | undefined + session: Pick +} + +export class YdbCountBuilder extends SQL { + static override readonly [entityKind] = 'YdbCountBuilder' + readonly [Symbol.toStringTag] = 'YdbCountBuilder' + + private readonly session: Pick + private readonly countSql: SQL + + constructor(params: YdbCountBuilderParams) { + const embeddedCount = YdbCountBuilder.buildEmbeddedCount(params.source, params.filters) + super(embeddedCount.queryChunks) + this.session = params.session + this.mapWith(Number) + this.countSql = YdbCountBuilder.buildCount(params.source, params.filters) + } + + static buildEmbeddedCount(source: SQLWrapper, filters?: SQL): SQL { + return yql`(select count(*) from ${source}${yql.raw(' where ').if(filters)}${filters})` + } + + static buildCount(source: SQLWrapper, filters?: SQL): SQL { + return yql`select count(*) as count from ${source}${yql.raw(' where ').if(filters)}${filters}` + } + + then( + onfulfilled?: ((value: number) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return Promise.resolve(this.session.count(this.countSql)).then(onfulfilled, onrejected) + } + + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this.then(undefined, onrejected) + } + + finally(onfinally?: (() => void) | null): Promise { + return this.then( + (value) => { + onfinally?.() + return value + }, + (reason) => { + onfinally?.() + throw reason + } + ) + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/delete.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/delete.ts new file mode 100644 index 00000000..63e05657 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/delete.ts @@ -0,0 +1,174 @@ +import { is } from 'drizzle-orm/entity' +import { QueryPromise } from 'drizzle-orm/query-promise' +import { SQL, type SQL as SQLType, type SQLWrapper } from 'drizzle-orm/sql/sql' +import type { Subquery } from 'drizzle-orm/subquery' +import { Table } from 'drizzle-orm/table' +import type { YdbPreparedQueryConfig, YdbSession } from '../session.js' +import type { YdbTable } from '../table.js' +import { type YdbSelectedFieldsOrdered, orderSelectedFields } from '../result-mapping.js' +import { YdbDialect } from '../../ydb/dialect.js' +import { validateSetBasedMutationSelection } from './utils.js' +import { YdbQueryBuilder } from './query-builder.js' + +type DeleteOnQuery = + | SQLType + | { + getSQL(): SQLType + getSelectedFields(): Record | undefined + } + +function getAllReturningFields(table: YdbTable): Record { + return (table as any)[(Table as any).Symbol.Columns] ?? {} +} + +export class YdbDeleteBuilder extends QueryPromise { + private whereClause: SQLType | undefined + private usingTables: SQLWrapper[] = [] + private onQuery: DeleteOnQuery | undefined + private returningFields: YdbSelectedFieldsOrdered | undefined + + constructor( + private readonly table: YdbTable, + private readonly session: YdbSession, + private readonly dialect = new YdbDialect(), + private readonly withList: Subquery[] = [] + ) { + super() + } + + where(where: SQLType | undefined): this { + if (this.onQuery) { + throw new Error('YDB delete().on() does not support where()') + } + + this.whereClause = where ?? undefined + return this + } + + using(...tables: SQLWrapper[]): this { + if (this.onQuery) { + throw new Error('YDB delete().on() does not support using()') + } + + this.usingTables = [...tables] + return this + } + + on(query: DeleteOnQuery | ((qb: YdbQueryBuilder) => DeleteOnQuery)): this { + const resolved = + typeof query === 'function' ? query(new YdbQueryBuilder(this.dialect)) : query + + if (!is(resolved, SQL)) { + validateSetBasedMutationSelection(this.table, resolved.getSelectedFields(), 'delete') + } + + this.onQuery = resolved + this.whereClause = undefined + this.usingTables = [] + return this + } + + returning(fields: Record = getAllReturningFields(this.table)): this { + const orderedFields = orderSelectedFields(fields) + if (orderedFields.length === 0) { + throw new Error('YDB returning() requires at least one field') + } + + this.returningFields = orderedFields + return this + } + + getSQL(): SQLType { + if (this.onQuery) { + const onSql = is(this.onQuery, SQL) ? this.onQuery : this.onQuery.getSQL() + + return this.dialect.buildDeleteQuery({ + table: this.table, + on: onSql, + withList: this.withList, + returning: this.returningFields, + }) + } + + return this.dialect.buildDeleteQuery({ + table: this.table, + where: this.whereClause, + using: this.usingTables.length > 0 ? [...this.usingTables] : undefined, + withList: this.withList, + returning: this.returningFields, + }) + } + + toSQL() { + const { typings: _typings, ...query } = this.dialect.sqlToQuery(this.getSQL()) + return query + } + + prepare(name?: string) { + return this.session.prepareQuery( + this.getSQL(), + this.returningFields, + name, + this.returningFields !== undefined + ) + } + + override execute(): Promise { + return this.prepare().execute() as Promise + } +} + +export class YdbBatchDeleteBuilder extends QueryPromise { + private whereClause: SQLType | undefined + + constructor( + private readonly table: YdbTable, + private readonly session: YdbSession, + private readonly dialect = new YdbDialect() + ) { + super() + } + + where(where: SQLType | undefined): this { + this.whereClause = where ?? undefined + return this + } + + using(): never { + throw new Error('YDB batchDelete().using() is not supported') + } + + on(): never { + throw new Error('YDB batchDelete().on() is not supported') + } + + returning(): never { + throw new Error('YDB batchDelete().returning() is not supported') + } + + getSQL(): SQLType { + return this.dialect.buildDeleteQuery({ + table: this.table, + where: this.whereClause, + batch: true, + }) + } + + toSQL() { + const { typings: _typings, ...query } = this.dialect.sqlToQuery(this.getSQL()) + return query + } + + prepare(name?: string) { + return this.session.prepareQuery( + this.getSQL(), + undefined, + name, + false + ) + } + + override execute(): Promise { + return this.prepare().execute() as Promise + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/index.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/index.ts new file mode 100644 index 00000000..fd107f9e --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/index.ts @@ -0,0 +1,63 @@ +export { YdbCountBuilder } from './count.js' +export { + YdbSelectBuilder, + asTable, + commit, + cube, + declareParam, + defineAction, + distinctHint, + doAction, + doBlock, + except, + groupKey, + grouping, + groupingSets, + hop, + hopEnd, + hopStart, + intersect, + intoResult, + kMeansTreeSearchTopSize, + knnCosineDistance, + knnCosineSimilarity, + knnDistance, + knnEuclideanDistance, + knnInnerProductSimilarity, + knnManhattanDistance, + knnSimilarity, + matchRecognize, + pragma, + rollup, + sessionStart, + sessionWindow, + uniqueHint, + union, + unionAll, + values, + valuesTable, + windowDefinition, + yqlScript, + type YdbActionParameter, + type YdbFlattenConfig, + type YdbFlattenMode, + type YdbGroupingSet, + type YdbKnnDistanceFunction, + type YdbKnnSimilarityFunction, + type YdbMatchRecognizeConfig, + type YdbSampleConfig, + type YdbScriptExpression, + type YdbScriptPrimitive, + type YdbUniqueDistinctHint, + type YdbValuesOptions, + type YdbValuesPrimitive, + type YdbValuesRow, + type YdbWindowClause, + type YdbWindowDefinitionConfig, +} from './select.js' +export { YdbInsertBuilder, YdbReplaceBuilder, YdbUpsertBuilder } from './insert.js' +export { YdbBatchUpdateBuilder, YdbUpdateBuilder } from './update.js' +export { YdbBatchDeleteBuilder, YdbDeleteBuilder } from './delete.js' +export { YdbQueryBuilder } from './query-builder.js' +export { YdbRelationalQueryBuilder } from './query.js' +export { YdbRelationalQuery } from './query.js' diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/insert.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/insert.ts new file mode 100644 index 00000000..e171915b --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/insert.ts @@ -0,0 +1,398 @@ +import { is } from 'drizzle-orm/entity' +import { QueryPromise } from 'drizzle-orm/query-promise' +import { Param, SQL, type SQL as SQLType, sql as yql } from 'drizzle-orm/sql/sql' +import type { Subquery } from 'drizzle-orm/subquery' +import { Table } from 'drizzle-orm/table' +import { haveSameKeys } from 'drizzle-orm/utils' +import type { YdbPreparedQueryConfig, YdbSession } from '../session.js' +import type { YdbTable } from '../table.js' +import type { YdbColumn } from '../columns/common.js' +import { type YdbSelectedFieldsOrdered, orderSelectedFields } from '../result-mapping.js' +import { YdbDialect } from '../../ydb/dialect.js' +import { + getInsertColumnEntries, + getPrimaryColumnKeys, + getTableColumns, + resolveInsertValue, + validateTableColumnKeys, +} from './utils.js' +import { YdbQueryBuilder } from './query-builder.js' + +type InsertValues = Record +type OnDuplicateKeyUpdateConfig = { set: InsertValues } +type InsertCommand = 'insert' | 'upsert' | 'replace' +type InsertSelectQuery = + | SQLType + | { + getSQL(): SQLType + getSelectedFields(): Record | undefined + } + +function qualifyAlias(alias: string, columnName: string): SQLType { + return yql`${yql.identifier(alias)}.${yql.identifier(columnName)}` +} + +function resolveOnDuplicateValue(column: YdbColumn, value: unknown): unknown { + return is(value, SQL) || is(value, Param) ? value : yql.param(value, column) +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function getAllReturningFields(table: YdbTable): Record { + return (table as any)[(Table as any).Symbol.Columns] ?? {} +} + +function getProvidedColumnEntries( + table: YdbTable, + rows: InsertValues[], + command: InsertCommand +): Array<[string, YdbColumn]> { + const firstRow = rows[0] ?? {} + const firstKeys = Object.keys(firstRow) + if (firstKeys.length === 0) { + throw new Error(`YDB ${command} values must include at least one column`) + } + + for (const row of rows) { + validateTableColumnKeys(table, row, command) + if (!haveSameKeys(firstRow, row)) { + throw new Error(`YDB ${command} values must provide the same columns for every row`) + } + } + + const keys = new Set(firstKeys) + return getInsertColumnEntries(table).filter(([key]) => keys.has(key)) +} + +function hasRuntimeInsertValue(column: YdbColumn): boolean { + return ( + column.defaultFn !== undefined || + column.default !== undefined || + column.onUpdateFn !== undefined + ) +} + +function getDefaultAwareInsertColumnEntries( + table: YdbTable, + rows: InsertValues[] +): Array<[string, YdbColumn]> { + const explicitKeys = new Set() + for (const row of rows) { + validateTableColumnKeys(table, row, 'insert') + for (const key of Object.keys(row)) { + explicitKeys.add(key) + } + } + + const entries = getInsertColumnEntries(table).filter( + ([key, column]) => explicitKeys.has(key) || hasRuntimeInsertValue(column) + ) + + for (const row of rows) { + for (const [key, column] of entries) { + if (!(key in row) && !hasRuntimeInsertValue(column)) { + throw new Error( + 'YDB insert values must provide the same non-default columns for every row' + ) + } + } + } + + return entries +} + +function getSelectColumnEntries( + table: YdbTable, + fields: Record | undefined, + command: InsertCommand +): Array<[string, YdbColumn]> { + const selectedKeys = Object.keys(fields ?? {}) + if (selectedKeys.length === 0) { + throw new Error( + 'Insert select error: selected fields must include at least one table column' + ) + } + + const columns = getTableColumns(table) + const insertableColumns = new Map(getInsertColumnEntries(table)) + for (const key of selectedKeys) { + if (!(key in columns)) { + throw new Error( + `Insert select error: selected field "${key}" is not a column of the target table` + ) + } + + if (!insertableColumns.has(key)) { + throw new Error( + `Insert select error: selected field "${key}" is not insertable in ${command}()` + ) + } + } + + return selectedKeys.map((key) => [key, insertableColumns.get(key)!]) +} + +abstract class YdbInsertLikeBuilder extends QueryPromise { + protected valuesData: InsertValues | InsertValues[] | undefined + protected selectQuery: InsertSelectQuery | undefined + protected selectColumnEntries: Array<[string, YdbColumn]> | undefined + protected returningFields: YdbSelectedFieldsOrdered | undefined + + constructor( + protected readonly table: YdbTable, + protected readonly session: YdbSession, + protected readonly dialect: YdbDialect, + protected readonly withList: Subquery[], + protected readonly command: InsertCommand, + private readonly valuesColumnMode: 'all' | 'provided' | 'default-aware' + ) { + super() + } + + values(values: InsertValues | InsertValues[]): this { + this.valuesData = values + this.selectQuery = undefined + this.selectColumnEntries = undefined + return this + } + + select(query: InsertSelectQuery | ((qb: YdbQueryBuilder) => InsertSelectQuery)): this { + const resolved = + typeof query === 'function' ? query(new YdbQueryBuilder(this.dialect)) : query + + this.selectQuery = resolved + this.selectColumnEntries = is(resolved, SQL) + ? undefined + : getSelectColumnEntries(this.table, resolved.getSelectedFields(), this.command) + this.valuesData = undefined + return this + } + + protected setReturning(fields: Record): this { + const orderedFields = orderSelectedFields(fields) + if (orderedFields.length === 0) { + throw new Error('YDB returning() requires at least one field') + } + + this.returningFields = orderedFields + return this + } + + protected getRows(): InsertValues[] { + if (!this.valuesData) { + throw new Error(`${capitalize(this.command)} values are missing`) + } + + const rows = Array.isArray(this.valuesData) ? this.valuesData : [this.valuesData] + if (rows.length === 0) { + throw new Error(`${capitalize(this.command)} values are empty`) + } + + for (const row of rows) { + validateTableColumnKeys(this.table, row, this.command) + } + + return rows + } + + protected buildStandardQuery(): SQLType { + if (this.selectQuery) { + return this.dialect.buildInsertQuery({ + table: this.table, + values: this.selectQuery, + select: true, + withList: this.withList, + command: this.command, + columnEntries: this.selectColumnEntries, + returning: this.returningFields, + }) + } + + const rows = this.getRows() + const columnEntries = + this.valuesColumnMode === 'all' + ? getInsertColumnEntries(this.table) + : this.valuesColumnMode === 'default-aware' + ? getDefaultAwareInsertColumnEntries(this.table, rows) + : getProvidedColumnEntries(this.table, rows, this.command) + + return this.dialect.buildInsertQuery({ + table: this.table, + values: rows, + withList: this.withList, + command: this.command, + columnEntries, + returning: this.returningFields, + }) + } + + getSQL(): SQLType { + return this.buildStandardQuery() + } + + toSQL() { + const { typings: _typings, ...query } = this.dialect.sqlToQuery(this.getSQL()) + return query + } + + prepare(name?: string) { + return this.session.prepareQuery( + this.getSQL(), + this.returningFields, + name, + this.returningFields !== undefined + ) + } + + override execute(): Promise { + return this.prepare().execute() as Promise + } +} + +export class YdbInsertBuilder extends YdbInsertLikeBuilder { + private onDuplicateSet: InsertValues | undefined + + constructor( + table: YdbTable, + session: YdbSession, + dialect = new YdbDialect(), + withList: Subquery[] = [] + ) { + super(table, session, dialect, withList, 'insert', 'default-aware') + } + + returning(fields: Record = getAllReturningFields(this.table)): this { + return this.setReturning(fields) + } + + onDuplicateKeyUpdate(config: OnDuplicateKeyUpdateConfig): this { + validateTableColumnKeys(this.table, config.set, 'update') + this.onDuplicateSet = { ...config.set } + return this + } + + private buildOnDuplicateKeyUpdateQuery(rows: InsertValues[]): SQLType { + if (this.selectQuery) { + throw new Error('YDB onDuplicateKeyUpdate() does not support insert().select(...)') + } + + const columnEntries = getInsertColumnEntries(this.table) + if (columnEntries.length === 0) { + throw new Error('Insertable columns are missing') + } + + const columnsByKey = new Map(columnEntries) + const primaryColumns = getPrimaryColumnKeys(this.table) + .map((key) => columnsByKey.get(key)) + .filter((column): column is YdbColumn => column !== undefined) + const primaryColumnSet = new Set(primaryColumns) + + if (primaryColumns.length === 0) { + throw new Error('YDB onDuplicateKeyUpdate() requires at least one primary key column') + } + + const incomingAlias = '__ydb_incoming' + const incomingSql = yql.join( + rows.map( + (row) => + yql`select ${yql.join( + columnEntries.map( + ([key, column]) => + yql`${resolveInsertValue(column, row[key])} as ${yql.identifier(column.name)}` + ), + yql`, ` + )}` + ), + yql` union all ` + ) + + const conflictDetectedSql = yql`${this.table}.${yql.identifier(primaryColumns[0]!.name)}` + const mergedSelections = yql.join( + columnEntries.map(([key, column]) => { + if (primaryColumnSet.has(column)) { + return yql`${qualifyAlias(incomingAlias, column.name)} as ${yql.identifier(column.name)}` + } + + if (this.onDuplicateSet && key in this.onDuplicateSet) { + return yql`case when ${conflictDetectedSql} is null then ${qualifyAlias( + incomingAlias, + column.name + )} else ${resolveOnDuplicateValue(column, this.onDuplicateSet[key])} end as ${yql.identifier(column.name)}` + } + + return yql`case when ${conflictDetectedSql} is null then ${qualifyAlias( + incomingAlias, + column.name + )} else ${column} end as ${yql.identifier(column.name)}` + }), + yql`, ` + ) + + const joinSql = yql.join( + primaryColumns.map( + (column) => yql`${column} = ${qualifyAlias(incomingAlias, column.name)}` + ), + yql` and ` + ) + const columnList = yql.join( + columnEntries.map(([, column]) => yql.identifier(column.name)), + yql`, ` + ) + const withSql = this.dialect.buildWithCTE([ + ...this.withList, + { _: { alias: incomingAlias, sql: incomingSql } } as any, + ]) + const returningSql = this.returningFields + ? yql` returning ${this.dialect.buildReturningSelection(this.returningFields)}` + : undefined + + return yql`${withSql}upsert into ${this.table} (${columnList}) select ${mergedSelections} from ${yql.raw( + `$${incomingAlias}` + )} as ${yql.identifier(incomingAlias)} left join ${this.table} on ${joinSql}${returningSql}` + } + + override getSQL(): SQLType { + if (this.onDuplicateSet) { + if (this.selectQuery) { + return this.buildOnDuplicateKeyUpdateQuery([]) + } + + const rows = this.getRows() + return this.buildOnDuplicateKeyUpdateQuery(rows) + } + + return this.buildStandardQuery() + } +} + +export class YdbUpsertBuilder extends YdbInsertLikeBuilder { + constructor( + table: YdbTable, + session: YdbSession, + dialect = new YdbDialect(), + withList: Subquery[] = [] + ) { + super(table, session, dialect, withList, 'upsert', 'provided') + } + + returning(fields: Record = getAllReturningFields(this.table)): this { + return this.setReturning(fields) + } +} + +export class YdbReplaceBuilder extends YdbInsertLikeBuilder { + constructor( + table: YdbTable, + session: YdbSession, + dialect = new YdbDialect(), + withList: Subquery[] = [] + ) { + super(table, session, dialect, withList, 'replace', 'all') + } + + returning(): never { + throw new Error('YDB replace().returning() is not documented or supported') + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/query-builder.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/query-builder.ts new file mode 100644 index 00000000..f5c016e3 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/query-builder.ts @@ -0,0 +1,100 @@ +import { entityKind } from 'drizzle-orm/entity' +import { SelectionProxyHandler } from 'drizzle-orm/selection-proxy' +import type { SQL, SQLWrapper } from 'drizzle-orm/sql/sql' +import { type Subquery, WithSubquery } from 'drizzle-orm/subquery' +import { YdbDialect } from '../../ydb/dialect.js' +import { YdbSelectBuilder } from './select.builder.js' +import type { SelectFields } from './select.types.js' + +type YdbSelectableQuery = + | SQL + | { + getSQL(): SQL + getSelectedFields(): SelectFields | undefined + } + +export class YdbQueryBuilder { + static readonly [entityKind] = 'YdbQueryBuilder' + + private dialect: YdbDialect | undefined + + constructor(dialect?: YdbDialect) { + this.dialect = dialect + } + + $with(alias: TAlias) { + return { + as: (query: YdbSelectableQuery | ((qb: YdbQueryBuilder) => YdbSelectableQuery)) => { + const resolved = typeof query === 'function' ? query(this) : query + const selectedFields = + 'getSelectedFields' in resolved ? (resolved.getSelectedFields() ?? {}) : {} + + return new Proxy( + new WithSubquery(resolved.getSQL(), selectedFields, alias, true), + new SelectionProxyHandler({ + alias, + sqlAliasedBehavior: 'alias', + sqlBehavior: 'error', + }) + ) + }, + } + } + + with(...queries: Subquery[]) { + const select = (fields?: TFields) => + new YdbSelectBuilder(undefined, this.getDialect(), fields as any, {}, queries) + + const selectDistinct = ( + fields?: TFields + ) => + new YdbSelectBuilder( + undefined, + this.getDialect(), + fields as any, + { distinct: true }, + queries + ) + + const selectDistinctOn = ( + on: SQLWrapper | SQLWrapper[], + fields?: TFields + ) => + new YdbSelectBuilder( + undefined, + this.getDialect(), + fields as any, + { + distinctOn: Array.isArray(on) ? on : [on], + }, + queries + ) + + return { select, selectDistinct, selectDistinctOn } + } + + select(fields?: TFields) { + return new YdbSelectBuilder(undefined, this.getDialect(), fields as any) + } + + selectDistinct(fields?: TFields) { + return new YdbSelectBuilder(undefined, this.getDialect(), fields as any, { distinct: true }) + } + + selectDistinctOn( + on: SQLWrapper | SQLWrapper[], + fields?: TFields + ) { + return new YdbSelectBuilder(undefined, this.getDialect(), fields as any, { + distinctOn: Array.isArray(on) ? on : [on], + }) + } + + private getDialect(): YdbDialect { + if (!this.dialect) { + this.dialect = new YdbDialect() + } + + return this.dialect + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/query.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/query.ts new file mode 100644 index 00000000..1417ffc6 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/query.ts @@ -0,0 +1,729 @@ +// eslint-disable no-await-in-loop +import { + aliasedTable, + aliasedTableColumn, + mapColumnsInAliasedSQLToAlias, + mapColumnsInSQLToAlias, +} from 'drizzle-orm/alias' +import { Column } from 'drizzle-orm/column' +import { entityKind, is } from 'drizzle-orm/entity' +import { + type BuildQueryResult, + type DBQueryConfig, + Many, + One, + type Relation, + type TableRelationalConfig, + type TablesRelationalConfig, + getOperators, + getOrderByOperators, + normalizeRelation, +} from 'drizzle-orm/relations' +import { QueryPromise } from 'drizzle-orm/query-promise' +import { and, eq, inArray, or } from 'drizzle-orm/sql/expressions' +import { type SQL, type SQLWrapper, sql as yql } from 'drizzle-orm/sql/sql' +import { getTableUniqueName } from 'drizzle-orm/table' +import type { KnownKeysOnly, ValueOrArray } from 'drizzle-orm/utils' +import type { YdbDialect } from '../../ydb/dialect.js' +import type { YdbPreparedQueryConfig, YdbSession } from '../session.js' +import type { YdbColumn } from '../columns/common.js' +import type { YdbTable } from '../table.js' + +function toArray(value: ValueOrArray | undefined): T[] { + if (value === undefined) { + return [] + } + + return Array.isArray(value) ? value : [value] +} + +function isNumberValue(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function dedupeColumns(columns: YdbColumn[]): YdbColumn[] { + const seen = new Set() + const result: YdbColumn[] = [] + + for (const column of columns) { + if (seen.has(column)) { + continue + } + + seen.add(column) + result.push(column) + } + + return result +} + +function encodeTuplePart(value: unknown): string { + if (typeof value === 'bigint') { + return `bigint:${value.toString()}` + } + + if (value instanceof Date) { + return `date:${value.toISOString()}` + } + + if (value instanceof Uint8Array) { + return `bytes:${Buffer.from(value).toString('base64')}` + } + + if (value === null) { + return 'null:' + } + + if (value === undefined) { + return 'undefined:' + } + + return `${typeof value}:${String(value)}` +} + +function getTupleKey(values: unknown[]): string { + return values.map((value) => encodeTuplePart(value)).join('|') +} + +type YdbRelationalManyConfig< + TSchema extends TablesRelationalConfig, + TFields extends TableRelationalConfig, +> = DBQueryConfig<'many', true, TSchema, TFields> + +type YdbRelationalFirstConfig< + TSchema extends TablesRelationalConfig, + TFields extends TableRelationalConfig, +> = Omit, 'limit'> + +type YdbRelationalAnyConfig = DBQueryConfig<'many', boolean> + +type YdbVisibleSelectionEntry = { + tsKey: string + alias: string + field: SQLWrapper +} + +type YdbSelectedRelation = { + tsKey: string + relation: Relation + relationTableTsName: string + queryConfig: true | DBQueryConfig<'many', false> + normalizedRelation: ReturnType +} + +type YdbFlatQueryPlan = { + sql: SQL + tableAlias: string + columnEntries: YdbVisibleSelectionEntry[] + extraEntries: YdbVisibleSelectionEntry[] + hiddenColumnAliases: Map + selectedRelations: YdbSelectedRelation[] +} + +type YdbExecutedLevel = { + plan: YdbFlatQueryPlan + rows: Array> + values: Array> +} + +type YdbExecuteLevelOptions = { + table: YdbTable + tableConfig: TableRelationalConfig + config: YdbRelationalAnyConfig + tableAlias: string + requiredColumns?: YdbColumn[] + extraWhere?: SQL + applyLimit?: boolean + applyOffset?: boolean +} + +const relationalRelationParameterBudget = 256 + +function chunkRelationTuples(tuples: unknown[][], columnCount: number): unknown[][][] { + const chunkSize = Math.max( + 1, + Math.floor(relationalRelationParameterBudget / Math.max(1, columnCount)) + ) + const chunks: unknown[][][] = [] + + for (let index = 0; index < tuples.length; index += chunkSize) { + chunks.push(tuples.slice(index, index + chunkSize)) + } + + return chunks +} + +function getSelectedColumnKeys( + tableConfig: TableRelationalConfig, + config: YdbRelationalAnyConfig +): string[] { + if (!config.columns) { + return Object.keys(tableConfig.columns) + } + + const explicitEntries = Object.entries(config.columns).filter( + ([key, include]) => key in tableConfig.columns && include !== undefined + ) + const includeKeys = explicitEntries + .filter(([, include]) => include === true) + .map(([key]) => key) + + if (includeKeys.length > 0) { + return includeKeys + } + + const excludeKeys = new Set( + explicitEntries.filter(([, include]) => include === false).map(([key]) => key) + ) + + return Object.keys(tableConfig.columns).filter((key) => !excludeKeys.has(key)) +} + +function getAliasedColumns( + tableConfig: TableRelationalConfig, + tableAlias: string +): Record { + return Object.fromEntries( + Object.entries(tableConfig.columns).map(([key, value]) => [ + key, + aliasedTableColumn(value, tableAlias), + ]) + ) as Record +} + +function getWhereClause( + tableConfig: TableRelationalConfig, + config: YdbRelationalAnyConfig, + tableAlias: string +): SQL | undefined { + if (config.where === undefined) { + return undefined + } + + const aliasedColumns = getAliasedColumns(tableConfig, tableAlias) + const whereSql = + typeof config.where === 'function' + ? config.where(aliasedColumns as Record, getOperators()) + : config.where + + return whereSql ? mapColumnsInSQLToAlias(whereSql, tableAlias) : undefined +} + +function getOrderByClause( + tableConfig: TableRelationalConfig, + config: YdbRelationalAnyConfig, + tableAlias: string +): SQL[] { + if (config.orderBy === undefined) { + return [] + } + + const aliasedColumns = getAliasedColumns(tableConfig, tableAlias) + const orderBy = + typeof config.orderBy === 'function' + ? config.orderBy(aliasedColumns as Record, getOrderByOperators()) + : config.orderBy + + return toArray(orderBy).map((field) => { + if (is(field, Column)) { + return aliasedTableColumn(field, tableAlias) as unknown as SQL + } + + return mapColumnsInSQLToAlias(field as SQL, tableAlias) + }) +} + +function getLimitClause(config: YdbRelationalAnyConfig): number | undefined { + if (config.limit === undefined) { + return undefined + } + + if (!isNumberValue(config.limit)) { + throw new Error('YDB relational query limit must be a finite number') + } + + return config.limit +} + +function getOffsetClause(config: YdbRelationalAnyConfig): number | undefined { + const offset = 'offset' in config ? config.offset : undefined + if (offset === undefined) { + return undefined + } + + if (!isNumberValue(offset)) { + throw new Error('YDB relational query offset must be a finite number') + } + + return offset +} + +function getExtrasSelection( + tableConfig: TableRelationalConfig, + config: YdbRelationalAnyConfig, + tableAlias: string +): Array<{ tsKey: string; field: SQL.Aliased }> { + if (!config.extras) { + return [] + } + + const aliasedColumns = getAliasedColumns(tableConfig, tableAlias) + const extras = + typeof config.extras === 'function' + ? config.extras(aliasedColumns as Record, { sql: yql }) + : config.extras + + return Object.entries(extras).map(([tsKey, value]) => ({ + tsKey, + field: mapColumnsInAliasedSQLToAlias(value, tableAlias) as SQL.Aliased, + })) +} + +function buildRelationFilter(columns: YdbColumn[], tuples: unknown[][]): SQL { + if (columns.length === 0) { + throw new Error('YDB relational relation filter requires at least one column') + } + + if (columns.length === 1) { + const column = columns[0]! + const values = tuples.map(([value]) => value) + return values.length === 1 ? eq(column, values[0]) : inArray(column, values) + } + + if (tuples.length === 1) { + return and(...columns.map((column, index) => eq(column, tuples[0]![index])))! + } + + return or( + ...tuples.map((tuple) => and(...columns.map((column, index) => eq(column, tuple[index])))) + )! +} + +export class YdbRelationalQueryBuilder< + TSchema extends TablesRelationalConfig, + TFields extends TableRelationalConfig, +> { + static readonly [entityKind] = 'YdbRelationalQueryBuilder' + + constructor( + private readonly fullSchema: Record, + private readonly schema: TSchema, + private readonly tableNamesMap: Record, + private readonly table: YdbTable, + private readonly tableConfig: TFields, + private readonly dialect: YdbDialect, + private readonly session: YdbSession + ) {} + + findMany>( + config?: KnownKeysOnly> + ): YdbRelationalQuery[]> { + return new YdbRelationalQuery( + this.fullSchema, + this.schema, + this.tableNamesMap, + this.table, + this.tableConfig, + this.dialect, + this.session, + (config ?? {}) as YdbRelationalAnyConfig, + 'many' + ) + } + + findFirst>( + config?: KnownKeysOnly> + ): YdbRelationalQuery | undefined> { + return new YdbRelationalQuery( + this.fullSchema, + this.schema, + this.tableNamesMap, + this.table, + this.tableConfig, + this.dialect, + this.session, + { ...((config ?? {}) as YdbRelationalAnyConfig), limit: 1 }, + 'first' + ) + } +} + +export class YdbRelationalQuery extends QueryPromise { + static override readonly [entityKind] = 'YdbRelationalQuery' + + declare readonly _: { + readonly dialect: 'ydb' + readonly result: TResult + } + + constructor( + private readonly fullSchema: Record, + private readonly schema: TablesRelationalConfig, + private readonly tableNamesMap: Record, + private readonly table: YdbTable, + private readonly tableConfig: TableRelationalConfig, + private readonly dialect: YdbDialect, + private readonly session: YdbSession, + private readonly config: YdbRelationalAnyConfig, + private readonly mode: 'many' | 'first' + ) { + super() + } + + private getSelectedRelations( + tableConfig: TableRelationalConfig, + config: YdbRelationalAnyConfig + ): YdbSelectedRelation[] { + if (!config.with) { + return [] + } + + return Object.entries(config.with).flatMap(([tsKey, queryConfig]) => { + if (!queryConfig) { + return [] + } + + const relation = tableConfig.relations[tsKey] + if (!relation) { + throw new Error( + `YDB relational query relation "${tableConfig.tsName}.${tsKey}" is missing` + ) + } + + const relationTableTsName = + this.tableNamesMap[getTableUniqueName(relation.referencedTable)] + if (!relationTableTsName) { + throw new Error( + `YDB relational query table metadata for "${relation.referencedTableName}" is missing` + ) + } + + return [ + { + tsKey, + relation, + relationTableTsName, + queryConfig: queryConfig as true | DBQueryConfig<'many', false>, + normalizedRelation: normalizeRelation( + this.schema, + this.tableNamesMap, + relation + ), + }, + ] + }) + } + + private buildFlatQueryPlan({ + table, + tableConfig, + config, + tableAlias, + requiredColumns = [], + extraWhere, + applyLimit = true, + applyOffset = true, + }: YdbExecuteLevelOptions): YdbFlatQueryPlan { + const selectedRelations = this.getSelectedRelations(tableConfig, config) + const selectedColumnKeys = getSelectedColumnKeys(tableConfig, config) + const selectedExtras = getExtrasSelection(tableConfig, config, tableAlias) + + const columnEntries = selectedColumnKeys.map((tsKey, index) => ({ + tsKey, + alias: `__ydb_c${index}`, + field: aliasedTableColumn(tableConfig.columns[tsKey]!, tableAlias), + })) + const extraEntries = selectedExtras.map(({ tsKey, field }, index) => ({ + tsKey, + alias: `__ydb_e${index}`, + field, + })) + + const hiddenColumnAliases = new Map() + for (const entry of columnEntries) { + hiddenColumnAliases.set(tableConfig.columns[entry.tsKey] as YdbColumn, entry.alias) + } + + const hiddenFields: Array<{ alias: string; field: SQLWrapper }> = [] + const hiddenColumns = dedupeColumns([ + ...requiredColumns, + ...selectedRelations.flatMap( + ({ normalizedRelation }) => normalizedRelation.fields as YdbColumn[] + ), + ]) + + let hiddenIndex = 0 + for (const column of hiddenColumns) { + if (hiddenColumnAliases.has(column)) { + continue + } + + const alias = `__ydb_h${hiddenIndex++}` + hiddenColumnAliases.set(column, alias) + hiddenFields.push({ + alias, + field: aliasedTableColumn(column, tableAlias), + }) + } + + const fields = [...columnEntries, ...extraEntries, ...hiddenFields] + if (fields.length === 0) { + throw new Error(`YDB relational query selected zero fields for "${tableConfig.tsName}"`) + } + + const where = and( + extraWhere ? mapColumnsInSQLToAlias(extraWhere, tableAlias) : undefined, + getWhereClause(tableConfig, config, tableAlias) + ) + const orderBy = getOrderByClause(tableConfig, config, tableAlias) + const limit = applyLimit ? getLimitClause(config) : undefined + const offset = applyOffset ? getOffsetClause(config) : undefined + + return { + sql: this.dialect.buildSelectQuery({ + table: aliasedTable(table, tableAlias), + fields: {}, + fieldsFlat: fields.map(({ field }) => ({ path: [], field })), + where, + joins: undefined, + orderBy, + groupBy: undefined, + having: undefined, + limit, + offset, + distinct: false, + distinctOn: undefined, + selectionAliases: fields.map(({ alias }) => alias), + setOperators: [], + }), + tableAlias, + columnEntries, + extraEntries, + hiddenColumnAliases, + selectedRelations, + } + } + + private getColumnTuple( + row: Record, + columns: YdbColumn[], + aliases: Map + ): unknown[] { + return columns.map((column) => { + const alias = aliases.get(column) + if (!alias) { + throw new Error( + `YDB relational query hidden alias for column "${column.name}" is missing` + ) + } + + return row[alias] + }) + } + + private async executeLevel(options: YdbExecuteLevelOptions): Promise { + const plan = this.buildFlatQueryPlan(options) + const rows = await this.session + .prepareQuery> }>( + plan.sql, + undefined, + undefined, + false + ) + .execute() + const values = rows.map((row) => { + const result: Record = {} + + for (const entry of plan.columnEntries) { + result[entry.tsKey] = row[entry.alias] + } + + for (const entry of plan.extraEntries) { + result[entry.tsKey] = row[entry.alias] + } + + return result + }) + + for (const relation of plan.selectedRelations) { + await this.hydrateRelation(plan, rows, values, relation) + } + + return { plan, rows, values } + } + + private async hydrateRelation( + parentPlan: YdbFlatQueryPlan, + parentRows: Array>, + parentValues: Array>, + relationSelection: YdbSelectedRelation + ): Promise { + const parentTuples = parentRows + .map((row) => + this.getColumnTuple( + row, + relationSelection.normalizedRelation.fields as YdbColumn[], + parentPlan.hiddenColumnAliases + ) + ) + .filter((tuple) => tuple.every((value) => value !== null && value !== undefined)) + + if (parentTuples.length === 0) { + for (const parentValue of parentValues) { + parentValue[relationSelection.tsKey] = is(relationSelection.relation, One) + ? null + : [] + } + return + } + + const uniqueParentTuples = Array.from( + new Map(parentTuples.map((tuple) => [getTupleKey(tuple), tuple])), + ([, tuple]) => tuple + ) + const relationTableConfig = this.schema[relationSelection.relationTableTsName] + const relationTable = this.fullSchema[relationSelection.relationTableTsName] as + | YdbTable + | undefined + + if (!relationTableConfig || !relationTable) { + throw new Error( + `YDB relational query table "${relationSelection.relationTableTsName}" is missing` + ) + } + + const relationConfig = + relationSelection.queryConfig === true + ? ({} as YdbRelationalAnyConfig) + : (relationSelection.queryConfig as YdbRelationalAnyConfig) + const referenceColumns = relationSelection.normalizedRelation.references as YdbColumn[] + const relationResults: YdbExecutedLevel[] = [] + + for (const tupleChunk of chunkRelationTuples(uniqueParentTuples, referenceColumns.length)) { + const relationFilter = buildRelationFilter(referenceColumns, tupleChunk) + const relationResult = await this.executeLevel({ + table: relationTable, + tableConfig: relationTableConfig, + config: relationConfig, + tableAlias: `${parentPlan.tableAlias}_${relationSelection.tsKey}`, + requiredColumns: referenceColumns, + extraWhere: relationFilter, + applyLimit: !is(relationSelection.relation, Many), + applyOffset: !is(relationSelection.relation, Many), + }) + relationResults.push(relationResult) + } + + const groupedValues = new Map>>() + for (const relationResult of relationResults) { + for (const [index, row] of relationResult.rows.entries()) { + const key = getTupleKey( + this.getColumnTuple( + row, + referenceColumns, + relationResult.plan.hiddenColumnAliases + ) + ) + const existing = groupedValues.get(key) + + if (existing) { + existing.push(relationResult.values[index]!) + } else { + groupedValues.set(key, [relationResult.values[index]!]) + } + } + } + + const relationOffset = getOffsetClause(relationConfig) ?? 0 + const relationLimit = getLimitClause(relationConfig) + + for (const [index, parentValue] of parentValues.entries()) { + const tuple = this.getColumnTuple( + parentRows[index]!, + relationSelection.normalizedRelation.fields as YdbColumn[], + parentPlan.hiddenColumnAliases + ) + + if (tuple.some((value) => value === null || value === undefined)) { + parentValue[relationSelection.tsKey] = is(relationSelection.relation, One) + ? null + : [] + continue + } + + const relatedValues = groupedValues.get(getTupleKey(tuple)) ?? [] + if (is(relationSelection.relation, One)) { + parentValue[relationSelection.tsKey] = relatedValues[0] ?? null + continue + } + + const sliced = + relationLimit === undefined + ? relatedValues.slice(relationOffset) + : relatedValues.slice(relationOffset, relationOffset + relationLimit) + parentValue[relationSelection.tsKey] = sliced + } + } + + getSQL(): SQL { + return this.buildFlatQueryPlan({ + table: this.table, + tableConfig: this.tableConfig, + config: this.config, + tableAlias: this.tableConfig.tsName, + }).sql + } + + private async run(): Promise { + const result = await this.executeLevel({ + table: this.table, + tableConfig: this.tableConfig, + config: this.config, + tableAlias: this.tableConfig.tsName, + }) + + if (this.mode === 'first') { + return result.values[0] as TResult | undefined as TResult + } + + return result.values as TResult + } + + prepare(name?: string) { + const flatPrepared = this.session.prepareQuery(this.getSQL(), undefined, name, false) + + return { + getQuery: () => { + return flatPrepared.getQuery() + }, + execute: async () => { + return this.run() + }, + all: async () => { + const result = await this.run() + if (Array.isArray(result)) { + return result + } + + return result === undefined ? [] : [result] + }, + get: async () => { + const result = await this.run() + return Array.isArray(result) ? result[0] : result + }, + values: async () => { + return flatPrepared.values() + }, + } + } + + toSQL() { + const prepared = this.prepare() + const { typings: _typings, ...query } = prepared.getQuery() + return query + } + + override execute(): Promise { + return this.run() + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/select-syntax.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select-syntax.ts new file mode 100644 index 00000000..234e3341 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select-syntax.ts @@ -0,0 +1,431 @@ +import { SQL, type SQLWrapper, sql as yql } from 'drizzle-orm/sql/sql' + +export type YdbOrderDirection = 'asc' | 'desc' + +export type YdbValuesPrimitive = string | number | boolean | bigint | null | Uint8Array | Date +export type YdbValuesRow = + | readonly YdbValuesPrimitive[] + | Readonly> + +export interface YdbValuesOptions { + alias?: string | undefined + columns?: readonly string[] | undefined +} + +export type YdbFlattenMode = 'by' | 'list by' | 'dict by' | 'optional by' | 'columns' + +export interface YdbFlattenConfig { + mode: YdbFlattenMode + expressions?: SQLWrapper[] | undefined +} + +export type YdbSampleConfig = + | { kind: 'sample'; ratio: number | SQLWrapper } + | { + kind: 'tablesample' + method: string + size: number | SQLWrapper + repeatable?: number | SQLWrapper | undefined + } + +export interface YdbMatchRecognizeConfig { + partitionBy?: SQLWrapper[] | undefined + orderBy?: SQLWrapper[] | undefined + measures?: Readonly> | undefined + rowsPerMatch?: 'ONE ROW PER MATCH' | 'ALL ROWS PER MATCH' | undefined + afterMatchSkip?: string | undefined + pattern: string | SQLWrapper + define?: Readonly> | undefined +} + +export interface YdbUniqueDistinctHint { + kind: 'unique' | 'distinct' + columns?: readonly string[] | undefined +} + +export interface YdbWindowClause { + name: string + definition: SQLWrapper +} + +export interface YdbWindowDefinitionConfig { + partitionBy?: readonly SQLWrapper[] | undefined + orderBy?: readonly SQLWrapper[] | undefined + frame?: string | SQLWrapper | undefined +} + +export type YdbGroupingSet = readonly SQLWrapper[] + +export type YdbKnnDistanceFunction = 'CosineDistance' | 'EuclideanDistance' | 'ManhattanDistance' + +export type YdbKnnSimilarityFunction = 'CosineSimilarity' | 'InnerProductSimilarity' + +function assertIdentifier(name: string, context: string): void { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + throw new Error(`YDB ${context} must be a simple identifier`) + } +} + +function assertHintColumn(name: string): void { + assertIdentifier(name, 'UNIQUE DISTINCT hint column') +} + +function renderBindingName(name: string): SQL { + const binding = name.startsWith('$') ? name : `$${name}` + if (!/^\$[A-Za-z_][A-Za-z0-9_]*$/u.test(binding)) { + throw new Error('YDB AS_TABLE binding must look like $name') + } + + return yql.raw(binding) +} + +function renderValue(value: YdbValuesPrimitive): SQL { + return value === null ? yql`NULL` : yql`${value}` +} + +function normalizeRows(rows: readonly YdbValuesRow[]): { + rows: readonly SQL[] + columns?: string[] +} { + if (rows.length === 0) { + throw new Error('YDB VALUES requires at least one row') + } + + if (Array.isArray(rows[0])) { + const expectedLength = (rows[0] as readonly unknown[]).length + if (expectedLength === 0) { + throw new Error('YDB VALUES rows require at least one value') + } + + return { + rows: rows.map((row) => { + if (!Array.isArray(row) || row.length !== expectedLength) { + throw new Error('YDB VALUES array rows must have the same length') + } + + return yql`(${yql.join( + row.map((value) => renderValue(value)), + yql`, ` + )})` + }), + } + } + + const columns = Object.keys(rows[0] as Record) + if (columns.length === 0) { + throw new Error('YDB VALUES object rows require at least one key') + } + + for (const column of columns) { + assertIdentifier(column, 'VALUES column') + } + + return { + columns, + rows: rows.map((row) => { + if (Array.isArray(row)) { + throw new Error('YDB VALUES cannot mix object and array rows') + } + + const keys = Object.keys(row) + if ( + keys.length !== columns.length || + keys.some((key, index) => key !== columns[index]) + ) { + throw new Error('YDB VALUES object rows must have identical key order') + } + + const objectRow = row as Record + return yql`(${yql.join( + columns.map((column) => renderValue(objectRow[column] ?? null)), + yql`, ` + )})` + }), + } +} + +function isMatchRecognizeConfig( + value: YdbMatchRecognizeConfig | SQLWrapper +): value is YdbMatchRecognizeConfig { + return typeof value === 'object' && value !== null && 'pattern' in value +} + +function isWindowDefinitionConfig( + value: YdbWindowDefinitionConfig | SQLWrapper +): value is YdbWindowDefinitionConfig { + return ( + typeof value === 'object' && + value !== null && + ('partitionBy' in value || 'orderBy' in value || 'frame' in value) + ) +} + +function renderExpressionList(expressions: readonly SQLWrapper[]): SQL { + return yql.join( + expressions.map((value) => yql`${value}`), + yql`, ` + ) +} + +function renderIntervalLiteral(value: string | SQLWrapper): SQLWrapper { + return typeof value === 'string' ? yql`${value}` : value +} + +export function values(rows: readonly YdbValuesRow[]): SQL { + const normalized = normalizeRows(rows) + return yql`VALUES ${yql.join([...normalized.rows], yql`, `)}` +} + +export function valuesTable(rows: readonly YdbValuesRow[], options: YdbValuesOptions = {}): SQL { + const normalized = normalizeRows(rows) + const alias = options.alias + const columns = options.columns ?? normalized.columns + const columnSql = + columns && columns.length > 0 + ? yql.raw( + `(${columns + .map((column) => { + assertIdentifier(column, 'VALUES column') + return `\`${column.replace(/`/g, '``')}\`` + }) + .join(', ')})` + ) + : undefined + const source = yql`(${yql.raw('VALUES')} ${yql.join([...normalized.rows], yql`, `)})` + + if (!alias) { + return source + } + + assertIdentifier(alias, 'VALUES alias') + return columnSql + ? yql`${source} as ${yql.identifier(alias)}${columnSql}` + : yql`${source} as ${yql.identifier(alias)}` +} + +export function asTable(binding: string | SQLWrapper, alias?: string): SQL { + const source = + typeof binding === 'string' + ? yql`AS_TABLE(${renderBindingName(binding)})` + : yql`AS_TABLE(${binding})` + + if (!alias) { + return source + } + + assertIdentifier(alias, 'AS_TABLE alias') + return yql`${source} as ${yql.identifier(alias)}` +} + +export function matchRecognize(config: YdbMatchRecognizeConfig | SQLWrapper): SQL { + if (!isMatchRecognizeConfig(config)) { + return yql`${config}` + } + + const partitionBy = + config.partitionBy && config.partitionBy.length > 0 + ? yql`PARTITION BY ${yql.join( + config.partitionBy.map((value) => yql`${value}`), + yql`, ` + )} ` + : undefined + const orderBy = + config.orderBy && config.orderBy.length > 0 + ? yql`ORDER BY ${yql.join( + config.orderBy.map((value) => yql`${value}`), + yql`, ` + )} ` + : undefined + const measures = + config.measures && Object.keys(config.measures).length > 0 + ? yql`MEASURES ${yql.join( + Object.entries(config.measures).map(([alias, expression]) => { + assertIdentifier(alias, 'MATCH_RECOGNIZE measure alias') + return yql`${expression} AS ${yql.identifier(alias)}` + }), + yql`, ` + )} ` + : undefined + const rowsPerMatch = config.rowsPerMatch ? yql.raw(`${config.rowsPerMatch} `) : undefined + const afterMatchSkip = config.afterMatchSkip + ? yql.raw(`AFTER MATCH SKIP ${config.afterMatchSkip} `) + : undefined + const pattern = typeof config.pattern === 'string' ? yql.raw(config.pattern) : config.pattern + const define = + config.define && Object.keys(config.define).length > 0 + ? yql` DEFINE ${yql.join( + Object.entries(config.define).map(([name, expression]) => { + assertIdentifier(name, 'MATCH_RECOGNIZE variable') + return yql`${yql.raw(name)} AS ${expression}` + }), + yql`, ` + )}` + : undefined + + return yql`(${partitionBy}${orderBy}${measures}${rowsPerMatch}${afterMatchSkip}PATTERN ${pattern}${define})` +} + +export function uniqueHint(...columns: string[]): YdbUniqueDistinctHint { + columns.forEach(assertHintColumn) + return { kind: 'unique', columns } +} + +export function distinctHint(...columns: string[]): YdbUniqueDistinctHint { + columns.forEach(assertHintColumn) + return { kind: 'distinct', columns } +} + +export function renderUniqueDistinctHints(hints: readonly YdbUniqueDistinctHint[]): SQL { + if (hints.length === 0) { + throw new Error('YDB UNIQUE DISTINCT hints require at least one hint') + } + + const rendered = hints.map((hint) => { + const columns = hint.columns ?? [] + columns.forEach(assertHintColumn) + return `${hint.kind}(${columns.join(' ')})` + }) + + return yql.raw(`/*+ ${rendered.join(' ')} */`) +} + +export function windowDefinition(config: YdbWindowDefinitionConfig | SQLWrapper): SQL { + if (!isWindowDefinitionConfig(config)) { + return yql`${config}` + } + + const parts: SQL[] = [] + if (config.partitionBy && config.partitionBy.length > 0) { + parts.push(yql`PARTITION BY ${renderExpressionList(config.partitionBy)}`) + } + if (config.orderBy && config.orderBy.length > 0) { + parts.push(yql`ORDER BY ${renderExpressionList(config.orderBy)}`) + } + if (config.frame) { + parts.push(typeof config.frame === 'string' ? yql.raw(config.frame) : yql`${config.frame}`) + } + + return yql`(${yql.join(parts, yql` `)})` +} + +export function groupKey(expression: SQLWrapper, alias: string): SQL { + assertIdentifier(alias, 'GROUP BY alias') + return yql`${expression} AS ${yql.identifier(alias)}` +} + +export function rollup(...expressions: SQLWrapper[]): SQL { + if (expressions.length === 0) { + throw new Error('YDB ROLLUP requires at least one expression') + } + + return yql`ROLLUP(${renderExpressionList(expressions)})` +} + +export function cube(...expressions: SQLWrapper[]): SQL { + if (expressions.length === 0) { + throw new Error('YDB CUBE requires at least one expression') + } + + return yql`CUBE(${renderExpressionList(expressions)})` +} + +export function groupingSets(...sets: [YdbGroupingSet, ...YdbGroupingSet[]]): SQL { + return yql`GROUPING SETS(${yql.join( + sets.map((set) => yql`(${renderExpressionList(set)})`), + yql`, ` + )})` +} + +export function grouping(...expressions: SQLWrapper[]): SQL { + if (expressions.length === 0) { + throw new Error('YDB GROUPING requires at least one expression') + } + + return yql`GROUPING(${renderExpressionList(expressions)})` +} + +export function sessionWindow( + orderExpression: SQLWrapper, + timeoutExpression: string | SQLWrapper +): SQL +export function sessionWindow( + orderExpression: SQLWrapper, + initLambda: SQLWrapper, + updateLambda: SQLWrapper, + calculateLambda: SQLWrapper +): SQL +export function sessionWindow( + orderExpression: SQLWrapper, + second: string | SQLWrapper, + updateLambda?: SQLWrapper, + calculateLambda?: SQLWrapper +): SQL { + if (updateLambda || calculateLambda) { + if (!updateLambda || !calculateLambda || typeof second === 'string') { + throw new Error( + 'YDB SessionWindow extended form requires init, update, and calculate lambdas' + ) + } + + return yql`SessionWindow(${orderExpression}, ${second}, ${updateLambda}, ${calculateLambda})` + } + + return yql`SessionWindow(${orderExpression}, ${renderIntervalLiteral(second)})` +} + +export function sessionStart(): SQL { + return yql.raw('SessionStart()') +} + +export function hop( + timeExtractor: SQLWrapper, + hopInterval: string | SQLWrapper, + windowInterval: string | SQLWrapper, + delay: string | SQLWrapper +): SQL { + return yql`HOP(${timeExtractor}, ${renderIntervalLiteral(hopInterval)}, ${renderIntervalLiteral(windowInterval)}, ${renderIntervalLiteral(delay)})` +} + +export function hopStart(): SQL { + return yql.raw('HOP_START()') +} + +export function hopEnd(): SQL { + return yql.raw('HOP_END()') +} + +export function knnDistance( + fn: YdbKnnDistanceFunction, + vector: SQLWrapper, + target: SQLWrapper +): SQL { + return yql`${yql.raw(`Knn::${fn}`)}(${vector}, ${target})` +} + +export function knnSimilarity( + fn: YdbKnnSimilarityFunction, + vector: SQLWrapper, + target: SQLWrapper +): SQL { + return yql`${yql.raw(`Knn::${fn}`)}(${vector}, ${target})` +} + +export function knnCosineDistance(vector: SQLWrapper, target: SQLWrapper): SQL { + return knnDistance('CosineDistance', vector, target) +} + +export function knnEuclideanDistance(vector: SQLWrapper, target: SQLWrapper): SQL { + return knnDistance('EuclideanDistance', vector, target) +} + +export function knnManhattanDistance(vector: SQLWrapper, target: SQLWrapper): SQL { + return knnDistance('ManhattanDistance', vector, target) +} + +export function knnCosineSimilarity(vector: SQLWrapper, target: SQLWrapper): SQL { + return knnSimilarity('CosineSimilarity', vector, target) +} + +export function knnInnerProductSimilarity(vector: SQLWrapper, target: SQLWrapper): SQL { + return knnSimilarity('InnerProductSimilarity', vector, target) +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.builder.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.builder.ts new file mode 100644 index 00000000..20faee52 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.builder.ts @@ -0,0 +1,670 @@ +import { entityKind, is } from 'drizzle-orm/entity' +import { Column } from 'drizzle-orm/column' +import { QueryPromise } from 'drizzle-orm/query-promise' +import { SQL, type SQLWrapper, sql as yql } from 'drizzle-orm/sql/sql' +import type { Subquery } from 'drizzle-orm/subquery' +import { haveSameKeys } from 'drizzle-orm/utils' +import { mapResultRow, orderSelectedFields } from '../result-mapping.js' +import type { YdbPreparedQueryConfig, YdbSession } from '../session.js' +import { YdbDialect } from '../../ydb/dialect.js' +import type { + YdbJoinType, + YdbSelectConfig, + YdbSetOperatorConfig, + YdbSetOperatorSource, +} from '../../ydb/dialect.types.js' +import { getSetOperatorHelpers } from './select.set-operators.js' +import type { + SelectConfigWithTable, + SelectFields, + YdbSelectBuilderOptions, +} from './select.types.js' +import { + createSelectionProxy, + getSourceSelection, + getTableLikeName, + normalizeCountValue, + normalizeSqlWrapperArray, +} from './select.utils.js' +import { + type YdbFlattenMode, + type YdbMatchRecognizeConfig, + type YdbUniqueDistinctHint, + type YdbValuesOptions, + type YdbValuesRow, + type YdbWindowClause, + type YdbWindowDefinitionConfig, + asTable, + valuesTable, + windowDefinition, +} from './select-syntax.js' + +type SelectionCallback = (fields: any) => SQLWrapper | SQLWrapper[] + +export class YdbSelectBuilder + extends QueryPromise + implements YdbSetOperatorSource +{ + static override readonly [entityKind] = 'YdbSelectBuilder' + + private readonly session: YdbSession | undefined + private readonly dialect: YdbDialect + private readonly config: Omit & { table?: unknown } + private readonly isPartialSelect: boolean + private joinsNotNullableMap: Record = {} + private tableName: string | undefined + private usedInSetOperation = false + + constructor(session: YdbSession | undefined, fields?: SelectFields) + constructor( + session: YdbSession | undefined, + dialect: YdbDialect, + fields?: SelectFields, + options?: YdbSelectBuilderOptions, + withList?: Subquery[] + ) + constructor( + session: YdbSession | undefined, + dialectOrFields?: YdbDialect | SelectFields, + fieldsOrUndefined?: SelectFields, + options: YdbSelectBuilderOptions = {}, + withList: Subquery[] = [] + ) { + super() + this.session = session + + if (dialectOrFields instanceof YdbDialect) { + this.dialect = dialectOrFields + this.isPartialSelect = fieldsOrUndefined !== undefined + this.config = { + table: undefined, + fields: fieldsOrUndefined ? { ...fieldsOrUndefined } : {}, + withList, + distinct: options.distinct, + distinctOn: options.distinctOn, + setOperators: [], + } + return + } + + this.dialect = new YdbDialect() + this.isPartialSelect = dialectOrFields !== undefined + this.config = { + table: undefined, + fields: dialectOrFields ? { ...(dialectOrFields as SelectFields) } : {}, + withList, + distinct: false, + distinctOn: undefined, + setOperators: [], + } + } + + private requireTable(): unknown | undefined { + if (this.config.table === undefined && Object.keys(this.config.fields).length === 0) { + throw new Error('Missing table in select().from()') + } + + return this.config.table + } + + private requireConfigWithTable(): SelectConfigWithTable { + const table = this.requireTable() + + if (Object.keys(this.config.fields).length === 0) { + throw new Error('YDB select() selected zero columns') + } + + return { + ...this.config, + table, + } + } + + private requireSession(): YdbSession { + if (!this.session) { + throw new Error( + 'Cannot execute a query on a query builder. Please use a database instance instead.' + ) + } + + return this.session + } + + private markUsedInSetOperation(): void { + this.usedInSetOperation = true + } + + private shouldUseSelectionAliases(): boolean { + return ( + this.usedInSetOperation || + (this.config.joins?.length ?? 0) > 0 || + (this.config.distinctOn?.length ?? 0) > 0 + ) + } + + private getOrderedFields() { + return orderSelectedFields(this.getSelectedFields()) + } + + private getSelectionAliases(fields = this.getOrderedFields()): string[] | undefined { + return this.shouldUseSelectionAliases() + ? this.dialect.getSelectionAliases(fields) + : undefined + } + + private getTargetConfigForTailClauses(): + | YdbSetOperatorConfig + | (Omit & { table?: unknown }) { + return this.config.setOperators[this.config.setOperators.length - 1] ?? this.config + } + + private createJoin(joinType: YdbJoinType) { + return (table: unknown, on?: SQL | ((fields: SelectFields) => SQL)) => { + if (this.config.without && this.config.without.length > 0) { + throw new Error('YDB without() cannot be combined with joins') + } + + const baseTableName = this.tableName + const tableName = getTableLikeName(table) + + if ( + typeof tableName === 'string' && + this.config.joins?.some((join) => join.alias === tableName) + ) { + throw new Error(`Alias "${tableName}" is already used in this query`) + } + + if (!this.isPartialSelect) { + if ( + Object.keys(this.joinsNotNullableMap).length === 1 && + typeof baseTableName === 'string' + ) { + this.config.fields = { + [baseTableName]: this.config.fields, + } + } + + if (typeof tableName === 'string' && !is(table, SQL)) { + this.config.fields[tableName] = getSourceSelection(table) + } + } + + const resolvedOn = + typeof on === 'function' ? on(createSelectionProxy(this.config.fields, 'sql')) : on + + if (!this.config.joins) { + this.config.joins = [] + } + + this.config.joins.push({ + table, + joinType, + alias: tableName, + on: resolvedOn, + }) + + if (typeof tableName === 'string') { + switch (joinType) { + case 'left': + this.joinsNotNullableMap[tableName] = false + break + case 'right': + this.joinsNotNullableMap = Object.fromEntries( + Object.keys(this.joinsNotNullableMap).map((key) => [key, false]) + ) + this.joinsNotNullableMap[tableName] = true + break + case 'full': + this.joinsNotNullableMap = Object.fromEntries( + Object.keys(this.joinsNotNullableMap).map((key) => [key, false]) + ) + this.joinsNotNullableMap[tableName] = false + break + case 'inner': + case 'cross': + this.joinsNotNullableMap[tableName] = true + break + } + } + + return this + } + } + + private applyWithoutToFields(values: readonly (string | SQLWrapper)[]): void { + if (Object.keys(this.config.fields).length === 0) { + return + } + + const fieldEntries = Object.entries(this.config.fields) + const removedKeys = new Set() + const removedColumnNames = new Set() + + for (const value of values) { + if (typeof value === 'string') { + removedKeys.add(value) + removedColumnNames.add(value) + continue + } + + if (is(value, Column)) { + removedColumnNames.add(value.name) + } + } + + this.config.fields = Object.fromEntries( + fieldEntries.filter(([key, field]) => { + if (removedKeys.has(key)) { + return false + } + + return !(is(field, Column) && removedColumnNames.has(field.name)) + }) + ) + } + + private normalizeWithout(values: readonly (string | SQLWrapper)[]): SQLWrapper[] { + if (values.length === 0) { + throw new Error('YDB without() requires at least one column') + } + + return values.map((value) => { + if (typeof value === 'string') { + return yql.identifier(value) + } + + if (is(value, Column)) { + return yql.identifier(value.name) + } + + return value + }) + } + + private setFlatten(mode: YdbFlattenMode, expressions: readonly SQLWrapper[] = []): this { + if (mode !== 'columns' && expressions.length === 0) { + throw new Error('YDB flatten requires at least one expression') + } + + this.config.flatten = { + mode, + expressions: expressions.length > 0 ? [...expressions] : undefined, + } + return this + } + + private normalizeColumnExpressions( + values: readonly (string | SQLWrapper)[], + context: string + ): SQLWrapper[] { + if (values.length === 0) { + throw new Error(`YDB ${context} requires at least one column`) + } + + return values.map((value) => { + if (typeof value === 'string') { + return yql.identifier(value) + } + + if (is(value, Column)) { + return yql.identifier(value.name) + } + + return value + }) + } + + private createSetOperator(type: 'union' | 'intersect' | 'except', isAll: boolean) { + return ( + rightSelection: + | YdbSetOperatorSource + | ((operators: ReturnType) => YdbSetOperatorSource) + ) => { + const rightSelect = + typeof rightSelection === 'function' + ? rightSelection(getSetOperatorHelpers()) + : rightSelection + + if (!haveSameKeys(this.getSelectedFields(), rightSelect.getSelectedFields())) { + throw new Error( + 'Set operator error (union / intersect / except): selected fields are not the same or are in a different order' + ) + } + + this.markUsedInSetOperation() + if ( + 'markUsedInSetOperation' in rightSelect && + typeof (rightSelect as any).markUsedInSetOperation === 'function' + ) { + ;(rightSelect as any).markUsedInSetOperation() + } + + this.config.setOperators.push({ + type, + isAll, + rightSelect, + }) + + return this + } + } + + from(source: unknown): this { + this.config.table = source + this.tableName = getTableLikeName(source) + + if (!this.isPartialSelect) { + this.config.fields = getSourceSelection(source) + if (this.config.without && this.config.without.length > 0) { + this.applyWithoutToFields(this.config.without) + } + } + + this.joinsNotNullableMap = + typeof this.tableName === 'string' ? { [this.tableName]: true } : {} + return this + } + + fromAsTable(binding: string | SQLWrapper, alias?: string): this { + return this.from(asTable(binding, alias)) + } + + fromValues(rows: readonly YdbValuesRow[], options?: YdbValuesOptions): this { + return this.from(valuesTable(rows, options)) + } + + getSelectedFields(): SelectFields { + return this.config.fields + } + + where(where: SQL | ((fields: SelectFields) => SQL) | undefined): this { + this.config.where = + typeof where === 'function' + ? where(createSelectionProxy(this.config.fields, 'sql')) + : (where ?? undefined) + return this + } + + having(having: SQL | ((fields: SelectFields) => SQL) | undefined): this { + this.config.having = + typeof having === 'function' + ? having(createSelectionProxy(this.config.fields, 'sql')) + : (having ?? undefined) + return this + } + + groupBy(...columns: SQLWrapper[] | [SelectionCallback]): this { + if (typeof columns[0] === 'function') { + const groupBy = columns[0](createSelectionProxy(this.config.fields, 'alias')) + this.config.groupBy = Array.isArray(groupBy) ? groupBy : [groupBy] + this.config.groupByCompact = false + return this + } + + this.config.groupBy = columns as SQLWrapper[] + this.config.groupByCompact = false + return this + } + + groupCompactBy(...columns: SQLWrapper[] | [SelectionCallback]): this { + if (typeof columns[0] === 'function') { + const groupBy = columns[0](createSelectionProxy(this.config.fields, 'alias')) + this.config.groupBy = Array.isArray(groupBy) ? groupBy : [groupBy] + this.config.groupByCompact = true + return this + } + + this.config.groupBy = columns as SQLWrapper[] + this.config.groupByCompact = true + return this + } + + orderBy(...columns: SQLWrapper[] | [SelectionCallback]): this { + const target = this.getTargetConfigForTailClauses() + + if (target === this.config && this.config.assumeOrderBy?.length) { + throw new Error('YDB orderBy() cannot be combined with assumeOrderBy()') + } + + if (typeof columns[0] === 'function') { + const orderBy = columns[0](createSelectionProxy(this.config.fields, 'alias')) + ;(target as YdbSetOperatorConfig | typeof this.config).orderBy = Array.isArray(orderBy) + ? orderBy + : [orderBy] + return this + } + + ;(target as YdbSetOperatorConfig | typeof this.config).orderBy = columns as SQLWrapper[] + return this + } + + assumeOrderBy(...columns: (string | SQLWrapper)[] | [(string | SQLWrapper)[]]): this { + if (this.config.orderBy && this.config.orderBy.length > 0) { + throw new Error('YDB assumeOrderBy() cannot be combined with orderBy()') + } + + const resolved = Array.isArray(columns[0]) + ? (columns[0] as (string | SQLWrapper)[]) + : (columns as (string | SQLWrapper)[]) + this.config.assumeOrderBy = this.normalizeColumnExpressions(resolved, 'assumeOrderBy()') + return this + } + + limit(limit: number): this { + const target = this.getTargetConfigForTailClauses() + ;(target as YdbSetOperatorConfig | typeof this.config).limit = normalizeCountValue( + limit, + 'limit' + ) + return this + } + + offset(offset: number): this { + const target = this.getTargetConfigForTailClauses() + ;(target as YdbSetOperatorConfig | typeof this.config).offset = normalizeCountValue( + offset, + 'offset' + ) + return this + } + + without(...columns: (string | SQLWrapper)[] | [(string | SQLWrapper)[]]): this { + if (this.config.table === undefined) { + throw new Error('YDB without() must be called after from()') + } + + if (this.config.joins && this.config.joins.length > 0) { + throw new Error('YDB without() cannot be combined with joins') + } + + if (this.isPartialSelect) { + throw new Error('YDB without() is only supported for whole-source select() queries') + } + + if (this.config.distinctOn && this.config.distinctOn.length > 0) { + throw new Error('YDB without() cannot be combined with distinctOn()') + } + + const resolved = Array.isArray(columns[0]) + ? (columns[0] as (string | SQLWrapper)[]) + : (columns as (string | SQLWrapper)[]) + this.config.without = this.normalizeWithout(resolved) + this.applyWithoutToFields(resolved) + return this + } + + flattenBy(...expressions: SQLWrapper[]): this { + return this.setFlatten('by', expressions) + } + + flattenListBy(...expressions: SQLWrapper[]): this { + return this.setFlatten('list by', expressions) + } + + flattenDictBy(...expressions: SQLWrapper[]): this { + return this.setFlatten('dict by', expressions) + } + + flattenOptionalBy(...expressions: SQLWrapper[]): this { + return this.setFlatten('optional by', expressions) + } + + flattenColumns(): this { + return this.setFlatten('columns') + } + + sample(ratio: number | SQLWrapper): this { + if (typeof ratio === 'number') { + normalizeCountValue(ratio, 'sample') + } + + this.config.sample = { kind: 'sample', ratio } + return this + } + + tableSample(method: string, size: number | SQLWrapper, repeatable?: number | SQLWrapper): this { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(method)) { + throw new Error('YDB tableSample() method must be a simple identifier') + } + if (typeof size === 'number') { + normalizeCountValue(size, 'tableSample') + } + if (typeof repeatable === 'number') { + normalizeCountValue(repeatable, 'tableSample repeatable') + } + + this.config.sample = { kind: 'tablesample', method, size, repeatable } + return this + } + + matchRecognize(config: YdbMatchRecognizeConfig | SQLWrapper): this { + this.config.matchRecognize = config + return this + } + + window(name: string, definition: YdbWindowDefinitionConfig | SQLWrapper): this { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + throw new Error('YDB window() name must be a simple identifier') + } + + const windows = this.config.windows ?? [] + if (windows.some((value: YdbWindowClause) => value.name === name)) { + throw new Error(`YDB window() duplicate window name "${name}"`) + } + + this.config.windows = [...windows, { name, definition: windowDefinition(definition) }] + return this + } + + intoResult(name: string): this { + this.config.intoResult = name + return this + } + + uniqueDistinct(...hints: YdbUniqueDistinctHint[] | [YdbUniqueDistinctHint[]]): this { + const resolved = Array.isArray(hints[0]) + ? (hints[0] as YdbUniqueDistinctHint[]) + : (hints as YdbUniqueDistinctHint[]) + if (resolved.length === 0) { + throw new Error('YDB uniqueDistinct() requires at least one hint') + } + + this.config.uniqueDistinctHints = [...(this.config.uniqueDistinctHints ?? []), ...resolved] + return this + } + + distinct(): this { + if (this.config.distinctOn && this.config.distinctOn.length > 0) { + throw new Error('YDB select() cannot combine distinct() and distinctOn()') + } + + this.config.distinct = true + return this + } + + distinctOn(...values: SQLWrapper[] | [SQLWrapper[]]): this { + if (this.config.distinct) { + throw new Error('YDB select() cannot combine distinct() and distinctOn()') + } + + const resolved = Array.isArray(values[0]) + ? normalizeSqlWrapperArray(values[0] as SQLWrapper[]) + : normalizeSqlWrapperArray(values as SQLWrapper[]) + if (!resolved || resolved.length === 0) { + throw new Error('YDB distinctOn() requires at least one expression') + } + + this.config.distinctOn = resolved + return this + } + + innerJoin = this.createJoin('inner') + leftJoin = this.createJoin('left') + rightJoin = this.createJoin('right') + fullJoin = this.createJoin('full') + crossJoin = this.createJoin('cross') + leftSemiJoin = this.createJoin('left semi') + rightSemiJoin = this.createJoin('right semi') + leftOnlyJoin = this.createJoin('left only') + rightOnlyJoin = this.createJoin('right only') + exclusionJoin = this.createJoin('exclusion') + + union = this.createSetOperator('union', false) + unionAll = this.createSetOperator('union', true) + intersect = this.createSetOperator('intersect', false) + except = this.createSetOperator('except', false) + + addSetOperators(setOperators: YdbSetOperatorConfig[]): this { + this.markUsedInSetOperation() + + for (const setOperator of setOperators) { + if ( + 'markUsedInSetOperation' in setOperator.rightSelect && + typeof (setOperator.rightSelect as any).markUsedInSetOperation === 'function' + ) { + ;(setOperator.rightSelect as any).markUsedInSetOperation() + } + } + + this.config.setOperators.push(...setOperators) + return this + } + + getSQL(selectionAliases?: string[]): SQL { + const config = this.requireConfigWithTable() + const fieldsFlat = orderSelectedFields(config.fields) + const aliases = selectionAliases ?? this.getSelectionAliases(fieldsFlat) + + return this.dialect.buildSelectQuery({ + ...config, + fieldsFlat, + selectionAliases: aliases, + }) + } + + toSQL() { + const { typings: _typings, ...query } = this.dialect.sqlToQuery(this.getSQL()) + return query + } + + prepare(name?: string) { + const session = this.requireSession() + const orderedFields = this.getOrderedFields() + const joinsNotNullableMap = + Object.keys(this.joinsNotNullableMap).length > 0 ? this.joinsNotNullableMap : undefined + const resultMapper = (rows: unknown[][]) => + rows.map((row) => mapResultRow(orderedFields, row, joinsNotNullableMap)) + + return session.prepareQuery( + this.getSQL(), + orderedFields, + name, + true, + resultMapper as any + ) + } + + override execute(): Promise { + return this.prepare().execute() as Promise + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.set-operators.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.set-operators.ts new file mode 100644 index 00000000..0de90d93 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.set-operators.ts @@ -0,0 +1,36 @@ +import { haveSameKeys } from 'drizzle-orm/utils' +import type { YdbSetOperatorConfig, YdbSetOperatorSource } from '../../ydb/dialect.types.js' +import type { YdbSelectBuilder } from './select.builder.js' + +function createTopLevelSetOperator(type: 'union' | 'intersect' | 'except', isAll: boolean) { + return ( + leftSelect: YdbSelectBuilder, + rightSelect: YdbSetOperatorSource, + ...restSelects: YdbSetOperatorSource[] + ) => { + const setOperators = [rightSelect, ...restSelects].map((select) => { + if (!haveSameKeys(leftSelect.getSelectedFields(), select.getSelectedFields())) { + throw new Error( + 'Set operator error (union / intersect / except): selected fields are not the same or are in a different order' + ) + } + + return { + type, + isAll, + rightSelect: select, + } satisfies YdbSetOperatorConfig + }) + + return leftSelect.addSetOperators(setOperators) + } +} + +export const union = createTopLevelSetOperator('union', false) +export const unionAll = createTopLevelSetOperator('union', true) +export const intersect = createTopLevelSetOperator('intersect', false) +export const except = createTopLevelSetOperator('except', false) + +export function getSetOperatorHelpers() { + return { union, unionAll, intersect, except } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.ts new file mode 100644 index 00000000..be7caee2 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.ts @@ -0,0 +1,56 @@ +export { YdbSelectBuilder } from './select.builder.js' +export { except, intersect, union, unionAll } from './select.set-operators.js' +export { + asTable, + cube, + distinctHint, + groupKey, + grouping, + groupingSets, + hop, + hopEnd, + hopStart, + knnCosineDistance, + knnCosineSimilarity, + knnDistance, + knnEuclideanDistance, + knnInnerProductSimilarity, + knnManhattanDistance, + knnSimilarity, + matchRecognize, + rollup, + sessionStart, + sessionWindow, + uniqueHint, + values, + valuesTable, + windowDefinition, + type YdbGroupingSet, + type YdbFlattenConfig, + type YdbFlattenMode, + type YdbKnnDistanceFunction, + type YdbKnnSimilarityFunction, + type YdbMatchRecognizeConfig, + type YdbSampleConfig, + type YdbUniqueDistinctHint, + type YdbValuesOptions, + type YdbValuesPrimitive, + type YdbValuesRow, + type YdbWindowClause, + type YdbWindowDefinitionConfig, +} from './select-syntax.js' +export { + commit, + declareParam, + defineAction, + doAction, + doBlock, + intoResult, + kMeansTreeSearchTopSize, + pragma, + yqlScript, + type YdbActionParameter, + type YdbScriptExpression, + type YdbScriptPrimitive, +} from './yql-script.js' +export type { SelectFields, YdbSelectBuilderOptions } from './select.types.js' diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.types.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.types.ts new file mode 100644 index 00000000..0af4fa9a --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.types.ts @@ -0,0 +1,11 @@ +import type { SQLWrapper } from 'drizzle-orm/sql/sql' +import type { YdbSelectConfig } from '../../ydb/dialect.types.js' + +export type SelectFields = Record + +export interface YdbSelectBuilderOptions { + distinct?: boolean + distinctOn?: SQLWrapper[] +} + +export type SelectConfigWithTable = Omit & { table?: unknown } diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.utils.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.utils.ts new file mode 100644 index 00000000..3438b07c --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/select.utils.ts @@ -0,0 +1,73 @@ +import { is } from 'drizzle-orm/entity' +import { SelectionProxyHandler } from 'drizzle-orm/selection-proxy' +import { SQL, type SQLWrapper, View } from 'drizzle-orm/sql/sql' +import { Subquery } from 'drizzle-orm/subquery' +import { Table } from 'drizzle-orm/table' +import type { YdbTable } from '../table.js' +import { getTableColumns } from './utils.js' +import type { SelectFields } from './select.types.js' + +export function normalizeSqlWrapperArray( + values: SQLWrapper[] | SQLWrapper | undefined +): SQLWrapper[] | undefined { + if (values === undefined) { + return undefined + } + + return Array.isArray(values) ? values : [values] +} + +export function getTableLikeName(table: unknown): string | undefined { + if (is(table, Subquery)) { + return table._.alias + } + + if (is(table, View)) { + return table._.name + } + + if (is(table, Table)) { + return (table as any)[(Table as any).Symbol.Name] + } + + return undefined +} + +export function getSourceSelection(source: unknown): SelectFields { + if (is(source, Subquery)) { + return Object.fromEntries( + Object.keys(source._.selectedFields).map((key) => [ + key, + (source as unknown as Record)[key], + ]) + ) + } + + if (is(source, View)) { + return source._.selectedFields + } + + if (is(source, SQL)) { + return {} + } + + return getTableColumns(source as YdbTable) +} + +export function normalizeCountValue(value: number, methodName: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`YDB ${methodName}() expects a non-negative finite number`) + } + + return value +} + +export function createSelectionProxy(fields: SelectFields, sqlAliasedBehavior: 'sql' | 'alias') { + return new Proxy( + fields, + new SelectionProxyHandler({ + sqlAliasedBehavior, + sqlBehavior: 'sql', + }) + ) +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/update.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/update.ts new file mode 100644 index 00000000..53fbe2b5 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/update.ts @@ -0,0 +1,205 @@ +import { is } from 'drizzle-orm/entity' +import { QueryPromise } from 'drizzle-orm/query-promise' +import { SQL, type SQL as SQLType, sql as yql } from 'drizzle-orm/sql/sql' +import type { Subquery } from 'drizzle-orm/subquery' +import { Table } from 'drizzle-orm/table' +import type { YdbPreparedQueryConfig, YdbSession } from '../session.js' +import type { YdbTable } from '../table.js' +import { type YdbSelectedFieldsOrdered, orderSelectedFields } from '../result-mapping.js' +import { YdbDialect } from '../../ydb/dialect.js' +import { + getTableColumns, + resolveUpdateValue, + validateSetBasedMutationSelection, + validateTableColumnKeys, +} from './utils.js' +import { YdbQueryBuilder } from './query-builder.js' + +type UpdateValues = Record +type UpdateOnQuery = + | SQLType + | { + getSQL(): SQLType + getSelectedFields(): Record | undefined + } + +function getAllReturningFields(table: YdbTable): Record { + return (table as any)[(Table as any).Symbol.Columns] ?? {} +} + +export class YdbUpdateBuilder extends QueryPromise { + private valuesData: UpdateValues | undefined + private whereClause: SQLType | undefined + private onQuery: UpdateOnQuery | undefined + private returningFields: YdbSelectedFieldsOrdered | undefined + + constructor( + private readonly table: YdbTable, + private readonly session: YdbSession, + private readonly dialect = new YdbDialect(), + private readonly withList: Subquery[] = [] + ) { + super() + } + + set(values: UpdateValues): this { + this.valuesData = values + this.onQuery = undefined + return this + } + + where(where: SQLType | undefined): this { + if (this.onQuery) { + throw new Error('YDB update().on() does not support where()') + } + + this.whereClause = where ?? undefined + return this + } + + on(query: UpdateOnQuery | ((qb: YdbQueryBuilder) => UpdateOnQuery)): this { + const resolved = + typeof query === 'function' ? query(new YdbQueryBuilder(this.dialect)) : query + + if (!is(resolved, SQL)) { + validateSetBasedMutationSelection(this.table, resolved.getSelectedFields(), 'update') + } + + this.onQuery = resolved + this.valuesData = undefined + this.whereClause = undefined + return this + } + + returning(fields: Record = getAllReturningFields(this.table)): this { + const orderedFields = orderSelectedFields(fields) + if (orderedFields.length === 0) { + throw new Error('YDB returning() requires at least one field') + } + + this.returningFields = orderedFields + return this + } + + getSQL(): SQLType { + if (this.onQuery) { + const onSql = is(this.onQuery, SQL) ? this.onQuery : this.onQuery.getSQL() + return this.dialect.buildUpdateQuery({ + table: this.table, + on: onSql, + withList: this.withList, + returning: this.returningFields, + }) + } + + if (!this.valuesData) { + throw new Error('Update values are missing') + } + + validateTableColumnKeys(this.table, this.valuesData, 'update') + + const columns = getTableColumns(this.table) + const setEntries = Object.entries(columns).flatMap(([key, column]) => { + const value = resolveUpdateValue(column, this.valuesData?.[key]) + if (value === undefined) { + return [] + } + + return [yql`${yql.identifier(column.name)} = ${value}`] + }) + + if (setEntries.length === 0) { + throw new Error('Update values are empty') + } + + return this.dialect.buildUpdateQuery({ + table: this.table, + set: this.valuesData, + where: this.whereClause, + withList: this.withList, + returning: this.returningFields, + }) + } + + toSQL() { + const { typings: _typings, ...query } = this.dialect.sqlToQuery(this.getSQL()) + return query + } + + prepare(name?: string) { + return this.session.prepareQuery( + this.getSQL(), + this.returningFields, + name, + this.returningFields !== undefined + ) + } + + override execute(): Promise { + return this.prepare().execute() as Promise + } +} + +export class YdbBatchUpdateBuilder extends QueryPromise { + private valuesData: UpdateValues | undefined + private whereClause: SQLType | undefined + + constructor( + private readonly table: YdbTable, + private readonly session: YdbSession, + private readonly dialect = new YdbDialect() + ) { + super() + } + + set(values: UpdateValues): this { + this.valuesData = values + return this + } + + where(where: SQLType | undefined): this { + this.whereClause = where ?? undefined + return this + } + + returning(): never { + throw new Error('YDB batchUpdate().returning() is not supported') + } + + on(): never { + throw new Error('YDB batchUpdate().on() is not supported') + } + + getSQL(): SQLType { + if (!this.valuesData) { + throw new Error('Update values are missing') + } + + validateTableColumnKeys(this.table, this.valuesData, 'update') + + return this.dialect.buildUpdateQuery({ + table: this.table, + set: this.valuesData, + where: this.whereClause, + batch: true, + }) + } + + toSQL() { + const { typings: _typings, ...query } = this.dialect.sqlToQuery(this.getSQL()) + return query + } + + prepare(name?: string) { + return this.session.prepareQuery( + this.getSQL(), + undefined, + name, + false + ) + } + + override execute(): Promise { + return this.prepare().execute() as Promise + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/utils.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/utils.ts new file mode 100644 index 00000000..0da5a38a --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/utils.ts @@ -0,0 +1,123 @@ +import { is } from 'drizzle-orm/entity' +import { Param, SQL, sql as yql } from 'drizzle-orm/sql/sql' +import { Table } from 'drizzle-orm/table' +import type { YdbColumn } from '../columns/common.js' +import type { YdbTable } from '../table.js' +import { getTableConfig } from '../table.utils.js' + +type TableColumns = Record + +export function getTableColumns(table: YdbTable): TableColumns { + return ((table as any)[(Table as any).Symbol.Columns] ?? {}) as TableColumns +} + +export function validateTableColumnKeys( + table: YdbTable, + input: Record, + operation: 'insert' | 'update' | 'upsert' | 'replace' +): void { + const columns = getTableColumns(table) + + for (const key of Object.keys(input)) { + if (!(key in columns)) { + throw new Error(`Unknown column "${key}" in ${operation}()`) + } + } +} + +export function getPrimaryColumnKeys(table: YdbTable): string[] { + const columns = getTableColumns(table) + const primaryColumns = new Set() + + for (const column of Object.values(columns)) { + if (column.primary) { + primaryColumns.add(column) + } + } + + for (const primaryKey of getTableConfig(table as any).primaryKeys) { + for (const column of primaryKey.config.columns) { + primaryColumns.add(column) + } + } + + return Object.entries(columns) + .filter(([, column]) => primaryColumns.has(column)) + .map(([key]) => key) +} + +export function validateSetBasedMutationSelection( + table: YdbTable, + fields: Record | undefined, + operation: 'update' | 'delete' +): void { + const label = operation === 'update' ? 'Update on' : 'Delete on' + + if (!fields || Object.keys(fields).length === 0) { + throw new Error(`${label} error: selected fields must include table columns`) + } + + const columns = getTableColumns(table) + const selectedKeys = Object.keys(fields) + for (const key of selectedKeys) { + if (!(key in columns)) { + throw new Error( + `${label} error: selected field "${key}" is not a column of the target table` + ) + } + } + + const primaryKeys = getPrimaryColumnKeys(table) + if (primaryKeys.length === 0) { + throw new Error(`YDB ${operation}().on() requires at least one primary key column`) + } + + for (const key of primaryKeys) { + if (!selectedKeys.includes(key)) { + throw new Error( + `YDB ${operation}().on() requires primary key column "${key}" in selected fields` + ) + } + } +} + +export function getInsertColumnEntries(table: YdbTable): Array<[string, YdbColumn]> { + return Object.entries(getTableColumns(table)).filter( + ([, column]) => !(column as any).shouldDisableInsert?.() + ) +} + +export function resolveInsertValue(column: YdbColumn, value: unknown): unknown { + if (value === undefined || (is(value, Param) && value.value === undefined)) { + if (column.defaultFn !== undefined) { + const defaultValue = column.defaultFn() + return is(defaultValue, SQL) ? defaultValue : yql.param(defaultValue, column) + } + + if (column.default !== undefined) { + return is(column.default, SQL) ? column.default : yql.param(column.default, column) + } + + if (column.onUpdateFn !== undefined) { + const onUpdateValue = column.onUpdateFn() + return is(onUpdateValue, SQL) ? onUpdateValue : yql.param(onUpdateValue, column) + } + + return yql`default` + } + + return is(value, SQL) || is(value, Param) ? value : yql.param(value, column) +} + +export function resolveUpdateValue(column: YdbColumn, value: unknown): unknown { + if (value !== undefined) { + return is(value, SQL) || is(value, Param) ? value : yql.param(value, column) + } + + if (column.onUpdateFn !== undefined) { + const onUpdateValue = column.onUpdateFn() + return is(onUpdateValue, SQL) ? onUpdateValue : yql.param(onUpdateValue, column) + } + + return undefined +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/query-builders/yql-script.ts b/third-parties/drizzle-adapter/src/ydb-core/query-builders/yql-script.ts new file mode 100644 index 00000000..047d48ff --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/query-builders/yql-script.ts @@ -0,0 +1,179 @@ +import { SQL, type SQLWrapper, sql as yql } from 'drizzle-orm/sql/sql' + +export type YdbScriptPrimitive = string | number | boolean | bigint | null | Date | Uint8Array + +export type YdbScriptExpression = YdbScriptPrimitive | SQLWrapper | { kind: 'default' } + +export interface YdbActionParameter { + name: string + optional?: boolean +} + +function assertPragmaName(name: string): void { + if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/u.test(name)) { + throw new Error('YDB PRAGMA name must be a dotted identifier') + } +} + +function renderNamedExpression(name: string, context: string): string { + const rendered = name.startsWith('$') ? name : `$${name}` + if (!/^\$[A-Za-z_][A-Za-z0-9_]*$/u.test(rendered)) { + throw new Error(`YDB ${context} must look like $name`) + } + + return rendered +} + +function renderIdentifier(name: string, context: string): SQL { + if (!name) { + throw new Error(`YDB ${context} must not be empty`) + } + + return yql`${yql.identifier(name)}` +} + +function escapeDoubleQuoted(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +function renderExpression(value: YdbScriptExpression): SQL { + if ( + typeof value === 'object' && + value !== null && + 'kind' in value && + value.kind === 'default' + ) { + return yql.raw('default') + } + + if (value === null) { + return yql.raw('NULL') + } + + if (value instanceof Date) { + return yql.raw(escapeDoubleQuoted(value.toISOString())) + } + + if (value instanceof Uint8Array) { + return yql`${value}` + } + + if (typeof value === 'string') { + return yql.raw(escapeDoubleQuoted(value)) + } + + if (typeof value === 'number' || typeof value === 'bigint') { + return yql.raw(String(value)) + } + + if (typeof value === 'boolean') { + return yql.raw(value ? 'TRUE' : 'FALSE') + } + + return yql`${value}` +} + +function renderStatement(statement: string | SQLWrapper): SQL { + return typeof statement === 'string' ? yql.raw(statement) : yql`${statement}` +} + +function renderStatements(statements: readonly (string | SQLWrapper)[]): SQL { + return yql.join( + statements.map((statement) => renderStatement(statement)), + yql.raw('\n') + ) +} + +export function yqlScript(...statements: (string | SQLWrapper)[]): SQL { + if (statements.length === 0) { + throw new Error('YDB yqlScript() requires at least one statement') + } + + return renderStatements(statements) +} + +export function pragma( + name: string, + value?: YdbScriptExpression | readonly YdbScriptExpression[] +): SQL { + assertPragmaName(name) + + if (value === undefined) { + return yql.raw(`PRAGMA ${name};`) + } + + if (Array.isArray(value)) { + const values = value as readonly YdbScriptExpression[] + return yql`PRAGMA ${yql.raw(name)}(${yql.join( + values.map((item) => renderExpression(item)), + yql`, ` + )});` + } + + return yql`PRAGMA ${yql.raw(name)} = ${renderExpression(value as YdbScriptExpression)};` +} + +export function kMeansTreeSearchTopSize(value: number | string): SQL { + return pragma('ydb.KMeansTreeSearchTopSize', String(value)) +} + +export function declareParam(name: string, dataType: string): SQL { + const parameterName = renderNamedExpression(name, 'DECLARE parameter') + if (!dataType.trim()) { + throw new Error('YDB DECLARE data type must not be empty') + } + + return yql.raw(`DECLARE ${parameterName} AS ${dataType};`) +} + +export function commit(): SQL { + return yql.raw('COMMIT;') +} + +export function defineAction( + name: string, + parameters: readonly (string | YdbActionParameter)[], + statements: readonly (string | SQLWrapper)[] +): SQL { + const actionName = renderNamedExpression(name, 'ACTION name') + const renderedParameters = parameters.map((parameter) => { + const config = typeof parameter === 'string' ? { name: parameter } : parameter + const parameterName = renderNamedExpression(config.name, 'ACTION parameter') + return `${parameterName}${config.optional ? '?' : ''}` + }) + + if (statements.length === 0) { + throw new Error('YDB DEFINE ACTION requires at least one statement') + } + + return yql`DEFINE ACTION ${yql.raw(actionName)}(${yql.raw(renderedParameters.join(', '))}) AS +${renderStatements(statements)} +END DEFINE;` +} + +export function doAction(name: string, args: readonly YdbScriptExpression[] = []): SQL { + if (name !== 'EMPTY_ACTION') { + renderNamedExpression(name, 'DO action name') + } + + const actionName = + name === 'EMPTY_ACTION' ? name : renderNamedExpression(name, 'DO action name') + return yql`DO ${yql.raw(actionName)}(${yql.join( + args.map((arg) => renderExpression(arg)), + yql`, ` + )});` +} + +export function doBlock(statements: readonly (string | SQLWrapper)[]): SQL { + if (statements.length === 0) { + throw new Error('YDB DO BEGIN block requires at least one statement') + } + + return yql`DO BEGIN +${renderStatements(statements)} +END DO;` +} + +export function intoResult(query: SQLWrapper | string, resultName: string): SQL { + return yql`${renderStatement(query)} INTO RESULT ${renderIdentifier(resultName, 'INTO RESULT name')};` +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/result-mapping.ts b/third-parties/drizzle-adapter/src/ydb-core/result-mapping.ts new file mode 100644 index 00000000..bb5b2e54 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/result-mapping.ts @@ -0,0 +1,137 @@ +import { Column } from 'drizzle-orm/column' +import { is } from 'drizzle-orm/entity' +import { SQL } from 'drizzle-orm/sql/sql' +import { Subquery } from 'drizzle-orm/subquery' +import { Table, getTableName } from 'drizzle-orm/table' +import type { YdbColumn } from './columns/common.js' + +export type YdbSelectedField = { + path: string[] + field: unknown +} + +export type YdbSelectedFieldsOrdered = YdbSelectedField[] + +function getRowObjectValue( + row: Record, + path: string[], + field: unknown, + columnIndex: number +): unknown { + const pathKey = path[path.length - 1] + + if (is(field, Column)) { + if (field.name in row) { + return row[field.name] + } + + if (pathKey && pathKey in row) { + return row[pathKey] + } + } else if (is(field, SQL.Aliased)) { + if (field.fieldAlias in row) { + return row[field.fieldAlias] + } + + if (pathKey && pathKey in row) { + return row[pathKey] + } + } else if (pathKey && pathKey in row) { + return row[pathKey] + } + + return Object.values(row)[columnIndex] +} + +export function rowToArray( + columns: YdbSelectedFieldsOrdered, + row: unknown[] | Record +): unknown[] { + if (Array.isArray(row)) { + return row + } + + return columns.map(({ path, field }, columnIndex) => + getRowObjectValue(row, path, field, columnIndex) + ) +} + +export function orderSelectedFields( + fields: Record, + pathPrefix?: string[] +): YdbSelectedFieldsOrdered { + return Object.entries(fields).reduce((result, [name, field]) => { + const path = pathPrefix ? [...pathPrefix, name] : [name] + + if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) { + result.push({ path, field }) + } else if (is(field, Table)) { + result.push(...orderSelectedFields((field as any)[(Table as any).Symbol.Columns], path)) + } else { + result.push(...orderSelectedFields(field as Record, path)) + } + + return result + }, []) +} + +export function mapResultRow( + columns: YdbSelectedFieldsOrdered, + row: unknown[] | Record, + joinsNotNullableMap?: Record +): TResult { + const nullifyMap: Record = {} + const rowValues = rowToArray(columns, row) + + const result = columns.reduce>((current, { path, field }, columnIndex) => { + let decoder: { mapFromDriverValue(value: unknown): unknown } + + if (is(field, Column)) { + decoder = field as unknown as YdbColumn + } else if (is(field, SQL)) { + decoder = (field as any).decoder + } else if (is(field, Subquery)) { + decoder = (field as any)._.sql.decoder + } else { + decoder = (field as any).sql.decoder + } + + let node = current + for (const [pathChunkIndex, pathChunk] of path.entries()) { + if (pathChunkIndex < path.length - 1) { + if (!(pathChunk in node)) { + node[pathChunk] = {} + } + node = node[pathChunk] + } else { + const rawValue = rowValues[columnIndex] + const value = rawValue === null ? null : decoder.mapFromDriverValue(rawValue) + node[pathChunk] = value + + if (joinsNotNullableMap && is(field, Column) && path.length === 2) { + 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 + } + } + } + } + + return current + }, {}) + + if (joinsNotNullableMap && Object.keys(nullifyMap).length > 0) { + for (const [objectName, tableName] of Object.entries(nullifyMap)) { + if (typeof tableName === 'string' && !joinsNotNullableMap[tableName]) { + result[objectName] = null + } + } + } + + return result as TResult +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/schema.types.ts b/third-parties/drizzle-adapter/src/ydb-core/schema.types.ts new file mode 100644 index 00000000..e87f5bb1 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/schema.types.ts @@ -0,0 +1,9 @@ +import type { ExtractTablesWithRelations } from 'drizzle-orm/relations' + +export type YdbSchemaDefinition = Record + +export type YdbSchemaWithoutTables = Record + +export type YdbSchemaRelations< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaDefinition, +> = ExtractTablesWithRelations diff --git a/third-parties/drizzle-adapter/src/ydb-core/session.ts b/third-parties/drizzle-adapter/src/ydb-core/session.ts new file mode 100644 index 00000000..c7e228c0 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/session.ts @@ -0,0 +1,360 @@ +// eslint-disable no-await-in-loop +import { entityKind } from 'drizzle-orm/entity' +import { TransactionRollbackError } from 'drizzle-orm/errors' +import { type Logger, NoopLogger } from 'drizzle-orm/logger' +import type { RelationalSchemaConfig, TablesRelationalConfig } from 'drizzle-orm/relations' +import type { QueryWithTypings, SQL, SQLWrapper } from 'drizzle-orm/sql/sql' +import type { YdbDialect } from '../ydb/dialect.js' +import { mapYdbQueryError } from '../ydb/errors.js' +import type { + YdbExecuteOptions, + YdbExecutor, + YdbQueryResult, + YdbTransactionalExecutor, +} from '../ydb/driver.js' +import type { YdbTransactionConfig } from '../ydb/driver.js' +import { type YdbSelectedFieldsOrdered, mapResultRow, rowToArray } from './result-mapping.js' +import type { + YdbSchemaDefinition, + YdbSchemaRelations, + YdbSchemaWithoutTables, +} from './schema.types.js' +import { YdbTransaction } from './transaction.js' + +export interface YdbPreparedQueryConfig { + execute: unknown + all: unknown + get: unknown + values: unknown +} + +export interface YdbSessionOptions { + logger?: Logger | undefined +} + +type SelectedFieldsOrdered = YdbSelectedFieldsOrdered + +export type YdbQuerySource = SQL | SQLWrapper | QueryWithTypings +export type YdbBatchQuery = YdbQuerySource | YdbPreparedQuery | YdbRunnablePreparedQuery + +type YdbRunnablePreparedQuery = { + prepare(name?: string): Pick +} + +function isQueryWithTypings(query: YdbQuerySource): query is QueryWithTypings { + return 'sql' in query && 'params' in query && Array.isArray(query.params) +} + +function isRunnablePreparedQuery(query: unknown): query is SQLWrapper & YdbRunnablePreparedQuery { + return ( + !!query && + typeof query === 'object' && + 'prepare' in query && + typeof query.prepare === 'function' + ) +} + +function supportsTransactions( + client: YdbExecutor | YdbTransactionalExecutor +): client is YdbTransactionalExecutor { + return 'transaction' in client && typeof client.transaction === 'function' +} + +function normalizeQuery(query: YdbQuerySource, dialect: YdbDialect): QueryWithTypings { + if (isQueryWithTypings(query)) { + return query as QueryWithTypings + } + + return dialect.sqlToQuery(query.getSQL()) +} + +function findTransactionRollbackError(error: unknown): TransactionRollbackError | undefined { + let current = error + + while (current instanceof Error) { + if (current instanceof TransactionRollbackError) { + return current + } + + current = current.cause + } + + return undefined +} + +function attachResultMeta(rows: unknown[], result: YdbQueryResult): unknown[] { + Object.defineProperties(rows, { + rowCount: { + configurable: true, + enumerable: false, + value: result.rowCount, + }, + command: { + configurable: true, + enumerable: false, + value: result.command, + }, + meta: { + configurable: true, + enumerable: false, + value: result.meta, + }, + }) + + return rows +} + +export class YdbPreparedQuery { + static readonly [entityKind] = 'YdbPreparedQuery' + + constructor( + private readonly client: YdbExecutor, + private readonly query: QueryWithTypings, + private readonly logger: Logger, + private readonly fields: SelectedFieldsOrdered | undefined, + private readonly responseInArrayMode: boolean, + private readonly customResultMapper?: ( + rows: unknown[][], + mapColumnValue?: (value: unknown) => unknown + ) => T['execute'] + ) {} + + getQuery(): QueryWithTypings { + return this.query + } + + isResponseInArrayMode(): boolean { + return this.responseInArrayMode + } + + mapResult(response: unknown, _isFromBatch?: boolean): unknown { + if (!Array.isArray(response)) { + return response + } + + if (this.customResultMapper) { + const rows = this.fields + ? response.map((row) => rowToArray(this.fields as any, row as any)) + : (response as unknown[][]) + return this.customResultMapper(rows as unknown[][], (value) => value) + } + + if (!this.fields) { + return response + } + + return (response as Array>).map((row) => + mapResultRow(this.fields as any, row, undefined) + ) + } + + private async run(method: 'execute' | 'all', arrayMode: boolean): Promise { + const options: YdbExecuteOptions = { + arrayMode, + } + if (this.query.typings !== undefined) { + options.typings = this.query.typings + } + + this.logger.logQuery(this.query.sql, this.query.params) + try { + const result = await this.client.execute( + this.query.sql, + this.query.params, + method, + options + ) + return attachResultMeta(result.rows, result) + } catch (error) { + throw mapYdbQueryError(this.query.sql, this.query.params, error) + } + } + + async execute(): Promise { + const rows = await this.run('execute', this.responseInArrayMode) + return this.mapResult(rows) as T['execute'] + } + + async all(): Promise { + const rows = await this.run('all', this.responseInArrayMode) + return this.mapResult(rows) as T['all'] + } + + async get(): Promise { + const rows = await this.run('all', this.responseInArrayMode) + const result = this.mapResult(rows) + return (Array.isArray(result) ? result[0] : result) as T['get'] + } + + async values(): Promise { + const rows = await this.run('all', true) + return rows as T['values'] + } +} + +export class YdbSession { + static readonly [entityKind] = 'YdbSession' + private readonly logger: Logger + + constructor( + private readonly client: YdbExecutor | YdbTransactionalExecutor, + private readonly dialect: YdbDialect, + options: YdbSessionOptions = {} + ) { + this.logger = options.logger ?? new NoopLogger() + } + + prepareQuery( + query: YdbQuerySource, + fields: SelectedFieldsOrdered | undefined, + _name?: string, + isResponseInArrayMode = false, + customResultMapper?: ( + rows: unknown[][], + mapColumnValue?: (value: unknown) => unknown + ) => T['execute'] + ): YdbPreparedQuery { + return new YdbPreparedQuery( + this.client, + normalizeQuery(query, this.dialect), + this.logger, + fields, + isResponseInArrayMode, + customResultMapper + ) + } + + async execute(query: YdbQuerySource, options?: YdbExecuteOptions): Promise { + if (isRunnablePreparedQuery(query)) { + return query.prepare().execute() as Promise + } + + const prepared = this.prepareQuery( + query, + undefined, + undefined, + false + ) + if (options?.arrayMode === true) { + return prepared.values() as Promise + } + return prepared.execute() as Promise + } + + async all(query: YdbQuerySource, options?: YdbExecuteOptions): Promise { + if (isRunnablePreparedQuery(query)) { + return query.prepare().all() as Promise + } + + const prepared = this.prepareQuery( + query, + undefined, + undefined, + false + ) + if (options?.arrayMode === true) { + return prepared.values() as Promise + } + return prepared.all() as Promise + } + + async get(query: YdbQuerySource): Promise { + if (isRunnablePreparedQuery(query)) { + return query.prepare().get() as Promise + } + + return this.prepareQuery( + query, + undefined, + undefined, + false + ).get() + } + + async values(query: YdbQuerySource): Promise { + if (isRunnablePreparedQuery(query)) { + return query.prepare().values() as Promise + } + + return this.prepareQuery( + query, + undefined, + undefined, + true + ).values() + } + + async batch( + queries: T + ): Promise<{ [K in keyof T]: unknown }> { + const results: unknown[] = [] + + for (const query of queries) { + if (query instanceof YdbPreparedQuery) { + results.push(await query.execute()) + continue + } + + if (isRunnablePreparedQuery(query)) { + results.push(await query.prepare().execute()) + continue + } + + results.push(await this.execute(query as YdbQuerySource)) + } + + return results as { [K in keyof T]: unknown } + } + + async count(query: YdbQuerySource): Promise { + const rows = await this.values<[number | bigint | string]>(query) + const value = rows[0]?.[0] + return Number(value ?? 0) + } + + async transaction( + transaction: (tx: YdbTransaction) => Promise, + config?: YdbTransactionConfig + ): Promise + async transaction< + T, + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, + TSchemaRelations extends TablesRelationalConfig = YdbSchemaRelations, + >( + transaction: (tx: YdbTransaction) => Promise, + config: YdbTransactionConfig | undefined, + schema: RelationalSchemaConfig | undefined + ): Promise + async transaction< + T, + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, + TSchemaRelations extends TablesRelationalConfig = YdbSchemaRelations, + >( + transaction: (tx: YdbTransaction) => Promise, + config?: YdbTransactionConfig, + schema?: RelationalSchemaConfig + ): Promise { + if (!supportsTransactions(this.client)) { + throw new Error('Transactions are not supported') + } + + try { + return await this.client.transaction(async (txClient) => { + const session = new YdbSession(txClient, this.dialect, { logger: this.logger }) + const tx = new YdbTransaction( + this.dialect, + session, + schema + ) + return transaction(tx) + }, config) + } catch (error) { + const rollbackError = findTransactionRollbackError(error) + if (rollbackError) { + throw rollbackError + } + + throw error + } + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/table-options.ts b/third-parties/drizzle-adapter/src/ydb-core/table-options.ts new file mode 100644 index 00000000..99614979 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/table-options.ts @@ -0,0 +1,213 @@ +import { entityKind } from 'drizzle-orm/entity' +import type { YdbColumn } from './columns/common.js' +import type { YdbTable } from './table.js' + +export type YdbTableOptionValue = string | number | boolean | YdbRawTableOptionValue + +export interface YdbRawTableOptionValue { + readonly kind: 'raw' + readonly value: string +} + +export interface YdbTableOptionsConfig { + readonly table: YdbTable + readonly options: Readonly> +} + +export type YdbPartitioningConfig = { + readonly table: YdbTable + readonly type: 'hash' + readonly columns: readonly YdbColumn[] +} + +export type YdbTtlUnit = 'SECONDS' | 'MILLISECONDS' | 'MICROSECONDS' | 'NANOSECONDS' +export type YdbTtlAction = + | { + readonly interval: string + readonly delete?: true + } + | { + readonly interval: string + readonly externalDataSource: string + } + +export interface YdbTtlConfig { + readonly table: YdbTable + readonly column: YdbColumn + readonly actions: readonly YdbTtlAction[] + readonly unit?: YdbTtlUnit | undefined +} + +export type YdbColumnFamilyCompression = 'off' | 'lz4' | 'zstd' | (string & {}) +export type YdbColumnFamilyData = 'ssd' | 'rot' | (string & {}) + +export interface YdbColumnFamilyOptions { + readonly data?: YdbColumnFamilyData | undefined + readonly compression?: YdbColumnFamilyCompression | undefined + readonly compressionLevel?: number | undefined +} + +export interface YdbColumnFamilyConfig { + readonly table: YdbTable + readonly name: string + readonly options: YdbColumnFamilyOptions + readonly columns: readonly YdbColumn[] +} + +function assertColumnsBelongToTable( + table: YdbTable, + columns: readonly YdbColumn[], + kind: string +): void { + for (const column of columns) { + if (column.table !== table) { + throw new Error(`${kind} column "${column.name}" does not belong to table`) + } + } +} + +export function rawTableOption(value: string): YdbRawTableOptionValue { + return { kind: 'raw', value } +} + +export class YdbTableOptionsBuilder { + static readonly [entityKind] = 'YdbTableOptionsBuilder' + + constructor(private readonly options: Readonly>) {} + + build(table: YdbTable): YdbTableOptions { + return new YdbTableOptions({ + table, + options: { ...this.options }, + }) + } +} + +export class YdbTableOptions { + static readonly [entityKind] = 'YdbTableOptions' + + constructor(readonly config: YdbTableOptionsConfig) {} +} + +export class YdbPartitioningBuilder { + static readonly [entityKind] = 'YdbPartitioningBuilder' + + constructor(private readonly columns: [YdbColumn, ...YdbColumn[]]) {} + + build(table: YdbTable): YdbPartitioning { + assertColumnsBelongToTable(table, this.columns, 'Partitioning') + return new YdbPartitioning({ + table, + type: 'hash', + columns: [...this.columns], + }) + } +} + +export class YdbPartitioning { + static readonly [entityKind] = 'YdbPartitioning' + + constructor(readonly config: YdbPartitioningConfig) {} +} + +export class YdbTtlBuilder { + static readonly [entityKind] = 'YdbTtlBuilder' + + constructor( + private readonly column: YdbColumn, + private readonly actions: readonly YdbTtlAction[], + private readonly unit?: YdbTtlUnit + ) {} + + build(table: YdbTable): YdbTtl { + assertColumnsBelongToTable(table, [this.column], 'TTL') + if (this.actions.length === 0) { + throw new Error('YDB TTL requires at least one action') + } + + return new YdbTtl({ + table, + column: this.column, + actions: [...this.actions], + unit: this.unit, + }) + } +} + +export class YdbTtl { + static readonly [entityKind] = 'YdbTtl' + + constructor(readonly config: YdbTtlConfig) {} +} + +export class YdbColumnFamilyBuilder { + static readonly [entityKind] = 'YdbColumnFamilyBuilder' + + private familyColumns: YdbColumn[] = [] + + constructor( + private readonly name: string, + private readonly options: YdbColumnFamilyOptions = {} + ) {} + + columns(...columns: YdbColumn[]): this { + this.familyColumns = [...columns] + return this + } + + build(table: YdbTable): YdbColumnFamily { + assertColumnsBelongToTable(table, this.familyColumns, 'Column family') + return new YdbColumnFamily({ + table, + name: this.name, + options: { ...this.options }, + columns: [...this.familyColumns], + }) + } +} + +export class YdbColumnFamily { + static readonly [entityKind] = 'YdbColumnFamily' + + constructor(readonly config: YdbColumnFamilyConfig) {} +} + +export function tableOptions( + options: Readonly> +): YdbTableOptionsBuilder { + return new YdbTableOptionsBuilder(options) +} + +export function partitionByHash(...columns: [YdbColumn, ...YdbColumn[]]): YdbPartitioningBuilder { + return new YdbPartitioningBuilder(columns) +} + +export function ttl( + column: YdbColumn, + interval: string, + options?: { unit?: YdbTtlUnit } +): YdbTtlBuilder +export function ttl( + column: YdbColumn, + actions: [YdbTtlAction, ...YdbTtlAction[]], + options?: { unit?: YdbTtlUnit } +): YdbTtlBuilder +export function ttl( + column: YdbColumn, + intervalOrActions: string | [YdbTtlAction, ...YdbTtlAction[]], + options: { unit?: YdbTtlUnit } = {} +): YdbTtlBuilder { + const actions = + typeof intervalOrActions === 'string' + ? [{ interval: intervalOrActions }] + : intervalOrActions + + return new YdbTtlBuilder(column, actions, options.unit) +} + +export function columnFamily( + name: string, + options?: YdbColumnFamilyOptions +): YdbColumnFamilyBuilder { + return new YdbColumnFamilyBuilder(name, options) +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/table.ts b/third-parties/drizzle-adapter/src/ydb-core/table.ts new file mode 100644 index 00000000..adba90cf --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/table.ts @@ -0,0 +1,194 @@ +import type { MakeColumnConfig } from 'drizzle-orm/column-builder' +import { entityKind } from 'drizzle-orm/entity' +import { + Table, + type TableConfig as TableConfigBase, + type UpdateTableConfig, +} from 'drizzle-orm/table' +import type { Assume, Simplify } from 'drizzle-orm/utils' +import type { YdbColumn, YdbColumnBuilderBase } from './columns/common.js' +import type { YdbColumnBuilders } from './columns/all.js' +import type { YdbIndexBuilder } from './indexes.js' +import type { YdbPrimaryKeyBuilder } from './primary-keys.js' +import type { + YdbColumnFamilyBuilder, + YdbPartitioningBuilder, + YdbTableOptionsBuilder, + YdbTtlBuilder, +} from './table-options.js' +import type { YdbUniqueConstraintBuilder } from './unique-constraint.js' +import { getYdbColumnBuilders } from './columns/all.js' + +export type TableConfig = TableConfigBase +const drizzleTableSymbol = (Table as any).Symbol + +export class YdbTable extends Table { + static override readonly [entityKind] = 'YdbTable' + static readonly Symbol = Object.assign({}, drizzleTableSymbol) +} + +export type AnyYdbTable = {}> = YdbTable< + UpdateTableConfig +> + +export type YdbBuildColumn< + TTableName extends string, + TBuilder extends YdbColumnBuilderBase, +> = YdbColumn< + MakeColumnConfig, + {}, + Simplify< + Omit | 'brand' | 'dialect'> + > +> + +export type YdbBuildColumns< + TTableName extends string, + TColumnsMap extends Record, +> = { + [Key in keyof TColumnsMap]: YdbBuildColumn< + TTableName, + { + _: Omit & { + name: TColumnsMap[Key]['_']['name'] extends '' + ? Assume + : TColumnsMap[Key]['_']['name'] + } + } + > +} + +export type YdbTableWithColumns = YdbTable & { + [Key in keyof T['columns']]: T['columns'][Key] +} & Record + +export type YdbColumnsMap = Record +export type YdbColumnsFactory = ( + builders: YdbColumnBuilders +) => TColumnsMap +export type YdbColumnsInput = + | TColumnsMap + | YdbColumnsFactory +export type YdbTableExtraConfigValue = + | YdbIndexBuilder + | YdbPrimaryKeyBuilder + | YdbTableOptionsBuilder + | YdbPartitioningBuilder + | YdbTtlBuilder + | YdbColumnFamilyBuilder + | YdbUniqueConstraintBuilder +export type YdbTableExtraConfig = Record + +function ydbTableBase< + TTableName extends string, + TSchemaName extends string | undefined, + TColumnsMap extends YdbColumnsMap, +>( + name: TTableName, + columns: TColumnsMap | ((builders: YdbColumnBuilders) => TColumnsMap), + extraConfig: + | (( + self: YdbBuildColumns + ) => YdbTableExtraConfigValue[] | YdbTableExtraConfig) + | undefined, + schema?: TSchemaName, + baseName = name +): YdbTableWithColumns<{ + name: TTableName + schema: TSchemaName + columns: YdbBuildColumns + dialect: 'ydb' +}> { + const rawTable = new YdbTable(name, schema, baseName) + const parsedColumns = ( + typeof columns === 'function' ? columns(getYdbColumnBuilders()) : columns + ) as TColumnsMap + const builtColumns = Object.fromEntries( + Object.entries(parsedColumns).map(([key, builder]) => { + ;(builder as any).setName?.(key) + const column = (builder as any).build?.(rawTable) + return [key, column] + }) + ) as Record + + const table = Object.assign(rawTable, builtColumns) + ;(table as any)[drizzleTableSymbol.Columns] = builtColumns + ;(table as any)[drizzleTableSymbol.ExtraConfigColumns] = builtColumns + + if (extraConfig) { + ;(table as any)[drizzleTableSymbol.ExtraConfigBuilder] = extraConfig + } + + return table as YdbTableWithColumns<{ + name: TTableName + schema: TSchemaName + columns: YdbBuildColumns + dialect: 'ydb' + }> +} + +export interface YdbTableFn { + ( + name: TTableName, + columns: TColumnsMap, + extraConfig?: (self: YdbBuildColumns) => YdbTableExtraConfigValue[] + ): YdbTableWithColumns<{ + name: TTableName + schema: TSchemaName + columns: YdbBuildColumns + dialect: 'ydb' + }> + ( + name: TTableName, + columns: (columnTypes: YdbColumnBuilders) => TColumnsMap, + extraConfig?: (self: YdbBuildColumns) => YdbTableExtraConfigValue[] + ): YdbTableWithColumns<{ + name: TTableName + schema: TSchemaName + columns: YdbBuildColumns + dialect: 'ydb' + }> + ( + name: TTableName, + columns: TColumnsMap, + extraConfig: (self: YdbBuildColumns) => YdbTableExtraConfig + ): YdbTableWithColumns<{ + name: TTableName + schema: TSchemaName + columns: YdbBuildColumns + dialect: 'ydb' + }> + ( + name: TTableName, + columns: (columnTypes: YdbColumnBuilders) => TColumnsMap, + extraConfig: (self: YdbBuildColumns) => YdbTableExtraConfig + ): YdbTableWithColumns<{ + name: TTableName + schema: TSchemaName + columns: YdbBuildColumns + dialect: 'ydb' + }> +} + +export const ydbTable: YdbTableFn = (( + name: string, + columns: YdbColumnsInput, + extraConfig?: unknown +) => ydbTableBase(name as any, columns as any, extraConfig as any)) as any + +export function ydbTableCreator(customizeTableName: (name: string) => string): YdbTableFn { + return (( + name: string, + columns: YdbColumnsInput, + extraConfig?: ( + self: Record + ) => YdbTableExtraConfigValue[] | YdbTableExtraConfig + ) => + ydbTableBase( + customizeTableName(name), + columns as any, + extraConfig as any, + undefined, + name + )) as YdbTableFn +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/table.utils.ts b/third-parties/drizzle-adapter/src/ydb-core/table.utils.ts new file mode 100644 index 00000000..1b2094b9 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/table.utils.ts @@ -0,0 +1,111 @@ +import { is } from 'drizzle-orm/entity' +import { Table } from 'drizzle-orm/table' +import type { YdbColumn } from './columns/common.js' +import { type YdbIndex, YdbIndexBuilder } from './indexes.js' +import { type YdbPrimaryKey, YdbPrimaryKeyBuilder } from './primary-keys.js' +import { + type YdbColumnFamily, + YdbColumnFamilyBuilder, + type YdbPartitioning, + YdbPartitioningBuilder, + type YdbTableOptions, + YdbTableOptionsBuilder, + type YdbTtl, + YdbTtlBuilder, +} from './table-options.js' +import { YdbTable, type YdbTableExtraConfigValue, type YdbTableWithColumns } from './table.js' +import { type YdbUniqueConstraint, YdbUniqueConstraintBuilder } from './unique-constraint.js' +const drizzleTableSymbol = (Table as any).Symbol + +export interface YdbTableRuntimeConfig { + readonly name: string + readonly columns: readonly YdbColumn[] + readonly indexes: readonly YdbIndex[] + readonly primaryKeys: readonly YdbPrimaryKey[] + readonly uniqueConstraints: readonly YdbUniqueConstraint[] + readonly tableOptions: readonly YdbTableOptions[] + readonly partitioning: readonly YdbPartitioning[] + readonly ttls: readonly YdbTtl[] + readonly columnFamilies: readonly YdbColumnFamily[] +} + +function normalizeExtraConfig( + extraConfig: YdbTableExtraConfigValue[] | Record | undefined +): YdbTableExtraConfigValue[] { + if (!extraConfig) { + return [] + } + + if (Array.isArray(extraConfig)) { + return extraConfig.flat(1) as YdbTableExtraConfigValue[] + } + + return Object.values(extraConfig) +} + +export function getTableConfig(table: YdbTableWithColumns): YdbTableRuntimeConfig { + const columns = Object.values((table as any)[YdbTable.Symbol.Columns] ?? {}) as YdbColumn[] + const indexes: YdbIndex[] = [] + const primaryKeys: YdbPrimaryKey[] = [] + const uniqueConstraints: YdbUniqueConstraint[] = [] + const tableOptions: YdbTableOptions[] = [] + const partitioning: YdbPartitioning[] = [] + const ttls: YdbTtl[] = [] + const columnFamilies: YdbColumnFamily[] = [] + + const extraConfigBuilder = (table as any)[YdbTable.Symbol.ExtraConfigBuilder] as + | (( + self: YdbTableWithColumns + ) => YdbTableExtraConfigValue[] | Record) + | undefined + + const extraValues = normalizeExtraConfig(extraConfigBuilder?.(table)) + for (const builder of extraValues) { + if (is(builder, YdbIndexBuilder)) { + indexes.push(builder.build(table)) + } else if (is(builder, YdbPrimaryKeyBuilder)) { + primaryKeys.push(builder.build(table)) + } else if (is(builder, YdbTableOptionsBuilder)) { + tableOptions.push(builder.build(table)) + } else if (is(builder, YdbPartitioningBuilder)) { + partitioning.push(builder.build(table)) + } else if (is(builder, YdbTtlBuilder)) { + ttls.push(builder.build(table)) + } else if (is(builder, YdbColumnFamilyBuilder)) { + columnFamilies.push(builder.build(table)) + } else if (is(builder, YdbUniqueConstraintBuilder)) { + uniqueConstraints.push(builder.build(table)) + } + } + + for (const column of columns) { + if (!column.isUnique) { + continue + } + + const hasTableLevelDuplicate = uniqueConstraints.some( + (constraint) => + constraint.config.columns.length === 1 && constraint.config.columns[0] === column + ) + + if (hasTableLevelDuplicate) { + continue + } + + uniqueConstraints.push( + new YdbUniqueConstraintBuilder(column.uniqueName, [column]).build(table) + ) + } + + return { + name: (table as any)[drizzleTableSymbol.Name] as string, + columns, + indexes, + primaryKeys, + uniqueConstraints, + tableOptions, + partitioning, + ttls, + columnFamilies, + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/transaction.ts b/third-parties/drizzle-adapter/src/ydb-core/transaction.ts new file mode 100644 index 00000000..28e55cc7 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/transaction.ts @@ -0,0 +1,21 @@ +import { TransactionRollbackError } from 'drizzle-orm/errors' +import type { TablesRelationalConfig } from 'drizzle-orm/relations' +import type { + YdbSchemaDefinition, + YdbSchemaRelations, + YdbSchemaWithoutTables, +} from './schema.types.js' +import { YdbDatabase } from './db.js' + +export class YdbTransaction< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, + TSchemaRelations extends TablesRelationalConfig = YdbSchemaRelations, +> extends YdbDatabase { + rollback(): never { + throw new TransactionRollbackError() + } + + override transaction(): never { + throw new Error('Nested YDB transactions are not supported') + } +} diff --git a/third-parties/drizzle-adapter/src/ydb-core/unique-constraint.ts b/third-parties/drizzle-adapter/src/ydb-core/unique-constraint.ts new file mode 100644 index 00000000..e1327468 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb-core/unique-constraint.ts @@ -0,0 +1,72 @@ +import { entityKind } from 'drizzle-orm/entity' +import { getTableName } from 'drizzle-orm/table' +import type { YdbColumn } from './columns/common.js' +import type { YdbTable } from './table.js' + +export function uniqueKeyName(table: YdbTable, columns: readonly string[]): string { + return `${getTableName(table)}_${columns.join('_')}_unique` +} + +function defaultUniqueName(table: YdbTable, columns: readonly YdbColumn[]): string { + return uniqueKeyName( + table, + columns.map((column) => column.name) + ) +} + +function assertColumnsBelongToTable(table: YdbTable, columns: readonly YdbColumn[]): void { + const tableName = getTableName(table) + + for (const column of columns) { + if (column.table !== table) { + throw new Error( + `Unique constraint column "${column.name}" does not belong to table "${tableName}"` + ) + } + } +} + +export interface YdbUniqueConstraintConfig { + readonly table: YdbTable + readonly columns: readonly YdbColumn[] + readonly name: string +} + +export class YdbUniqueConstraintBuilderOn { + static readonly [entityKind] = 'YdbUniqueConstraintBuilderOn' + + constructor(private readonly name: string | undefined) {} + + on(...columns: [YdbColumn, ...YdbColumn[]]): YdbUniqueConstraintBuilder { + return new YdbUniqueConstraintBuilder(this.name, columns) + } +} + +export class YdbUniqueConstraintBuilder { + static readonly [entityKind] = 'YdbUniqueConstraintBuilder' + + constructor( + private readonly name: string | undefined, + private readonly columns: [YdbColumn, ...YdbColumn[]] + ) {} + + build(table: YdbTable): YdbUniqueConstraint { + assertColumnsBelongToTable(table, this.columns) + + return new YdbUniqueConstraint({ + table, + columns: [...this.columns], + name: this.name ?? defaultUniqueName(table, this.columns), + }) + } +} + +export class YdbUniqueConstraint { + static readonly [entityKind] = 'YdbUniqueConstraint' + + constructor(readonly config: YdbUniqueConstraintConfig) {} +} + +export function unique(name?: string): YdbUniqueConstraintBuilderOn { + return new YdbUniqueConstraintBuilderOn(name) +} diff --git a/third-parties/drizzle-adapter/src/ydb/createDrizzle.ts b/third-parties/drizzle-adapter/src/ydb/createDrizzle.ts new file mode 100644 index 00000000..876fbb33 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/createDrizzle.ts @@ -0,0 +1,143 @@ +import { DefaultLogger, type Logger } from 'drizzle-orm/logger' +import { createTableRelationsHelpers, extractTablesRelationalConfig } from 'drizzle-orm/relations' +import type { Casing } from 'drizzle-orm/utils' +import { YdbDialect } from './dialect.js' +import { + YdbDriver, + type YdbExecutor, + type YdbRemoteCallback, + type YdbTransactionalExecutor, +} from './driver.js' +import type { + YdbSchemaDefinition, + YdbSchemaRelations, + YdbSchemaWithoutTables, +} from '../ydb-core/schema.types.js' +import { YdbSession } from '../ydb-core/session.js' +import { YdbDatabase } from '../ydb-core/db.js' + +export interface YdbDrizzleConfig< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, +> { + casing?: Casing | undefined + logger?: boolean | Logger | undefined + /** Exact schema object that powers typed queries like `db.query.users.findMany()`. */ + schema?: TSchemaDefinition | undefined +} + +export interface YdbDrizzleOptions< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, +> extends YdbDrizzleConfig { + connectionString?: string | undefined + client?: YdbExecutor | YdbTransactionalExecutor | undefined +} + +export type YdbDrizzleDatabase< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, +> = YdbDatabase> & { $client: YdbExecutor } + +function isYdbExecutor(value: unknown): value is YdbExecutor { + return ( + !!value && typeof value === 'object' && typeof (value as YdbExecutor).execute === 'function' + ) +} + +function makeDb( + executor: YdbExecutor | YdbTransactionalExecutor, + config: YdbDrizzleConfig = {} +): YdbDrizzleDatabase { + const dialect = new YdbDialect(config.casing === undefined ? {} : { casing: config.casing }) + + let logger: Logger | undefined = undefined + if (config.logger === true) { + logger = new DefaultLogger() + } else if (config.logger !== false) { + logger = config.logger + } + + const schema = config.schema + ? (() => { + const tablesConfig = extractTablesRelationalConfig( + config.schema, + createTableRelationsHelpers + ) + + return { + fullSchema: config.schema, + schema: tablesConfig.tables as YdbSchemaRelations, + tableNamesMap: tablesConfig.tableNamesMap, + } + })() + : undefined + + const session = new YdbSession(executor, dialect, logger === undefined ? {} : { logger }) + const db = new YdbDatabase( + dialect, + session, + schema + ) as YdbDrizzleDatabase + db.$client = executor + return db +} + +function isYdbOptions( + value: unknown +): value is YdbDrizzleOptions { + if (!value || typeof value !== 'object') { + return false + } + + if (isYdbExecutor(value)) { + return false + } + + return 'connectionString' in value || 'client' in value || 'schema' in value +} + +export function createDrizzle( + input: YdbExecutor | YdbTransactionalExecutor | YdbRemoteCallback, + config: YdbDrizzleConfig & { schema: TSchemaDefinition } +): YdbDrizzleDatabase +export function createDrizzle( + input: YdbExecutor | YdbTransactionalExecutor | YdbRemoteCallback, + config?: YdbDrizzleConfig +): YdbDrizzleDatabase +export function createDrizzle( + input: YdbDrizzleOptions & { schema: TSchemaDefinition } +): YdbDrizzleDatabase +export function createDrizzle( + input: YdbDrizzleOptions +): YdbDrizzleDatabase +export function createDrizzle< + TSchemaDefinition extends YdbSchemaDefinition = YdbSchemaWithoutTables, +>( + input: + | YdbExecutor + | YdbTransactionalExecutor + | YdbRemoteCallback + | YdbDrizzleOptions, + config?: YdbDrizzleConfig +): YdbDrizzleDatabase { + if (typeof input === 'function') { + return makeDb(YdbDriver.fromCallback(input), config) + } + + if (isYdbExecutor(input)) { + return makeDb(input, config) + } + + if (isYdbOptions(input)) { + if (input.client) { + return makeDb(input.client, input) + } + if (input.connectionString) { + const client = new YdbDriver(input.connectionString) + return makeDb(client, input) + } + throw new Error('Must include either `client` or `connectionString`.') + } + + return makeDb(input, config) +} + +export const drizzle = createDrizzle diff --git a/third-parties/drizzle-adapter/src/ydb/dialect.select.ts b/third-parties/drizzle-adapter/src/ydb/dialect.select.ts new file mode 100644 index 00000000..017e433b --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/dialect.select.ts @@ -0,0 +1,540 @@ +import { Column } from 'drizzle-orm/column' +import { is } from 'drizzle-orm/entity' +import { SQL, type SQLChunk, type SQLWrapper, sql as yql } from 'drizzle-orm/sql/sql' +import { Subquery } from 'drizzle-orm/subquery' +import { Table } from 'drizzle-orm/table' +import { type YdbSelectedFieldsOrdered, orderSelectedFields } from '../ydb-core/result-mapping.js' +import type { YdbJoinConfig, YdbSelectConfig, YdbSetOperatorConfig } from './dialect.types.js' +import { + type YdbFlattenConfig, + type YdbSampleConfig, + type YdbWindowClause, + matchRecognize as buildMatchRecognizeClause, + renderUniqueDistinctHints, +} from '../ydb-core/query-builders/select-syntax.js' + +function qualifyIdentifier(tableAlias: string, columnName: string): SQL { + return yql`${yql.identifier(tableAlias)}.${yql.identifier(columnName)}` +} + +function yqlBindingName(alias: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(alias)) { + throw new Error(`YDB CTE alias "${alias}" cannot be used as a YQL binding name`) + } + + return `$${alias}` +} + +function findSelectionAlias( + value: unknown, + fields: YdbSelectedFieldsOrdered, + selectionAliases: string[] +): string | undefined { + const foundIndex = fields.findIndex(({ path, field }) => { + if (field === value) { + return true + } + + if (is(value, SQL.Aliased)) { + if (is(field, SQL.Aliased) && field.fieldAlias === value.fieldAlias) { + return true + } + + return path[path.length - 1] === value.fieldAlias + } + + return false + }) + + return foundIndex >= 0 ? selectionAliases[foundIndex] : undefined +} + +function mapChunkToSelectionAlias( + chunk: SQLChunk, + fields: YdbSelectedFieldsOrdered, + selectionAliases: string[], + context: string +): SQLChunk { + if (is(chunk, SQL)) { + return new SQL( + chunk.queryChunks.map((value) => + mapChunkToSelectionAlias(value, fields, selectionAliases, context) + ) + ) + } + + if (is(chunk, Column) || is(chunk, SQL.Aliased)) { + const alias = findSelectionAlias(chunk, fields, selectionAliases) + + if (!alias) { + throw new Error(`YDB ${context} can only reference selected fields`) + } + + return yql.identifier(alias) + } + + return chunk +} + +export function getSelectionAliases(fields: YdbSelectedFieldsOrdered): string[] { + return fields.map((_, index) => `__ydb_f${index}`) +} + +export function mapExpressionsToSelectionAliases( + expressions: SQLWrapper[], + fields: YdbSelectedFieldsOrdered, + selectionAliases: string[], + context: string +): SQLWrapper[] { + return expressions.map((expression) => { + if (is(expression, Column) || is(expression, SQL.Aliased)) { + const alias = findSelectionAlias(expression, fields, selectionAliases) + + if (!alias) { + throw new Error(`YDB ${context} can only reference selected fields`) + } + + return yql.identifier(alias) + } + + if (is(expression, SQL)) { + return new SQL( + expression.queryChunks.map((chunk) => + mapChunkToSelectionAlias(chunk, fields, selectionAliases, context) + ) + ) + } + + return expression + }) +} + +export function buildSelection(fields: YdbSelectedFieldsOrdered, aliases?: string[]): SQL { + if (fields.length === 0) { + return yql.raw('*') + } + + const selection = fields.map(({ field }, index) => { + const alias = aliases?.[index] + + if (is(field, SQL.Aliased) && (field as any).isSelectionField) { + const base = yql.identifier(field.fieldAlias) + return alias ? yql`${base} as ${yql.identifier(alias)}` : base + } + + if (is(field, SQL.Aliased)) { + return alias + ? yql`${field.sql} as ${yql.identifier(alias)}` + : yql`${field.sql} as ${yql.identifier(field.fieldAlias)}` + } + + if (is(field, Column) || is(field, SQL) || is(field, Subquery)) { + return alias + ? yql`${field as SQLWrapper} as ${yql.identifier(alias)}` + : yql`${field as SQLWrapper}` + } + + return alias + ? yql`${field as SQLWrapper} as ${yql.identifier(alias)}` + : yql`${field as SQLWrapper}` + }) + + return yql.join(selection, yql`, `) +} + +function buildSelectionWithout(without: SQLWrapper[]): SQL { + return yql`* WITHOUT ${yql.join( + without.map((value) => yql`${value}`), + yql`, ` + )}` +} + +export function buildReturningSelection(fields: YdbSelectedFieldsOrdered): SQL { + if (fields.length === 0) { + return yql.raw('*') + } + + const selection = fields.map(({ field }) => { + if (is(field, SQL.Aliased) && (field as any).isSelectionField) { + return yql.identifier(field.fieldAlias) + } + + if (is(field, SQL.Aliased)) { + return yql`${field.sql} as ${yql.identifier(field.fieldAlias)}` + } + + if (is(field, Column)) { + return yql.identifier(field.name) + } + + return yql`${field as SQLWrapper}` + }) + + return yql.join(selection, yql`, `) +} + +export function buildFromTable(table: unknown): SQLWrapper { + if (is(table, Subquery)) { + const alias = table._.alias + + if (table._.isWith) { + return yql`${yql.raw(yqlBindingName(alias))} as ${yql.identifier(alias)}` + } + + return yql`(${table._.sql}) as ${yql.identifier(alias)}` + } + + if (is(table, Table) && (table as any)[(Table as any).Symbol.IsAlias]) { + return yql`${yql.identifier((table as any)[(Table as any).Symbol.OriginalName])} ${yql.identifier((table as any)[(Table as any).Symbol.Name])}` + } + + return table as SQLWrapper +} + +export function buildJoins(joins: YdbJoinConfig[] | undefined): SQL | undefined { + if (!joins || joins.length === 0) { + return undefined + } + + const joinsSql = joins.map((join) => { + const onSql = join.on ? yql` on ${join.on}` : undefined + const joinKeyword = yql.raw(`${join.joinType} join`) + + if (is(join.table, Table) && (join.table as any)[(Table as any).Symbol.IsAlias]) { + return yql`${joinKeyword} ${yql.identifier((join.table as any)[(Table as any).Symbol.OriginalName])} ${yql.identifier((join.table as any)[(Table as any).Symbol.Name])}${onSql}` + } + + return yql`${joinKeyword} ${join.table as SQLWrapper}${onSql}` + }) + + return yql` ${yql.join(joinsSql, yql` `)}` +} + +export function buildOrderBy(orderBy: SQLWrapper[] | undefined): SQL | undefined { + if (!orderBy || orderBy.length === 0) { + return undefined + } + + return yql` order by ${yql.join( + orderBy.map((value) => yql`${value}`), + yql`, ` + )}` +} + +export function buildAssumeOrderBy(assumeOrderBy: SQLWrapper[] | undefined): SQL | undefined { + if (!assumeOrderBy || assumeOrderBy.length === 0) { + return undefined + } + + return yql` assume order by ${yql.join( + assumeOrderBy.map((value) => yql`${value}`), + yql`, ` + )}` +} + +export function buildLimit(limit: number | undefined): SQL | undefined { + return limit !== undefined ? yql` limit ${limit}` : undefined +} + +export function buildOffset(offset: number | undefined): SQL | undefined { + return offset !== undefined ? yql` offset ${offset}` : undefined +} + +function buildFlatten(flatten: YdbFlattenConfig | undefined): SQL | undefined { + if (!flatten) { + return undefined + } + + if (flatten.mode === 'columns') { + return yql` flatten columns` + } + + if (!flatten.expressions || flatten.expressions.length === 0) { + throw new Error('YDB flatten requires at least one expression') + } + + return yql` flatten ${yql.raw(flatten.mode)} ${yql.join( + flatten.expressions.map((value) => yql`${value}`), + yql`, ` + )}` +} + +function buildSample(sample: YdbSampleConfig | undefined): SQL | undefined { + if (!sample) { + return undefined + } + + if (sample.kind === 'sample') { + return yql` sample ${typeof sample.ratio === 'number' ? yql`${sample.ratio}` : sample.ratio}` + } + + const repeatable = + sample.repeatable === undefined + ? undefined + : yql` repeatable(${typeof sample.repeatable === 'number' ? yql`${sample.repeatable}` : sample.repeatable})` + + return yql` tablesample ${yql.raw(sample.method)}(${typeof sample.size === 'number' ? yql`${sample.size}` : sample.size})${repeatable}` +} + +function buildMatchRecognize(matchRecognize: YdbSelectConfig['matchRecognize']): SQL | undefined { + if (!matchRecognize) { + return undefined + } + + return yql` match_recognize ${buildMatchRecognizeClause(matchRecognize)}` +} + +function buildWindows(windows: YdbWindowClause[] | undefined): SQL | undefined { + if (!windows || windows.length === 0) { + return undefined + } + + return yql` window ${yql.join( + windows.map((window) => yql`${yql.identifier(window.name)} AS ${window.definition}`), + yql`, ` + )}` +} + +function buildSimpleSelectQuery( + config: Omit & { + table?: unknown + fieldsFlat: YdbSelectedFieldsOrdered + extraSelections?: SQL[] + } +): SQL { + const selection = + config.without && config.without.length > 0 + ? buildSelectionWithout(config.without) + : buildSelection(config.fieldsFlat, config.selectionAliases) + const allSelections = + config.extraSelections && config.extraSelections.length > 0 + ? yql`${selection}, ${yql.join(config.extraSelections, yql`, `)}` + : selection + const joinsSql = buildJoins(config.joins) + const whereSql = config.where ? yql` where ${config.where}` : undefined + const groupBySql = + config.groupBy && config.groupBy.length > 0 + ? yql` ${yql.raw(config.groupByCompact ? 'group compact by' : 'group by')} ${yql.join( + config.groupBy.map((value) => yql`${value}`), + yql`, ` + )}` + : undefined + const havingSql = config.having ? yql` having ${config.having}` : undefined + const windowSql = buildWindows(config.windows) + const orderBySql = buildOrderBy(config.orderBy) + const assumeOrderBySql = buildAssumeOrderBy(config.assumeOrderBy) + const limitSql = buildLimit(config.limit) + const offsetSql = buildOffset(config.offset) + const intoResultSql = config.intoResult + ? yql` into result ${yql.identifier(config.intoResult)}` + : undefined + const uniqueDistinctSql = + config.uniqueDistinctHints && config.uniqueDistinctHints.length > 0 + ? yql` ${renderUniqueDistinctHints(config.uniqueDistinctHints)}` + : undefined + const distinctSql = config.distinct ? yql` distinct` : undefined + const sampleSql = buildSample(config.sample) + const matchRecognizeSql = buildMatchRecognize(config.matchRecognize) + const flattenSql = buildFlatten(config.flatten) + const fromSql = + config.table === undefined + ? undefined + : yql` from ${buildFromTable(config.table)}${sampleSql}${matchRecognizeSql}${flattenSql}` + + return yql`select${uniqueDistinctSql}${distinctSql} ${allSelections}${fromSql}${joinsSql}${whereSql}${groupBySql}${havingSql}${windowSql}${orderBySql}${assumeOrderBySql}${limitSql}${offsetSql}${intoResultSql}` +} + +function buildDistinctOnQuery( + config: YdbSelectConfig, + fieldsFlat: YdbSelectedFieldsOrdered, + selectionAliases: string[] +): SQL { + if (!config.distinctOn || config.distinctOn.length === 0) { + throw new Error('YDB distinctOn() requires at least one expression') + } + + const distinctAlias = '__ydb_distinct_on' + const rowNumberAlias = '__ydb_row_number' + const rowNumberSelection = yql`row_number() over ( + partition by ${yql.join( + config.distinctOn.map((value) => yql`${value}`), + yql`, ` + )} + ${buildOrderBy(config.orderBy)} + ) as ${yql.identifier(rowNumberAlias)}` + + const innerQuery = buildSimpleSelectQuery({ + table: config.table, + fieldsFlat, + joins: config.joins, + where: config.where, + groupBy: config.groupBy, + groupByCompact: config.groupByCompact, + having: config.having, + windows: config.windows, + distinct: false, + without: config.without, + flatten: config.flatten, + sample: config.sample, + matchRecognize: config.matchRecognize, + uniqueDistinctHints: config.uniqueDistinctHints, + selectionAliases, + extraSelections: [rowNumberSelection], + }) + + const outerOrderBy = + config.orderBy && config.orderBy.length > 0 + ? mapExpressionsToSelectionAliases( + config.orderBy, + fieldsFlat, + selectionAliases, + 'distinctOn() orderBy()' + ) + : undefined + const selection = yql.join( + selectionAliases.map((alias) => yql.identifier(alias)), + yql`, ` + ) + const rowNumberFilter = yql`${qualifyIdentifier(distinctAlias, rowNumberAlias)} = 1` + + const intoResultSql = config.intoResult + ? yql` into result ${yql.identifier(config.intoResult)}` + : undefined + + return yql`select ${selection} from (${innerQuery}) as ${yql.identifier(distinctAlias)} where ${rowNumberFilter}${buildOrderBy(outerOrderBy)}${buildLimit(config.limit)}${buildOffset(config.offset)}${intoResultSql}` +} + +function buildEmulatedSetOperationQuery( + type: 'intersect' | 'except', + leftSelect: SQL, + rightSelect: SQL, + selectionAliases: string[], + orderBy: SQLWrapper[] | undefined, + limit: number | undefined, + offset: number | undefined +): SQL { + const leftAlias = '__ydb_left' + const rightAlias = '__ydb_right' + const matchAlias = '__ydb_match' + const rightInputAlias = '__ydb_right_input' + const rightSelection = yql.join( + selectionAliases.map( + (alias) => yql`${qualifyIdentifier(rightInputAlias, alias)} as ${yql.identifier(alias)}` + ), + yql`, ` + ) + const rightComparable = yql`select ${rightSelection}, 1 as ${yql.identifier(matchAlias)} from (${rightSelect}) as ${yql.identifier(rightInputAlias)}` + const joinConditions = selectionAliases.map((alias) => { + const leftValue = qualifyIdentifier(leftAlias, alias) + const rightValue = qualifyIdentifier(rightAlias, alias) + return yql`${leftValue} = ${rightValue}` + }) + const onSql = yql.join(joinConditions, yql` and `) + const selection = yql.join( + selectionAliases.map( + (alias) => yql`${qualifyIdentifier(leftAlias, alias)} as ${yql.identifier(alias)}` + ), + yql`, ` + ) + const joinSql = + type === 'intersect' + ? yql`inner join (${rightComparable}) as ${yql.identifier(rightAlias)} on ${onSql}` + : yql`left join (${rightComparable}) as ${yql.identifier(rightAlias)} on ${onSql}` + const whereSql = + type === 'except' + ? yql` where ${qualifyIdentifier(rightAlias, matchAlias)} is null` + : undefined + + return yql`select distinct ${selection} from (${leftSelect}) as ${yql.identifier(leftAlias)} ${joinSql}${whereSql}${buildOrderBy(orderBy)}${buildLimit(limit)}${buildOffset(offset)}` +} + +export function buildSetOperationQuery( + leftSelect: SQL, + fields: YdbSelectedFieldsOrdered, + selectionAliases: string[], + setOperator: YdbSetOperatorConfig +): SQL { + const rightSelect = setOperator.rightSelect.getSQL(selectionAliases) + const mappedOrderBy = + setOperator.orderBy && setOperator.orderBy.length > 0 + ? mapExpressionsToSelectionAliases( + setOperator.orderBy, + fields, + selectionAliases, + `${setOperator.type}() orderBy()` + ) + : undefined + + if (setOperator.type === 'union') { + const operator = yql.raw(`union${setOperator.isAll ? ' all' : ''}`) + return yql`${leftSelect} ${operator} ${rightSelect}${buildOrderBy(mappedOrderBy)}${buildLimit(setOperator.limit)}${buildOffset(setOperator.offset)}` + } + + return buildEmulatedSetOperationQuery( + setOperator.type, + leftSelect, + rightSelect, + selectionAliases, + mappedOrderBy, + setOperator.limit, + setOperator.offset + ) +} + +export function buildSetOperations( + leftSelect: SQL, + fields: YdbSelectedFieldsOrdered, + selectionAliases: string[], + setOperators: YdbSetOperatorConfig[] +): SQL { + return setOperators.reduce( + (current, setOperator) => + buildSetOperationQuery(current, fields, selectionAliases, setOperator), + leftSelect + ) +} + +export function buildSelectQuery(config: YdbSelectConfig): SQL { + const fieldsFlat = config.fieldsFlat ?? orderSelectedFields(config.fields) + const selectionAliases = config.selectionAliases + const baseQuery = + config.distinctOn && config.distinctOn.length > 0 + ? buildDistinctOnQuery( + config, + fieldsFlat, + selectionAliases ?? getSelectionAliases(fieldsFlat) + ) + : buildSimpleSelectQuery({ + table: config.table, + fieldsFlat, + joins: config.joins, + where: config.where, + groupBy: config.groupBy, + groupByCompact: config.groupByCompact, + having: config.having, + windows: config.windows, + orderBy: config.orderBy, + assumeOrderBy: config.assumeOrderBy, + limit: config.limit, + offset: config.offset, + intoResult: config.intoResult, + without: config.without, + flatten: config.flatten, + sample: config.sample, + matchRecognize: config.matchRecognize, + uniqueDistinctHints: config.uniqueDistinctHints, + distinct: config.distinct, + selectionAliases, + }) + + if (config.setOperators.length === 0) { + return baseQuery + } + + return buildSetOperations( + baseQuery, + fieldsFlat, + selectionAliases ?? getSelectionAliases(fieldsFlat), + config.setOperators + ) +} diff --git a/third-parties/drizzle-adapter/src/ydb/dialect.ts b/third-parties/drizzle-adapter/src/ydb/dialect.ts new file mode 100644 index 00000000..96f343db --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/dialect.ts @@ -0,0 +1,1089 @@ +// eslint-disable no-await-in-loop +import * as crypto from 'node:crypto' +import { + aliasedTable, + aliasedTableColumn, + mapColumnsInAliasedSQLToAlias, + mapColumnsInSQLToAlias, +} from 'drizzle-orm/alias' +import { CasingCache } from 'drizzle-orm/casing' +import { Column } from 'drizzle-orm/column' +import { entityKind, is } from 'drizzle-orm/entity' +import { DrizzleError } from 'drizzle-orm/errors' +import { getOperators, getOrderByOperators } from 'drizzle-orm/relations' +import { + type DriverValueEncoder, + type QueryTypingsValue, + type QueryWithTypings, + SQL, + sql as yql, +} from 'drizzle-orm/sql/sql' +import { and } from 'drizzle-orm/sql/expressions' +import type { Casing } from 'drizzle-orm/utils' +import type { YdbSession } from '../ydb-core/session.js' +import type { YdbSelectedFieldsOrdered } from '../ydb-core/result-mapping.js' +import type { YdbColumn } from '../ydb-core/columns/common.js' +import { + type YdbMigrationStatus, + buildMigrationHistoryInsertSql, + buildMigrationHistoryMetadataColumnSql, + buildMigrationHistoryMetadataProbeSql, + buildMigrationHistorySelectSql, + buildMigrationLockRefreshSql, + buildMigrationLockReleaseSql, + buildMigrationLockSelectSql, + buildMigrationLockTableBootstrapSql, + buildMigrationLockUpsertSql, + buildMigrationTableBootstrapSql, + buildStableMigrationName, +} from './migration-ddl.js' +import { + buildFromTable, + buildJoins, + buildLimit, + buildOffset, + buildOrderBy, + buildReturningSelection, + buildSelectQuery, + buildSelection, + buildSetOperationQuery, + buildSetOperations, + getSelectionAliases, + mapExpressionsToSelectionAliases, +} from './dialect.select.js' +import type { + YdbDeleteConfig, + YdbDialectMigration, + YdbDialectMigrationConfig, + YdbInsertConfig, + YdbJoinConfig, + YdbRelationalQueryConfig, + YdbRelationalQueryResult, + YdbSelectConfig, + YdbSetOperatorConfig, + YdbUpdateConfig, +} from './dialect.types.js' +import { + getInsertColumnEntries, + getPrimaryColumnKeys, + getTableColumns, + resolveInsertValue, + resolveUpdateValue, + validateTableColumnKeys, +} from '../ydb-core/query-builders/utils.js' + +export interface YdbDialectConfig { + casing?: Casing | undefined +} + +export { + type YdbDeleteConfig, + type YdbDialectMigration, + type YdbDialectMigrationConfig, + type YdbFlatRelationalQueryConfig, + type YdbInsertConfig, + type YdbJoinConfig, + type YdbJoinType, + type YdbRelationalQueryConfig, + type YdbRelationalQueryResult, + type YdbSelectConfig, + type YdbSetOperatorConfig, + type YdbSetOperatorSource, + type YdbUpdateConfig, +} from './dialect.types.js' + +function isNumberValue(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function deriveMigrationName(migration: YdbDialectMigration): string { + return buildStableMigrationName(migration) +} + +type MigrationSession = Pick + +type MigrationTransactionalSession = MigrationSession & { + transaction( + callback: (tx: MigrationSession) => Promise, + config?: { accessMode?: 'read only' | 'read write'; idempotent?: boolean } + ): Promise +} + +type MigrationSessionInput = MigrationSession & { + transaction?: MigrationTransactionalSession['transaction'] +} + +interface NormalizedMigrationHistoryRow { + hash: string + folderMillis: number + name: string + status: YdbMigrationStatus + startedAt?: number | undefined + finishedAt?: number | undefined + error?: string | undefined + ownerId?: string | undefined + statementsTotal?: number | undefined + statementsApplied?: number | undefined +} + +interface NormalizedMigrationLockConfig { + key: string + ownerId: string + leaseMs: number + acquireTimeoutMs: number + retryIntervalMs: number +} + +interface MigrationLockHandle { + ownerId: string + assertHealthy(): void + release(): Promise +} + +const defaultMigrationLockLeaseMs = 10 * 60 * 1000 +const defaultMigrationLockAcquireTimeoutMs = 60 * 1000 +const defaultMigrationLockRetryIntervalMs = 1000 +const defaultMigrationStaleRunningAfterMs = 60 * 60 * 1000 + +function isMigrationTransactionalSession( + session: MigrationSession +): session is MigrationTransactionalSession { + return ( + 'transaction' in session && + typeof (session as MigrationTransactionalSession).transaction === 'function' + ) +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isMissingMigrationMetadataError(error: unknown): boolean { + return /column|member|unknown|not found|does not exist|no such|type annotation/i.test( + getErrorMessage(error) + ) +} + +function isAlreadyExistsError(error: unknown): boolean { + return /already|exists|duplicate/i.test(getErrorMessage(error)) +} + +function toOptionalNumber(value: unknown): number | undefined { + if (value === undefined || value === null) { + return undefined + } + + const numberValue = typeof value === 'bigint' ? Number(value) : Number(value) + return Number.isFinite(numberValue) ? numberValue : undefined +} + +function normalizeMigrationHistoryRow(row: unknown[]): NormalizedMigrationHistoryRow { + const status = + row[3] === 'running' || row[3] === 'failed' || row[3] === 'applied' ? row[3] : 'applied' + + return { + hash: String(row[0]), + folderMillis: Number(row[1]), + name: String(row[2]), + status, + startedAt: toOptionalNumber(row[4]), + finishedAt: toOptionalNumber(row[5]), + error: row[6] === undefined || row[6] === null ? undefined : String(row[6]), + ownerId: row[7] === undefined || row[7] === null ? undefined : String(row[7]), + statementsTotal: toOptionalNumber(row[8]), + statementsApplied: toOptionalNumber(row[9]), + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function normalizePositiveNumber(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback +} + +function normalizeMigrationLockConfig( + config: YdbDialectMigrationConfig +): NormalizedMigrationLockConfig { + const lockConfig = typeof config.migrationLock === 'object' ? config.migrationLock : {} + + return { + key: lockConfig.key ?? 'migrate', + ownerId: lockConfig.ownerId ?? `ydb-drizzle-${process.pid}-${crypto.randomUUID()}`, + leaseMs: normalizePositiveNumber(lockConfig.leaseMs, defaultMigrationLockLeaseMs), + acquireTimeoutMs: normalizePositiveNumber( + lockConfig.acquireTimeoutMs, + defaultMigrationLockAcquireTimeoutMs + ), + retryIntervalMs: normalizePositiveNumber( + lockConfig.retryIntervalMs, + defaultMigrationLockRetryIntervalMs + ), + } +} + +function shouldRetryMigration( + row: NormalizedMigrationHistoryRow, + now: number, + config: YdbDialectMigrationConfig +): boolean { + const recovery = config.migrationRecovery ?? {} + const mode = recovery.mode ?? 'fail' + if (mode !== 'retry') { + return false + } + + if (row.status === 'failed') { + return true + } + + const staleAfterMs = normalizePositiveNumber( + recovery.staleRunningAfterMs, + defaultMigrationStaleRunningAfterMs + ) + return ( + row.status === 'running' && + row.startedAt !== undefined && + now - row.startedAt > staleAfterMs + ) +} + +function assertMigrationRecoverable( + row: NormalizedMigrationHistoryRow, + now: number, + config: YdbDialectMigrationConfig +): void { + if (row.status === 'applied') { + return + } + + if (shouldRetryMigration(row, now, config)) { + return + } + + if (row.status === 'failed') { + throw new Error( + `YDB migration "${row.name}" (${row.hash}) is marked as failed after ${row.statementsApplied ?? 0}/${row.statementsTotal ?? 0} statements. ` + + 'Fix the migration manually or rerun with migrationRecovery.mode = "retry".' + ) + } + + const staleAfterMs = normalizePositiveNumber( + config.migrationRecovery?.staleRunningAfterMs, + defaultMigrationStaleRunningAfterMs + ) + const age = row.startedAt === undefined ? 'unknown' : `${now - row.startedAt}ms` + throw new Error( + `YDB migration "${row.name}" (${row.hash}) is still marked as running (age: ${age}, owner: ${row.ownerId ?? 'unknown'}). ` + + `It is treated as active until it is older than ${staleAfterMs}ms; use migrationRecovery.mode = "retry" only after verifying the previous run is dead.` + ) +} + +function yqlBindingName(alias: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(alias)) { + throw new Error(`YDB CTE alias "${alias}" cannot be used as a YQL binding name`) + } + + return `$${alias}` +} + +async function ensureMigrationHistoryTable( + session: MigrationSession, + config: YdbDialectMigrationConfig +): Promise { + await session.execute(yql.raw(buildMigrationTableBootstrapSql(config))) + + try { + await session.values(yql.raw(buildMigrationHistoryMetadataProbeSql(config))) + return + } catch (error) { + if (!isMissingMigrationMetadataError(error)) { + throw error + } + } + + for (const statement of buildMigrationHistoryMetadataColumnSql(config)) { + try { + await session.execute(yql.raw(statement)) + } catch (error) { + if (!isAlreadyExistsError(error)) { + throw error + } + } + } +} + +async function acquireMigrationLock( + session: MigrationSession, + config: YdbDialectMigrationConfig +): Promise { + if (!isMigrationTransactionalSession(session)) { + throw new Error( + 'YDB migrate() migrationLock requires a transactional YDB session. Pass migrationLock: false to opt out.' + ) + } + + const lockConfig = normalizeMigrationLockConfig(config) + const deadline = Date.now() + lockConfig.acquireTimeoutMs + let lastError: unknown + + while (Date.now() <= deadline) { + const now = Date.now() + + try { + const acquired = await session.transaction( + async (tx) => { + const rows = await tx.values<[string, number | string]>( + yql.raw(buildMigrationLockSelectSql(config, lockConfig.key)) + ) + const [ownerId, expiresAtRaw] = rows[0] ?? [] + const expiresAt = Number(expiresAtRaw ?? 0) + + if (ownerId && ownerId !== lockConfig.ownerId && expiresAt > now) { + return false + } + + await tx.execute( + yql.raw( + buildMigrationLockUpsertSql(config, { + key: lockConfig.key, + ownerId: lockConfig.ownerId, + acquiredAt: now, + heartbeatAt: now, + expiresAt: now + lockConfig.leaseMs, + }) + ) + ) + + return true + }, + { accessMode: 'read write', idempotent: false } + ) + + if (acquired) { + let heartbeatError: unknown + let heartbeatInFlight = false + const heartbeatEveryMs = Math.max(1000, Math.floor(lockConfig.leaseMs / 3)) + const heartbeat = setInterval(() => { + if (heartbeatInFlight) { + return + } + + heartbeatInFlight = true + const heartbeatAt = Date.now() + void session + .execute( + yql.raw( + buildMigrationLockRefreshSql(config, { + key: lockConfig.key, + ownerId: lockConfig.ownerId, + heartbeatAt, + expiresAt: heartbeatAt + lockConfig.leaseMs, + }) + ) + ) + .catch((error) => { + heartbeatError = error + }) + .finally(() => { + heartbeatInFlight = false + }) + }, heartbeatEveryMs) + heartbeat.unref?.() + + return { + ownerId: lockConfig.ownerId, + assertHealthy() { + if (heartbeatError) { + throw new Error( + `YDB migrate() lock heartbeat failed: ${getErrorMessage(heartbeatError)}`, + { cause: heartbeatError } + ) + } + }, + async release() { + clearInterval(heartbeat) + await session.execute( + yql.raw( + buildMigrationLockReleaseSql(config, { + key: lockConfig.key, + ownerId: lockConfig.ownerId, + }) + ) + ) + }, + } + } + } catch (error) { + lastError = error + } + + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + break + } + + await sleep(Math.min(lockConfig.retryIntervalMs, remainingMs)) + } + + throw new Error( + `YDB migrate() could not acquire migration lock "${lockConfig.key}" within ${lockConfig.acquireTimeoutMs}ms.`, + { cause: lastError } + ) +} + +export class YdbDialect { + static readonly [entityKind] = 'YdbDialect' + private readonly casing: CasingCache + + constructor(config: YdbDialectConfig = {}) { + this.casing = new CasingCache(config.casing) + } + + escapeName(name: string): string { + return `\`${name.replace(/`/g, '``')}\`` + } + + escapeParam(num: number): string { + return `$p${num}` + } + + escapeString(str: string): string { + return `'${str.replace(/'/g, "''")}'` + } + + prepareTyping(encoder?: DriverValueEncoder): QueryTypingsValue { + const sqlType = + typeof (encoder as unknown as { getSQLType?: () => string } | undefined)?.getSQLType === + 'function' + ? (encoder as unknown as { getSQLType(): string }).getSQLType() + : undefined + + if (sqlType === 'Json' || sqlType === 'JsonDocument') { + return 'json' + } + + if (sqlType?.startsWith('Decimal(')) { + return 'decimal' + } + + if (sqlType === 'Date' || sqlType === 'Date32') { + return 'date' + } + + if ( + sqlType === 'Datetime' || + sqlType === 'Timestamp' || + sqlType === 'Datetime64' || + sqlType === 'Timestamp64' + ) { + return 'timestamp' + } + + if (sqlType === 'Uuid') { + return 'uuid' + } + + return 'none' + } + + buildWithCTE(queries: { _: { alias: string; sql: SQL } }[] | undefined): SQL | undefined { + if (!queries || queries.length === 0) { + return undefined + } + + const withSqlChunks: SQL[] = [] + for (const query of queries) { + withSqlChunks.push(yql`${yql.raw(yqlBindingName(query._.alias))} = (${query._.sql}); `) + } + + return yql.join(withSqlChunks) + } + + getSelectionAliases(fields: YdbSelectedFieldsOrdered): string[] { + return getSelectionAliases(fields) + } + + mapExpressionsToSelectionAliases( + expressions: Parameters[0], + fields: Parameters[1], + selectionAliases: Parameters[2], + context: Parameters[3] + ) { + return mapExpressionsToSelectionAliases(expressions, fields, selectionAliases, context) + } + + buildSelection(fields: YdbSelectedFieldsOrdered, aliases?: string[]) { + return buildSelection(fields, aliases) + } + + buildReturningSelection(fields: YdbSelectedFieldsOrdered) { + return buildReturningSelection(fields) + } + + buildFromTable(table: unknown) { + return buildFromTable(table) + } + + buildJoins(joins: YdbJoinConfig[] | undefined) { + return buildJoins(joins) + } + + buildOrderBy(orderBy: Parameters[0]) { + return buildOrderBy(orderBy) + } + + buildLimit(limit: number | undefined) { + return buildLimit(limit) + } + + buildOffset(offset: number | undefined) { + return buildOffset(offset) + } + + buildSetOperationQuery( + leftSelect: SQL, + fields: YdbSelectedFieldsOrdered, + selectionAliases: string[], + setOperator: YdbSetOperatorConfig + ) { + return buildSetOperationQuery(leftSelect, fields, selectionAliases, setOperator) + } + + buildSetOperations( + leftSelect: SQL, + fields: YdbSelectedFieldsOrdered, + selectionAliases: string[], + setOperators: YdbSetOperatorConfig[] + ) { + return buildSetOperations(leftSelect, fields, selectionAliases, setOperators) + } + + buildSelectQuery(config: YdbSelectConfig) { + const withSql = this.buildWithCTE(config.withList) + const query = buildSelectQuery(config) + return withSql ? yql`${withSql}${query}` : query + } + + buildInsertQuery(config: YdbInsertConfig): SQL { + const withSql = this.buildWithCTE(config.withList) + const columnEntries = config.columnEntries ?? getInsertColumnEntries(config.table) + if (columnEntries.length === 0) { + throw new Error('Insertable columns are missing') + } + + const commandName = config.command ?? 'insert' + const commandLabel = commandName.charAt(0).toUpperCase() + commandName.slice(1) + const insertOrder = yql`(${yql.join( + columnEntries.map(([, column]) => yql.identifier(column.name)), + yql`, ` + )})` + const command = yql.raw(commandName) + const returningSql = config.returning + ? yql` returning ${this.buildReturningSelection(config.returning)}` + : undefined + + if (config.select) { + const selectQuery = is(config.values, SQL) + ? config.values + : (config.values as { getSQL(): SQL }).getSQL() + return yql`${withSql}${command} into ${config.table} ${insertOrder} ${selectQuery}${returningSql}` + } + + if (!Array.isArray(config.values)) { + throw new Error(`YDB ${commandName} values must be an array when select is not used`) + } + + if (config.values.length === 0) { + throw new Error(`${commandLabel} values are empty`) + } + + for (const row of config.values) { + validateTableColumnKeys(config.table, row, commandName) + } + + const valuesSql = config.values.map( + (row) => + yql`(${yql.join( + columnEntries.map( + ([key, column]) => yql`${resolveInsertValue(column, row[key])}` + ), + yql`, ` + )})` + ) + + return yql`${withSql}${command} into ${config.table} ${insertOrder} values ${yql.join(valuesSql, yql`, `)}${returningSql}` + } + + buildUpdateSet(table: YdbUpdateConfig['table'], set: NonNullable): SQL { + const columns = getTableColumns(table) + const setEntries = Object.entries(columns).flatMap(([key, column]) => { + const value = resolveUpdateValue(column, set[key]) + if (value === undefined) { + return [] + } + + return [yql`${yql.identifier(column.name)} = ${value}`] + }) + + if (setEntries.length === 0) { + throw new Error('Update values are empty') + } + + return yql.join(setEntries, yql`, `) + } + + buildUpdateQuery(config: YdbUpdateConfig): SQL { + const withSql = this.buildWithCTE(config.withList) + const returningSql = config.returning + ? yql` returning ${this.buildReturningSelection(config.returning)}` + : undefined + const updateKeyword = config.batch ? yql`batch update` : yql`update` + + if (config.batch && (config.on || returningSql || withSql)) { + throw new Error('YDB BATCH UPDATE cannot use WITH, ON, or RETURNING') + } + + if (config.on) { + return yql`${withSql}update ${this.buildFromTable(config.table)} on ${config.on}${returningSql}` + } + + if (!config.set) { + throw new Error('Update values are missing') + } + + const set = config.set + const setSql = this.buildUpdateSet(config.table, set) + const whereSql = config.where ? yql` where ${config.where}` : undefined + + return yql`${withSql}${updateKeyword} ${this.buildFromTable(config.table)} set ${setSql}${whereSql}${returningSql}` + } + + buildDeleteQuery(config: YdbDeleteConfig): SQL { + const withSql = this.buildWithCTE(config.withList) + const returningSql = config.returning + ? yql` returning ${this.buildReturningSelection(config.returning)}` + : undefined + const deleteKeyword = config.batch ? yql`batch delete from` : yql`delete from` + + if ( + config.batch && + (config.on || returningSql || withSql || (config.using && config.using.length > 0)) + ) { + throw new Error('YDB BATCH DELETE cannot use WITH, ON, USING, or RETURNING') + } + + if (config.on) { + if (config.where || (config.using && config.using.length > 0)) { + throw new Error('YDB delete().on() cannot be combined with where() or using()') + } + + return yql`${withSql}delete from ${this.buildFromTable(config.table)} on ${config.on}${returningSql}` + } + + if (config.using && config.using.length > 0) { + const targetTable = config.table as Parameters[0] + const columns = getTableColumns(targetTable) + const primaryColumns = getPrimaryColumnKeys(targetTable) + .map((key) => columns[key]) + .filter((column): column is NonNullable => column !== undefined) + + if (primaryColumns.length === 0) { + throw new Error('YDB delete().using() requires at least one primary key column') + } + + const usingJoinsSql = yql.join( + config.using.map((table) => yql` cross join ${this.buildFromTable(table)}`), + yql`` + ) + const innerWhereSql = config.where ? yql` where ${config.where}` : undefined + const keySelection = yql.join( + primaryColumns.map((column) => yql`${column}`), + yql`, ` + ) + const outerKey = + primaryColumns.length === 1 ? yql`${primaryColumns[0]!}` : yql`(${keySelection})` + const innerKey = primaryColumns.length === 1 ? yql`${primaryColumns[0]!}` : keySelection + + return yql`${withSql}delete from ${this.buildFromTable(config.table)} where ${outerKey} in (select ${innerKey} from ${this.buildFromTable( + config.table + )}${usingJoinsSql}${innerWhereSql})${returningSql}` + } + + const whereSql = config.where ? yql` where ${config.where}` : undefined + + return yql`${withSql}${deleteKeyword} ${this.buildFromTable(config.table)}${whereSql}${returningSql}` + } + + buildRelationalQueryWithoutPK({ + table, + tableConfig, + queryConfig: config, + tableAlias, + joinOn, + }: YdbRelationalQueryConfig): YdbRelationalQueryResult { + let where: SQL | undefined + let orderBy: SQL[] = [] + let limit: number | undefined + let offset: number | undefined + let selectedColumns: string[] = [] + const selectedExtras: Array<{ tsKey: string; field: SQL.Aliased }> = [] + + const aliasedColumns = Object.fromEntries( + Object.entries(tableConfig.columns).map(([key, value]) => [ + key, + aliasedTableColumn(value, tableAlias), + ]) + ) as Record + + if (config === true) { + selectedColumns = Object.keys(tableConfig.columns) + } else { + if (config.where) { + const whereSql = + typeof config.where === 'function' + ? config.where(aliasedColumns, getOperators()) + : config.where + where = whereSql ? mapColumnsInSQLToAlias(whereSql, tableAlias) : undefined + } + + if (config.columns) { + let isIncludeMode = false + for (const [field, value] of Object.entries(config.columns)) { + if (value === undefined) { + continue + } + + if (field in tableConfig.columns) { + if (!isIncludeMode && value === true) { + isIncludeMode = true + } + selectedColumns.push(field) + } + } + + if (selectedColumns.length > 0) { + selectedColumns = isIncludeMode + ? selectedColumns.filter((column) => config.columns?.[column] === true) + : Object.keys(tableConfig.columns).filter( + (column) => !selectedColumns.includes(column) + ) + } + } else { + selectedColumns = Object.keys(tableConfig.columns) + } + + if (config.extras) { + const extras = + typeof config.extras === 'function' + ? config.extras(aliasedColumns as Record, { sql: yql }) + : config.extras + + for (const [tsKey, value] of Object.entries(extras)) { + selectedExtras.push({ + tsKey, + field: mapColumnsInAliasedSQLToAlias(value, tableAlias) as SQL.Aliased, + }) + } + } + + let orderByOrig = + typeof config.orderBy === 'function' + ? config.orderBy(aliasedColumns, getOrderByOperators()) + : (config.orderBy ?? []) + if (!Array.isArray(orderByOrig)) { + orderByOrig = [orderByOrig] + } + + orderBy = orderByOrig.map((orderByValue) => { + if (is(orderByValue, Column)) { + return aliasedTableColumn(orderByValue, tableAlias) as unknown as SQL + } + + return mapColumnsInSQLToAlias(orderByValue, tableAlias) + }) + + if (config.limit !== undefined) { + if (!isNumberValue(config.limit)) { + throw new Error('YDB relational query limit must be a finite number') + } + limit = config.limit + } + + const offsetValue = 'offset' in config ? config.offset : undefined + if (offsetValue !== undefined) { + if (!isNumberValue(offsetValue)) { + throw new Error('YDB relational query offset must be a finite number') + } + offset = offsetValue + } + } + + if (selectedColumns.length === 0 && selectedExtras.length === 0) { + selectedColumns = + tableConfig.primaryKey.length > 0 + ? tableConfig.primaryKey + .map( + (column) => + Object.entries(tableConfig.columns).find( + ([, value]) => value === column + )?.[0] + ) + .filter((value): value is string => !!value) + : Object.keys(tableConfig.columns).slice(0, 1) + } + + if (selectedColumns.length === 0 && selectedExtras.length === 0) { + throw new DrizzleError({ + message: `No fields selected for table "${tableConfig.tsName}" ("${tableAlias}")`, + }) + } + + const selection = [ + ...selectedColumns.map((field) => { + const column = tableConfig.columns[field]! + return { + dbKey: column.name, + tsKey: field, + field: aliasedTableColumn(column, tableAlias) as unknown as YdbColumn, + relationTableTsKey: undefined, + isJson: false, + selection: [], + } + }), + ...selectedExtras.map(({ tsKey, field }) => ({ + dbKey: field.fieldAlias, + tsKey, + field, + relationTableTsKey: undefined, + isJson: false, + isExtra: true, + selection: [], + })), + ] + + const result = this.buildSelectQuery({ + table: aliasedTable(table, tableAlias), + fields: {}, + fieldsFlat: selection.map(({ field }) => ({ + path: [], + field, + })) as YdbSelectedFieldsOrdered, + where: and(joinOn, where), + joins: undefined, + orderBy, + groupBy: undefined, + having: undefined, + limit, + offset, + distinct: false, + distinctOn: undefined, + selectionAliases: undefined, + setOperators: [], + }) + + return { + tableTsKey: tableConfig.tsName, + sql: result, + selection, + } + } + + async migrate( + migrations: readonly YdbDialectMigration[], + session: MigrationSessionInput, + config: string | YdbDialectMigrationConfig = {} + ): Promise { + const migrationConfig = typeof config === 'string' ? { migrationsTable: config } : config + const lockEnabled = migrationConfig.migrationLock !== false + let lock: MigrationLockHandle | undefined + let primaryError: unknown + let releaseError: unknown + + try { + if (lockEnabled) { + await session.execute(yql.raw(buildMigrationLockTableBootstrapSql(migrationConfig))) + lock = await acquireMigrationLock(session, migrationConfig) + } + + await ensureMigrationHistoryTable(session, migrationConfig) + + const appliedRows = await session.values< + [ + string, + number | string, + string, + YdbMigrationStatus | null, + number | string | null, + number | string | null, + string | null, + string | null, + number | string | null, + number | string | null, + ] + >(yql.raw(buildMigrationHistorySelectSql(migrationConfig))) + const historyRows = appliedRows.map((row) => normalizeMigrationHistoryRow(row)) + const historyByHash = new Map(historyRows.map((row) => [row.hash, row])) + const appliedHashes = new Set( + historyRows.filter((row) => row.status === 'applied').map((row) => row.hash) + ) + const orderedMigrations = [...migrations].sort( + (left, right) => left.folderMillis - right.folderMillis + ) + + for (const migration of orderedMigrations) { + if (appliedHashes.has(migration.hash)) { + continue + } + + const now = Date.now() + const existingRow = historyByHash.get(migration.hash) + if (existingRow) { + assertMigrationRecoverable(existingRow, now, migrationConfig) + } + + lock?.assertHealthy() + + const migrationName = deriveMigrationName(migration) + const statements = migration.sql + .map((statement) => statement.trim()) + .filter((statement) => statement !== '') + const startedAt = Date.now() + let statementsApplied = 0 + + await session.execute( + yql.raw( + buildMigrationHistoryInsertSql( + { + hash: migration.hash, + folderMillis: migration.folderMillis, + name: migrationName, + status: 'running', + startedAt, + ownerId: lock?.ownerId, + statementsTotal: statements.length, + statementsApplied, + }, + migrationConfig + ) + ) + ) + + try { + for (const statement of statements) { + lock?.assertHealthy() + await session.execute(yql.raw(statement)) + statementsApplied += 1 + await session.execute( + yql.raw( + buildMigrationHistoryInsertSql( + { + hash: migration.hash, + folderMillis: migration.folderMillis, + name: migrationName, + status: 'running', + startedAt, + ownerId: lock?.ownerId, + statementsTotal: statements.length, + statementsApplied, + }, + migrationConfig + ) + ) + ) + } + + const finishedAt = Date.now() + await session.execute( + yql.raw( + buildMigrationHistoryInsertSql( + { + hash: migration.hash, + folderMillis: migration.folderMillis, + name: migrationName, + status: 'applied', + startedAt, + finishedAt, + ownerId: lock?.ownerId, + statementsTotal: statements.length, + statementsApplied, + }, + migrationConfig + ) + ) + ) + + appliedHashes.add(migration.hash) + historyByHash.set(migration.hash, { + hash: migration.hash, + folderMillis: migration.folderMillis, + name: migrationName, + status: 'applied', + startedAt, + finishedAt, + ownerId: lock?.ownerId, + statementsTotal: statements.length, + statementsApplied, + }) + } catch (error) { + const finishedAt = Date.now() + const message = getErrorMessage(error) + await session.execute( + yql.raw( + buildMigrationHistoryInsertSql( + { + hash: migration.hash, + folderMillis: migration.folderMillis, + name: migrationName, + status: 'failed', + startedAt, + finishedAt, + error: message.slice(0, 4096), + ownerId: lock?.ownerId, + statementsTotal: statements.length, + statementsApplied, + }, + migrationConfig + ) + ) + ) + + throw new Error( + `YDB migration "${migrationName}" failed after ${statementsApplied}/${statements.length} statements: ${message}`, + { cause: error } + ) + } + } + } catch (error) { + primaryError = error + } finally { + if (lock) { + try { + await lock.release() + } catch (error) { + releaseError = error + } + } + } + + if (primaryError) { + throw primaryError + } + + if (releaseError) { + throw releaseError + } + } + + sqlToQuery(sqlValue: SQL, invokeSource?: 'indexes'): QueryWithTypings { + return sqlValue.toQuery({ + casing: this.casing, + escapeName: this.escapeName, + escapeParam: this.escapeParam, + escapeString: this.escapeString, + prepareTyping: this.prepareTyping, + invokeSource, + }) + } +} diff --git a/third-parties/drizzle-adapter/src/ydb/dialect.types.ts b/third-parties/drizzle-adapter/src/ydb/dialect.types.ts new file mode 100644 index 00000000..1a569dd8 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/dialect.types.ts @@ -0,0 +1,132 @@ +import type { + BuildRelationalQueryResult, + DBQueryConfig, + Relation, + TableRelationalConfig, + TablesRelationalConfig, +} from 'drizzle-orm/relations' +import type { MigrationMeta } from 'drizzle-orm/migrator' +import type { SQL, SQLWrapper } from 'drizzle-orm/sql/sql' +import type { Subquery } from 'drizzle-orm/subquery' +import type { UpdateSet } from 'drizzle-orm/utils' +import type { YdbSelectedFieldsOrdered } from '../ydb-core/result-mapping.js' +import type { YdbColumn } from '../ydb-core/columns/common.js' +import type { + YdbFlattenConfig, + YdbMatchRecognizeConfig, + YdbSampleConfig, + YdbUniqueDistinctHint, + YdbWindowClause, +} from '../ydb-core/query-builders/select-syntax.js' +import type { YdbTable } from '../ydb-core/table.js' +import type { YdbMigrationTableConfig } from './migration-ddl.js' + +export type YdbJoinType = + | 'inner' + | 'left' + | 'right' + | 'full' + | 'cross' + | 'left semi' + | 'right semi' + | 'left only' + | 'right only' + | 'exclusion' + +export interface YdbJoinConfig { + table: unknown + joinType: YdbJoinType + alias?: string | undefined + on?: SQL | undefined +} + +export interface YdbSetOperatorSource { + getSelectedFields(): Record + getSQL(selectionAliases?: string[]): SQL +} + +export interface YdbSetOperatorConfig { + type: 'union' | 'intersect' | 'except' + isAll: boolean + rightSelect: YdbSetOperatorSource + orderBy?: SQLWrapper[] | undefined + limit?: number | undefined + offset?: number | undefined +} + +export interface YdbSelectConfig { + table?: unknown | undefined + fields: Record + fieldsFlat?: YdbSelectedFieldsOrdered | undefined + withList?: Subquery[] | undefined + joins?: YdbJoinConfig[] | undefined + where?: SQL | undefined + groupBy?: SQLWrapper[] | undefined + groupByCompact?: boolean | undefined + having?: SQL | undefined + windows?: YdbWindowClause[] | undefined + orderBy?: SQLWrapper[] | undefined + assumeOrderBy?: SQLWrapper[] | undefined + limit?: number | undefined + offset?: number | undefined + intoResult?: string | undefined + without?: SQLWrapper[] | undefined + flatten?: YdbFlattenConfig | undefined + sample?: YdbSampleConfig | undefined + matchRecognize?: YdbMatchRecognizeConfig | SQLWrapper | undefined + uniqueDistinctHints?: YdbUniqueDistinctHint[] | undefined + distinct?: boolean | undefined + distinctOn?: SQLWrapper[] | undefined + selectionAliases?: string[] | undefined + setOperators: YdbSetOperatorConfig[] +} + +export interface YdbInsertConfig { + table: YdbTable + values: Record[] | SQL | SQLWrapper + select?: boolean | undefined + withList?: Subquery[] | undefined + command?: 'insert' | 'upsert' | 'replace' | undefined + columnEntries?: Array<[string, YdbColumn]> | undefined + returning?: YdbSelectedFieldsOrdered | undefined +} + +export interface YdbUpdateConfig { + table: YdbTable + set?: UpdateSet | Record | undefined + where?: SQL | undefined + withList?: Subquery[] | undefined + on?: SQL | SQLWrapper | undefined + returning?: YdbSelectedFieldsOrdered | undefined + batch?: boolean | undefined +} + +export interface YdbDeleteConfig { + table: YdbTable | SQLWrapper + where?: SQL | undefined + using?: SQLWrapper[] | undefined + withList?: Subquery[] | undefined + on?: SQL | SQLWrapper | undefined + returning?: YdbSelectedFieldsOrdered | undefined + batch?: boolean | undefined +} + +export type YdbFlatRelationalQueryConfig = DBQueryConfig<'many', boolean> + +export interface YdbRelationalQueryConfig { + fullSchema: Record + schema: TablesRelationalConfig + tableNamesMap: Record + table: YdbTable + tableConfig: TableRelationalConfig + queryConfig: true | YdbFlatRelationalQueryConfig + tableAlias: string + joinOn?: SQL | undefined + nestedQueryRelation?: Relation | undefined +} + +export type YdbRelationalQueryResult = BuildRelationalQueryResult + +export interface YdbDialectMigrationConfig extends YdbMigrationTableConfig {} + +export type YdbDialectMigration = MigrationMeta & { name?: string } diff --git a/third-parties/drizzle-adapter/src/ydb/driver.ts b/third-parties/drizzle-adapter/src/ydb/driver.ts new file mode 100644 index 00000000..e9df4532 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/driver.ts @@ -0,0 +1,204 @@ +import { Driver } from '@ydbjs/core' +import { type QueryClient, type TX, query as createQueryClient } from '@ydbjs/query' +import { fromJs } from '@ydbjs/value' +export interface YdbTransactionConfig { + accessMode?: 'read only' | 'read write' | undefined + isolationLevel?: 'serializableReadWrite' | 'snapshotReadOnly' | undefined + idempotent?: boolean | undefined +} + +export type YdbExecutionMethod = 'all' | 'execute' + +export interface YdbExecuteOptions { + arrayMode?: boolean | undefined + typings?: unknown[] | undefined +} + +export interface YdbQueryMeta { + arrayMode: boolean + typings?: unknown[] | undefined +} + +export interface YdbQueryResult { + rows: unknown[] + rowCount?: number + command?: YdbExecutionMethod + meta?: YdbQueryMeta +} + +export type YdbRemoteCallback = ( + sql: string, + params: unknown[], + method: YdbExecutionMethod, + options?: YdbExecuteOptions +) => Promise + +export interface YdbExecutor { + execute( + sql: string, + params: unknown[], + method: YdbExecutionMethod, + options?: YdbExecuteOptions + ): Promise + ready?(signal?: AbortSignal): Promise + close?(): Promise | void +} + +export interface YdbTransactionalExecutor extends YdbExecutor { + transaction( + callback: (tx: YdbExecutor) => Promise, + config?: YdbTransactionConfig + ): Promise +} + +function getRows(result: unknown): T[] { + if (!Array.isArray(result) || result.length === 0) { + return [] + } + + const [rows] = result + return Array.isArray(rows) ? (rows as T[]) : [] +} + +async function execQuery( + ql: QueryClient | TX, + text: string, + params: unknown[], + method: YdbExecutionMethod, + options?: YdbExecuteOptions +): Promise { + let query = ql(text) + + for (let i = 0; i < params.length; i++) { + query = query.parameter(`p${i}`, fromJs(params[i] as any)) + } + + const executedQuery = options?.arrayMode ? query.values() : query + const raw = await executedQuery + const rows = getRows(raw) + return { + rows, + rowCount: rows.length, + command: method, + meta: { + arrayMode: options?.arrayMode === true, + typings: options?.typings ? [...options.typings] : undefined, + }, + } +} + +function mapTransactionConfig( + config?: YdbTransactionConfig +): { isolation?: 'serializableReadWrite' | 'snapshotReadOnly'; idempotent?: boolean } | undefined { + if (!config) { + return undefined + } + + function withIdempotent( + isolation: 'serializableReadWrite' | 'snapshotReadOnly', + idempotent?: boolean + ): { isolation: 'serializableReadWrite' | 'snapshotReadOnly'; idempotent?: boolean } { + return idempotent === undefined ? { isolation } : { isolation, idempotent } + } + + if (config.isolationLevel) { + return withIdempotent(config.isolationLevel, config.idempotent) + } + + if (config.accessMode === 'read only') { + return { isolation: 'snapshotReadOnly', idempotent: true } + } + + return withIdempotent('serializableReadWrite', config.idempotent) +} + +class YdbTxExecutor implements YdbExecutor { + constructor(private readonly tx: TX) {} + + execute( + sql: string, + params: unknown[], + method: YdbExecutionMethod, + options?: YdbExecuteOptions + ): Promise { + return execQuery(this.tx, sql, params, method, options) + } +} + +export interface YdbDriverOptions { + connectionString: string +} + +export class YdbDriver implements YdbTransactionalExecutor { + readonly driver: Driver + readonly client: QueryClient + #ownsDriver: boolean + + constructor(connectionString: string) + constructor(options: YdbDriverOptions) + /** + * Wraps an existing YDB driver instance. + * + * @param driver Existing YDB driver instance. The adapter does not close borrowed drivers. + */ + constructor(driver: Driver) + constructor(arg: string | YdbDriverOptions | Driver) { + if (arg instanceof Driver) { + this.driver = arg + this.#ownsDriver = false + } else if (typeof arg === 'string') { + this.driver = new Driver(arg) + this.#ownsDriver = true + } else { + this.driver = new Driver(arg.connectionString) + this.#ownsDriver = true + } + + this.client = createQueryClient(this.driver) + } + + async ready(signal?: AbortSignal): Promise { + await this.driver.ready(signal) + } + + execute( + sql: string, + params: unknown[], + method: YdbExecutionMethod, + options?: YdbExecuteOptions + ): Promise { + return execQuery(this.client, sql, params, method, options) + } + + async transaction( + callback: (tx: YdbExecutor) => Promise, + config?: YdbTransactionConfig + ): Promise { + const options = mapTransactionConfig(config) + + if (options) { + return this.client.begin(options, async (tx) => callback(new YdbTxExecutor(tx))) + } + + return this.client.begin(async (tx) => callback(new YdbTxExecutor(tx))) + } + + /** + * Closes the owned YDB driver instance. + * + * Borrowed driver instances passed to the constructor are not closed. + */ + close(): void { + if (this.#ownsDriver) { + this.driver.close() + } + } + + static fromCallback(callback: YdbRemoteCallback): YdbExecutor { + return { + execute(sql, params, method, options) { + return callback(sql, params, method, options) + }, + } + } +} diff --git a/third-parties/drizzle-adapter/src/ydb/errors.ts b/third-parties/drizzle-adapter/src/ydb/errors.ts new file mode 100644 index 00000000..5d68e005 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/errors.ts @@ -0,0 +1,364 @@ +import { DrizzleQueryError } from 'drizzle-orm/errors' +import { StatusIds_StatusCode } from '@ydbjs/api/operation' + +export type YdbQueryErrorKind = + | 'authentication' + | 'cancelled' + | 'overloaded' + | 'retryable' + | 'timeout' + | 'unavailable' + | 'unique_constraint' + +export interface YdbQueryErrorDetails { + kind: YdbQueryErrorKind + retryable: boolean + statusCode?: number | string | undefined +} + +type YdbErrorDiagnostics = { + messages: string[] + statusCodes: Set + retryable?: boolean +} + +const grpcStatus = { + CANCELLED: 1, + DEADLINE_EXCEEDED: 4, + PERMISSION_DENIED: 7, + RESOURCE_EXHAUSTED: 8, + UNAVAILABLE: 14, + UNAUTHENTICATED: 16, +} as const + +const retryableYdbStatusCodes = new Set([ + StatusIds_StatusCode.ABORTED, + StatusIds_StatusCode.INTERNAL_ERROR, + StatusIds_StatusCode.UNAVAILABLE, + StatusIds_StatusCode.OVERLOADED, + StatusIds_StatusCode.TIMEOUT, + StatusIds_StatusCode.BAD_SESSION, + StatusIds_StatusCode.SESSION_EXPIRED, + StatusIds_StatusCode.UNDETERMINED, + StatusIds_StatusCode.SESSION_BUSY, + StatusIds_StatusCode.EXTERNAL_ERROR, +]) + +function toError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)) +} + +function collectDiagnostics( + value: unknown, + diagnostics: YdbErrorDiagnostics, + seen = new Set() +): void { + if (!value || seen.has(value)) { + return + } + seen.add(value) + + if (value instanceof Error) { + diagnostics.messages.push(value.name, value.message) + let record = value as unknown as Record + for (let key of ['reason', 'code', 'status', 'statusCode']) { + let field = record[key] + if (typeof field === 'string' || typeof field === 'number') { + diagnostics.messages.push(String(field)) + if (key !== 'reason') { + diagnostics.statusCodes.add(field) + } + } + } + if (typeof record['retryable'] === 'boolean') { + diagnostics.retryable = record['retryable'] + } + for (let key of ['issues', 'cause']) { + collectDiagnostics(record[key], diagnostics, seen) + } + return + } + + if (Array.isArray(value)) { + for (let item of value) { + collectDiagnostics(item, diagnostics, seen) + } + return + } + + if (typeof value !== 'object') { + diagnostics.messages.push(String(value)) + return + } + + let record = value as Record + for (let key of ['name', 'message', 'reason', 'code', 'status', 'statusCode']) { + let field = record[key] + if (typeof field === 'string' || typeof field === 'number') { + diagnostics.messages.push(String(field)) + if (key !== 'name' && key !== 'message' && key !== 'reason') { + diagnostics.statusCodes.add(field) + } + } + } + + if (typeof record['retryable'] === 'boolean') { + diagnostics.retryable = record['retryable'] + } + + for (let key of ['issues', 'cause']) { + collectDiagnostics(record[key], diagnostics, seen) + } +} + +function getDiagnostics(error: unknown): YdbErrorDiagnostics { + let diagnostics: YdbErrorDiagnostics = { + messages: [], + statusCodes: new Set(), + } + collectDiagnostics(error, diagnostics) + return diagnostics +} + +function hasStatus(diagnostics: YdbErrorDiagnostics, ...codes: Array): boolean { + for (let code of codes) { + if (diagnostics.statusCodes.has(code)) { + return true + } + if (typeof code === 'string' && diagnostics.statusCodes.has(code.toUpperCase())) { + return true + } + } + return false +} + +function getStatusCode(diagnostics: YdbErrorDiagnostics): number | string | undefined { + return diagnostics.statusCodes.values().next().value +} + +function getDiagnosticText(diagnostics: YdbErrorDiagnostics): string { + return diagnostics.messages.join('\n') +} + +function isUniqueConstraintError(diagnostics: YdbErrorDiagnostics): boolean { + let text = getDiagnosticText(diagnostics) + return ( + /(unique|constraint).*(violation|violated|duplicate|already exists|conflict)/iu.test( + text + ) || /(duplicate|already exists|conflict).*(key|unique|constraint)/iu.test(text) + ) +} + +function classifyYdbQueryError(diagnostics: YdbErrorDiagnostics): YdbQueryErrorDetails | undefined { + if (isUniqueConstraintError(diagnostics)) { + return { + kind: 'unique_constraint', + retryable: false, + statusCode: getStatusCode(diagnostics), + } + } + + let text = getDiagnosticText(diagnostics) + let statusCode = getStatusCode(diagnostics) + + if ( + hasStatus( + diagnostics, + StatusIds_StatusCode.UNAUTHORIZED, + grpcStatus.UNAUTHENTICATED, + grpcStatus.PERMISSION_DENIED, + 'UNAUTHORIZED', + 'UNAUTHENTICATED', + 'PERMISSION_DENIED' + ) || + /(unauthorized|unauthenticated|permission denied|access denied|invalid token)/iu.test(text) + ) { + return { kind: 'authentication', retryable: false, statusCode } + } + + if ( + hasStatus( + diagnostics, + StatusIds_StatusCode.CANCELLED, + grpcStatus.CANCELLED, + 'CANCELLED', + 'CANCELED' + ) || + /\bcancell?ed\b/iu.test(text) + ) { + return { kind: 'cancelled', retryable: diagnostics.retryable === true, statusCode } + } + + if ( + hasStatus( + diagnostics, + StatusIds_StatusCode.TIMEOUT, + grpcStatus.DEADLINE_EXCEEDED, + 'TIMEOUT', + 'DEADLINE_EXCEEDED' + ) || + /(timeout|deadline exceeded|timed out)/iu.test(text) + ) { + return { kind: 'timeout', retryable: true, statusCode } + } + + if ( + hasStatus( + diagnostics, + StatusIds_StatusCode.OVERLOADED, + grpcStatus.RESOURCE_EXHAUSTED, + 'OVERLOADED', + 'RESOURCE_EXHAUSTED' + ) || + /(overloaded|resource exhausted|too many requests|throttl)/iu.test(text) + ) { + return { kind: 'overloaded', retryable: true, statusCode } + } + + if ( + hasStatus( + diagnostics, + StatusIds_StatusCode.UNAVAILABLE, + grpcStatus.UNAVAILABLE, + 'UNAVAILABLE' + ) || + /(unavailable|connection refused|connection reset|transport.*closed|no connection)/iu.test( + text + ) + ) { + return { kind: 'unavailable', retryable: true, statusCode } + } + + for (let code of diagnostics.statusCodes) { + if (typeof code === 'number' && retryableYdbStatusCodes.has(code)) { + return { kind: 'retryable', retryable: true, statusCode } + } + } + + if (diagnostics.retryable === true) { + return { kind: 'retryable', retryable: true, statusCode } + } + + return undefined +} + +function attachYdbErrorDetails( + target: T, + cause: unknown, + details?: YdbQueryErrorDetails +): T { + if (cause && typeof cause === 'object') { + let record = cause as Record + for (let key of ['code', 'status', 'statusCode', 'issues', 'retryable']) { + if (key in record) { + Object.defineProperty(target, key, { + configurable: true, + enumerable: false, + value: record[key], + }) + } + } + } + + if (details) { + Object.defineProperties(target, { + kind: { + configurable: true, + enumerable: false, + value: details.kind, + }, + retryable: { + configurable: true, + enumerable: false, + value: details.retryable, + }, + statusCode: { + configurable: true, + enumerable: false, + value: details.statusCode, + }, + }) + } + + return target +} + +export class YdbQueryExecutionError extends DrizzleQueryError { + override name = 'YdbQueryExecutionError' + declare readonly kind: YdbQueryErrorKind + declare readonly retryable: boolean + declare readonly statusCode?: number | string +} + +export class YdbUniqueConstraintViolationError extends YdbQueryExecutionError { + override name = 'YdbUniqueConstraintViolationError' +} + +export class YdbAuthenticationError extends YdbQueryExecutionError { + override name = 'YdbAuthenticationError' +} + +export class YdbCancelledQueryError extends YdbQueryExecutionError { + override name = 'YdbCancelledQueryError' +} + +export class YdbRetryableQueryError extends YdbQueryExecutionError { + override name = 'YdbRetryableQueryError' +} + +export class YdbTimeoutQueryError extends YdbRetryableQueryError { + override name = 'YdbTimeoutQueryError' +} + +export class YdbUnavailableQueryError extends YdbRetryableQueryError { + override name = 'YdbUnavailableQueryError' +} + +export class YdbOverloadedQueryError extends YdbRetryableQueryError { + override name = 'YdbOverloadedQueryError' +} + +function createMappedError( + query: string, + params: unknown[], + cause: Error, + details: YdbQueryErrorDetails +): YdbQueryExecutionError { + switch (details.kind) { + case 'unique_constraint': + return new YdbUniqueConstraintViolationError(query, params, cause) + case 'authentication': + return new YdbAuthenticationError(query, params, cause) + case 'cancelled': + return new YdbCancelledQueryError(query, params, cause) + case 'timeout': + return new YdbTimeoutQueryError(query, params, cause) + case 'unavailable': + return new YdbUnavailableQueryError(query, params, cause) + case 'overloaded': + return new YdbOverloadedQueryError(query, params, cause) + case 'retryable': + return new YdbRetryableQueryError(query, params, cause) + } +} + +export function mapYdbQueryError( + query: string, + params: unknown[], + cause: unknown +): DrizzleQueryError { + let error = toError(cause) + let diagnostics = getDiagnostics(cause) + collectDiagnostics(error, diagnostics) + let details = classifyYdbQueryError(diagnostics) + + if (details) { + return attachYdbErrorDetails( + createMappedError(query, params, error, details), + cause, + details + ) + } + + return attachYdbErrorDetails(new DrizzleQueryError(query, params, error), cause) +} diff --git a/third-parties/drizzle-adapter/src/ydb/migration-ddl.ts b/third-parties/drizzle-adapter/src/ydb/migration-ddl.ts new file mode 100644 index 00000000..3227a8cc --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/migration-ddl.ts @@ -0,0 +1,1957 @@ +import * as crypto from 'node:crypto' +import { getTableName } from 'drizzle-orm/table' +import type { YdbColumn } from '../ydb-core/columns/common.js' +import type { YdbIndex, YdbIndexConfig } from '../ydb-core/indexes.js' +import type { YdbPrimaryKey } from '../ydb-core/primary-keys.js' +import type { + YdbColumnFamily, + YdbColumnFamilyOptions, + YdbTableOptionValue, + YdbTableOptions, + YdbTtl, +} from '../ydb-core/table-options.js' +import { getTableConfig } from '../ydb-core/table.utils.js' +import type { YdbTable, YdbTableWithColumns } from '../ydb-core/table.js' +import type { YdbUniqueConstraint } from '../ydb-core/unique-constraint.js' + +export interface YdbMigrationTableConfig { + migrationsTable?: string + migrationsSchema?: string + migrationsLockTable?: string + migrationLock?: boolean | YdbMigrationLockConfig + migrationRecovery?: YdbMigrationRecoveryConfig +} + +export interface YdbMigrationLockConfig { + table?: string + key?: string + ownerId?: string + leaseMs?: number + acquireTimeoutMs?: number + retryIntervalMs?: number +} + +export interface YdbMigrationRecoveryConfig { + mode?: 'fail' | 'retry' + staleRunningAfterMs?: number +} + +export type YdbMigrationStatus = 'running' | 'applied' | 'failed' + +export interface YdbMigrationHistoryRecord { + hash: string + folderMillis: number + name: string + status: YdbMigrationStatus + startedAt?: number + finishedAt?: number + error?: string + ownerId?: string + statementsTotal?: number + statementsApplied?: number +} + +export interface YdbCreateTableOperation { + kind: 'create_table' + table: YdbTableWithColumns + ifNotExists?: boolean + temporary?: boolean | 'temp' | 'temporary' +} + +export interface YdbDropTableOperation { + kind: 'drop_table' + table: string | YdbTable + ifExists?: boolean +} + +export interface YdbAnalyzeOperation { + kind: 'analyze' + table: string | YdbTable + columns?: readonly (string | YdbColumn)[] +} + +export interface YdbCreateViewOperation { + kind: 'create_view' + name: string + query: string + ifNotExists?: boolean + options?: YdbCreateViewOptions +} + +export interface YdbDropViewOperation { + kind: 'drop_view' + name: string + ifExists?: boolean +} + +export interface YdbCreateTopicOperation { + kind: 'create_topic' + name: string + options?: YdbCreateTopicOptions +} + +export interface YdbAlterTopicOperation { + kind: 'alter_topic' + name: string + actions: [YdbAlterTopicAction, ...YdbAlterTopicAction[]] +} + +export interface YdbDropTopicOperation { + kind: 'drop_topic' + name: string +} + +export interface YdbCreateAsyncReplicationOperation { + kind: 'create_async_replication' + name: string + targets: [YdbAsyncReplicationTarget, ...YdbAsyncReplicationTarget[]] + options: YdbAsyncReplicationOptions +} + +export interface YdbAlterAsyncReplicationOperation { + kind: 'alter_async_replication' + name: string + options: YdbAlterAsyncReplicationOptions +} + +export interface YdbDropAsyncReplicationOperation { + kind: 'drop_async_replication' + name: string + cascade?: boolean +} + +export interface YdbCreateTransferOperation { + kind: 'create_transfer' + name: string + from: string + to: string + using: string + options?: YdbTransferOptions +} + +export interface YdbAlterTransferOperation { + kind: 'alter_transfer' + name: string + using?: string + options?: YdbAlterTransferOptions +} + +export interface YdbDropTransferOperation { + kind: 'drop_transfer' + name: string +} + +export interface YdbCreateSecretOperation { + kind: 'create_secret' + name: string + value: string +} + +export interface YdbCreateUserOperation { + kind: 'create_user' + name: string + options?: YdbUserOptions +} + +export interface YdbAlterUserOperation { + kind: 'alter_user' + name: string + options: YdbUserOptions +} + +export interface YdbDropUserOperation { + kind: 'drop_user' + names: [string, ...string[]] + ifExists?: boolean +} + +export interface YdbCreateGroupOperation { + kind: 'create_group' + name: string + users?: readonly string[] +} + +export interface YdbAlterGroupOperation { + kind: 'alter_group' + name: string + action: 'add_user' | 'drop_user' + users: [string, ...string[]] +} + +export interface YdbDropGroupOperation { + kind: 'drop_group' + names: [string, ...string[]] + ifExists?: boolean +} + +export interface YdbGrantOperation { + kind: 'grant' + permissions: YdbAccessPermissions + on: [string, ...string[]] + to: [string, ...string[]] + withGrantOption?: boolean +} + +export interface YdbRevokeOperation { + kind: 'revoke' + permissions: YdbAccessPermissions + on: [string, ...string[]] + from: [string, ...string[]] + grantOptionFor?: boolean +} + +export interface YdbShowCreateOperation { + kind: 'show_create' + objectType: YdbShowCreateObjectType + name: string +} + +export interface YdbAddColumnsOperation { + kind: 'add_columns' + table: string | YdbTable + columns: [YdbColumn, ...YdbColumn[]] +} + +export interface YdbDropColumnsOperation { + kind: 'drop_columns' + table: string | YdbTable + columns: [string, ...string[]] +} + +export interface YdbAddIndexOperation { + kind: 'add_index' + table: string | YdbTable + index: YdbIndex | YdbUniqueConstraint +} + +export interface YdbDropIndexOperation { + kind: 'drop_index' + table: string | YdbTable + name: string +} + +export interface YdbSetTableOptionsOperation { + kind: 'set_table_options' + table: string | YdbTable + options: Readonly> +} + +export interface YdbResetTableOptionsOperation { + kind: 'reset_table_options' + table: string | YdbTable + names: [string, ...string[]] +} + +export interface YdbAddColumnFamilyOperation { + kind: 'add_column_family' + table: string | YdbTable + family: Pick +} + +export interface YdbAlterColumnFamilyOperation { + kind: 'alter_column_family' + table: string | YdbTable + name: string + options: YdbColumnFamilyOptions +} + +export interface YdbSetColumnFamilyOperation { + kind: 'set_column_family' + table: string | YdbTable + familyName: string + columns: [YdbColumn, ...YdbColumn[]] | [string, ...string[]] +} + +export interface YdbRenameTableOperation { + kind: 'rename_table' + table: string | YdbTable + to: string +} + +export interface YdbAddChangefeedOperation { + kind: 'add_changefeed' + table: string | YdbTable + name: string + options: YdbChangefeedOptions +} + +export interface YdbDropChangefeedOperation { + kind: 'drop_changefeed' + table: string | YdbTable + name: string +} + +export interface YdbAlterTableOperation { + kind: 'alter_table' + table: string | YdbTable + actions: [YdbAlterTableAction, ...YdbAlterTableAction[]] +} + +export type YdbMigrationOperation = + | YdbCreateTableOperation + | YdbDropTableOperation + | YdbAnalyzeOperation + | YdbCreateViewOperation + | YdbDropViewOperation + | YdbCreateTopicOperation + | YdbAlterTopicOperation + | YdbDropTopicOperation + | YdbCreateAsyncReplicationOperation + | YdbAlterAsyncReplicationOperation + | YdbDropAsyncReplicationOperation + | YdbCreateTransferOperation + | YdbAlterTransferOperation + | YdbDropTransferOperation + | YdbCreateSecretOperation + | YdbCreateUserOperation + | YdbAlterUserOperation + | YdbDropUserOperation + | YdbCreateGroupOperation + | YdbAlterGroupOperation + | YdbDropGroupOperation + | YdbGrantOperation + | YdbRevokeOperation + | YdbShowCreateOperation + | YdbAddColumnsOperation + | YdbDropColumnsOperation + | YdbAddIndexOperation + | YdbDropIndexOperation + | YdbSetTableOptionsOperation + | YdbResetTableOptionsOperation + | YdbAddColumnFamilyOperation + | YdbAlterColumnFamilyOperation + | YdbSetColumnFamilyOperation + | YdbRenameTableOperation + | YdbAddChangefeedOperation + | YdbDropChangefeedOperation + | YdbAlterTableOperation + +export interface YdbCreateViewOptions { + ifNotExists?: boolean + /** + * YDB currently requires security_invoker = TRUE for views. + * Defaults to true to generate executable view DDL. + */ + securityInvoker?: boolean + options?: Readonly> +} + +export interface YdbTopicConsumer { + name: string + settings?: Readonly> +} + +export interface YdbCreateTopicOptions { + consumers?: readonly YdbTopicConsumer[] + settings?: Readonly> +} + +export interface YdbAsyncReplicationTarget { + remote: string + local: string +} + +export type YdbAsyncReplicationConsistencyLevel = 'ROW' | 'GLOBAL' + +export interface YdbAsyncReplicationOptions { + connectionString?: string + caCert?: string + tokenSecretName?: string + user?: string + passwordSecretName?: string + serviceAccountId?: string + initialTokenSecretName?: string + consistencyLevel?: YdbAsyncReplicationConsistencyLevel + commitInterval?: string + options?: Readonly> +} + +export interface YdbAlterAsyncReplicationOptions { + state?: 'DONE' + failoverMode?: 'FORCE' + options?: Readonly> +} + +export interface YdbTransferOptions { + connectionString?: string + tokenSecretName?: string + user?: string + passwordSecretName?: string + serviceAccountId?: string + initialTokenSecretName?: string + consumer?: string + batchSizeBytes?: number + flushInterval?: string + options?: Readonly> +} + +export interface YdbAlterTransferOptions { + state?: 'PAUSED' | 'ACTIVE' + batchSizeBytes?: number + flushInterval?: string + options?: Readonly> +} + +export interface YdbUserOptions { + password?: string | null + login?: boolean + withKeyword?: boolean +} + +export type YdbAccessPermission = + | string + | { kind: 'all'; privileges?: boolean } + | { kind: 'raw'; value: string } + +export type YdbAccessPermissions = + | YdbAccessPermission + | readonly [YdbAccessPermission, ...YdbAccessPermission[]] + +export type YdbShowCreateObjectType = 'table' | 'view' | 'topic' | 'async replication' | 'transfer' + +export type YdbAlterTopicAction = + | { kind: 'add_consumer'; consumer: YdbTopicConsumer } + | { kind: 'drop_consumer'; name: string } + | { + kind: 'alter_consumer_set' + name: string + settings: Readonly> + } + | { + kind: 'set_options' + settings: Readonly> + } + +export type YdbChangefeedMode = + | 'KEYS_ONLY' + | 'UPDATES' + | 'NEW_IMAGE' + | 'OLD_IMAGE' + | 'NEW_AND_OLD_IMAGES' + +export type YdbChangefeedFormat = 'JSON' | 'DEBEZIUM_JSON' + +export interface YdbChangefeedOptions { + mode?: YdbChangefeedMode + format?: YdbChangefeedFormat + virtualTimestamps?: boolean + barriersInterval?: string + retentionPeriod?: string + topicAutoPartitioning?: 'ENABLED' | 'DISABLED' + topicMinActivePartitions?: number + initialScan?: boolean + options?: Readonly> +} + +export type YdbAlterTableAction = + | { kind: 'add_column'; column: YdbColumn } + | { kind: 'drop_column'; name: string } + | { kind: 'add_index'; index: YdbIndex | YdbUniqueConstraint } + | { kind: 'drop_index'; name: string } + | { + kind: 'set_table_options' + options: Readonly> + } + | { kind: 'reset_table_options'; names: [string, ...string[]] } + | { + kind: 'add_column_family' + family: Pick + } + | { + kind: 'alter_column_family' + name: string + options: YdbColumnFamilyOptions + } + | { + kind: 'set_column_family' + familyName: string + column: YdbColumn | string + } + | { kind: 'rename_table'; to: string } + | { kind: 'add_changefeed'; name: string; options: YdbChangefeedOptions } + | { kind: 'drop_changefeed'; name: string } + +export interface YdbInlineMigration { + readonly name?: string + readonly folderMillis?: number + readonly hash?: string + readonly breakpoints?: boolean + readonly sql?: readonly string[] + readonly operations?: readonly YdbMigrationOperation[] +} + +export interface YdbNormalizedMigration { + readonly name: string + readonly folderMillis: number + readonly hash: string + readonly bps: boolean + readonly sql: string[] +} + +function escapeName(name: string): string { + return `\`${name.replace(/`/g, '``')}\`` +} + +function escapeString(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function escapeDoubleQuoted(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +function getMigrationHashNamePart(hash: string): string { + const sanitized = hash.replace(/[^a-zA-Z0-9]+/gu, '').slice(0, 12) + return sanitized || crypto.createHash('sha256').update(hash).digest('hex').slice(0, 12) +} + +export function buildStableMigrationName( + migration: { + readonly name?: string | undefined + readonly folderMillis?: number | undefined + readonly hash: string + }, + prefix = 'migration' +): string { + if (migration.name !== undefined && migration.name !== '') { + return migration.name + } + + const hashPart = getMigrationHashNamePart(migration.hash) + if (typeof migration.folderMillis === 'number' && Number.isFinite(migration.folderMillis)) { + return `${prefix}_${Math.trunc(migration.folderMillis)}_${hashPart}` + } + + return `${prefix}_${hashPart}` +} + +function renderOptionName(name: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + throw new Error(`YDB migrate() invalid option name "${name}"`) + } + + return name +} + +function renderOptionNames(names: readonly string[]): string { + return names.map((name) => renderOptionName(name)).join(', ') +} + +function getObjectName(value: string | YdbTable): string { + return typeof value === 'string' ? value : getTableName(value) +} + +function getMigrationTableName(config: YdbMigrationTableConfig): string { + const tableName = config.migrationsTable ?? '__drizzle_migrations' + return config.migrationsSchema ? `${config.migrationsSchema}/${tableName}` : tableName +} + +function getMigrationLockTableName(config: YdbMigrationTableConfig): string { + const configuredTable = + typeof config.migrationLock === 'object' ? config.migrationLock.table : undefined + const tableName = + config.migrationsLockTable ?? + configuredTable ?? + `${config.migrationsTable ?? '__drizzle_migrations'}_lock` + return config.migrationsSchema ? `${config.migrationsSchema}/${tableName}` : tableName +} + +function ensureSupportedColumn(column: YdbColumn): void { + if ((column as any).generated !== undefined) { + throw new Error( + `YDB migrate() DDL generation does not support generated columns: "${column.name}"` + ) + } +} + +function renderColumnDefinition(column: YdbColumn, familyName?: string): string { + ensureSupportedColumn(column) + + const parts = [escapeName(column.name), column.getSQLType()] + if (familyName) { + parts.push('FAMILY', escapeName(familyName)) + } + + if ((column as any).notNull === true) { + parts.push('NOT NULL') + } + + return parts.join(' ') +} + +function getPrimaryKeyColumns( + columns: readonly YdbColumn[], + primaryKeys: readonly YdbPrimaryKey[] +): YdbColumn[] { + const inlinePrimaryKeys = columns.filter((column) => (column as any).primary === true) + + if (inlinePrimaryKeys.length > 0 && primaryKeys.length > 0) { + throw new Error( + 'YDB migrate() DDL generation found both inline and table-level primary keys' + ) + } + + if (primaryKeys.length > 1) { + throw new Error( + 'YDB migrate() DDL generation supports only one table-level primary key definition' + ) + } + + return inlinePrimaryKeys.length > 0 + ? inlinePrimaryKeys + : [...(primaryKeys[0]?.config.columns ?? [])] +} + +function isRawTableOptionValue( + value: YdbTableOptionValue +): value is Extract { + return typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'raw' +} + +function renderTableOptionValue(value: YdbTableOptionValue): string { + if (isRawTableOptionValue(value)) { + return value.value + } + + if (typeof value === 'number') { + return String(value) + } + + if (typeof value === 'boolean') { + return value ? 'TRUE' : 'FALSE' + } + + return value +} + +function renderTableOptions(options: Readonly>): string[] { + return Object.entries(options).map( + ([key, value]) => `${renderOptionName(key)} = ${renderTableOptionValue(value)}` + ) +} + +function renderStatementOptionValue(value: YdbTableOptionValue): string { + if (isRawTableOptionValue(value)) { + return value.value + } + + if (typeof value === 'string') { + return escapeString(value) + } + + return renderTableOptionValue(value) +} + +function renderStatementOptions(options: Readonly>): string[] { + return Object.entries(options).map( + ([key, value]) => `${renderOptionName(key)} = ${renderStatementOptionValue(value)}` + ) +} + +function requireNonEmptyOptions( + options: Readonly>, + context: string +): string[] { + const rendered = renderTableOptions(options) + if (rendered.length === 0) { + throw new Error(`YDB migrate() ${context} requires at least one option`) + } + + return rendered +} + +function renderTtl(ttl: YdbTtl): string { + const { column, actions, unit } = ttl.config + const actionSql = actions + .map((action) => { + const interval = `Interval(${escapeDoubleQuoted(action.interval)})` + + if ('externalDataSource' in action) { + return `${interval} TO EXTERNAL DATA SOURCE ${escapeName(action.externalDataSource)}` + } + + return action.delete === true ? `${interval} DELETE` : interval + }) + .join(', ') + const unitSql = unit ? ` AS ${unit}` : '' + + return `${actionSql} ON ${escapeName(column.name)}${unitSql}` +} + +function collectWithOptions( + tableOptions: readonly YdbTableOptions[], + ttls: readonly YdbTtl[] +): string[] { + const rendered: string[] = [] + const used = new Set() + + for (const tableOption of tableOptions) { + for (const [key, value] of Object.entries(tableOption.config.options)) { + if (used.has(key)) { + throw new Error(`YDB migrate() duplicate table option "${key}"`) + } + used.add(key) + rendered.push(`${renderOptionName(key)} = ${renderTableOptionValue(value)}`) + } + } + + if (ttls.length > 1) { + throw new Error('YDB migrate() supports only one TTL definition per table') + } + + if (ttls.length === 1) { + if (used.has('TTL')) { + throw new Error('YDB migrate() duplicate table option "TTL"') + } + rendered.push(`TTL = ${renderTtl(ttls[0]!)}`) + } + + return rendered +} + +function renderColumnFamilyOptions(options: YdbColumnFamilyOptions): string[] { + const rendered: string[] = [] + if (options.data !== undefined) { + rendered.push(`DATA = ${escapeDoubleQuoted(options.data)}`) + } + if (options.compression !== undefined) { + rendered.push(`COMPRESSION = ${escapeDoubleQuoted(options.compression)}`) + } + if (options.compressionLevel !== undefined) { + rendered.push(`COMPRESSION_LEVEL = ${String(options.compressionLevel)}`) + } + return rendered +} + +function renderColumnFamilyAlterActions(name: string, options: YdbColumnFamilyOptions): string[] { + const familyName = escapeName(name) + const rendered: string[] = [] + if (options.data !== undefined) { + rendered.push(`ALTER FAMILY ${familyName} SET DATA ${escapeDoubleQuoted(options.data)}`) + } + if (options.compression !== undefined) { + rendered.push( + `ALTER FAMILY ${familyName} SET COMPRESSION ${escapeDoubleQuoted(options.compression)}` + ) + } + if (options.compressionLevel !== undefined) { + rendered.push( + `ALTER FAMILY ${familyName} SET COMPRESSION_LEVEL ${String(options.compressionLevel)}` + ) + } + return rendered +} + +function renderColumnFamilyDefinition( + family: Pick +): string { + const options = renderColumnFamilyOptions(family.options) + return options.length > 0 + ? `FAMILY ${escapeName(family.name)} (${options.join(', ')})` + : `FAMILY ${escapeName(family.name)}` +} + +function getColumnFamilyByColumnName( + columnFamilies: readonly YdbColumnFamily[] +): Map { + const familyByColumn = new Map() + const usedFamilyNames = new Set() + + for (const family of columnFamilies) { + if (usedFamilyNames.has(family.config.name)) { + throw new Error(`YDB migrate() duplicate column family "${family.config.name}"`) + } + usedFamilyNames.add(family.config.name) + + for (const column of family.config.columns) { + const existing = familyByColumn.get(column.name) + if (existing) { + throw new Error( + `YDB migrate() column "${column.name}" is assigned to both "${existing}" and "${family.config.name}" families` + ) + } + + familyByColumn.set(column.name, family.config.name) + } + } + + return familyByColumn +} + +function renderPartitioning( + partitioning: ReturnType['partitioning'] +): string | undefined { + if (partitioning.length === 0) { + return undefined + } + + if (partitioning.length > 1) { + throw new Error('YDB migrate() supports only one PARTITION BY definition per table') + } + + const partitioningConfig = partitioning[0]!.config + return `PARTITION BY HASH(${partitioningConfig.columns.map((column) => escapeName(column.name)).join(', ')})` +} + +function renderIndexConfig(config: YdbIndexConfig): string { + const fragments = [ + 'INDEX', + escapeName( + config.name ?? + `${getTableName(config.table)}_${config.columns.map((column) => column.name).join('_')}_idx` + ), + config.locality, + ] + + if (config.unique) { + fragments.push('UNIQUE') + } + + fragments.push(config.sync) + + if (config.indexType && config.indexType !== 'secondary') { + fragments.push('USING', config.indexType) + } + + fragments.push(`ON (${config.columns.map((column) => escapeName(column.name)).join(', ')})`) + + if (config.cover.length > 0) { + fragments.push( + `COVER (${config.cover.map((column) => escapeName(column.name)).join(', ')})` + ) + } + + const withEntries = Object.entries(config.withOptions) + if (withEntries.length > 0) { + fragments.push( + `WITH (${withEntries.map(([key, value]) => `${renderOptionName(key)} = ${typeof value === 'string' ? escapeString(value) : String(value)}`).join(', ')})` + ) + } + + return fragments.join(' ') +} + +function renderAddIndexAction(index: YdbIndex | YdbUniqueConstraint): string { + const rendered = + 'config' in index && 'unique' in index.config + ? renderIndexConfig(index.config) + : renderIndexConfig(uniqueConstraintToIndex(index as YdbUniqueConstraint)) + + if (rendered.includes(' UNIQUE ')) { + throw new Error( + 'YDB migrate() cannot add UNIQUE indexes to existing tables; create them inline in CREATE TABLE' + ) + } + + return `ADD INDEX ${rendered.replace(/^INDEX\s+/u, '')}` +} + +function uniqueConstraintToIndex(constraint: YdbUniqueConstraint): YdbIndexConfig { + return { + name: constraint.config.name, + table: constraint.config.table, + columns: constraint.config.columns, + unique: true, + locality: 'GLOBAL', + sync: 'SYNC', + indexType: undefined, + cover: [], + withOptions: {}, + } +} + +function renderColumnsList(columns: readonly (string | YdbColumn)[]): string { + return columns + .map((column) => escapeName(typeof column === 'string' ? column : column.name)) + .join(', ') +} + +function renderTopicConsumer(consumer: YdbTopicConsumer): string { + const settings = consumer.settings ? renderTableOptions(consumer.settings) : [] + return settings.length > 0 + ? `CONSUMER ${escapeName(consumer.name)} WITH (${settings.join(', ')})` + : `CONSUMER ${escapeName(consumer.name)}` +} + +function renderAlterTopicAction(action: YdbAlterTopicAction): string { + switch (action.kind) { + case 'add_consumer': + return `ADD ${renderTopicConsumer(action.consumer)}` + case 'drop_consumer': + return `DROP CONSUMER ${escapeName(action.name)}` + case 'alter_consumer_set': + return `ALTER CONSUMER ${escapeName(action.name)} SET (${requireNonEmptyOptions(action.settings, 'ALTER TOPIC ALTER CONSUMER SET').join(', ')})` + case 'set_options': + return `SET (${requireNonEmptyOptions(action.settings, 'ALTER TOPIC SET').join(', ')})` + } +} + +function addIntervalOption( + target: Record, + key: string, + value: string | undefined +): void { + if (value !== undefined) { + target[key] = { kind: 'raw', value: `Interval(${escapeString(value)})` } + } +} + +function normalizeAsyncReplicationOptions( + options: YdbAsyncReplicationOptions +): Record { + const rendered: Record = { + ...(options.options ?? {}), + } + + if (options.connectionString !== undefined) + rendered['CONNECTION_STRING'] = options.connectionString + if (options.caCert !== undefined) rendered['CA_CERT'] = options.caCert + if (options.tokenSecretName !== undefined) + rendered['TOKEN_SECRET_NAME'] = options.tokenSecretName + if (options.user !== undefined) rendered['USER'] = options.user + if (options.passwordSecretName !== undefined) + rendered['PASSWORD_SECRET_NAME'] = options.passwordSecretName + if (options.serviceAccountId !== undefined) + rendered['SERVICE_ACCOUNT_ID'] = options.serviceAccountId + if (options.initialTokenSecretName !== undefined) + rendered['INITIAL_TOKEN_SECRET_NAME'] = options.initialTokenSecretName + if (options.consistencyLevel !== undefined) + rendered['CONSISTENCY_LEVEL'] = options.consistencyLevel + addIntervalOption(rendered, 'COMMIT_INTERVAL', options.commitInterval) + + return rendered +} + +function normalizeAlterAsyncReplicationOptions( + options: YdbAlterAsyncReplicationOptions +): Record { + const rendered: Record = { + ...(options.options ?? {}), + } + + if (options.state !== undefined) rendered['STATE'] = options.state + if (options.failoverMode !== undefined) rendered['FAILOVER_MODE'] = options.failoverMode + + return rendered +} + +function normalizeTransferOptions( + options: YdbTransferOptions +): Record { + const rendered: Record = { + ...(options.options ?? {}), + } + + if (options.connectionString !== undefined) + rendered['CONNECTION_STRING'] = options.connectionString + if (options.tokenSecretName !== undefined) + rendered['TOKEN_SECRET_NAME'] = options.tokenSecretName + if (options.user !== undefined) rendered['USER'] = options.user + if (options.passwordSecretName !== undefined) + rendered['PASSWORD_SECRET_NAME'] = options.passwordSecretName + if (options.serviceAccountId !== undefined) + rendered['SERVICE_ACCOUNT_ID'] = options.serviceAccountId + if (options.initialTokenSecretName !== undefined) + rendered['INITIAL_TOKEN_SECRET_NAME'] = options.initialTokenSecretName + if (options.consumer !== undefined) rendered['CONSUMER'] = options.consumer + if (options.batchSizeBytes !== undefined) rendered['BATCH_SIZE_BYTES'] = options.batchSizeBytes + addIntervalOption(rendered, 'FLUSH_INTERVAL', options.flushInterval) + + return rendered +} + +function normalizeAlterTransferOptions( + options: YdbAlterTransferOptions +): Record { + const rendered: Record = { + ...(options.options ?? {}), + } + + if (options.state !== undefined) rendered['STATE'] = options.state + if (options.batchSizeBytes !== undefined) rendered['BATCH_SIZE_BYTES'] = options.batchSizeBytes + addIntervalOption(rendered, 'FLUSH_INTERVAL', options.flushInterval) + + return rendered +} + +function renderAdminOptions( + options: Readonly>, + context: string +): string { + const rendered = renderStatementOptions(options) + if (rendered.length === 0) { + throw new Error(`YDB migrate() ${context} requires at least one option`) + } + + return rendered.join(', ') +} + +function renderUserOptions(options: YdbUserOptions = {}): string { + const rendered: string[] = [] + if ('password' in options) { + rendered.push( + options.password === null + ? 'PASSWORD NULL' + : `PASSWORD ${escapeString(options.password ?? '')}` + ) + } + if (options.login !== undefined) { + rendered.push(options.login ? 'LOGIN' : 'NOLOGIN') + } + + return rendered.join(' ') +} + +function renderRoleList(values: readonly string[]): string { + return values.map((value) => escapeName(value)).join(', ') +} + +function renderAccessPermission(permission: YdbAccessPermission): string { + if (typeof permission !== 'string') { + if (permission.kind === 'all') { + return permission.privileges ? 'ALL PRIVILEGES' : 'ALL' + } + + return permission.value + } + + if (permission.includes('.')) { + return escapeString(permission) + } + + if (!/^[A-Za-z]+(?:\s+[A-Za-z]+)*$/u.test(permission)) { + throw new Error(`YDB migrate() invalid permission "${permission}"`) + } + + return permission.toUpperCase() +} + +function renderAccessPermissions(permissions: YdbAccessPermissions): string { + const list = Array.isArray(permissions) ? permissions : [permissions] + if (list.length === 0) { + throw new Error('YDB migrate() ACL statement requires permissions') + } + + return list.map((permission) => renderAccessPermission(permission)).join(', ') +} + +function renderShowCreateObjectType(type: YdbShowCreateObjectType): string { + return type.toUpperCase() +} + +function normalizeChangefeedOptions( + options: YdbChangefeedOptions +): Record { + const rendered: Record = { + ...(options.options ?? {}), + } + + if (options.mode !== undefined) rendered['MODE'] = options.mode + if (options.format !== undefined) rendered['FORMAT'] = options.format + if (options.virtualTimestamps !== undefined) + rendered['VIRTUAL_TIMESTAMPS'] = options.virtualTimestamps + if (options.barriersInterval !== undefined) + rendered['BARRIERS_INTERVAL'] = { + kind: 'raw', + value: `Interval(${escapeString(options.barriersInterval)})`, + } + if (options.retentionPeriod !== undefined) + rendered['RETENTION_PERIOD'] = { + kind: 'raw', + value: `Interval(${escapeString(options.retentionPeriod)})`, + } + if (options.topicAutoPartitioning !== undefined) + rendered['TOPIC_AUTO_PARTITIONING'] = options.topicAutoPartitioning + if (options.topicMinActivePartitions !== undefined) + rendered['TOPIC_MIN_ACTIVE_PARTITIONS'] = options.topicMinActivePartitions + if (options.initialScan !== undefined) rendered['INITIAL_SCAN'] = options.initialScan + + return rendered +} + +function renderChangefeedOptions(options: YdbChangefeedOptions): string { + const renderedOptions = Object.entries(normalizeChangefeedOptions(options)).map( + ([key, value]) => { + if (isRawTableOptionValue(value)) { + return `${renderOptionName(key)} = ${value.value}` + } + + if (typeof value === 'string') { + return `${renderOptionName(key)} = ${escapeString(value)}` + } + + return `${renderOptionName(key)} = ${renderTableOptionValue(value)}` + } + ) + + if (renderedOptions.length === 0) { + throw new Error('YDB migrate() CHANGEFEED WITH requires at least one option') + } + + return renderedOptions.join(', ') +} + +function renderAlterTableAction(action: YdbAlterTableAction): string[] { + switch (action.kind) { + case 'add_column': + return [`ADD COLUMN ${renderColumnDefinition(action.column)}`] + case 'drop_column': + return [`DROP COLUMN ${escapeName(action.name)}`] + case 'add_index': + return [renderAddIndexAction(action.index)] + case 'drop_index': + return [`DROP INDEX ${escapeName(action.name)}`] + case 'set_table_options': + return [`SET (${requireNonEmptyOptions(action.options, 'ALTER TABLE SET').join(', ')})`] + case 'reset_table_options': + return [`RESET (${renderOptionNames(action.names)})`] + case 'add_column_family': + return [`ADD ${renderColumnFamilyDefinition(action.family)}`] + case 'alter_column_family': + return renderColumnFamilyAlterActions(action.name, action.options) + case 'set_column_family': { + const columnName = + typeof action.column === 'string' ? action.column : action.column.name + return [ + `ALTER COLUMN ${escapeName(columnName)} SET FAMILY ${escapeName(action.familyName)}`, + ] + } + case 'rename_table': + return [`RENAME TO ${escapeName(action.to)}`] + case 'add_changefeed': + return [ + `ADD CHANGEFEED ${escapeName(action.name)} WITH (${renderChangefeedOptions(action.options)})`, + ] + case 'drop_changefeed': + return [`DROP CHANGEFEED ${escapeName(action.name)}`] + } +} + +type DefinedOptions> = { + [K in keyof T]?: Exclude +} + +function definedOptions>(options: T): DefinedOptions { + const result: Partial> = {} + + for (const [key, value] of Object.entries(options) as Array<[keyof T, T[keyof T]]>) { + if (value !== undefined) { + result[key] = value + } + } + + return result as DefinedOptions +} + +export function buildMigrationTableBootstrapSql(config: YdbMigrationTableConfig = {}): string { + const migrationTableName = getMigrationTableName(config) + + return [ + `CREATE TABLE IF NOT EXISTS ${escapeName(migrationTableName)} (`, + ` ${escapeName('hash')} Utf8 NOT NULL,`, + ` ${escapeName('created_at')} Int64 NOT NULL,`, + ` ${escapeName('name')} Utf8 NOT NULL,`, + ` ${escapeName('status')} Utf8,`, + ` ${escapeName('started_at')} Int64,`, + ` ${escapeName('finished_at')} Int64,`, + ` ${escapeName('error')} Utf8,`, + ` ${escapeName('owner_id')} Utf8,`, + ` ${escapeName('statements_total')} Uint32,`, + ` ${escapeName('statements_applied')} Uint32,`, + ` PRIMARY KEY (${escapeName('hash')})`, + `)`, + ].join('\n') +} + +export function buildMigrationHistoryMetadataProbeSql( + config: YdbMigrationTableConfig = {} +): string { + const migrationTableName = getMigrationTableName(config) + return `SELECT ${escapeName('status')} FROM ${escapeName(migrationTableName)} LIMIT 1` +} + +export function buildMigrationHistoryMetadataColumnSql( + config: YdbMigrationTableConfig = {} +): string[] { + const migrationTableName = escapeName(getMigrationTableName(config)) + return [ + `ALTER TABLE ${migrationTableName} ADD COLUMN ${escapeName('status')} Utf8`, + `ALTER TABLE ${migrationTableName} ADD COLUMN ${escapeName('started_at')} Int64`, + `ALTER TABLE ${migrationTableName} ADD COLUMN ${escapeName('finished_at')} Int64`, + `ALTER TABLE ${migrationTableName} ADD COLUMN ${escapeName('error')} Utf8`, + `ALTER TABLE ${migrationTableName} ADD COLUMN ${escapeName('owner_id')} Utf8`, + `ALTER TABLE ${migrationTableName} ADD COLUMN ${escapeName('statements_total')} Uint32`, + `ALTER TABLE ${migrationTableName} ADD COLUMN ${escapeName('statements_applied')} Uint32`, + ] +} + +export function buildMigrationLockTableBootstrapSql(config: YdbMigrationTableConfig = {}): string { + const lockTableName = getMigrationLockTableName(config) + + return [ + `CREATE TABLE IF NOT EXISTS ${escapeName(lockTableName)} (`, + ` ${escapeName('lock_key')} Utf8 NOT NULL,`, + ` ${escapeName('owner_id')} Utf8 NOT NULL,`, + ` ${escapeName('acquired_at')} Int64 NOT NULL,`, + ` ${escapeName('heartbeat_at')} Int64 NOT NULL,`, + ` ${escapeName('expires_at')} Int64 NOT NULL,`, + ` PRIMARY KEY (${escapeName('lock_key')})`, + `)`, + ].join('\n') +} + +export function buildCreateTableSql( + table: YdbTableWithColumns, + options: { + ifNotExists?: boolean + temporary?: boolean | 'temp' | 'temporary' + } = {} +): string { + const { + columns, + indexes, + primaryKeys, + uniqueConstraints, + tableOptions, + partitioning, + ttls, + columnFamilies, + } = getTableConfig(table) + + const primaryKeyColumns = getPrimaryKeyColumns(columns, primaryKeys) + if (primaryKeyColumns.length === 0) { + throw new Error( + `YDB migrate() CREATE TABLE requires a primary key for "${getTableName(table)}"` + ) + } + + const familyByColumnName = getColumnFamilyByColumnName(columnFamilies) + const definitions = [ + ...columns.map((column) => + renderColumnDefinition(column, familyByColumnName.get(column.name)) + ), + ...indexes.map((index) => renderIndexConfig(index.config)), + ...uniqueConstraints.map((constraint) => + renderIndexConfig(uniqueConstraintToIndex(constraint)) + ), + ...columnFamilies.map((family) => renderColumnFamilyDefinition(family.config)), + `PRIMARY KEY (${primaryKeyColumns.map((column) => escapeName(column.name)).join(', ')})`, + ] + const partitioningSql = renderPartitioning(partitioning) + const withOptions = collectWithOptions(tableOptions, ttls) + + const temporarySql = + options.temporary === true + ? 'TEMPORARY ' + : options.temporary + ? `${options.temporary.toUpperCase()} ` + : '' + const parts = [ + `CREATE ${temporarySql}TABLE ${options.ifNotExists ? 'IF NOT EXISTS ' : ''}${escapeName(getTableName(table))} (`, + definitions.map((definition) => ` ${definition}`).join(',\n'), + `)`, + ] + + if (partitioningSql) { + parts.push(partitioningSql) + } + + if (withOptions.length > 0) { + parts.push('WITH (', withOptions.map((option) => ` ${option}`).join(',\n'), ')') + } + + return parts.join('\n') +} + +export function buildDropTableSql( + table: string | YdbTable, + options: { ifExists?: boolean } = {} +): string { + return `DROP TABLE ${options.ifExists ? 'IF EXISTS ' : ''}${escapeName(getObjectName(table))}` +} + +export function buildAnalyzeSql( + table: string | YdbTable, + columns?: readonly (string | YdbColumn)[] +): string { + const columnSql = columns && columns.length > 0 ? ` (${renderColumnsList(columns)})` : '' + return `ANALYZE ${escapeName(getObjectName(table))}${columnSql}` +} + +export function buildCreateViewSql( + name: string, + query: string, + options: YdbCreateViewOptions = {} +): string { + const viewOptions: Record = { + security_invoker: options.securityInvoker ?? true, + ...(options.options ?? {}), + } + const renderedOptions = renderTableOptions(viewOptions) + const ifNotExists = options.ifNotExists ? 'IF NOT EXISTS ' : '' + return `CREATE VIEW ${ifNotExists}${escapeName(name)} WITH (${renderedOptions.join(', ')}) AS ${query}` +} + +export function buildDropViewSql(name: string, options: { ifExists?: boolean } = {}): string { + return `DROP VIEW ${options.ifExists ? 'IF EXISTS ' : ''}${escapeName(name)}` +} + +export function buildCreateTopicSql(name: string, options: YdbCreateTopicOptions = {}): string { + const consumers = (options.consumers ?? []).map((consumer) => renderTopicConsumer(consumer)) + const settings = renderTableOptions(options.settings ?? {}) + const consumersSql = consumers.length > 0 ? ` (\n ${consumers.join(',\n ')}\n)` : '' + const settingsSql = settings.length > 0 ? ` WITH (\n ${settings.join(',\n ')}\n)` : '' + return `CREATE TOPIC ${escapeName(name)}${consumersSql}${settingsSql}` +} + +export function buildAlterTopicSql( + name: string, + actions: [YdbAlterTopicAction, ...YdbAlterTopicAction[]] +): string { + return `ALTER TOPIC ${escapeName(name)} ${actions.map((action) => renderAlterTopicAction(action)).join(', ')}` +} + +export function buildDropTopicSql(name: string): string { + return `DROP TOPIC ${escapeName(name)}` +} + +export function buildCreateAsyncReplicationSql( + name: string, + targets: [YdbAsyncReplicationTarget, ...YdbAsyncReplicationTarget[]], + options: YdbAsyncReplicationOptions +): string { + const targetSql = targets + .map((target) => `${escapeName(target.remote)} AS ${escapeName(target.local)}`) + .join(', ') + const optionSql = renderAdminOptions( + normalizeAsyncReplicationOptions(options), + 'CREATE ASYNC REPLICATION WITH' + ) + + return `CREATE ASYNC REPLICATION ${escapeName(name)} FOR ${targetSql} WITH (${optionSql})` +} + +export function buildAlterAsyncReplicationSql( + name: string, + options: YdbAlterAsyncReplicationOptions +): string { + const optionSql = renderAdminOptions( + normalizeAlterAsyncReplicationOptions(options), + 'ALTER ASYNC REPLICATION SET' + ) + return `ALTER ASYNC REPLICATION ${escapeName(name)} SET (${optionSql})` +} + +export function buildDropAsyncReplicationSql( + name: string, + options: { cascade?: boolean } = {} +): string { + return `DROP ASYNC REPLICATION ${escapeName(name)}${options.cascade ? ' CASCADE' : ''}` +} + +export function buildCreateTransferSql( + name: string, + from: string, + to: string, + using: string, + options: YdbTransferOptions = {} +): string { + const renderedOptions = renderStatementOptions(normalizeTransferOptions(options)) + const withSql = renderedOptions.length > 0 ? ` WITH (${renderedOptions.join(', ')})` : '' + return `CREATE TRANSFER ${escapeName(name)} FROM ${escapeName(from)} TO ${escapeName(to)} USING ${using}${withSql}` +} + +export function buildAlterTransferSql( + name: string, + config: { using?: string; options?: YdbAlterTransferOptions } +): string { + if (config.using && config.options) { + throw new Error( + 'YDB migrate() ALTER TRANSFER supports either SET USING or SET options, not both' + ) + } + + if (config.using) { + return `ALTER TRANSFER ${escapeName(name)} SET USING ${config.using}` + } + + if (!config.options) { + throw new Error('YDB migrate() ALTER TRANSFER requires using or options') + } + + const optionSql = renderAdminOptions( + normalizeAlterTransferOptions(config.options), + 'ALTER TRANSFER SET' + ) + return `ALTER TRANSFER ${escapeName(name)} SET (${optionSql})` +} + +export function buildDropTransferSql(name: string): string { + return `DROP TRANSFER ${escapeName(name)}` +} + +export function buildCreateSecretSql(name: string, value: string): string { + return `CREATE OBJECT ${escapeName(name)} (TYPE SECRET) WITH value=${escapeDoubleQuoted(value)}` +} + +export function buildCreateUserSql(name: string, options: YdbUserOptions = {}): string { + const optionSql = renderUserOptions(options) + return `CREATE USER ${escapeName(name)}${optionSql ? ` ${optionSql}` : ''}` +} + +export function buildAlterUserSql(name: string, options: YdbUserOptions): string { + const optionSql = renderUserOptions(options) + if (!optionSql) { + throw new Error('YDB migrate() ALTER USER requires at least one option') + } + + return `ALTER USER ${escapeName(name)}${options.withKeyword ? ' WITH' : ''} ${optionSql}` +} + +export function buildDropUserSql( + names: [string, ...string[]], + options: { ifExists?: boolean } = {} +): string { + return `DROP USER ${options.ifExists ? 'IF EXISTS ' : ''}${renderRoleList(names)}` +} + +export function buildCreateGroupSql( + name: string, + options: { users?: readonly string[] } = {} +): string { + const usersSql = + options.users && options.users.length > 0 + ? ` WITH USER ${renderRoleList(options.users)}` + : '' + return `CREATE GROUP ${escapeName(name)}${usersSql}` +} + +export function buildAlterGroupSql( + name: string, + action: 'add_user' | 'drop_user', + users: [string, ...string[]] +): string { + return `ALTER GROUP ${escapeName(name)} ${action === 'add_user' ? 'ADD' : 'DROP'} USER ${renderRoleList(users)}` +} + +export function buildDropGroupSql( + names: [string, ...string[]], + options: { ifExists?: boolean } = {} +): string { + return `DROP GROUP ${options.ifExists ? 'IF EXISTS ' : ''}${renderRoleList(names)}` +} + +export function buildGrantSql(config: { + permissions: YdbAccessPermissions + on: [string, ...string[]] + to: [string, ...string[]] + withGrantOption?: boolean +}): string { + return `GRANT ${renderAccessPermissions(config.permissions)} ON ${renderRoleList(config.on)} TO ${renderRoleList(config.to)}${config.withGrantOption ? ' WITH GRANT OPTION' : ''}` +} + +export function buildRevokeSql(config: { + permissions: YdbAccessPermissions + on: [string, ...string[]] + from: [string, ...string[]] + grantOptionFor?: boolean +}): string { + return `REVOKE ${config.grantOptionFor ? 'GRANT OPTION FOR ' : ''}${renderAccessPermissions(config.permissions)} ON ${renderRoleList(config.on)} FROM ${renderRoleList(config.from)}` +} + +export function buildShowCreateSql(objectType: YdbShowCreateObjectType, name: string): string { + return `SHOW CREATE ${renderShowCreateObjectType(objectType)} ${escapeName(name)}` +} + +export function buildAddColumnsSql( + table: string | YdbTable, + columns: [YdbColumn, ...YdbColumn[]] +): string[] { + return columns.map( + (column) => + `ALTER TABLE ${escapeName(getObjectName(table))} ADD COLUMN ${renderColumnDefinition(column)}` + ) +} + +export function buildDropColumnsSql( + table: string | YdbTable, + columns: [string, ...string[]] +): string[] { + return columns.map( + (column) => + `ALTER TABLE ${escapeName(getObjectName(table))} DROP COLUMN ${escapeName(column)}` + ) +} + +export function buildAddIndexSql( + table: string | YdbTable, + index: YdbIndex | YdbUniqueConstraint +): string { + return `ALTER TABLE ${escapeName(getObjectName(table))} ${renderAddIndexAction(index)}` +} + +export function buildDropIndexSql(table: string | YdbTable, name: string): string { + return `ALTER TABLE ${escapeName(getObjectName(table))} DROP INDEX ${escapeName(name)}` +} + +export function buildAlterTableSetOptionsSql( + table: string | YdbTable, + options: Readonly> +): string { + const renderedOptions = requireNonEmptyOptions(options, 'ALTER TABLE SET') + return `ALTER TABLE ${escapeName(getObjectName(table))} SET (${renderedOptions.join(', ')})` +} + +export function buildAlterTableResetOptionsSql( + table: string | YdbTable, + names: [string, ...string[]] +): string { + return `ALTER TABLE ${escapeName(getObjectName(table))} RESET (${renderOptionNames(names)})` +} + +export function buildAddColumnFamilySql( + table: string | YdbTable, + family: Pick +): string { + return `ALTER TABLE ${escapeName(getObjectName(table))} ADD ${renderColumnFamilyDefinition(family)}` +} + +export function buildAlterColumnFamilySql( + table: string | YdbTable, + name: string, + options: YdbColumnFamilyOptions +): string { + const actions = renderColumnFamilyAlterActions(name, options) + if (actions.length === 0) { + throw new Error('YDB migrate() ALTER FAMILY requires at least one option') + } + + return `ALTER TABLE ${escapeName(getObjectName(table))} ${actions.join(', ')}` +} + +export function buildAlterColumnSetFamilySql( + table: string | YdbTable, + columns: [YdbColumn, ...YdbColumn[]] | [string, ...string[]], + familyName: string +): string[] { + return columns.map((column) => { + const columnName = typeof column === 'string' ? column : column.name + return `ALTER TABLE ${escapeName(getObjectName(table))} ALTER COLUMN ${escapeName(columnName)} SET FAMILY ${escapeName(familyName)}` + }) +} + +export function buildRenameTableSql(table: string | YdbTable, to: string): string { + return `ALTER TABLE ${escapeName(getObjectName(table))} RENAME TO ${escapeName(to)}` +} + +export function buildAddChangefeedSql( + table: string | YdbTable, + name: string, + options: YdbChangefeedOptions +): string { + return `ALTER TABLE ${escapeName(getObjectName(table))} ADD CHANGEFEED ${escapeName(name)} WITH (${renderChangefeedOptions(options)})` +} + +export function buildDropChangefeedSql(table: string | YdbTable, name: string): string { + return `ALTER TABLE ${escapeName(getObjectName(table))} DROP CHANGEFEED ${escapeName(name)}` +} + +export function buildAlterTableSql( + table: string | YdbTable, + actions: [YdbAlterTableAction, ...YdbAlterTableAction[]] +): string { + const rendered = actions.flatMap((action) => renderAlterTableAction(action)) + if (rendered.length === 0) { + throw new Error('YDB migrate() ALTER TABLE requires at least one action') + } + + return `ALTER TABLE ${escapeName(getObjectName(table))} ${rendered.join(', ')}` +} + +export function buildMigrationSql(operations: readonly YdbMigrationOperation[]): string[] { + const statements: string[] = [] + + for (const operation of operations) { + switch (operation.kind) { + case 'create_table': + statements.push( + buildCreateTableSql( + operation.table, + definedOptions({ + ifNotExists: operation.ifNotExists, + temporary: operation.temporary, + }) + ) + ) + break + case 'drop_table': + statements.push( + buildDropTableSql( + operation.table, + definedOptions({ ifExists: operation.ifExists }) + ) + ) + break + case 'analyze': + statements.push(buildAnalyzeSql(operation.table, operation.columns)) + break + case 'create_view': + const createViewOptions: YdbCreateViewOptions = { ...operation.options } + const createViewIfNotExists = + operation.ifNotExists ?? operation.options?.ifNotExists + if (createViewIfNotExists !== undefined) { + createViewOptions.ifNotExists = createViewIfNotExists + } + statements.push( + buildCreateViewSql(operation.name, operation.query, createViewOptions) + ) + break + case 'drop_view': + statements.push( + buildDropViewSql( + operation.name, + definedOptions({ ifExists: operation.ifExists }) + ) + ) + break + case 'create_topic': + statements.push(buildCreateTopicSql(operation.name, operation.options)) + break + case 'alter_topic': + statements.push(buildAlterTopicSql(operation.name, operation.actions)) + break + case 'drop_topic': + statements.push(buildDropTopicSql(operation.name)) + break + case 'create_async_replication': + statements.push( + buildCreateAsyncReplicationSql( + operation.name, + operation.targets, + operation.options + ) + ) + break + case 'alter_async_replication': + statements.push(buildAlterAsyncReplicationSql(operation.name, operation.options)) + break + case 'drop_async_replication': + statements.push( + buildDropAsyncReplicationSql( + operation.name, + definedOptions({ + cascade: operation.cascade, + }) + ) + ) + break + case 'create_transfer': + statements.push( + buildCreateTransferSql( + operation.name, + operation.from, + operation.to, + operation.using, + operation.options + ) + ) + break + case 'alter_transfer': + statements.push( + buildAlterTransferSql( + operation.name, + definedOptions({ + using: operation.using, + options: operation.options, + }) + ) + ) + break + case 'drop_transfer': + statements.push(buildDropTransferSql(operation.name)) + break + case 'create_secret': + statements.push(buildCreateSecretSql(operation.name, operation.value)) + break + case 'create_user': + statements.push(buildCreateUserSql(operation.name, operation.options)) + break + case 'alter_user': + statements.push(buildAlterUserSql(operation.name, operation.options)) + break + case 'drop_user': + statements.push( + buildDropUserSql( + operation.names, + definedOptions({ ifExists: operation.ifExists }) + ) + ) + break + case 'create_group': + statements.push( + buildCreateGroupSql(operation.name, definedOptions({ users: operation.users })) + ) + break + case 'alter_group': + statements.push( + buildAlterGroupSql(operation.name, operation.action, operation.users) + ) + break + case 'drop_group': + statements.push( + buildDropGroupSql( + operation.names, + definedOptions({ ifExists: operation.ifExists }) + ) + ) + break + case 'grant': + statements.push( + buildGrantSql({ + permissions: operation.permissions, + on: operation.on, + to: operation.to, + ...definedOptions({ withGrantOption: operation.withGrantOption }), + }) + ) + break + case 'revoke': + statements.push( + buildRevokeSql({ + permissions: operation.permissions, + on: operation.on, + from: operation.from, + ...definedOptions({ grantOptionFor: operation.grantOptionFor }), + }) + ) + break + case 'show_create': + statements.push(buildShowCreateSql(operation.objectType, operation.name)) + break + case 'add_columns': + statements.push(...buildAddColumnsSql(operation.table, operation.columns)) + break + case 'drop_columns': + statements.push(...buildDropColumnsSql(operation.table, operation.columns)) + break + case 'add_index': + statements.push(buildAddIndexSql(operation.table, operation.index)) + break + case 'drop_index': + statements.push(buildDropIndexSql(operation.table, operation.name)) + break + case 'set_table_options': + statements.push(buildAlterTableSetOptionsSql(operation.table, operation.options)) + break + case 'reset_table_options': + statements.push(buildAlterTableResetOptionsSql(operation.table, operation.names)) + break + case 'add_column_family': + statements.push(buildAddColumnFamilySql(operation.table, operation.family)) + break + case 'alter_column_family': + statements.push( + buildAlterColumnFamilySql(operation.table, operation.name, operation.options) + ) + break + case 'set_column_family': + statements.push( + ...buildAlterColumnSetFamilySql( + operation.table, + operation.columns, + operation.familyName + ) + ) + break + case 'rename_table': + statements.push(buildRenameTableSql(operation.table, operation.to)) + break + case 'add_changefeed': + statements.push( + buildAddChangefeedSql(operation.table, operation.name, operation.options) + ) + break + case 'drop_changefeed': + statements.push(buildDropChangefeedSql(operation.table, operation.name)) + break + case 'alter_table': + statements.push(buildAlterTableSql(operation.table, operation.actions)) + break + } + } + + return statements +} + +export function normalizeInlineMigration( + migration: YdbInlineMigration, + index: number +): YdbNormalizedMigration { + const sqlStatements = migration.sql + ? [...migration.sql] + : migration.operations + ? buildMigrationSql(migration.operations) + : [] + + if (sqlStatements.length === 0) { + throw new Error(`YDB migrate() received migration #${index + 1} without sql or operations`) + } + + const text = sqlStatements.join('\n--> statement-breakpoint\n') + const hash = migration.hash ?? crypto.createHash('sha256').update(text).digest('hex') + + return { + name: buildStableMigrationName( + { name: migration.name, folderMillis: migration.folderMillis, hash }, + 'inline' + ), + folderMillis: migration.folderMillis ?? index + 1, + hash, + bps: migration.breakpoints ?? false, + sql: sqlStatements, + } +} + +export function buildMigrationHistorySelectSql(config: YdbMigrationTableConfig = {}): string { + const migrationTableName = getMigrationTableName(config) + + return [ + `SELECT ${escapeName('hash')}, ${escapeName('created_at')}, ${escapeName('name')}, ${escapeName('status')},`, + `${escapeName('started_at')}, ${escapeName('finished_at')}, ${escapeName('error')}, ${escapeName('owner_id')},`, + `${escapeName('statements_total')}, ${escapeName('statements_applied')}`, + `FROM ${escapeName(migrationTableName)}`, + `ORDER BY ${escapeName('created_at')} DESC`, + ].join(' ') +} + +function renderNullableString(value: string | undefined): string { + return value === undefined ? 'NULL' : escapeString(value) +} + +function renderNullableNumber(value: number | undefined): string { + return value === undefined ? 'NULL' : String(value) +} + +export function buildMigrationHistoryInsertSql( + migration: + | Pick + | YdbMigrationHistoryRecord, + config: YdbMigrationTableConfig = {} +): string { + const migrationTableName = getMigrationTableName(config) + const record: YdbMigrationHistoryRecord = + 'status' in migration + ? migration + : { + hash: migration.hash, + folderMillis: migration.folderMillis, + name: migration.name, + status: 'applied', + } + + return [ + `UPSERT INTO ${escapeName(migrationTableName)} (`, + [ + escapeName('hash'), + escapeName('created_at'), + escapeName('name'), + escapeName('status'), + escapeName('started_at'), + escapeName('finished_at'), + escapeName('error'), + escapeName('owner_id'), + escapeName('statements_total'), + escapeName('statements_applied'), + ].join(', '), + `) VALUES (`, + [ + escapeString(record.hash), + String(record.folderMillis), + escapeString(record.name), + escapeString(record.status), + renderNullableNumber(record.startedAt), + renderNullableNumber(record.finishedAt), + renderNullableString(record.error), + renderNullableString(record.ownerId), + renderNullableNumber(record.statementsTotal), + renderNullableNumber(record.statementsApplied), + ].join(', '), + `)`, + ].join(' ') +} + +export function buildMigrationLockSelectSql( + config: YdbMigrationTableConfig = {}, + key = 'migrate' +): string { + const lockTableName = getMigrationLockTableName(config) + + return [ + `SELECT ${escapeName('owner_id')}, ${escapeName('expires_at')}`, + `FROM ${escapeName(lockTableName)}`, + `WHERE ${escapeName('lock_key')} = ${escapeString(key)}`, + ].join(' ') +} + +export function buildMigrationLockUpsertSql( + config: YdbMigrationTableConfig = {}, + lock: { + key: string + ownerId: string + acquiredAt: number + heartbeatAt: number + expiresAt: number + } +): string { + const lockTableName = getMigrationLockTableName(config) + + return [ + `UPSERT INTO ${escapeName(lockTableName)} (`, + [ + escapeName('lock_key'), + escapeName('owner_id'), + escapeName('acquired_at'), + escapeName('heartbeat_at'), + escapeName('expires_at'), + ].join(', '), + `) VALUES (`, + [ + escapeString(lock.key), + escapeString(lock.ownerId), + String(lock.acquiredAt), + String(lock.heartbeatAt), + String(lock.expiresAt), + ].join(', '), + `)`, + ].join(' ') +} + +export function buildMigrationLockRefreshSql( + config: YdbMigrationTableConfig = {}, + lock: { + key: string + ownerId: string + heartbeatAt: number + expiresAt: number + } +): string { + const lockTableName = getMigrationLockTableName(config) + + return [ + `UPDATE ${escapeName(lockTableName)}`, + `SET ${escapeName('heartbeat_at')} = ${String(lock.heartbeatAt)}, ${escapeName('expires_at')} = ${String(lock.expiresAt)}`, + `WHERE ${escapeName('lock_key')} = ${escapeString(lock.key)} AND ${escapeName('owner_id')} = ${escapeString(lock.ownerId)}`, + ].join(' ') +} + +export function buildMigrationLockReleaseSql( + config: YdbMigrationTableConfig = {}, + lock: { key: string; ownerId: string } +): string { + const lockTableName = getMigrationLockTableName(config) + + return [ + `DELETE FROM ${escapeName(lockTableName)}`, + `WHERE ${escapeName('lock_key')} = ${escapeString(lock.key)} AND ${escapeName('owner_id')} = ${escapeString(lock.ownerId)}`, + ].join(' ') +} diff --git a/third-parties/drizzle-adapter/src/ydb/migrator.ts b/third-parties/drizzle-adapter/src/ydb/migrator.ts new file mode 100644 index 00000000..53f5e220 --- /dev/null +++ b/third-parties/drizzle-adapter/src/ydb/migrator.ts @@ -0,0 +1,77 @@ +import { + type MigrationConfig as DrizzleMigrationConfig, + type MigrationMeta, + readMigrationFiles, +} from 'drizzle-orm/migrator' +import type { YdbDatabase } from '../ydb-core/db.js' +import { YdbDialect } from './dialect.js' +import { + type YdbInlineMigration, + type YdbMigrationTableConfig, + type YdbNormalizedMigration, + buildStableMigrationName, + normalizeInlineMigration, +} from './migration-ddl.js' + +export interface YdbMigratorConfig extends YdbMigrationTableConfig { + migrations: readonly YdbInlineMigration[] +} + +export type YdbMigrateConfig = + | (DrizzleMigrationConfig & YdbMigrationTableConfig) + | YdbMigratorConfig + +function isDrizzleMigrationConfig(config: YdbMigrateConfig): config is DrizzleMigrationConfig { + return 'migrationsFolder' in config +} + +function normalizeFolderMigrations(config: DrizzleMigrationConfig): YdbNormalizedMigration[] { + return readMigrationFiles(config).map((migration: MigrationMeta) => ({ + name: buildStableMigrationName(migration, 'folder'), + folderMillis: migration.folderMillis, + hash: migration.hash, + bps: migration.bps, + sql: migration.sql.filter((statement) => statement.trim() !== ''), + })) +} + +function normalizeMigrations(config: YdbMigrateConfig): YdbNormalizedMigration[] { + if (isDrizzleMigrationConfig(config)) { + return normalizeFolderMigrations(config) + } + + return config.migrations.map((migration, index) => normalizeInlineMigration(migration, index)) +} + +function getMigrationTableConfig(config: YdbMigrateConfig): YdbMigrationTableConfig { + const migrationConfig: YdbMigrationTableConfig = {} + + if (config.migrationsTable !== undefined) { + migrationConfig.migrationsTable = config.migrationsTable + } + if (config.migrationsSchema !== undefined) { + migrationConfig.migrationsSchema = config.migrationsSchema + } + if (config.migrationsLockTable !== undefined) { + migrationConfig.migrationsLockTable = config.migrationsLockTable + } + if (config.migrationLock !== undefined) { + migrationConfig.migrationLock = config.migrationLock + } + if (config.migrationRecovery !== undefined) { + migrationConfig.migrationRecovery = config.migrationRecovery + } + + return migrationConfig +} + +export async function migrate>( + db: YdbDatabase, + config: YdbMigrateConfig +): Promise { + const session = db._.session + const migrationConfig = getMigrationTableConfig(config) + + const dialect = new YdbDialect() + await dialect.migrate(normalizeMigrations(config), session, migrationConfig) +} diff --git a/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/0000_create_folder_users.sql b/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/0000_create_folder_users.sql new file mode 100644 index 00000000..9e177db8 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/0000_create_folder_users.sql @@ -0,0 +1,5 @@ +CREATE TABLE `folder_users` ( + `id` Int32 NOT NULL, + `name` Utf8, + PRIMARY KEY (`id`) +) diff --git a/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/0001_add_age_to_folder_users.sql b/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/0001_add_age_to_folder_users.sql new file mode 100644 index 00000000..22f14814 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/0001_add_age_to_folder_users.sql @@ -0,0 +1 @@ +ALTER TABLE `folder_users` ADD COLUMN `age` Int32 diff --git a/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/meta/_journal.json b/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/meta/_journal.json new file mode 100644 index 00000000..55c2aea4 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/fixtures/migrations/basic/meta/_journal.json @@ -0,0 +1,16 @@ +{ + "entries": [ + { + "idx": 0, + "when": 1, + "tag": "0000_create_folder_users", + "breakpoints": true + }, + { + "idx": 1, + "when": 2, + "tag": "0001_add_age_to_folder_users", + "breakpoints": true + } + ] +} diff --git a/third-parties/drizzle-adapter/tests/helpers/load-env.ts b/third-parties/drizzle-adapter/tests/helpers/load-env.ts new file mode 100644 index 00000000..74ed61fb --- /dev/null +++ b/third-parties/drizzle-adapter/tests/helpers/load-env.ts @@ -0,0 +1,58 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +let envLoaded = false + +function parseEnvLine(line: string): [string, string] | undefined { + let trimmed = line.trim() + + if (trimmed === '' || trimmed.startsWith('#')) { + return undefined + } + + let equalsIndex = trimmed.indexOf('=') + if (equalsIndex <= 0) { + return undefined + } + + let key = trimmed.slice(0, equalsIndex).trim() + let value = trimmed.slice(equalsIndex + 1).trim() + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + return [key, value] +} + +export function loadTestEnv(): void { + if (envLoaded) { + return + } + + let root = resolve(process.cwd()) + for (let fileName of ['.env.test', '.env']) { + let filePath = resolve(root, fileName) + if (!existsSync(filePath)) { + continue + } + + let lines = readFileSync(filePath, 'utf8').split(/\r?\n/u) + for (let line of lines) { + let parsed = parseEnvLine(line) + if (!parsed) { + continue + } + + let [key, value] = parsed + if (!(key in process.env)) { + process.env[key] = value + } + } + } + + envLoaded = true +} diff --git a/third-parties/drizzle-adapter/tests/helpers/mock-driver.ts b/third-parties/drizzle-adapter/tests/helpers/mock-driver.ts new file mode 100644 index 00000000..65b00821 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/helpers/mock-driver.ts @@ -0,0 +1,47 @@ +export type MockQueryCall = { + text: string + params: Array<{ name: string; value: unknown }> + valuesRows: unknown[] + executeRows: unknown[] +} + +export function createMockQueryFunction(executeRows: unknown[], valuesRows = executeRows) { + let calls: MockQueryCall[] = [] + + let ql = ((text: string) => { + let call: MockQueryCall = { + text, + params: [], + executeRows, + valuesRows, + } + calls.push(call) + + let queryObject: { + parameter(name: string, value: unknown): typeof queryObject + values(): Promise + then( + onfulfilled?: ((value: unknown[][]) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise + } = { + parameter(name: string, value: unknown) { + call.params.push({ name, value }) + return queryObject + }, + async values() { + return [call.valuesRows as unknown[]] + }, + then(onfulfilled, onrejected) { + return Promise.resolve([call.executeRows as unknown[]]).then( + onfulfilled, + onrejected + ) + }, + } + + return queryObject + }) as any + + return { ql, calls } +} diff --git a/third-parties/drizzle-adapter/tests/helpers/unit-basic.ts b/third-parties/drizzle-adapter/tests/helpers/unit-basic.ts new file mode 100644 index 00000000..a1a259a9 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/helpers/unit-basic.ts @@ -0,0 +1,54 @@ +import { integer, text, ydbTable } from '../../src/index.ts' +import { YdbDialect } from '../../src/ydb/dialect.ts' + +export let dialect = new YdbDialect() +export let session = {} as any + +export let users = ydbTable('users', { + id: integer('id').notNull(), + name: text('name').notNull(), + createdAt: integer('created_at').$defaultFn(() => 100), + updatedAt: integer('updated_at').$onUpdateFn(() => 200), +}) + +export let posts = ydbTable('posts', { + id: integer('id').notNull(), + userId: integer('user_id').notNull(), + title: text('title').notNull(), +}) + +export function createMockSession() { + let prepareCalls: Array<{ + name?: string + isResponseInArrayMode: boolean + query: { sql: string; params: unknown[]; typings?: unknown[] } + fields: unknown + }> = [] + + return { + prepareCalls, + session: { + prepareQuery( + query: any, + fields: unknown, + name?: string, + isResponseInArrayMode = false + ) { + let built = + 'sql' in query && Array.isArray(query.params) + ? query + : dialect.sqlToQuery(query) + prepareCalls.push({ name, isResponseInArrayMode, query: built, fields }) + + return { + getQuery() { + return built + }, + async execute() { + return { prepared: built.sql, params: built.params, name, fields } + }, + } + }, + } as any, + } +} diff --git a/third-parties/drizzle-adapter/tests/live/adapter-methods.test.ts b/third-parties/drizzle-adapter/tests/live/adapter-methods.test.ts new file mode 100644 index 00000000..5cb98ebd --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/adapter-methods.test.ts @@ -0,0 +1,362 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { and, eq, sql as yql } from 'drizzle-orm' +import { + buildCreateTableSql, + columnFamily, + integer, + partitionByHash, + tableOptions, + text, + ttl, + uint32, + ydbTable, +} from '../../src/index.ts' +import { createLiveContext } from './helpers/context.ts' +import { ignoreUnsupportedYqlFeature } from './helpers/errors.ts' +import { posts, users } from './helpers/schema.ts' + +let live = createLiveContext() + +test('cte helpers and count builder', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one user, query it through a CTE, and verify db.$count() against the live table' + ) + let userId = live.baseIntId + 401 + let userName = 'cte pony' + + live.liveQueryLog.length = 0 + await live.deleteUserRows([userId]) + + try { + await live.db.insert(users).values({ id: userId, name: userName }) + + let count = await live.db.$count(users, eq(users.id, userId)) + let sq = live.db.$with('sq_users').as( + live.db + .select({ + id: users.id, + name: users.name, + }) + .from(users) + .where(eq(users.id, userId)) + ) + let rows = await live.db.with(sq).select().from(sq) + + assert.equal(count, 1) + assert.deepEqual(rows, [{ id: userId, name: userName }]) + assert.ok(live.liveQueryLog.some(({ query }) => query.startsWith('$sq_users = (select'))) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('select count(*) as count'))) + } finally { + await live.deleteUserRows([userId]) + } +}) + +test('insert select', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one source row, then insert a second row via insert().select(...) and verify both exist' + ) + let sourceId = live.baseIntId + 402 + let copiedId = live.baseIntId + 403 + + await live.deleteUserRows([sourceId, copiedId]) + + try { + await live.db.insert(users).values({ id: sourceId, name: 'insert select source' }) + + await live.db.insert(users).select((qb) => + qb + .select({ + id: yql`${copiedId}`.as('id'), + name: users.name, + }) + .from(users) + .where(eq(users.id, sourceId)) + ) + + let rows = (await live.db + .select() + .from(users) + .where(yql`${users.id} in (${sourceId}, ${copiedId})`)) as Array<{ + id: number + name: string + }> + + assert.deepEqual(live.sortById(rows), [ + { id: sourceId, name: 'insert select source' }, + { id: copiedId, name: 'insert select source' }, + ]) + } finally { + await live.deleteUserRows([sourceId, copiedId]) + } +}) + +test('onDuplicateKeyUpdate', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'exercise onDuplicateKeyUpdate() for both conflict and insert paths on the live users table' + ) + let existingId = live.baseIntId + 407 + let newId = live.baseIntId + 408 + + await live.deleteUserRows([existingId, newId]) + + try { + await live.db.insert(users).values({ id: existingId, name: 'existing value' }) + + await live.db + .insert(users) + .values({ id: existingId, name: 'insert path' }) + .onDuplicateKeyUpdate({ set: { name: 'updated value' } }) + + await live.db + .insert(users) + .values({ id: newId, name: 'fresh value' }) + .onDuplicateKeyUpdate({ set: { name: 'should not win on insert' } }) + + let rows = (await live.db + .select() + .from(users) + .where(yql`${users.id} in (${existingId}, ${newId})`)) as Array<{ + id: number + name: string + }> + + assert.deepEqual(live.sortById(rows), [ + { id: existingId, name: 'updated value' }, + { id: newId, name: 'fresh value' }, + ]) + assert.ok( + live.liveQueryLog.some(({ query }) => query.startsWith('$__ydb_incoming = (select')) + ) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('upsert into'))) + } finally { + await live.deleteUserRows([existingId, newId]) + } +}) + +test('mutation returning', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'exercise INSERT/UPSERT/UPDATE/DELETE RETURNING against the live users table' + ) + let insertId = live.baseIntId + 421 + let upsertId = live.baseIntId + 422 + let deleteId = live.baseIntId + 423 + + await live.deleteUserRows([insertId, upsertId, deleteId]) + + try { + let inserted = await live.db + .insert(users) + .values({ id: insertId, name: 'returning insert' }) + .returning({ id: users.id, name: users.name }) + let upserted = await live.db + .upsert(users) + .values({ id: upsertId, name: 'returning upsert' }) + .returning({ id: users.id, name: users.name }) + let updated = await live.db + .update(users) + .set({ name: 'returning updated' }) + .where(eq(users.id, insertId)) + .returning({ id: users.id, name: users.name }) + + await live.db.insert(users).values({ id: deleteId, name: 'returning delete' }) + let deleted = await live.db + .delete(users) + .where(eq(users.id, deleteId)) + .returning({ id: users.id, name: users.name }) + + assert.deepEqual(inserted, [{ id: insertId, name: 'returning insert' }]) + assert.deepEqual(upserted, [{ id: upsertId, name: 'returning upsert' }]) + assert.deepEqual(updated, [{ id: insertId, name: 'returning updated' }]) + assert.deepEqual(deleted, [{ id: deleteId, name: 'returning delete' }]) + } finally { + await live.deleteUserRows([insertId, upsertId, deleteId]) + } +}) + +test('native set and batch mutations', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'create a keyed temp table and exercise UPDATE ON, DELETE ON, BATCH UPDATE and BATCH DELETE' + ) + let suffix = live.baseIntId + 431 + let tableName = `set_mutations_${suffix}` + let keyedUsers = ydbTable(tableName, { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + }) + + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + + try { + await live.db.execute(yql.raw(buildCreateTableSql(keyedUsers, { ifNotExists: true }))) + await live.db.insert(keyedUsers).values([ + { id: 1, name: 'update target' }, + { id: 2, name: 'delete target' }, + { id: 3, name: 'batch target' }, + ]) + + let updated = await live.db + .update(keyedUsers) + .on((qb) => + qb + .select({ + id: keyedUsers.id, + name: yql`${'set updated'}`.as('name'), + }) + .from(keyedUsers) + .where(eq(keyedUsers.id, 1)) + ) + .returning({ id: keyedUsers.id, name: keyedUsers.name }) + let deleted = await live.db + .delete(keyedUsers) + .on((qb) => + qb.select({ id: keyedUsers.id }).from(keyedUsers).where(eq(keyedUsers.id, 2)) + ) + .returning({ id: keyedUsers.id }) + + let batchUpdateUnsupported = await ignoreUnsupportedYqlFeature('BATCH UPDATE', () => + live.db + .batchUpdate(keyedUsers) + .set({ name: 'batch updated' }) + .where(eq(keyedUsers.id, 3)) + ) + let batchUpdated = batchUpdateUnsupported + ? [{ id: 3, name: 'batch target' }] + : await live.db.select().from(keyedUsers).where(eq(keyedUsers.id, 3)) + let batchDeleteUnsupported = await ignoreUnsupportedYqlFeature('BATCH DELETE', () => + live.db.batchDelete(keyedUsers).where(eq(keyedUsers.id, 3)) + ) + let batchDeleted = batchDeleteUnsupported + ? [{ id: 3, name: 'batch target' }] + : await live.db.select().from(keyedUsers).where(eq(keyedUsers.id, 3)) + + assert.deepEqual(updated, [{ id: 1, name: 'set updated' }]) + assert.deepEqual(deleted, [{ id: 2 }]) + if (!batchUpdateUnsupported) { + assert.deepEqual(batchUpdated, [{ id: 3, name: 'batch updated' }]) + } + + if (!batchDeleteUnsupported) { + assert.deepEqual(batchDeleted, []) + } + } finally { + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + } +}) + +test('advanced table DDL', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'create a temp table with table options, partitioning, TTL and column-family DDL, then verify it accepts rows' + ) + let suffix = live.baseIntId + 441 + let tableName = `advanced_ddl_${suffix}` + let events = ydbTable( + tableName, + { + id: integer('id').notNull().primaryKey(), + payload: text('payload'), + expiresAt: uint32('expires_at').notNull(), + }, + (table) => [ + columnFamily('cold', { compression: 'lz4' }).columns(table.payload), + partitionByHash(table.id), + ttl(table.expiresAt, 'P1D', { unit: 'SECONDS' }), + tableOptions({ + AUTO_PARTITIONING_BY_SIZE: 'ENABLED', + AUTO_PARTITIONING_PARTITION_SIZE_MB: 512, + }), + ] + ) + + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + + try { + await live.db.execute(yql.raw(buildCreateTableSql(events, { ifNotExists: true }))) + await live.db + .insert(events) + .values({ id: 1, payload: 'advanced ddl', expiresAt: 4_102_444_800 }) + + let rows = await live.db.select().from(events).where(eq(events.id, 1)) + assert.deepEqual(rows, [{ id: 1, payload: 'advanced ddl', expiresAt: 4_102_444_800 }]) + } finally { + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + } +}) + +test('delete using', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'delete a user row through delete().using(posts) with a correlated EXISTS-based condition' + ) + let userId = live.baseIntId + 404 + let postId = live.baseIntId + 405 + + await live.deletePostRows([postId]) + await live.deleteUserRows([userId]) + + try { + await live.db.insert(users).values({ id: userId, name: 'delete using target' }) + await live.db.insert(posts).values({ id: postId, userId, title: 'delete using post' }) + + await live.db + .delete(users) + .using(posts) + .where(and(eq(users.id, posts.userId), eq(users.id, userId), eq(posts.id, postId))) + + let remainingUsers = (await live.db + .select() + .from(users) + .where(eq(users.id, userId))) as Array<{ id: number; name: string }> + let remainingPosts = (await live.db + .select() + .from(posts) + .where(eq(posts.id, postId))) as Array<{ + id: number + userId: number + title: string + }> + + assert.deepEqual(remainingUsers, []) + assert.deepEqual(remainingPosts, [{ id: postId, userId, title: 'delete using post' }]) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes(' in (select '))) + } finally { + await live.deletePostRows([postId]) + await live.deleteUserRows([userId]) + } +}) + +test('session batch', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'execute an insert and a follow-up select through session.batch() against the live database' + ) + let userId = live.baseIntId + 406 + + await live.deleteUserRows([userId]) + + try { + let results = await live.db._.session.batch([ + live.db.insert(users).values({ id: userId, name: 'batch user' }), + live.db.select().from(users).where(eq(users.id, userId)), + ] as const) + + assert.deepEqual(results[0], []) + assert.deepEqual(results[1], [{ id: userId, name: 'batch user' }]) + } finally { + await live.deleteUserRows([userId]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/driver-dialect.test.ts b/third-parties/drizzle-adapter/tests/live/driver-dialect.test.ts new file mode 100644 index 00000000..13dc1f98 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/driver-dialect.test.ts @@ -0,0 +1,261 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { createTableRelationsHelpers, extractTablesRelationalConfig } from 'drizzle-orm/relations' +import { eq, sql as yql } from 'drizzle-orm' +import { WithSubquery } from 'drizzle-orm/subquery' +import { YdbDriver } from '../../src/index.ts' +import { YdbDialect } from '../../src/ydb/dialect.ts' +import { orderSelectedFields } from '../../src/ydb-core/result-mapping.ts' +import { createLiveContext } from './helpers/context.ts' +import { liveSchema, posts, users } from './helpers/schema.ts' + +let live = createLiveContext() + +test('driver execute and transaction preserve direct result metadata on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'read-only direct driver queries; validates result metadata and transaction executor plumbing' + ) + + let dialect = new YdbDialect() + let driver = live.db.$client as YdbDriver + let query = dialect.sqlToQuery(yql`select ${1} as ${yql.identifier('value')}`) + + let executeResult = await driver.execute(query.sql, query.params, 'execute', { + typings: query.typings, + }) + let txRows = await driver.transaction( + async (tx) => { + let txResult = await tx.execute(query.sql, query.params, 'execute', { + typings: query.typings, + }) + + return txResult.rows + }, + { accessMode: 'read only' } + ) + + assert.deepEqual(executeResult.rows, [{ value: 1 }]) + assert.equal(executeResult.rowCount, 1) + assert.equal(executeResult.command, 'execute') + assert.deepEqual(executeResult.meta, { + arrayMode: false, + typings: ['none'], + }) + assert.deepEqual(txRows, [{ value: 1 }]) +}) + +test('dialect helper queries execute on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one user through dialect SQL builders, verify CTE and flat relational SQL, update and delete it again' + ) + + let dialect = new YdbDialect() + let driver = live.db.$client as YdbDriver + let userId = live.baseIntId + 401 + + await live.deleteUserRows([userId]) + + try { + let insertQuery = dialect.sqlToQuery( + dialect.buildInsertQuery({ + table: users, + values: [{ id: userId, name: 'Starlight Glimmer' }], + }) + ) + + await driver.execute(insertQuery.sql, insertQuery.params, 'execute', { + typings: insertQuery.typings, + }) + + let cte = new WithSubquery( + yql`select ${userId} as ${yql.identifier('id')}, ${'Starlight Glimmer'} as ${yql.identifier('name')}`, + { id: users.id, name: users.name } as any, + 'seed_user' + ) + let cteQuery = dialect.sqlToQuery( + yql`${dialect.buildWithCTE([cte])}select * from ${dialect.buildFromTable(cte)}` + ) + let cteResult = await driver.execute(cteQuery.sql, cteQuery.params, 'execute', { + typings: cteQuery.typings, + }) + + assert.deepEqual(cteResult.rows, [{ id: userId, name: 'Starlight Glimmer' }]) + + let tablesConfig = extractTablesRelationalConfig(liveSchema, createTableRelationsHelpers) + let relationalQuery = dialect.buildRelationalQueryWithoutPK({ + fullSchema: liveSchema, + schema: tablesConfig.tables as any, + tableNamesMap: tablesConfig.tableNamesMap, + table: users, + tableConfig: (tablesConfig.tables as any).users, + queryConfig: { + columns: { id: true, name: true }, + where: (fields, operators) => operators.eq(fields['id'], userId), + limit: 1, + }, + tableAlias: 'users_live', + }) + let relationalBuilt = dialect.sqlToQuery(relationalQuery.sql as any) + let relationalResult = await driver.execute( + relationalBuilt.sql, + relationalBuilt.params, + 'execute', + { + typings: relationalBuilt.typings, + } + ) + + assert.deepEqual(relationalResult.rows, [{ id: userId, name: 'Starlight Glimmer' }]) + + let updateQuery = dialect.sqlToQuery( + dialect.buildUpdateQuery({ + table: users, + set: { name: 'Starlight Updated' }, + where: eq(users.id, userId), + }) + ) + await driver.execute(updateQuery.sql, updateQuery.params, 'execute', { + typings: updateQuery.typings, + }) + + let updatedRows = await live.db.select().from(users).where(eq(users.id, userId)) + assert.deepEqual(updatedRows, [{ id: userId, name: 'Starlight Updated' }]) + + let deleteQuery = dialect.sqlToQuery( + dialect.buildDeleteQuery({ + table: users, + where: eq(users.id, userId), + }) + ) + await driver.execute(deleteQuery.sql, deleteQuery.params, 'execute', { + typings: deleteQuery.typings, + }) + + let remainingRows = await live.db.select().from(users).where(eq(users.id, userId)) + assert.deepEqual(remainingRows, []) + } finally { + await live.deleteUserRows([userId]) + } +}) + +test('direct dialect helper wrappers execute on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert two users and one post, then execute SQL composed directly from dialect fragment/set-operation helpers' + ) + + let dialect = new YdbDialect() + let driver = live.db.$client as YdbDriver + let firstUserId = live.baseIntId + 451 + let secondUserId = live.baseIntId + 452 + let postId = live.baseIntId + 453 + + await live.deletePostRows([postId]) + await live.deleteUserRows([firstUserId, secondUserId]) + + try { + await live.db.insert(users).values([ + { id: firstUserId, name: 'Luna' }, + { id: secondUserId, name: 'Celestia' }, + ]) + await live.db.insert(posts).values({ id: postId, userId: firstUserId, title: 'Moonlight' }) + + let fields = orderSelectedFields({ userId: users.id, postTitle: posts.title }) + let selectionAliases = ['user_id_alias', 'post_title_alias'] + let mappedOrderBy = dialect.mapExpressionsToSelectionAliases( + [users.id, yql`${posts.title} desc`], + fields, + selectionAliases, + 'orderBy()' + ) + let helperQuery = dialect.sqlToQuery( + yql`select ${dialect.buildSelection( + fields, + selectionAliases + )} from ${dialect.buildFromTable(users)}${dialect.buildJoins([ + { table: posts, joinType: 'left', on: eq(users.id, posts.userId) }, + ])} where ${eq(users.id, firstUserId)}${dialect.buildOrderBy( + mappedOrderBy + )}${dialect.buildLimit(1)}${dialect.buildOffset(0)}` + ) + + let helperResult = await driver.execute(helperQuery.sql, helperQuery.params, 'execute', { + typings: helperQuery.typings, + }) + + assert.deepEqual(helperResult.rows, [ + { + user_id_alias: firstUserId, + post_title_alias: 'Moonlight', + }, + ]) + + let leftSelectBuilder = live.db + .select({ value: users.name }) + .from(users) + .where(eq(users.id, firstUserId)) + let rightUnionBuilder = live.db + .select({ value: users.name }) + .from(users) + .where(eq(users.id, secondUserId)) + let rightExceptBuilder = live.db + .select({ value: users.name }) + .from(users) + .where(eq(users.id, firstUserId)) + let setFields = orderSelectedFields(leftSelectBuilder.getSelectedFields()) + let setAliases = dialect.getSelectionAliases(setFields) + let leftSelect = leftSelectBuilder.getSQL(setAliases) + + let singleSetQuery = dialect.sqlToQuery( + dialect.buildSetOperationQuery(leftSelect, setFields, setAliases, { + type: 'union', + isAll: true, + rightSelect: rightUnionBuilder, + orderBy: [users.name], + }) + ) + let singleSetResult = await driver.execute( + singleSetQuery.sql, + singleSetQuery.params, + 'execute', + { + typings: singleSetQuery.typings, + } + ) + + assert.deepEqual(singleSetResult.rows, [{ __ydb_f0: 'Celestia' }, { __ydb_f0: 'Luna' }]) + + let chainedSetQuery = dialect.sqlToQuery( + dialect.buildSetOperations(leftSelect, setFields, setAliases, [ + { + type: 'union', + isAll: true, + rightSelect: rightUnionBuilder, + }, + { + type: 'except', + isAll: false, + rightSelect: rightExceptBuilder, + }, + ]) + ) + let chainedSetResult = await driver.execute( + chainedSetQuery.sql, + chainedSetQuery.params, + 'execute', + { + typings: chainedSetQuery.typings, + } + ) + + assert.deepEqual(chainedSetResult.rows, [{ __ydb_f0: 'Celestia' }]) + } finally { + await live.deletePostRows([postId]) + await live.deleteUserRows([firstUserId, secondUserId]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/helpers/context.ts b/third-parties/drizzle-adapter/tests/live/helpers/context.ts new file mode 100644 index 00000000..8c7e38c1 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/helpers/context.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict' +import { type TestContext, afterAll, beforeAll } from 'vitest' +import { eq, sql as yql } from 'drizzle-orm' +import { YdbDriver, type YdbDrizzleDatabase, drizzle } from '../../../src/index.ts' +import { + keepData, + liveSchema, + posts, + postsTableName, + requireLiveYdb, + typesTable, + typesTableName, + users, + usersTableName, + verbose, + ydbUrl, +} from './schema.ts' + +export interface LiveTestContext { + readonly db: YdbDrizzleDatabase + readonly liveQueryLog: Array<{ query: string; params: unknown[] }> + readonly baseIntId: number + readonly baseUint64Id: bigint + log(...args: unknown[]): void + requireLiveYdb(t: TestContext): boolean + describeDbChange(t: TestContext, description: string): void + deleteUserRows(ids: number[]): Promise + deletePostRows(ids: number[]): Promise + deleteTypeRows(ids: bigint[]): Promise + normalizeTypeRow(row: Record): Record + sortById(rows: T[]): T[] +} + +export function createLiveContext(): LiveTestContext { + let driver: YdbDriver + let db!: YdbDrizzleDatabase + let liveDbUnavailableReason: string | undefined + let liveQueryLog: Array<{ query: string; params: unknown[] }> = [] + let uniqueSeed = Number( + (process.hrtime.bigint() + BigInt(Math.floor(Math.random() * 1_000_000))) % 1_000_000_000n + ) + let baseIntId = uniqueSeed + let baseUint64Id = 2_000_000_000n + BigInt(uniqueSeed % 1_000_000) + + function log(...args: unknown[]): void { + if (!verbose) { + return + } + + console.log('[test]', ...args) + } + + function sortById(rows: T[]): T[] { + return [...rows].sort((left, right) => { + if (left.id < right.id) { + return -1 + } + + if (left.id > right.id) { + return 1 + } + + return 0 + }) + } + + async function ensureTables(): Promise { + await db.execute( + yql.raw(` + CREATE TABLE IF NOT EXISTS ${usersTableName} ( + id Int32, + name Utf8, + PRIMARY KEY (id) + ) + `) + ) + + await db.execute( + yql.raw(` + CREATE TABLE IF NOT EXISTS ${postsTableName} ( + id Int32, + user_id Int32, + title Utf8, + PRIMARY KEY (id) + ) + `) + ) + + await db.execute( + yql.raw(` + CREATE TABLE IF NOT EXISTS ${typesTableName} ( + id Uint64, + flag Bool, + signed64 Int64, + u32 Uint32, + f32 Float, + f64 Double, + bytes_value String, + date_value Date, + datetime_value Datetime, + timestamp_value Timestamp, + json_value Json, + json_document_value JsonDocument, + uuid_value Uuid, + yson_value Yson, + PRIMARY KEY (id) + ) + `) + ) + } + + async function deleteUserRows(ids: number[]): Promise { + if (keepData) { + return + } + + await Promise.all(ids.map((id) => db.delete(users).where(eq(users.id, id)))) + } + + async function deletePostRows(ids: number[]): Promise { + if (keepData) { + return + } + + await Promise.all(ids.map((id) => db.delete(posts).where(eq(posts.id, id)))) + } + + async function deleteTypeRows(ids: bigint[]): Promise { + if (keepData) { + return + } + + await Promise.all(ids.map((id) => db.delete(typesTable).where(eq(typesTable.id, id)))) + } + + function normalizeTypeRow(row: Record) { + assert.ok(row['bytesValue'] instanceof Uint8Array) + assert.ok(row['ysonValue'] instanceof Uint8Array) + assert.ok(row['dateValue'] instanceof Date) + assert.ok(row['datetimeValue'] instanceof Date) + assert.ok(row['timestampValue'] instanceof Date) + + return { + id: row['id'], + flag: row['flag'], + signed64: row['signed64'], + u32: row['u32'], + f32: row['f32'], + f64: row['f64'], + bytesValue: Array.from(row['bytesValue'] as Uint8Array), + dateValue: (row['dateValue'] as Date).toISOString(), + datetimeValue: (row['datetimeValue'] as Date).toISOString(), + timestampValue: (row['timestampValue'] as Date).toISOString(), + jsonValue: row['jsonValue'], + jsonDocumentValue: row['jsonDocumentValue'], + uuidValue: row['uuidValue'], + ysonValue: Array.from(row['ysonValue'] as Uint8Array), + } + } + + beforeAll(async () => { + try { + driver = new YdbDriver(ydbUrl) + await driver.ready() + db = drizzle(driver, { + schema: liveSchema, + logger: { + logQuery(query, params) { + liveQueryLog.push({ query, params: [...params] }) + + if (verbose) { + console.log('[sql]', query, params) + } + }, + }, + }) + + await ensureTables() + log('up', usersTableName, postsTableName, typesTableName, keepData ? 'keep' : 'clean') + } catch (error) { + liveDbUnavailableReason = error instanceof Error ? error.message : String(error) + if (requireLiveYdb) { + throw new Error(`YDB unavailable: ${liveDbUnavailableReason}`, { cause: error }) + } + } + }) + + afterAll(async () => { + driver?.close() + }) + + return { + get db() { + return db + }, + liveQueryLog, + baseIntId, + baseUint64Id, + log, + requireLiveYdb(t: TestContext): boolean { + if (liveDbUnavailableReason) { + t.skip(`YDB unavailable: ${liveDbUnavailableReason}`) + return false + } + + return true + }, + describeDbChange(t: TestContext, description: string): void { + void t + log('DB change:', description) + }, + deleteUserRows, + deletePostRows, + deleteTypeRows, + normalizeTypeRow, + sortById, + } +} diff --git a/third-parties/drizzle-adapter/tests/live/helpers/errors.ts b/third-parties/drizzle-adapter/tests/live/helpers/errors.ts new file mode 100644 index 00000000..22f0df48 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/helpers/errors.ts @@ -0,0 +1,76 @@ +function collectIssueMessages(value: unknown, messages: string[]): void { + if (!value) { + return + } + + if (Array.isArray(value)) { + for (let item of value) { + collectIssueMessages(item, messages) + } + + return + } + + if (typeof value !== 'object') { + return + } + + let issue = value as { message?: unknown; issues?: unknown } + if (typeof issue.message === 'string') { + messages.push(issue.message) + } + + collectIssueMessages(issue.issues, messages) +} + +export function getErrorDetails(error: unknown): string { + let messages: string[] = [] + + if (error instanceof Error) { + messages.push(error.message) + } else { + messages.push(String(error)) + } + + if (error && typeof error === 'object' && 'issues' in error) { + collectIssueMessages((error as { issues?: unknown }).issues, messages) + } + + return messages.join('\n') +} + +export async function ignoreMissingObject(action: () => Promise): Promise { + try { + await action() + } catch (error) { + let message = getErrorDetails(error) + if (!/(not found|not exist|does not exist|missing|no such|path)/iu.test(message)) { + throw error + } + } +} + +export async function ignoreUnsupportedYqlFeature( + feature: string, + action: () => Promise +): Promise { + try { + await action() + return false + } catch (error) { + let message = getErrorDetails(error) + if ( + /(not supported|unsupported|pre type annotation|type annotation|expression evaluation|precondition_failed|generic_error|batch operations are not supported|unqualified alter table request)/iu.test( + message + ) + ) { + if (process.env['YDB_TEST_VERBOSE'] === '1') { + console.log(`[test] Skipping optional live check: ${feature}`) + } + + return true + } + + throw error + } +} diff --git a/third-parties/drizzle-adapter/tests/live/helpers/schema.ts b/third-parties/drizzle-adapter/tests/live/helpers/schema.ts new file mode 100644 index 00000000..88d29a90 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/helpers/schema.ts @@ -0,0 +1,83 @@ +import { + bigint, + boolean, + bytes, + date, + datetime, + double, + float, + integer, + json, + jsonDocument, + relations, + text, + timestamp, + uint32, + uint64, + uuid, + ydbTable, + yson, +} from '../../../src/index.ts' +import { loadTestEnv } from '../../helpers/load-env.ts' +import { inject } from 'vitest' + +loadTestEnv() + +function getSafeTableName(envName: string, fallback: string): string { + let tableName = process.env[envName] ?? fallback + + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(tableName)) { + throw new Error(`Invalid YDB table name in ${envName}: ${tableName}`) + } + + return tableName +} + +export let ydbUrl = process.env['YDB_CONNECTION_STRING'] ?? inject('connectionString') +export let usersTableName = getSafeTableName('YDB_TEST_TABLE', 'adapter_test_users') +export let postsTableName = getSafeTableName('YDB_POSTS_TEST_TABLE', 'adapter_test_posts') +export let typesTableName = getSafeTableName('YDB_TYPES_TEST_TABLE', 'adapter_test_column_types') +export let keepData = process.env['YDB_TEST_KEEP_DATA'] === '1' +export let verbose = process.env['YDB_TEST_VERBOSE'] === '1' +export let requireLiveYdb = process.env['YDB_TEST_REQUIRE_LIVE'] === '1' + +export let users = ydbTable(usersTableName, { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), +}) + +export let posts = ydbTable(postsTableName, { + id: integer('id').notNull().primaryKey(), + userId: integer('user_id').notNull(), + title: text('title').notNull(), +}) + +export let usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), +})) + +export let postsRelations = relations(posts, ({ one }) => ({ + author: one(users, { + fields: [posts.userId], + references: [users.id], + }), +})) + +export let typesTable = ydbTable(typesTableName, { + id: uint64('id').notNull().primaryKey(), + flag: boolean('flag'), + signed64: bigint('signed64'), + u32: uint32('u32'), + f32: float('f32'), + f64: double('f64'), + bytesValue: bytes('bytes_value'), + dateValue: date('date_value'), + datetimeValue: datetime('datetime_value'), + timestampValue: timestamp('timestamp_value'), + jsonValue: json('json_value'), + jsonDocumentValue: jsonDocument('json_document_value'), + uuidValue: uuid('uuid_value'), + ysonValue: yson('yson_value'), +}) + +export let liveSchema = { users, posts, typesTable, usersRelations, postsRelations } diff --git a/third-parties/drizzle-adapter/tests/live/inputs-and-crud.test.ts b/third-parties/drizzle-adapter/tests/live/inputs-and-crud.test.ts new file mode 100644 index 00000000..eacbea06 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/inputs-and-crud.test.ts @@ -0,0 +1,147 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { eq, sql as yql } from 'drizzle-orm' +import { YdbDriver, drizzle } from '../../src/index.ts' +import { createLiveContext } from './helpers/context.ts' +import { liveSchema, users, usersTableName, ydbUrl } from './helpers/schema.ts' + +let live = createLiveContext() + +test('createDrizzle inputs', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'no persistent data change; verifies that connection-string and callback inputs execute against the same live database' + ) + let connectionDb = drizzle({ + connectionString: ydbUrl, + schema: liveSchema, + }) + let callbackCalls: Array<{ query: string; method: string; params: unknown[] }> = [] + let callbackDb = drizzle( + async (query, params, method, options) => { + callbackCalls.push({ query, method, params: [...params] }) + return live.db.$client.execute(query, params, method, options) + }, + { schema: liveSchema } + ) + + try { + await (connectionDb.$client as YdbDriver).ready?.() + + let connectionRows = await connectionDb.execute>( + yql`select ${1} as ${yql.identifier('value')}` + ) + let callbackRows = await callbackDb.execute>( + yql`select ${2} as ${yql.identifier('value')}` + ) + + assert.deepEqual(connectionRows, [{ value: 1 }]) + assert.deepEqual(callbackRows, [{ value: 2 }]) + assert.equal(callbackCalls.length, 1) + assert.equal(callbackCalls[0]?.method, 'execute') + assert.equal(callbackCalls[0]?.query, 'select $p0 as `value`') + } finally { + ;(connectionDb.$client as YdbDriver).close() + } +}) + +test('builder CRUD', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert two users, update one, delete one, then leave the table clean again' + ) + let firstId = live.baseIntId + 101 + let secondId = live.baseIntId + 102 + + live.log('crud', firstId, secondId) + await live.deleteUserRows([firstId, secondId]) + + try { + await live.db.insert(users).values([ + { id: firstId, name: 'rarity' }, + { id: secondId, name: 'applejack' }, + ]) + + let inserted = live.sortById( + (await live.db + .select() + .from(users) + .where(yql`${users.id} IN (${firstId}, ${secondId})`)) as Array<{ + id: number + name: string + }> + ) + + assert.deepEqual(inserted, [ + { id: firstId, name: 'rarity' }, + { id: secondId, name: 'applejack' }, + ]) + + await live.db.update(users).set({ name: 'rarity updated' }).where(eq(users.id, firstId)) + await live.db.delete(users).where(eq(users.id, secondId)) + + let remaining = (await live.db + .select() + .from(users) + .where(yql`${users.id} IN (${firstId}, ${secondId})`)) as Array<{ + id: number + name: string + }> + + assert.deepEqual(remaining, [{ id: firstId, name: 'rarity updated' }]) + } finally { + await live.deleteUserRows([firstId, secondId]) + } +}) + +test('db helpers', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one user, read it through execute/all/get/values helpers, then delete it and confirm helper-layer logging' + ) + let id = live.baseIntId + 151 + + live.liveQueryLog.length = 0 + live.log('helpers', id) + await live.deleteUserRows([id]) + + try { + await live.db.execute(live.db.insert(users).values({ id, name: 'sunset shimmer' })) + + let selectQuery = live.db.select().from(users).where(eq(users.id, id)) + let selectedRows = (await selectQuery.prepare('select_user_prepared').execute()) as Array<{ + id: number + name: string + }> + let executeRows = await live.db.execute>(selectQuery) + let allRows = await live.db.all<{ id: number; name: string }>(selectQuery) + let oneRow = await live.db.get<{ id: number; name: string }>(selectQuery) + let valueRows = await live.db.values<[number, string]>(selectQuery) + + assert.deepEqual(selectedRows, [{ id, name: 'sunset shimmer' }]) + assert.deepEqual(executeRows, [{ id, name: 'sunset shimmer' }]) + assert.deepEqual(allRows, [{ id, name: 'sunset shimmer' }]) + assert.deepEqual(oneRow, { id, name: 'sunset shimmer' }) + assert.deepEqual(valueRows, [[id, 'sunset shimmer']]) + + await live.db.execute( + live.db.update(users).set({ name: 'sunset updated' }).where(eq(users.id, id)) + ) + await live.db.execute(live.db.delete(users).where(eq(users.id, id))) + + let remainingRows = await live.db.select().from(users).where(eq(users.id, id)) + assert.deepEqual(remainingRows, []) + assert.ok( + live.liveQueryLog.some(({ query }) => + query.includes(`insert into \`${usersTableName}\``) + ) + ) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('update'))) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('delete from'))) + } finally { + await live.deleteUserRows([id]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/migrations.test.ts b/third-parties/drizzle-adapter/tests/live/migrations.test.ts new file mode 100644 index 00000000..4b5204a4 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/migrations.test.ts @@ -0,0 +1,390 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { sql as yql } from 'drizzle-orm' +import { + type YdbInlineMigration, + buildCreateTableSql, + buildMigrationLockTableBootstrapSql, + index, + integer, + migrate, + text, + ydbTable, +} from '../../src/index.ts' +import { createLiveContext } from './helpers/context.ts' + +let live = createLiveContext() + +function normalize( + rows: Array<[number, string, number | null]> +): Array<[number, string, number | null]> { + return [...rows].sort((left, right) => left[0] - right[0]) +} + +test('inline migrate applies DDL, bookkeeping and remains idempotent on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'bootstrap migration history table, create a temp table via migrate(), add a column and index via later migrations, verify idempotency, then drop temp objects' + ) + + let suffix = live.baseIntId + 501 + let tableName = `migration_users_${suffix}` + let migrationTableName = `migration_history_${suffix}` + + let baseUsers = ydbTable( + tableName, + { + id: integer('id').notNull().primaryKey(), + name: text('name'), + }, + (table) => [index(`${tableName}_name_idx`).on(table.name)] + ) + + let usersWithAge = ydbTable( + tableName, + { + id: integer('id').notNull().primaryKey(), + name: text('name'), + age: integer('age'), + }, + (table) => [ + index(`${tableName}_name_idx`).on(table.name), + index(`${tableName}_age_idx`).on(table.age), + ] + ) + + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${migrationTableName}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + + try { + await migrate(live.db, { + migrationsTable: migrationTableName, + migrations: [ + { + name: '0001_create_users', + folderMillis: 1, + operations: [{ kind: 'create_table', table: baseUsers, ifNotExists: true }], + }, + ], + }) + + await live.db.execute( + yql.raw( + `UPSERT INTO \`${tableName}\` (\`id\`, \`name\`) VALUES (1, 'Twilight Sparkle')` + ) + ) + let initialRows = await live.db.values<[number, string]>( + yql.raw(`SELECT \`id\`, \`name\` FROM \`${tableName}\` ORDER BY \`id\``) + ) + assert.deepEqual(initialRows, [[1, 'Twilight Sparkle']]) + + let addAgeIndex = index(`${tableName}_age_idx`).on(usersWithAge.age).build(usersWithAge) + let createUsersMigration: YdbInlineMigration = { + name: '0001_create_users', + folderMillis: 1, + operations: [{ kind: 'create_table', table: baseUsers, ifNotExists: true }], + } + let addAgeMigration: YdbInlineMigration = { + name: '0002_add_age', + folderMillis: 2, + operations: [ + { kind: 'add_columns', table: tableName, columns: [usersWithAge.age] }, + { kind: 'add_index', table: tableName, index: addAgeIndex }, + ], + } + let incrementalConfig = { + migrationsTable: migrationTableName, + migrations: [createUsersMigration, addAgeMigration], + } + + await migrate(live.db, incrementalConfig) + await migrate(live.db, incrementalConfig) + + await live.db.execute( + yql.raw( + `UPSERT INTO \`${tableName}\` (\`id\`, \`name\`, \`age\`) VALUES (2, 'Rainbow Dash', 21)` + ) + ) + let rowsWithAge = await live.db.values<[number, string, number | null]>( + yql.raw(`SELECT \`id\`, \`name\`, \`age\` FROM \`${tableName}\` ORDER BY \`id\``) + ) + assert.deepEqual(normalize(rowsWithAge), [ + [1, 'Twilight Sparkle', null], + [2, 'Rainbow Dash', 21], + ]) + + let bookkeepingRows = await live.db.values<[string, number, string]>( + yql.raw( + `SELECT \`hash\`, \`created_at\`, \`name\` FROM \`${migrationTableName}\` ORDER BY \`created_at\`` + ) + ) + assert.equal(bookkeepingRows.length, 2) + + let dropAgeIndexMigration: YdbInlineMigration = { + name: '0003_drop_age_index', + folderMillis: 3, + operations: [{ kind: 'drop_index', table: tableName, name: `${tableName}_age_idx` }], + } + let dropUsersMigration: YdbInlineMigration = { + name: '0004_drop_users', + folderMillis: 4, + operations: [{ kind: 'drop_table', table: tableName, ifExists: true }], + } + + await migrate(live.db, { + migrationsTable: migrationTableName, + migrations: [ + ...incrementalConfig.migrations, + dropAgeIndexMigration, + dropUsersMigration, + ], + }) + + await assert.rejects(async () => live.db.values(yql.raw(`SELECT * FROM \`${tableName}\``))) + } finally { + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${migrationTableName}\``)) + } +}) + +test('folder migrate accepts drizzle journal/sql format on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'create a temp migration folder in drizzle journal format, run migrate() against it, verify created schema and bookkeeping rows, then clean all temp objects' + ) + + let suffix = live.baseIntId + 601 + let tableName = `folder_users_${suffix}` + let migrationTableName = `folder_history_${suffix}` + let tempDir = mkdtempSync(join(tmpdir(), 'ydb-migrator-')) + let metaDir = join(tempDir, 'meta') + + mkdirSync(metaDir, { recursive: true }) + writeFileSync( + join(metaDir, '_journal.json'), + JSON.stringify( + { + entries: [ + { idx: 0, when: 1, tag: '0000_create_folder_users', breakpoints: true }, + { idx: 1, when: 2, tag: '0001_add_age_to_folder_users', breakpoints: true }, + ], + }, + null, + 2 + ) + ) + writeFileSync( + join(tempDir, '0000_create_folder_users.sql'), + [ + `CREATE TABLE \`${tableName}\` (`, + ' `id` Int32 NOT NULL,', + ' `name` Utf8,', + ' PRIMARY KEY (`id`)', + ')', + ].join('\n') + ) + writeFileSync( + join(tempDir, '0001_add_age_to_folder_users.sql'), + `ALTER TABLE \`${tableName}\` ADD COLUMN \`age\` Int32` + ) + + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${migrationTableName}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + + try { + await migrate(live.db, { + migrationsFolder: tempDir, + migrationsTable: migrationTableName, + }) + + await live.db.execute( + yql.raw( + `UPSERT INTO \`${tableName}\` (\`id\`, \`name\`, \`age\`) VALUES (1, 'Applejack', 24)` + ) + ) + let rows = await live.db.values<[number, string, number]>( + yql.raw(`SELECT \`id\`, \`name\`, \`age\` FROM \`${tableName}\``) + ) + assert.deepEqual(rows, [[1, 'Applejack', 24]]) + + let bookkeepingRows = await live.db.values<[string, number, string]>( + yql.raw( + `SELECT \`hash\`, \`created_at\`, \`name\` FROM \`${migrationTableName}\` ORDER BY \`created_at\`` + ) + ) + assert.equal(bookkeepingRows.length, 2) + + await migrate(live.db, { + migrationsFolder: tempDir, + migrationsTable: migrationTableName, + }) + let bookkeepingRowsAfter = await live.db.values<[string, number, string]>( + yql.raw( + `SELECT \`hash\`, \`created_at\`, \`name\` FROM \`${migrationTableName}\` ORDER BY \`created_at\`` + ) + ) + assert.equal(bookkeepingRowsAfter.length, 2) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${migrationTableName}\``)) + } +}) + +test('migration lock and failed-state recovery guard work on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'create a live migration lock row and verify a concurrent migration times out, then record a failed migration and verify reruns are blocked until explicit recovery' + ) + + let suffix = live.baseIntId + 651 + let lockHistoryTable = `lock_history_${suffix}` + let lockTable = `${lockHistoryTable}_lock` + let failedTable = `failed_migration_${suffix}` + let failedHistoryTable = `failed_history_${suffix}` + let failedLockTable = `${failedHistoryTable}_lock` + let missingTable = `missing_recovery_source_${suffix}` + let failedHash = `failed_hash_${suffix}` + + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${failedTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${lockHistoryTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${lockTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${failedHistoryTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${failedLockTable}\``)) + + try { + await live.db.execute( + yql.raw(buildMigrationLockTableBootstrapSql({ migrationsTable: lockHistoryTable })) + ) + await live.db.execute( + yql.raw( + [ + `UPSERT INTO \`${lockTable}\` (\`lock_key\`, \`owner_id\`, \`acquired_at\`, \`heartbeat_at\`, \`expires_at\`)`, + `VALUES ('migrate', 'other-runner', 1, 1, ${Date.now() + 60_000})`, + ].join(' ') + ) + ) + + await assert.rejects( + () => + migrate(live.db, { + migrationsTable: lockHistoryTable, + migrationLock: { + ownerId: 'blocked-live-runner', + acquireTimeoutMs: 50, + retryIntervalMs: 10, + }, + migrations: [ + { + name: '0001_blocked', + folderMillis: 1, + sql: [ + `CREATE TABLE \`${failedTable}\` (\`id\` Int32 NOT NULL, PRIMARY KEY (\`id\`))`, + ], + }, + ], + }), + /could not acquire migration lock/u + ) + + await live.db.execute( + yql.raw(`DELETE FROM \`${lockTable}\` WHERE \`lock_key\` = 'migrate'`) + ) + + let failingMigration: YdbInlineMigration = { + name: '0001_failed', + folderMillis: 1, + hash: failedHash, + sql: [ + `CREATE TABLE IF NOT EXISTS \`${failedTable}\` (\`id\` Int32 NOT NULL, PRIMARY KEY (\`id\`))`, + `SELECT * FROM \`${missingTable}\``, + ], + } + + await assert.rejects( + () => + migrate(live.db, { + migrationsTable: failedHistoryTable, + migrationLock: { ownerId: 'failed-live-runner' }, + migrations: [failingMigration], + }), + /failed after 1\/2 statements/u + ) + + let failedRows = await live.db.values< + [string, string | null, number | null, number | null] + >( + yql.raw( + `SELECT \`status\`, \`error\`, \`statements_total\`, \`statements_applied\` FROM \`${failedHistoryTable}\` WHERE \`hash\` = '${failedHash}'` + ) + ) + assert.equal(failedRows.length, 1) + assert.equal(failedRows[0]![0], 'failed') + assert.equal(failedRows[0]![2], 2) + assert.equal(failedRows[0]![3], 1) + assert.notEqual(failedRows[0]![1], null) + + await assert.rejects( + () => + migrate(live.db, { + migrationsTable: failedHistoryTable, + migrationLock: { ownerId: 'blocked-after-failed-live-runner' }, + migrations: [failingMigration], + }), + /marked as failed/u + ) + } finally { + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${failedTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${lockHistoryTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${lockTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${failedHistoryTable}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${failedLockTable}\``)) + } +}) + +test('inline unique column constraints work on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'create a temp table with inline unique() metadata, verify duplicate values are rejected by YDB, then drop the temp table' + ) + + let suffix = live.baseIntId + 701 + let tableName = `unique_users_${suffix}` + let users = ydbTable(tableName, { + id: integer('id').notNull().primaryKey(), + email: text('email').notNull().unique(), + }) + + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + + try { + await live.db.execute(yql.raw(buildCreateTableSql(users, { ifNotExists: true }))) + await live.db.execute( + yql.raw( + `INSERT INTO \`${tableName}\` (\`id\`, \`email\`) VALUES (1, 'rarity@example.com')` + ) + ) + + await assert.rejects(() => + live.db.execute( + yql.raw( + `INSERT INTO \`${tableName}\` (\`id\`, \`email\`) VALUES (2, 'rarity@example.com')` + ) + ) + ) + + let rows = await live.db.values<[number, string]>( + yql.raw(`SELECT \`id\`, \`email\` FROM \`${tableName}\` ORDER BY \`id\``) + ) + assert.deepEqual(rows, [[1, 'rarity@example.com']]) + } finally { + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/raw-sql.test.ts b/third-parties/drizzle-adapter/tests/live/raw-sql.test.ts new file mode 100644 index 00000000..1f72c2c8 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/raw-sql.test.ts @@ -0,0 +1,41 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { sql as yql } from 'drizzle-orm' +import { createLiveContext } from './helpers/context.ts' +import { usersTableName } from './helpers/schema.ts' + +let live = createLiveContext() + +test('raw sql', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one user row via raw SQL, read it back, delete it, then confirm the row is gone' + ) + let id = live.baseIntId + 1 + + live.log('raw', id) + await live.deleteUserRows([id]) + + try { + await live.db.execute( + yql`insert into ${yql.identifier(usersTableName)} (id, name) values (${id}, ${'pinky'})` + ) + + let inserted = await live.db.execute>( + yql`select id, name from ${yql.identifier(usersTableName)} where id = ${id}` + ) + + assert.deepEqual(inserted, [{ id, name: 'pinky' }]) + + await live.db.execute(yql`delete from ${yql.identifier(usersTableName)} where id = ${id}`) + + let remaining = await live.db.execute>( + yql`select id, name from ${yql.identifier(usersTableName)} where id = ${id}` + ) + + assert.deepEqual(remaining, []) + } finally { + await live.deleteUserRows([id]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/relations.test.ts b/third-parties/drizzle-adapter/tests/live/relations.test.ts new file mode 100644 index 00000000..bd48d90d --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/relations.test.ts @@ -0,0 +1,191 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { createLiveContext } from './helpers/context.ts' +import { posts, postsTableName, users, usersTableName } from './helpers/schema.ts' + +let live = createLiveContext() + +test('schema query', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one user, resolve it through schema-aware query API, then delete it again' + ) + let id = live.baseIntId + 201 + + live.liveQueryLog.length = 0 + live.log('schema', id) + await live.deleteUserRows([id]) + + try { + await live.db.insert(users).values({ id, name: 'twilight sparkle' }) + + let inserted = await (live.db as any).query.users.findFirst({ + where: ( + fields: typeof users, + { eq }: { eq: (left: unknown, right: unknown) => unknown } + ) => eq(fields.id, id), + }) + + assert.deepEqual(inserted, { id, name: 'twilight sparkle' }) + assert.ok((live.db as any)._.schema?.users) + assert.ok( + live.liveQueryLog.some(({ query }) => + query.includes(`insert into \`${usersTableName}\``) + ) + ) + assert.ok( + live.liveQueryLog.some(({ query }) => query.includes(`from \`${usersTableName}\``)) + ) + } finally { + await live.deleteUserRows([id]) + } +}) + +test('findMany', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert two users, read them through schema-aware findMany with order/limit, then clean both rows' + ) + let firstId = live.baseIntId + 221 + let secondId = live.baseIntId + 222 + + live.log('many', firstId, secondId) + await live.deleteUserRows([firstId, secondId]) + + try { + await live.db.insert(users).values([ + { id: firstId, name: 'apple bloom' }, + { id: secondId, name: 'sweetie belle' }, + ]) + + let rows = await (live.db as any).query.users.findMany({ + columns: { id: true, name: true }, + where: ( + fields: typeof users, + { inArray }: { inArray: (left: unknown, right: unknown[]) => unknown } + ) => inArray(fields.id, [firstId, secondId]), + orderBy: (fields: typeof users, { desc }: { desc: (value: unknown) => unknown }) => + desc(fields.id), + limit: 2, + }) + + assert.deepEqual(rows, [ + { id: secondId, name: 'sweetie belle' }, + { id: firstId, name: 'apple bloom' }, + ]) + } finally { + await live.deleteUserRows([firstId, secondId]) + } +}) + +test('many relation hydration', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one user and two posts, resolve users.posts through schema-aware query API, then clean rows' + ) + let userId = live.baseIntId + 241 + let firstPostId = live.baseIntId + 242 + let secondPostId = live.baseIntId + 243 + + live.liveQueryLog.length = 0 + live.log('users.posts', userId, firstPostId, secondPostId) + await live.deletePostRows([firstPostId, secondPostId]) + await live.deleteUserRows([userId]) + + try { + await live.db.insert(users).values({ id: userId, name: 'pinkie pie' }) + await live.db.insert(posts).values([ + { id: firstPostId, userId, title: 'cupcakes' }, + { id: secondPostId, userId, title: 'party cannon' }, + ]) + + let row = await (live.db as any).query.users.findFirst({ + columns: { id: true, name: true }, + where: ( + fields: typeof users, + { eq }: { eq: (left: unknown, right: unknown) => unknown } + ) => eq(fields.id, userId), + with: { + posts: { + columns: { id: true, title: true }, + orderBy: ( + fields: typeof posts, + { asc }: { asc: (value: unknown) => unknown } + ) => asc(fields.id), + }, + }, + }) + + assert.deepEqual(row, { + id: userId, + name: 'pinkie pie', + posts: [ + { id: firstPostId, title: 'cupcakes' }, + { id: secondPostId, title: 'party cannon' }, + ], + }) + assert.ok( + live.liveQueryLog.some(({ query }) => query.includes(`from \`${usersTableName}\``)) + ) + assert.ok( + live.liveQueryLog.some(({ query }) => query.includes(`from \`${postsTableName}\``)) + ) + } finally { + await live.deletePostRows([firstPostId, secondPostId]) + await live.deleteUserRows([userId]) + } +}) + +test('one relation hydration', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one user and one post, resolve posts.author through schema-aware query API, then clean rows' + ) + let userId = live.baseIntId + 261 + let postId = live.baseIntId + 262 + + live.liveQueryLog.length = 0 + live.log('posts.author', userId, postId) + await live.deletePostRows([postId]) + await live.deleteUserRows([userId]) + + try { + await live.db.insert(users).values({ id: userId, name: 'fluttershy' }) + await live.db.insert(posts).values({ id: postId, userId, title: 'tea time' }) + + let row = await (live.db as any).query.posts.findFirst({ + columns: { id: true, title: true }, + where: ( + fields: typeof posts, + { eq }: { eq: (left: unknown, right: unknown) => unknown } + ) => eq(fields.id, postId), + with: { + author: { + columns: { id: true, name: true }, + }, + }, + }) + + assert.deepEqual(row, { + id: postId, + title: 'tea time', + author: { + id: userId, + name: 'fluttershy', + }, + }) + assert.ok( + live.liveQueryLog.some(({ query }) => query.includes(`from \`${postsTableName}\``)) + ) + assert.ok( + live.liveQueryLog.some(({ query }) => query.includes(`from \`${usersTableName}\``)) + ) + } finally { + await live.deletePostRows([postId]) + await live.deleteUserRows([userId]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/select-features.test.ts b/third-parties/drizzle-adapter/tests/live/select-features.test.ts new file mode 100644 index 00000000..e6fa6b46 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/select-features.test.ts @@ -0,0 +1,181 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { desc, eq, sql as yql } from 'drizzle-orm' +import { createLiveContext } from './helpers/context.ts' +import { posts, postsTableName, users, usersTableName } from './helpers/schema.ts' + +let live = createLiveContext() + +test('advanced select clauses', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert seed users/posts, verify distinct/groupBy/having/limit/offset/distinctOn result sets, then clean all seeded rows' + ) + let firstUserId = live.baseIntId + 241 + let secondUserId = live.baseIntId + 242 + let thirdUserId = live.baseIntId + 243 + let firstPostId = live.baseIntId + 244 + let secondPostId = live.baseIntId + 245 + let thirdPostId = live.baseIntId + 246 + + live.liveQueryLog.length = 0 + live.log( + 'advanced-select', + firstUserId, + secondUserId, + thirdUserId, + firstPostId, + secondPostId, + thirdPostId + ) + await live.deletePostRows([firstPostId, secondPostId, thirdPostId]) + await live.deleteUserRows([firstUserId, secondUserId, thirdUserId]) + + try { + await live.db.insert(users).values([ + { id: firstUserId, name: 'apple' }, + { id: secondUserId, name: 'apple' }, + { id: thirdUserId, name: 'berry' }, + ]) + + await live.db.insert(posts).values([ + { id: firstPostId, userId: firstUserId, title: 'A Tale' }, + { id: secondPostId, userId: firstUserId, title: 'Zecora' }, + { id: thirdPostId, userId: secondUserId, title: 'Rainbow Dash' }, + ]) + + let distinctNames = await live.db + .selectDistinct({ name: users.name }) + .from(users) + .where(yql`${users.id} in (${firstUserId}, ${secondUserId}, ${thirdUserId})`) + .orderBy(users.name) + let groupedUsers = await live.db + .select({ userId: posts.userId }) + .from(posts) + .where(yql`${posts.id} in (${firstPostId}, ${secondPostId}, ${thirdPostId})`) + .groupBy(posts.userId) + .having(yql`count(*) > ${1}`) + .orderBy(posts.userId) + let pagedPosts = await live.db + .select({ id: posts.id, title: posts.title }) + .from(posts) + .where(yql`${posts.id} in (${firstPostId}, ${secondPostId}, ${thirdPostId})`) + .orderBy(posts.id) + .limit(2) + .offset(1) + let distinctOnRows = (await live.db + .selectDistinctOn(posts.userId, { userId: posts.userId, title: posts.title }) + .from(posts) + .where(yql`${posts.id} in (${firstPostId}, ${secondPostId}, ${thirdPostId})`) + .orderBy(posts.userId, desc(posts.title))) as Array<{ userId: number; title: string }> + + assert.deepEqual(distinctNames, [{ name: 'apple' }, { name: 'berry' }]) + assert.deepEqual(groupedUsers, [{ userId: firstUserId }]) + assert.deepEqual(pagedPosts, [ + { id: secondPostId, title: 'Zecora' }, + { id: thirdPostId, title: 'Rainbow Dash' }, + ]) + assert.deepEqual( + [...distinctOnRows].sort((left, right) => left.userId - right.userId), + [ + { userId: firstUserId, title: 'Zecora' }, + { userId: secondUserId, title: 'Rainbow Dash' }, + ] + ) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('select distinct'))) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('group by'))) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('row_number() over'))) + } finally { + await live.deletePostRows([firstPostId, secondPostId, thirdPostId]) + await live.deleteUserRows([firstUserId, secondUserId, thirdUserId]) + } +}) + +test('joins and set operators', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert users/posts, verify left join and set-operator reads, then delete all seeded rows' + ) + let firstUserId = live.baseIntId + 261 + let secondUserId = live.baseIntId + 262 + let thirdUserId = live.baseIntId + 263 + let firstPostId = live.baseIntId + 264 + let secondPostId = live.baseIntId + 265 + + live.liveQueryLog.length = 0 + live.log('joins-set-ops', firstUserId, secondUserId, thirdUserId, firstPostId, secondPostId) + await live.deletePostRows([firstPostId, secondPostId]) + await live.deleteUserRows([firstUserId, secondUserId, thirdUserId]) + + try { + await live.db.insert(users).values([ + { id: firstUserId, name: 'apple' }, + { id: secondUserId, name: 'apple' }, + { id: thirdUserId, name: 'berry' }, + ]) + + await live.db.insert(posts).values([ + { id: firstPostId, userId: firstUserId, title: 'Lesson Zero' }, + { id: secondPostId, userId: secondUserId, title: 'Pinkie Keen' }, + ]) + + let leftJoined = await live.db + .select() + .from(users) + .leftJoin(posts, eq(users.id, posts.userId)) + .where(eq(users.id, thirdUserId)) + let unionRows = await live.db + .select({ value: users.name }) + .from(users) + .where(yql`${users.id} in (${firstUserId}, ${secondUserId})`) + .union( + live.db.select({ value: users.name }).from(users).where(eq(users.id, thirdUserId)) + ) + .orderBy((fields: { value: unknown }) => fields.value as any) + let unionAllRows = await live.db + .select({ value: users.name }) + .from(users) + .where(eq(users.id, firstUserId)) + .unionAll( + live.db.select({ value: users.name }).from(users).where(eq(users.id, secondUserId)) + ) + .orderBy((fields: { value: unknown }) => fields.value as any) + let intersectRows = await live.db + .select({ value: users.name }) + .from(users) + .where(yql`${users.id} in (${firstUserId}, ${secondUserId})`) + .intersect( + live.db + .select({ value: users.name }) + .from(users) + .where(yql`${users.id} in (${secondUserId}, ${thirdUserId})`) + ) + let exceptRows = await live.db + .select({ value: users.name }) + .from(users) + .where(yql`${users.id} in (${firstUserId}, ${thirdUserId})`) + .except( + live.db.select({ value: users.name }).from(users).where(eq(users.id, secondUserId)) + ) + + assert.deepEqual(leftJoined, [ + { + [usersTableName]: { id: thirdUserId, name: 'berry' }, + [postsTableName]: null, + }, + ]) + assert.deepEqual(unionRows, [{ value: 'apple' }, { value: 'berry' }]) + assert.deepEqual(unionAllRows, [{ value: 'apple' }, { value: 'apple' }]) + assert.deepEqual(intersectRows, [{ value: 'apple' }]) + assert.deepEqual(exceptRows, [{ value: 'berry' }]) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('left join'))) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes(' union '))) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('union all'))) + assert.ok(live.liveQueryLog.some(({ query }) => query.includes('__ydb_left'))) + } finally { + await live.deletePostRows([firstPostId, secondPostId]) + await live.deleteUserRows([firstUserId, secondUserId, thirdUserId]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/transactions.test.ts b/third-parties/drizzle-adapter/tests/live/transactions.test.ts new file mode 100644 index 00000000..c30c7089 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/transactions.test.ts @@ -0,0 +1,81 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { eq } from 'drizzle-orm' +import { TransactionRollbackError } from 'drizzle-orm/errors' +import { createLiveContext } from './helpers/context.ts' +import { users, usersTableName } from './helpers/schema.ts' + +let live = createLiveContext() + +test('transaction', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'commit one inserted row inside a transaction, then execute a rollback path and verify only committed data remains' + ) + let committedId = live.baseIntId + 301 + let rolledBackId = live.baseIntId + 302 + + live.liveQueryLog.length = 0 + live.log('tx', committedId, rolledBackId) + await live.deleteUserRows([committedId, rolledBackId]) + + try { + await live.db.transaction( + async (tx) => { + await tx.insert(users).values({ id: committedId, name: 'starlight glimmer' }) + + let insideTxRow = await (tx as any).query.users.findFirst({ + where: ( + fields: typeof users, + operators: { eq: (left: unknown, right: unknown) => unknown } + ) => operators.eq(fields.id, committedId), + }) + + assert.deepEqual(insideTxRow, { id: committedId, name: 'starlight glimmer' }) + }, + { accessMode: 'read write', idempotent: false } + ) + + let committedRow = await (live.db as any).query.users.findFirst({ + where: ( + fields: typeof users, + operators: { eq: (left: unknown, right: unknown) => unknown } + ) => operators.eq(fields.id, committedId), + }) + + assert.deepEqual(committedRow, { id: committedId, name: 'starlight glimmer' }) + + await assert.rejects( + async () => + live.db.transaction( + async (tx) => { + await tx.insert(users).values({ id: rolledBackId, name: 'tempest shadow' }) + tx.rollback() + }, + { accessMode: 'read write' } + ), + TransactionRollbackError + ) + + let rolledBackRows = (await live.db + .select() + .from(users) + .where(eq(users.id, rolledBackId))) as Array<{ + id: number + name: string + }> + + assert.deepEqual(rolledBackRows, []) + assert.ok( + live.liveQueryLog.some(({ query }) => + query.includes(`insert into \`${usersTableName}\``) + ) + ) + assert.ok( + live.liveQueryLog.some(({ query }) => query.includes(`from \`${usersTableName}\``)) + ) + } finally { + await live.deleteUserRows([committedId, rolledBackId]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/types-roundtrip.test.ts b/third-parties/drizzle-adapter/tests/live/types-roundtrip.test.ts new file mode 100644 index 00000000..45d08d37 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/types-roundtrip.test.ts @@ -0,0 +1,168 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { eq, sql as yql } from 'drizzle-orm' +import { createLiveContext } from './helpers/context.ts' +import { typesTable, typesTableName } from './helpers/schema.ts' +import { orderSelectedFields } from '../../src/ydb-core/result-mapping.ts' + +let live = createLiveContext() + +test('types round-trip', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one full typed row, read it back, update typed fields, read the updated row, then clean it' + ) + let id = live.baseUint64Id + 1n + let now = new Date() + let initialDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) + let initialDatetime = new Date(Math.floor(now.getTime() / 1000) * 1000) + let initialTimestamp = new Date(now) + let initialBytes = Uint8Array.from(Buffer.from('pony-bytes', 'utf8')) + let initialJson = { pony: 'Pinkie Pie', level: 7 } + let initialJsonDocument = ['Twilight', 'Sparkle'] + let initialUuid = '550e8400-e29b-41d4-a716-446655440000' + let initialYson = Uint8Array.from([ + 60, 97, 61, 49, 62, 91, 51, 59, 37, 102, 97, 108, 115, 101, 93, + ]) + + let updatedBytes = Uint8Array.from(Buffer.from('rainbow-bytes', 'utf8')) + let updatedJson = { pony: 'Rainbow Dash', level: 9 } + let updatedJsonDocument = { team: 'Mane Six' } + let updatedTimestamp = new Date(now.getTime() + 60_000) + let updatedYson = Uint8Array.from([91, 49, 59, 50, 59, 51, 93]) + + live.log('types', id.toString()) + await live.deleteTypeRows([id]) + + try { + await live.db.insert(typesTable).values({ + id, + flag: true, + signed64: -123n, + u32: 42, + f32: 1.5, + f64: 2.75, + bytesValue: initialBytes, + dateValue: initialDate, + datetimeValue: initialDatetime, + timestampValue: initialTimestamp, + jsonValue: initialJson, + jsonDocumentValue: initialJsonDocument, + uuidValue: initialUuid, + ysonValue: initialYson, + }) + + let insertedRows = (await live.db + .select() + .from(typesTable) + .where(eq(typesTable.id, id))) as Array> + + assert.deepEqual(live.normalizeTypeRow(insertedRows[0]!), { + id, + flag: true, + signed64: -123n, + u32: 42, + f32: 1.5, + f64: 2.75, + bytesValue: Array.from(initialBytes), + dateValue: initialDate.toISOString(), + datetimeValue: initialDatetime.toISOString(), + timestampValue: initialTimestamp.toISOString(), + jsonValue: initialJson, + jsonDocumentValue: initialJsonDocument, + uuidValue: initialUuid, + ysonValue: Array.from(initialYson), + }) + + await live.db + .update(typesTable) + .set({ + flag: false, + signed64: 777n, + u32: 99, + f32: 3.25, + f64: 6.5, + bytesValue: updatedBytes, + timestampValue: updatedTimestamp, + jsonValue: updatedJson, + jsonDocumentValue: updatedJsonDocument, + uuidValue: '123e4567-e89b-12d3-a456-426614174000', + ysonValue: updatedYson, + }) + .where(eq(typesTable.id, id)) + + let updatedRows = (await live.db + .select() + .from(typesTable) + .where(eq(typesTable.id, id))) as Array> + + assert.deepEqual(live.normalizeTypeRow(updatedRows[0]!), { + id, + flag: false, + signed64: 777n, + u32: 99, + f32: 3.25, + f64: 6.5, + bytesValue: Array.from(updatedBytes), + dateValue: initialDate.toISOString(), + datetimeValue: initialDatetime.toISOString(), + timestampValue: updatedTimestamp.toISOString(), + jsonValue: updatedJson, + jsonDocumentValue: updatedJsonDocument, + uuidValue: '123e4567-e89b-12d3-a456-426614174000', + ysonValue: Array.from(updatedYson), + }) + } finally { + await live.deleteTypeRows([id]) + } +}) + +test('prepared query decodes typed object rows on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'insert one typed row, read it through session.prepareQuery() in object mode, verify field codecs, then clean it' + ) + + let id = live.baseUint64Id + 2n + let timestampValue = new Date() + let bytesValue = Uint8Array.from(Buffer.from('prepared-bytes', 'utf8')) + let jsonValue = { pony: 'Starlight Glimmer', level: 11 } + + await live.deleteTypeRows([id]) + + try { + await live.db.insert(typesTable).values({ + id, + bytesValue, + jsonValue, + timestampValue, + }) + + let fields = orderSelectedFields({ + id: typesTable.id, + bytesValue: typesTable.bytesValue, + jsonValue: typesTable.jsonValue, + timestampValue: typesTable.timestampValue, + }) + let prepared = live.db._.session.prepareQuery( + yql.raw( + `SELECT \`id\`, \`bytes_value\`, \`json_value\`, \`timestamp_value\` FROM \`${typesTableName}\` WHERE \`id\` = ${id.toString()}` + ), + fields, + 'live_types_prepared_object_mode', + false + ) + let row = (await prepared.get()) as Record + + assert.equal(row['id'], id) + assert.ok(row['bytesValue'] instanceof Uint8Array) + assert.deepEqual(Array.from(row['bytesValue'] as Uint8Array), Array.from(bytesValue)) + assert.deepEqual(row['jsonValue'], jsonValue) + assert.ok(row['timestampValue'] instanceof Date) + assert.equal((row['timestampValue'] as Date).toISOString(), timestampValue.toISOString()) + } finally { + await live.deleteTypeRows([id]) + } +}) diff --git a/third-parties/drizzle-adapter/tests/live/yql-gaps.test.ts b/third-parties/drizzle-adapter/tests/live/yql-gaps.test.ts new file mode 100644 index 00000000..0c71925d --- /dev/null +++ b/third-parties/drizzle-adapter/tests/live/yql-gaps.test.ts @@ -0,0 +1,264 @@ +import { expect, test } from 'vitest' +import { eq, sql as yql } from 'drizzle-orm' +import { + buildAddChangefeedSql, + buildAlterTableSql, + buildAlterTopicSql, + buildAnalyzeSql, + buildCreateTableSql, + buildCreateTopicSql, + buildCreateViewSql, + buildDropChangefeedSql, + buildDropTopicSql, + buildDropViewSql, + buildRenameTableSql, + distinctHint, + groupKey, + index, + integer, + pragma, + rawTableOption, + sessionStart, + sessionWindow, + text, + uint32, + uniqueHint, + windowDefinition, + ydbTable, + yqlScript, +} from '../../src/index.ts' +import { createLiveContext } from './helpers/context.ts' +import { ignoreMissingObject, ignoreUnsupportedYqlFeature } from './helpers/errors.ts' +import { users } from './helpers/schema.ts' + +let live = createLiveContext() + +test('YQL SELECT gap helpers execute on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'seed one user row, execute VALUES/AS_TABLE/WITHOUT/FLATTEN/SAMPLE/TABLESAMPLE/UNIQUE DISTINCT/ASSUME ORDER BY/WINDOW/GROUP helper queries, then delete the seeded row' + ) + + let id = live.baseIntId + 801 + await live.deleteUserRows([id]) + + try { + await live.db.insert(users).values({ id, name: 'select-gaps' }) + + let fromValuesRows = await live.db + .select({ + id: yql`${yql.identifier('id')}`, + name: yql`${yql.identifier('name')}`, + }) + .fromValues( + [ + [2, 'two'], + [1, 'one'], + ], + { alias: 'v', columns: ['id', 'name'] } + ) + .orderBy(yql.identifier('id')) + + let asTableSql = live.db + .select({ + id: yql`${yql.identifier('id')}`, + name: yql`${yql.identifier('name')}`, + }) + .fromAsTable('$rows') + .orderBy(yql.identifier('id')) + .toSQL().sql + let asTableRows = await live.db.execute>( + yql.raw( + [ + '$rows = AsList(', + " AsStruct(2 AS id, CAST('two' AS Utf8) AS name),", + " AsStruct(1 AS id, CAST('one' AS Utf8) AS name)", + ');', + asTableSql, + ].join('\n') + ) + ) + + let withoutRows = await live.db + .select() + .from(users) + .without(users.name) + .where(eq(users.id, id)) + + let flattenedRows = await live.db + .select({ + item: yql`${yql.identifier('items')}`, + }) + .from(yql.raw("(SELECT AsList(CAST('apple' AS Utf8), CAST('berry' AS Utf8)) AS items)")) + .flattenListBy(yql.identifier('items')) + .orderBy(yql.identifier('items')) + + await ignoreUnsupportedYqlFeature('SAMPLE', async () => { + let sampledRows = await live.db + .select({ id: users.id }) + .from(users) + .sample(0.5) + .where(eq(users.id, id)) + .limit(0) + + expect(sampledRows).toEqual([]) + }) + + await ignoreUnsupportedYqlFeature('TABLESAMPLE', async () => { + let tableSampleRows = await live.db + .select({ id: users.id }) + .from(users) + .tableSample('bernoulli', 100) + .where(eq(users.id, id)) + .limit(0) + + expect(tableSampleRows).toEqual([]) + }) + let uniqueGroupedRows = await live.db + .select({ name: users.name, total: yql`count(*)` }) + .from(users) + .where(eq(users.id, id)) + .uniqueDistinct(uniqueHint('name'), distinctHint('name')) + .groupCompactBy(users.name) + .assumeOrderBy('name') + let windowRows = await live.db + .select({ id: users.id, rn: yql`row_number() over w` }) + .from(users) + .where(eq(users.id, id)) + .window('w', windowDefinition({ orderBy: [users.id] })) + await ignoreUnsupportedYqlFeature('SessionWindow', async () => { + let sessionWindowRows = await live.db + .select({ sessionStart: sessionStart(), total: yql`count(*)` }) + .from(yql.raw('(SELECT CurrentUtcTimestamp() AS ts)')) + .groupBy(groupKey(sessionWindow(yql.identifier('ts'), 'PT1H'), 'session_start')) + + expect(sessionWindowRows).toHaveLength(1) + }) + let pragmaRows = await live.db.values<[string]>( + yqlScript(pragma('Warning', ['disable', '1101']), "SELECT CAST('ok' AS Utf8);") + ) + + expect(fromValuesRows).toEqual([ + { id: 1, name: 'one' }, + { id: 2, name: 'two' }, + ]) + expect(asTableRows).toEqual([ + { id: 1, name: 'one' }, + { id: 2, name: 'two' }, + ]) + expect(withoutRows).toEqual([{ id }]) + expect(flattenedRows).toEqual([{ item: 'apple' }, { item: 'berry' }]) + expect(uniqueGroupedRows).toEqual([{ name: 'select-gaps', total: 1n }]) + expect(windowRows).toEqual([{ id, rn: 1n }]) + expect(pragmaRows).toEqual([['ok']]) + } finally { + await live.deleteUserRows([id]) + } +}) + +test('YQL DDL gap helpers execute on live YDB', async (t) => { + if (!live.requireLiveYdb(t)) return + live.describeDbChange( + t, + 'create temp tables/view/topic, execute ANALYZE, multi-action ALTER TABLE, CHANGEFEED, RENAME TABLE, CREATE/DROP VIEW, CREATE/ALTER/DROP TOPIC, then clean all objects' + ) + + let suffix = live.baseIntId + 901 + let tableName = `ddl_gaps_${suffix}` + let renamedTableName = `ddl_gaps_renamed_${suffix}` + let viewName = `ddl_gaps_view_${suffix}` + let topicName = `ddl_gaps_topic_${suffix}` + let usersTable = ydbTable(tableName, { + id: integer('id').notNull().primaryKey(), + name: text('name'), + age: uint32('age'), + }) + let usersTableWithStatus = ydbTable(tableName, { + id: integer('id').notNull().primaryKey(), + name: text('name'), + age: uint32('age'), + status: text('status'), + }) + let ageIndex = index(`${tableName}_age_idx`).on(usersTable.age).build(usersTable) + + await ignoreMissingObject(() => live.db.execute(yql.raw(buildDropTopicSql(topicName)))) + await live.db.execute(yql.raw(buildDropViewSql(viewName, { ifExists: true }))) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${renamedTableName}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + + try { + await live.db.execute(yql.raw(buildCreateTableSql(usersTable))) + await live.db.execute( + yql.raw(`UPSERT INTO \`${tableName}\` (\`id\`, \`name\`) VALUES (1, 'Twilight')`) + ) + await ignoreUnsupportedYqlFeature('ANALYZE', () => + live.db.execute(yql.raw(buildAnalyzeSql(tableName))) + ) + await ignoreUnsupportedYqlFeature('multi-action ALTER TABLE', () => + live.db.execute( + yql.raw( + buildAlterTableSql(tableName, [ + { kind: 'add_column', column: usersTableWithStatus.status }, + { kind: 'add_index', index: ageIndex }, + ]) + ) + ) + ) + await live.db.execute( + yql.raw( + buildAddChangefeedSql(tableName, 'updates_feed', { + mode: 'UPDATES', + format: 'JSON', + }) + ) + ) + await live.db.execute(yql.raw(buildDropChangefeedSql(tableName, 'updates_feed'))) + await live.db.execute(yql.raw(buildRenameTableSql(tableName, renamedTableName))) + + let renamedRows = await live.db.values<[number, string | null]>( + yql.raw(`SELECT \`id\`, \`name\` FROM \`${renamedTableName}\` ORDER BY \`id\``) + ) + expect(renamedRows).toEqual([[1, 'Twilight']]) + + await live.db.execute( + yql.raw( + buildCreateViewSql(viewName, `SELECT \`id\`, \`name\` FROM \`${renamedTableName}\``) + ) + ) + let viewRows = await live.db.values<[number, string | null]>( + yql.raw(`SELECT \`id\`, \`name\` FROM \`${viewName}\` ORDER BY \`id\``) + ) + expect(viewRows).toEqual([[1, 'Twilight']]) + await live.db.execute(yql.raw(buildDropViewSql(viewName, { ifExists: true }))) + + await live.db.execute( + yql.raw( + buildCreateTopicSql(topicName, { + consumers: [{ name: 'audit' }], + settings: { retention_period: rawTableOption("Interval('PT1H')") }, + }) + ) + ) + await ignoreUnsupportedYqlFeature('ALTER TOPIC', () => + live.db.execute( + yql.raw( + buildAlterTopicSql(topicName, [ + { kind: 'add_consumer', consumer: { name: 'analytics' } }, + { + kind: 'alter_consumer_set', + name: 'analytics', + settings: { important: true }, + }, + { kind: 'drop_consumer', name: 'analytics' }, + ]) + ) + ) + ) + } finally { + await ignoreMissingObject(() => live.db.execute(yql.raw(buildDropTopicSql(topicName)))) + await live.db.execute(yql.raw(buildDropViewSql(viewName, { ifExists: true }))) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${renamedTableName}\``)) + await live.db.execute(yql.raw(`DROP TABLE IF EXISTS \`${tableName}\``)) + } +}) diff --git a/third-parties/drizzle-adapter/tests/unit/adapter-methods.test.ts b/third-parties/drizzle-adapter/tests/unit/adapter-methods.test.ts new file mode 100644 index 00000000..40d393c8 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/adapter-methods.test.ts @@ -0,0 +1,303 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { eq, sql as yql } from 'drizzle-orm' +import { drizzle, integer, primaryKey, text, ydbTable } from '../../src/index.ts' +import { YdbDeleteBuilder, YdbInsertBuilder } from '../../src/ydb-core/query-builders/index.ts' +import { dialect, session, users } from '../helpers/unit-basic.ts' + +test('db $with()/with() builds CTE-backed select queries', () => { + let db = drizzle({ + async execute() { + return { rows: [] } + }, + }) + + let sq = db.$with('sq').as( + db + .select({ + id: users.id, + name: users.name, + }) + .from(users) + .where(eq(users.id, 1)) + ) + + let query = db.with(sq).select().from(sq).toSQL() + + assert.ok(query.sql.startsWith('$sq = (select')) + assert.ok(query.sql.includes('select `sq`.`id`, `sq`.`name` from $sq as `sq`')) + assert.deepEqual(query.params, [1]) +}) + +test('db $count() embeds count sql and resolves numeric results', async () => { + let queries: string[] = [] + let db = drizzle({ + async execute(query) { + queries.push(query) + return { rows: [[5]] } + }, + }) + + let result = await db.$count(users, eq(users.id, 1)) + let embedded = dialect.sqlToQuery( + yql`select ${db.$count(users, eq(users.id, 2))} as ${yql.identifier('count')}` + ) + + assert.equal(result, 5) + assert.equal(queries[0], 'select count(*) as count from `users` where `users`.`id` = $p0') + assert.equal( + embedded.sql, + 'select (select count(*) from `users` where `users`.`id` = $p0) as `count`' + ) + assert.deepEqual(embedded.params, [2]) +}) + +test('insert select sql', () => { + let query = dialect.sqlToQuery( + new YdbInsertBuilder(users, session, dialect) + .select((qb) => + qb + .select({ + id: yql`${2}`.as('id'), + name: users.name, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.id, 1)) + ) + .getSQL() + ) + + assert.equal( + query.sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) select $p0 as `id`, `users`.`name`, `users`.`created_at`, `users`.`updated_at` from `users` where `users`.`id` = $p1' + ) + assert.deepEqual(query.params, [2, 1]) +}) + +test('insert select supports partial target columns', () => { + let query = dialect.sqlToQuery( + new YdbInsertBuilder(users, session, dialect) + .select((qb) => + qb + .select({ + id: yql`${2}`.as('id'), + name: users.name, + }) + .from(users) + .where(eq(users.id, 1)) + ) + .getSQL() + ) + + assert.equal( + query.sql, + 'insert into `users` (`id`, `name`) select $p0 as `id`, `users`.`name` from `users` where `users`.`id` = $p1' + ) + assert.deepEqual(query.params, [2, 1]) +}) + +test('upsert and replace select support partial target columns', () => { + let upsertQuery = dialect.sqlToQuery( + drizzle({ + async execute() { + return { rows: [] } + }, + }) + .upsert(users) + .select((qb) => + qb + .select({ + id: yql`${2}`.as('id'), + name: users.name, + }) + .from(users) + ) + .getSQL() + ) + let replaceQuery = dialect.sqlToQuery( + drizzle({ + async execute() { + return { rows: [] } + }, + }) + .replace(users) + .select((qb) => + qb + .select({ + id: yql`${3}`.as('id'), + name: users.name, + }) + .from(users) + ) + .getSQL() + ) + + assert.equal( + upsertQuery.sql, + 'upsert into `users` (`id`, `name`) select $p0 as `id`, `users`.`name` from `users`' + ) + assert.equal( + replaceQuery.sql, + 'replace into `users` (`id`, `name`) select $p0 as `id`, `users`.`name` from `users`' + ) +}) + +test('insert select rejects mismatched fields', () => { + assert.throws( + () => + new YdbInsertBuilder(users, session, dialect).select((qb) => + qb + .select({ + id: users.id, + missing: users.name, + }) + .from(users) + ), + /Insert select error/ + ) +}) + +test('onDuplicateKeyUpdate rejects insert select', () => { + assert.throws( + () => + new YdbInsertBuilder(users, session, dialect) + .select((qb) => + qb + .select({ + id: users.id, + name: users.name, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + ) + .onDuplicateKeyUpdate({ set: { name: 'updated' } }) + .getSQL(), + /does not support insert\(\)\.select/ + ) +}) + +test('onDuplicateKeyUpdate sql', () => { + let plainUsers = ydbTable('plain_users', { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + }) + let query = dialect.sqlToQuery( + new YdbInsertBuilder(plainUsers, session, dialect) + .values({ id: 1, name: 'insert value' }) + .onDuplicateKeyUpdate({ set: { name: 'updated value' } }) + .getSQL() + ) + + assert.ok(query.sql.startsWith('$__ydb_incoming = (select')) + assert.ok(query.sql.includes('upsert into `plain_users` (`id`, `name`) select')) + assert.ok( + query.sql.includes( + 'case when `plain_users`.`id` is null then `__ydb_incoming`.`name` else $p2 end as `name`' + ) + ) + assert.ok(query.sql.includes('from $__ydb_incoming as `__ydb_incoming`')) + assert.deepEqual(query.params, [1, 'insert value', 'updated value']) +}) + +test('onDuplicateKeyUpdate supports table-level primary keys', () => { + let keyedUsers = ydbTable( + 'keyed_users', + { + id: integer('id').notNull(), + name: text('name').notNull(), + }, + (table) => [primaryKey(table.id)] + ) + let query = dialect.sqlToQuery( + new YdbInsertBuilder(keyedUsers, session, dialect) + .values({ id: 1, name: 'insert value' }) + .onDuplicateKeyUpdate({ set: { name: 'updated value' } }) + .getSQL() + ) + + assert.ok(query.sql.includes('upsert into `keyed_users` (`id`, `name`) select')) + assert.ok( + query.sql.includes('left join `keyed_users` on `keyed_users`.`id` = `__ydb_incoming`.`id`') + ) + assert.deepEqual(query.params, [1, 'insert value', 'updated value']) +}) + +test('db exposes native upsert and replace builders', () => { + let db = drizzle({ + async execute() { + return { rows: [] } + }, + }) + + assert.equal( + db.upsert(users).values({ id: 1, name: 'Twilight' }).toSQL().sql, + 'upsert into `users` (`id`, `name`) values ($p0, $p1)' + ) + assert.equal( + db.replace(users).values({ id: 1, name: 'Twilight' }).toSQL().sql, + 'replace into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3)' + ) +}) + +test('db exposes native batch mutation builders', () => { + let db = drizzle({ + async execute() { + return { rows: [] } + }, + }) + + assert.equal( + db.batchUpdate(users).set({ name: 'Twilight' }).where(eq(users.id, 1)).toSQL().sql, + 'batch update `users` set `name` = $p0, `updated_at` = $p1 where `users`.`id` = $p2' + ) + assert.equal( + db.batchDelete(users).where(eq(users.id, 1)).toSQL().sql, + 'batch delete from `users` where `users`.`id` = $p0' + ) +}) + +test('delete using sql', () => { + let keyedUsers = ydbTable('keyed_users', { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + }) + let query = dialect.sqlToQuery( + new YdbDeleteBuilder(keyedUsers, session, dialect) + .using(yql.identifier('posts')) + .where(yql`${keyedUsers.id} = ${yql.identifier('posts')}.${yql.identifier('user_id')}`) + .getSQL() + ) + + assert.equal( + query.sql, + 'delete from `keyed_users` where `keyed_users`.`id` in (select `keyed_users`.`id` from `keyed_users` cross join `posts` where `keyed_users`.`id` = `posts`.`user_id`)' + ) + assert.deepEqual(query.params, []) +}) + +test('session.batch() executes builders sequentially and returns mapped results', async () => { + let db = drizzle({ + async execute(query, _params, _method, options) { + if (query.startsWith('insert into `users`')) { + return { rows: [] } + } + + if (options?.arrayMode) { + return { rows: [[1, 'Rainbow Dash']] } + } + + return { rows: [{ id: 1, name: 'Rainbow Dash' }] } + }, + }) + + let results = await db._.session.batch([ + db.insert(users).values({ id: 1, name: 'Rainbow Dash' }), + db.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, 1)), + ] as const) + + assert.deepEqual(results[0], []) + assert.deepEqual(results[1], [{ id: 1, name: 'Rainbow Dash' }]) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/builder-contract.test.ts b/third-parties/drizzle-adapter/tests/unit/builder-contract.test.ts new file mode 100644 index 00000000..513ea8e3 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/builder-contract.test.ts @@ -0,0 +1,152 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { eq } from 'drizzle-orm' +import { + YdbDeleteBuilder, + YdbInsertBuilder, + YdbReplaceBuilder, + YdbSelectBuilder, + YdbUpdateBuilder, + YdbUpsertBuilder, +} from '../../src/ydb-core/query-builders/index.ts' +import { createMockSession, users } from '../helpers/unit-basic.ts' + +test('builders', async () => { + let { session, prepareCalls } = createMockSession() + + let selectBuilder = new YdbSelectBuilder(session).from(users).where(eq(users.id, 7)) + let insertBuilder = new YdbInsertBuilder(users, session).values({ id: 1, name: 'Twilight' }) + let upsertBuilder = new YdbUpsertBuilder(users, session).values({ id: 2, name: 'Starlight' }) + let replaceBuilder = new YdbReplaceBuilder(users, session).values({ id: 3, name: 'Fluttershy' }) + let updateBuilder = new YdbUpdateBuilder(users, session) + .set({ name: 'Rainbow' }) + .where(eq(users.id, 1)) + let deleteBuilder = new YdbDeleteBuilder(users, session).where(eq(users.id, 1)) + + assert.equal( + selectBuilder.toSQL().sql, + 'select `users`.`id`, `users`.`name`, `users`.`created_at`, `users`.`updated_at` from `users` where `users`.`id` = $p0' + ) + assert.equal( + insertBuilder.toSQL().sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3)' + ) + assert.equal(upsertBuilder.toSQL().sql, 'upsert into `users` (`id`, `name`) values ($p0, $p1)') + assert.equal( + replaceBuilder.toSQL().sql, + 'replace into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3)' + ) + assert.equal( + updateBuilder.toSQL().sql, + 'update `users` set `name` = $p0, `updated_at` = $p1 where `users`.`id` = $p2' + ) + assert.equal(deleteBuilder.toSQL().sql, 'delete from `users` where `users`.`id` = $p0') + + await selectBuilder.prepare('sel_users').execute() + await insertBuilder.prepare('ins_users').execute() + await upsertBuilder.prepare('ups_users').execute() + await replaceBuilder.prepare('rep_users').execute() + await updateBuilder.prepare('upd_users').execute() + await deleteBuilder.prepare('del_users').execute() + + assert.deepEqual( + prepareCalls.map(({ name, isResponseInArrayMode }) => ({ name, isResponseInArrayMode })), + [ + { name: 'sel_users', isResponseInArrayMode: true }, + { name: 'ins_users', isResponseInArrayMode: false }, + { name: 'ups_users', isResponseInArrayMode: false }, + { name: 'rep_users', isResponseInArrayMode: false }, + { name: 'upd_users', isResponseInArrayMode: false }, + { name: 'del_users', isResponseInArrayMode: false }, + ] + ) + + let executedSelect = (await selectBuilder.execute()) as unknown as { prepared: string } + let executedInsert = (await insertBuilder.execute()) as unknown as { prepared: string } + let executedUpsert = (await upsertBuilder.execute()) as unknown as { prepared: string } + let executedReplace = (await replaceBuilder.execute()) as unknown as { prepared: string } + let executedUpdate = (await updateBuilder.execute()) as unknown as { prepared: string } + let executedDelete = (await deleteBuilder.execute()) as unknown as { prepared: string } + + assert.match(executedSelect.prepared, /^select /) + assert.match(executedInsert.prepared, /^insert into /) + assert.match(executedUpsert.prepared, /^upsert into /) + assert.match(executedReplace.prepared, /^replace into /) + assert.match(executedUpdate.prepared, /^update /) + assert.match(executedDelete.prepared, /^delete from /) +}) + +test('returning mutation builders prepare in array mode', async () => { + let { session, prepareCalls } = createMockSession() + + await new YdbInsertBuilder(users, session) + .values({ id: 1, name: 'Twilight' }) + .returning({ id: users.id, name: users.name }) + .prepare('ins_ret') + .execute() + await new YdbUpsertBuilder(users, session) + .values({ id: 2, name: 'Starlight' }) + .returning({ id: users.id }) + .prepare('ups_ret') + .execute() + await new YdbUpdateBuilder(users, session) + .set({ name: 'Rainbow' }) + .returning({ id: users.id }) + .prepare('upd_ret') + .execute() + await new YdbDeleteBuilder(users, session) + .where(eq(users.id, 1)) + .returning({ id: users.id }) + .prepare('del_ret') + .execute() + + assert.deepEqual( + prepareCalls.map(({ name, isResponseInArrayMode, fields }) => ({ + name, + isResponseInArrayMode, + fieldCount: Array.isArray(fields) ? fields.length : 0, + })), + [ + { name: 'ins_ret', isResponseInArrayMode: true, fieldCount: 2 }, + { name: 'ups_ret', isResponseInArrayMode: true, fieldCount: 1 }, + { name: 'upd_ret', isResponseInArrayMode: true, fieldCount: 1 }, + { name: 'del_ret', isResponseInArrayMode: true, fieldCount: 1 }, + ] + ) +}) + +test('builders reject invalid state', () => { + let { session } = createMockSession() + + assert.throws( + () => new YdbSelectBuilder(session).getSQL(), + /Missing table in select\(\)\.from\(\)/ + ) + assert.throws(() => new YdbInsertBuilder(users, session).getSQL(), /Insert values are missing/) + assert.throws( + () => new YdbInsertBuilder(users, session).values([]).getSQL(), + /Insert values are empty/ + ) + assert.throws(() => new YdbUpsertBuilder(users, session).getSQL(), /Upsert values are missing/) + assert.throws( + () => new YdbReplaceBuilder(users, session).values([]).getSQL(), + /Replace values are empty/ + ) + assert.throws(() => new YdbUpdateBuilder(users, session).getSQL(), /Update values are missing/) + assert.throws( + () => new YdbSelectBuilder(session).from(users).limit(-1), + /YDB limit\(\) expects a non-negative finite number/ + ) + assert.throws( + () => new YdbSelectBuilder(session).from(users).offset(-1), + /YDB offset\(\) expects a non-negative finite number/ + ) + assert.throws( + () => new YdbSelectBuilder(session).distinct().distinctOn(users.id), + /cannot combine distinct\(\) and distinctOn\(\)/ + ) + assert.throws( + () => new YdbSelectBuilder(session).distinctOn(users.id).distinct(), + /cannot combine distinct\(\) and distinctOn\(\)/ + ) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/column-types.test.ts b/third-parties/drizzle-adapter/tests/unit/column-types.test.ts new file mode 100644 index 00000000..6ee0d230 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/column-types.test.ts @@ -0,0 +1,330 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { + bigint, + boolean, + bytes, + customType, + date, + date32, + datetime, + datetime64, + decimal, + double, + dyNumber, + float, + int16, + int8, + integer, + interval, + interval64, + json, + jsonDocument, + text, + timestamp, + timestamp64, + uint16, + uint32, + uint64, + uint8, + uuid, + ydbTable, + yson, +} from '../../src/index.ts' +import { YdbDialect } from '../../src/ydb/dialect.ts' +import { YdbInsertBuilder } from '../../src/ydb-core/query-builders/index.ts' +import { YdbSelectBuilder } from '../../src/ydb-core/query-builders/index.ts' +import { YdbSession } from '../../src/ydb-core/session.ts' +import { + Bool, + Date as YdbDate, + Datetime as YdbDatetime, + Double as YdbDouble, + Float as YdbFloat, + Int16 as YdbInt16, + Int64 as YdbInt64, + Int8 as YdbInt8, + Interval as YdbInterval, + Json as YdbJson, + JsonDocument as YdbJsonDocument, + Timestamp as YdbTimestamp, + Uint16 as YdbUint16, + Uint32 as YdbUint32, + Uint64 as YdbUint64, + Uint8 as YdbUint8, + Uuid as YdbUuid, + Yson as YdbYson, +} from '@ydbjs/value/primitive' +import { type SQL, sql as yql } from 'drizzle-orm/sql/sql' + +let dialect = new YdbDialect() +let session = {} as any + +let typesTable = ydbTable('column_types', { + id: integer('id').notNull(), + flag: boolean('flag'), + i8: int8('i8'), + i16: int16('i16'), + signed64: bigint('signed64'), + u8: uint8('u8'), + u16: uint16('u16'), + u32: uint32('u32'), + u64: uint64('u64'), + f32: float('f32'), + f64: double('f64'), + dyNumberValue: dyNumber('dy_number_value'), + bytesValue: bytes('bytes_value'), + dateValue: date('date_value'), + date32Value: date32('date32_value'), + datetimeValue: datetime('datetime_value'), + datetime64Value: datetime64('datetime64_value'), + timestampValue: timestamp('timestamp_value'), + timestamp64Value: timestamp64('timestamp64_value'), + intervalValue: interval('interval_value'), + interval64Value: interval64('interval64_value'), + jsonValue: json('json_value'), + jsonDocumentValue: jsonDocument('json_document_value'), + uuidValue: uuid('uuid_value'), + ysonValue: yson('yson_value'), + decimalValue: decimal('decimal_value', 22, 9), + name: text('name'), +}) + +function toQuery(builder: { getSQL(): any }) { + return dialect.sqlToQuery(builder.getSQL()) +} + +function typeRow(values: { + id?: unknown + flag?: unknown + i8?: unknown + i16?: unknown + signed64?: unknown + u8?: unknown + u16?: unknown + u32?: unknown + u64?: unknown + f32?: unknown + f64?: unknown + dyNumberValue?: unknown + bytesValue?: unknown + dateValue?: unknown + date32Value?: unknown + datetimeValue?: unknown + datetime64Value?: unknown + timestampValue?: unknown + timestamp64Value?: unknown + intervalValue?: unknown + interval64Value?: unknown + jsonValue?: unknown + jsonDocumentValue?: unknown + uuidValue?: unknown + ysonValue?: unknown + decimalValue?: unknown + name?: unknown +}) { + return [ + values.id ?? null, + values.flag ?? null, + values.i8 ?? null, + values.i16 ?? null, + values.signed64 ?? null, + values.u8 ?? null, + values.u16 ?? null, + values.u32 ?? null, + values.u64 ?? null, + values.f32 ?? null, + values.f64 ?? null, + values.dyNumberValue ?? null, + values.bytesValue ?? null, + values.dateValue ?? null, + values.date32Value ?? null, + values.datetimeValue ?? null, + values.datetime64Value ?? null, + values.timestampValue ?? null, + values.timestamp64Value ?? null, + values.intervalValue ?? null, + values.interval64Value ?? null, + values.jsonValue ?? null, + values.jsonDocumentValue ?? null, + values.uuidValue ?? null, + values.ysonValue ?? null, + values.decimalValue ?? null, + values.name ?? null, + ] +} + +test('sql types', () => { + assert.equal(typesTable.id.getSQLType(), 'Int32') + assert.equal(typesTable.flag.getSQLType(), 'Bool') + assert.equal(typesTable.i8.getSQLType(), 'Int8') + assert.equal(typesTable.i16.getSQLType(), 'Int16') + assert.equal(typesTable.signed64.getSQLType(), 'Int64') + assert.equal(typesTable.u8.getSQLType(), 'Uint8') + assert.equal(typesTable.u16.getSQLType(), 'Uint16') + assert.equal(typesTable.u32.getSQLType(), 'Uint32') + assert.equal(typesTable.u64.getSQLType(), 'Uint64') + assert.equal(typesTable.f32.getSQLType(), 'Float') + assert.equal(typesTable.f64.getSQLType(), 'Double') + assert.equal(typesTable.dyNumberValue.getSQLType(), 'DyNumber') + assert.equal(typesTable.bytesValue.getSQLType(), 'String') + assert.equal(typesTable.dateValue.getSQLType(), 'Date') + assert.equal(typesTable.date32Value.getSQLType(), 'Date32') + assert.equal(typesTable.datetimeValue.getSQLType(), 'Datetime') + assert.equal(typesTable.datetime64Value.getSQLType(), 'Datetime64') + assert.equal(typesTable.timestampValue.getSQLType(), 'Timestamp') + assert.equal(typesTable.timestamp64Value.getSQLType(), 'Timestamp64') + assert.equal(typesTable.intervalValue.getSQLType(), 'Interval') + assert.equal(typesTable.interval64Value.getSQLType(), 'Interval64') + assert.equal(typesTable.jsonValue.getSQLType(), 'Json') + assert.equal(typesTable.jsonDocumentValue.getSQLType(), 'JsonDocument') + assert.equal(typesTable.uuidValue.getSQLType(), 'Uuid') + assert.equal(typesTable.ysonValue.getSQLType(), 'Yson') + assert.equal(typesTable.decimalValue.getSQLType(), 'Decimal(22, 9)') + assert.equal(typesTable.name.getSQLType(), 'Utf8') +}) + +test('insert codecs', () => { + let now = new Date() + let rowDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) + let rowDatetime = new Date(Math.floor(now.getTime() / 1000) * 1000) + let rowTimestamp = new Date(now) + let rowYson = Buffer.from('[3;%false]') + + let query = toQuery( + new YdbInsertBuilder(typesTable, session).values({ + id: 1, + flag: true, + i8: -8, + i16: -16, + signed64: -123n, + u8: 8, + u16: 16, + u32: 42, + u64: 9007199254740993n, + f32: 1.5, + f64: 2, + dyNumberValue: '1234567890.123', + bytesValue: Buffer.from([1, 2, 3]), + dateValue: rowDate, + date32Value: rowDate, + datetimeValue: rowDatetime, + datetime64Value: rowDatetime, + timestampValue: rowTimestamp, + timestamp64Value: rowTimestamp, + intervalValue: 123456, + interval64Value: 123456789n, + jsonValue: { pony: 'Pinkie Pie' }, + jsonDocumentValue: ['Twilight', 'Sparkle'], + uuidValue: '550e8400-e29b-41d4-a716-446655440000', + ysonValue: rowYson, + decimalValue: '123.456', + name: 'Rarity', + }) + ) + + assert.equal( + query.sql, + 'insert into `column_types` (`id`, `flag`, `i8`, `i16`, `signed64`, `u8`, `u16`, `u32`, `u64`, `f32`, `f64`, `dy_number_value`, `bytes_value`, `date_value`, `date32_value`, `datetime_value`, `datetime64_value`, `timestamp_value`, `timestamp64_value`, `interval_value`, `interval64_value`, `json_value`, `json_document_value`, `uuid_value`, `yson_value`, `decimal_value`, `name`) values ($p0, $p1, $p2, $p3, $p4, $p5, $p6, $p7, $p8, $p9, $p10, $p11, $p12, $p13, $p14, $p15, $p16, $p17, $p18, $p19, $p20, $p21, $p22, $p23, $p24, Decimal("123.456", 22, 9), $p25)' + ) + + assert.equal(query.params[0], 1) + assert.ok(query.params[1] instanceof Bool) + assert.ok(query.params[2] instanceof YdbInt8) + assert.ok(query.params[3] instanceof YdbInt16) + assert.ok(query.params[4] instanceof YdbInt64) + assert.ok(query.params[5] instanceof YdbUint8) + assert.ok(query.params[6] instanceof YdbUint16) + assert.ok(query.params[7] instanceof YdbUint32) + assert.ok(query.params[8] instanceof YdbUint64) + assert.ok(query.params[9] instanceof YdbFloat) + assert.ok(query.params[10] instanceof YdbDouble) + assert.equal((query.params[11] as { type?: { id?: unknown } }).type?.id, 4866) + assert.ok(query.params[12] instanceof Uint8Array) + assert.ok(query.params[13] instanceof YdbDate) + assert.equal((query.params[14] as { type?: { id?: unknown } }).type?.id, 64) + assert.ok(query.params[15] instanceof YdbDatetime) + assert.equal((query.params[16] as { type?: { id?: unknown } }).type?.id, 65) + assert.ok(query.params[17] instanceof YdbTimestamp) + assert.equal((query.params[18] as { type?: { id?: unknown } }).type?.id, 66) + assert.ok(query.params[19] instanceof YdbInterval) + assert.equal((query.params[20] as { type?: { id?: unknown } }).type?.id, 67) + assert.ok(query.params[21] instanceof YdbJson) + assert.ok(query.params[22] instanceof YdbJsonDocument) + assert.ok(query.params[23] instanceof YdbUuid) + assert.ok(query.params[24] instanceof YdbYson) + assert.equal(query.params[25], 'Rarity') +}) + +test('decimal rejects invalid', () => { + assert.throws( + () => + toQuery( + new YdbInsertBuilder(typesTable, session).values({ + id: 1, + decimalValue: '12e3', + } as any) + ), + /Invalid decimal value: 12e3/ + ) +}) + +test('decimal supports inferred column names', () => { + let inferredDecimalTable = ydbTable('inferred_decimal', { + id: integer('id').notNull(), + amount: decimal(22, 9), + }) + + assert.equal(inferredDecimalTable.amount.name, 'amount') + assert.equal(inferredDecimalTable.amount.getSQLType(), 'Decimal(22, 9)') +}) + +test('customType', () => { + let slugType = customType<{ data: string; driverData: SQL }>({ + dataType() { + return 'Utf8' + }, + toDriver(value) { + return yql.raw(`Utf8("${value.toUpperCase()}")`) + }, + }) + + let customTable = ydbTable('custom_types', { + id: integer('id').notNull(), + slug: slugType('slug'), + }) + + let query = toQuery(new YdbInsertBuilder(customTable, session).values({ id: 1, slug: 'pony' })) + + assert.equal(customTable.slug.getSQLType(), 'Utf8') + assert.equal(query.sql, 'insert into `custom_types` (`id`, `slug`) values ($p0, Utf8("PONY"))') + assert.deepEqual(query.params, [1]) +}) + +test('select decoders', async () => { + let mockClient = { + execute: async () => ({ + rows: [ + typeRow({ + id: 1, + bytesValue: [1, 2, 3], + ysonValue: '[3;%false]', + }), + ], + }), + } as any + let mockSession = new YdbSession(mockClient, dialect) + + let [row] = (await new YdbSelectBuilder(mockSession).from(typesTable).execute()) as Array< + Record + > + + assert.equal(row['id'], 1) + assert.ok(row['bytesValue'] instanceof Uint8Array) + assert.deepEqual(Array.from(row['bytesValue'] as Uint8Array), [1, 2, 3]) + assert.ok(row['ysonValue'] instanceof Uint8Array) + assert.deepEqual( + Array.from(row['ysonValue'] as Uint8Array), + Array.from(Buffer.from('[3;%false]', 'latin1')) + ) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/create-drizzle.logger.test.ts b/third-parties/drizzle-adapter/tests/unit/create-drizzle.logger.test.ts new file mode 100644 index 00000000..531b84bf --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/create-drizzle.logger.test.ts @@ -0,0 +1,39 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { sql as yql } from 'drizzle-orm' +import { drizzle, integer, text, ydbTable } from '../../src/index.ts' + +let users = ydbTable('users', { + id: integer('id').notNull(), + name: text('name').notNull(), +}) + +test('logger', async () => { + let logs: Array<{ query: string; params: unknown[] }> = [] + + let db = drizzle( + { + async execute(query, params) { + return { + rows: query.startsWith('select') ? [{ value: params[0] }] : [], + } + }, + }, + { + logger: { + logQuery(query, params) { + logs.push({ query, params: [...params] }) + }, + }, + } + ) + + await db.execute<{ value: number }[]>(yql`select ${123} as value`) + await db.insert(users).values({ id: 1, name: 'Pinkie Pie' }) + + assert.equal(logs.length, 2) + assert.equal(logs[0]?.query, 'select $p0 as value') + assert.deepEqual(logs[0]?.params, [123]) + assert.equal(logs[1]?.query, 'insert into `users` (`id`, `name`) values ($p0, $p1)') + assert.deepEqual(logs[1]?.params, [1, 'Pinkie Pie']) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/create-drizzle.schema.test.ts b/third-parties/drizzle-adapter/tests/unit/create-drizzle.schema.test.ts new file mode 100644 index 00000000..3e6ebe58 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/create-drizzle.schema.test.ts @@ -0,0 +1,102 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { relations } from 'drizzle-orm' +import { drizzle, integer, text, ydbTable } from '../../src/index.ts' + +let users = ydbTable('users', { + id: integer('id').notNull(), + name: text('name').notNull(), +}) + +let posts = ydbTable('posts', { + id: integer('id').notNull(), + authorId: integer('author_id').notNull(), + title: text('title').notNull(), +}) + +let schema = { + users, + posts, + usersRelations: relations(users, ({ many }) => ({ + posts: many(posts), + })), + postsRelations: relations(posts, ({ one }) => ({ + author: one(users, { + fields: [posts.authorId], + references: [users.id], + }), + })), +} + +test('schema', async () => { + let executedQueries: string[] = [] + let buildObjectRows = (query: string, values: unknown[]) => { + let aliases = Array.from(query.matchAll(/ as `([^`]+)`/g), (match) => match[1]!) + return [Object.fromEntries(aliases.map((alias, index) => [alias, values[index]]))] + } + + let db = drizzle( + { + async execute(query, _params, _method, options) { + executedQueries.push(query) + + if (query.includes('from `users`')) { + return { + rows: options?.arrayMode + ? [[1, 'Twilight Sparkle']] + : buildObjectRows(query, [1, 'Twilight Sparkle']), + } + } + + return { rows: [] } + }, + }, + { schema } + ) + + let relationalSchema = (db as any)._.schema + + assert.deepEqual(Object.keys(relationalSchema).sort(), ['posts', 'users']) + assert.equal(relationalSchema.users.tsName, 'users') + assert.equal(relationalSchema.posts.dbName, 'posts') + assert.ok(Object.values((db as any)._.tableNamesMap).includes('users')) + assert.deepEqual(Object.keys((db as any).query).sort(), ['posts', 'users']) + assert.equal(Object.hasOwn((db as any).query, 'users'), true) + assert.equal('users' in (db as any).query, true) + assert.equal('missing' in (db as any).query, false) + assert.ok((db as any).query.users) + assert.ok((db as any).query.posts) + assert.equal((db as any).query.users, (db as any).query.users) + + let many = await (db as any).query.users.findMany() + let first = await (db as any).query.users.findFirst({ + where: (fields: typeof users, { eq }: { eq: (left: unknown, right: unknown) => unknown }) => + eq(fields.id, 1), + }) + + let firstViaGet = await db.get<{ id: number; name: string }>( + (db as any).query.users.findFirst({ + where: ( + fields: typeof users, + { eq }: { eq: (left: unknown, right: unknown) => unknown } + ) => eq(fields.id, 1), + }) + ) + + assert.deepEqual(many, [{ id: 1, name: 'Twilight Sparkle' }]) + assert.deepEqual(first, { id: 1, name: 'Twilight Sparkle' }) + assert.deepEqual(firstViaGet, { id: 1, name: 'Twilight Sparkle' }) + assert.equal(executedQueries.length, 3) + assert.match( + executedQueries[0] ?? '', + /^select `users`\.`id` as `__ydb_c0`, `users`\.`name` as `__ydb_c1` from `users` `users`$/ + ) + assert.match( + executedQueries[1] ?? '', + /^select `users`\.`id` as `__ydb_c0`, `users`\.`name` as `__ydb_c1` from `users` `users` where `users`\.`id` = \$p0 limit \$p1$/ + ) + assert.match( + executedQueries[2] ?? '', + /^select `users`\.`id` as `__ydb_c0`, `users`\.`name` as `__ydb_c1` from `users` `users` where `users`\.`id` = \$p0 limit \$p1$/ + ) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/create-drizzle.transaction.test.ts b/third-parties/drizzle-adapter/tests/unit/create-drizzle.transaction.test.ts new file mode 100644 index 00000000..61c12ad9 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/create-drizzle.transaction.test.ts @@ -0,0 +1,143 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { relations } from 'drizzle-orm' +import { TransactionRollbackError } from 'drizzle-orm/errors' +import { drizzle, integer, text, ydbTable } from '../../src/index.ts' + +let users = ydbTable('users', { + id: integer('id').notNull(), + name: text('name').notNull(), +}) + +let posts = ydbTable('posts', { + id: integer('id').notNull(), + authorId: integer('author_id').notNull(), + title: text('title').notNull(), +}) + +let schema = { + users, + posts, + usersRelations: relations(users, ({ many }) => ({ + posts: many(posts), + })), + postsRelations: relations(posts, ({ one }) => ({ + author: one(users, { + fields: [posts.authorId], + references: [users.id], + }), + })), +} + +test('transaction commit', async () => { + let transactionConfigs: unknown[] = [] + let logs: Array<{ query: string; params: unknown[] }> = [] + let buildObjectRows = (query: string, values: unknown[]) => { + let aliases = Array.from(query.matchAll(/ as `([^`]+)`/g), (match) => match[1]!) + return [Object.fromEntries(aliases.map((alias, index) => [alias, values[index]]))] + } + let executeInStore = async ( + query: string, + _params: unknown[], + options?: { arrayMode?: boolean } + ) => { + if (query.startsWith('select')) { + return { + rows: options?.arrayMode + ? [[1, 'Rainbow Dash']] + : buildObjectRows(query, [1, 'Rainbow Dash']), + } + } + + return { rows: [] } + } + + let db = drizzle( + { + async execute(query, params, _method, options) { + return executeInStore(query, params, options) + }, + async transaction(callback, config) { + transactionConfigs.push(config) + return callback({ + async execute(query, params, _method, options) { + return executeInStore(query, params, options) + }, + }) + }, + }, + { + schema, + logger: { + logQuery(query, params) { + logs.push({ query, params: [...params] }) + }, + }, + } + ) + + let result = await db.transaction( + async (tx) => { + await tx.insert(users).values({ id: 1, name: 'Rainbow Dash' }) + + let row = await tx.query.users.findFirst({ + where: (fields, { eq }) => eq(fields.id, 1), + }) + + return { + row, + hasSchema: !!tx._.schema?.users, + } + }, + { accessMode: 'read write', idempotent: false } + ) + + assert.deepEqual(result, { + row: { id: 1, name: 'Rainbow Dash' }, + hasSchema: true, + }) + assert.deepEqual(transactionConfigs, [{ accessMode: 'read write', idempotent: false }]) + assert.ok(logs.some(({ query }) => query.startsWith('insert into `users`'))) + assert.ok( + logs.some(({ query }) => + query.startsWith( + 'select `users`.`id` as `__ydb_c0`, `users`.`name` as `__ydb_c1` from `users` `users`' + ) + ) + ) +}) + +test('transaction rollback', async () => { + let rolledBack = false + + let db = drizzle( + { + async execute() { + return { rows: [] } + }, + async transaction(callback) { + try { + return await callback({ + async execute() { + return { rows: [] } + }, + }) + } catch (error) { + rolledBack = true + throw new Error('Transaction failed.', { cause: error }) + } + }, + }, + { schema } + ) + + await assert.rejects( + async () => + db.transaction(async (tx) => { + tx.rollback() + }), + TransactionRollbackError + ) + + assert.equal(rolledBack, true) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/dialect.methods.test.ts b/third-parties/drizzle-adapter/tests/unit/dialect.methods.test.ts new file mode 100644 index 00000000..191c5142 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/dialect.methods.test.ts @@ -0,0 +1,380 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { aliasedTable } from 'drizzle-orm/alias' +import { createTableRelationsHelpers, extractTablesRelationalConfig } from 'drizzle-orm/relations' +import { eq, sql as yql } from 'drizzle-orm' +import { WithSubquery } from 'drizzle-orm/subquery' +import { date, datetime, decimal, json, text, timestamp, uuid, ydbTable } from '../../src/index.ts' +import { YdbDialect } from '../../src/ydb/dialect.ts' +import { YdbSelectBuilder } from '../../src/ydb-core/query-builders/index.ts' +import { orderSelectedFields } from '../../src/ydb-core/result-mapping.ts' +import { posts, session, users } from '../helpers/unit-basic.ts' + +let dialect = new YdbDialect() + +let typedTable = ydbTable('typed_values', { + name: text('name'), + dateValue: date('date_value'), + datetimeValue: datetime('datetime_value'), + timestampValue: timestamp('timestamp_value'), + jsonValue: json('json_value'), + uuidValue: uuid('uuid_value'), + decimalValue: decimal('decimal_value', 22, 9), +}) + +test('prepareTyping maps YDB column encoders to Drizzle typings', () => { + assert.equal(dialect.prepareTyping(), 'none') + assert.equal(dialect.prepareTyping(typedTable.name), 'none') + assert.equal(dialect.prepareTyping(typedTable.dateValue), 'date') + assert.equal(dialect.prepareTyping(typedTable.datetimeValue), 'timestamp') + assert.equal(dialect.prepareTyping(typedTable.timestampValue), 'timestamp') + assert.equal(dialect.prepareTyping(typedTable.jsonValue), 'json') + assert.equal(dialect.prepareTyping(typedTable.uuidValue), 'uuid') + assert.equal(dialect.prepareTyping(typedTable.decimalValue), 'decimal') +}) + +test('direct dialect fragment helpers build selection, table, join and tail clauses', () => { + let aliasedUsers = aliasedTable(users, 'u') + let fields = orderSelectedFields({ userId: users.id, postTitle: posts.title }) + let selectionAliases = ['user_id_alias', 'post_title_alias'] + let mappedOrderBy = dialect.mapExpressionsToSelectionAliases( + [users.id, yql`${posts.title} desc`], + fields, + selectionAliases, + 'orderBy()' + ) + + let built = dialect.sqlToQuery( + yql`select ${dialect.buildSelection( + fields, + selectionAliases + )} from ${dialect.buildFromTable(users)}${dialect.buildJoins([ + { table: posts, joinType: 'left', on: eq(users.id, posts.userId) }, + ])}${dialect.buildOrderBy(mappedOrderBy)}${dialect.buildLimit(3)}${dialect.buildOffset(2)}` + ) + + assert.equal( + built.sql, + 'select `users`.`id` as `user_id_alias`, `posts`.`title` as `post_title_alias` from `users` left join `posts` on `users`.`id` = `posts`.`user_id` order by `user_id_alias`, `post_title_alias` desc limit $p0 offset $p1' + ) + assert.deepEqual(built.params, [3, 2]) + + assert.equal( + dialect.sqlToQuery(yql`${dialect.buildFromTable(aliasedUsers)}`).sql, + '`users` `u`' + ) + assert.equal( + dialect.sqlToQuery(yql`${dialect.buildOrderBy(mappedOrderBy)}`).sql, + ' order by `user_id_alias`, `post_title_alias` desc' + ) + + assert.throws( + () => + dialect.mapExpressionsToSelectionAliases( + [posts.userId], + fields, + selectionAliases, + 'orderBy()' + ), + /can only reference selected fields/ + ) +}) + +test('direct dialect set-operation helpers build single and chained set queries', () => { + let leftSelectBuilder = new YdbSelectBuilder(session, { value: users.name }) + .from(users) + .where(eq(users.id, 1)) + let rightUnionBuilder = new YdbSelectBuilder(session, { value: posts.title }) + .from(posts) + .where(eq(posts.userId, 1)) + let rightExceptBuilder = new YdbSelectBuilder(session, { value: users.name }) + .from(users) + .where(eq(users.id, 2)) + + let fields = orderSelectedFields(leftSelectBuilder.getSelectedFields()) + let selectionAliases = dialect.getSelectionAliases(fields) + let leftSelect = leftSelectBuilder.getSQL(selectionAliases) + + let single = dialect.sqlToQuery( + dialect.buildSetOperationQuery(leftSelect, fields, selectionAliases, { + type: 'union', + isAll: true, + rightSelect: rightUnionBuilder, + orderBy: [users.name], + limit: 2, + offset: 1, + }) + ) + + assert.equal( + single.sql, + 'select `users`.`name` as `__ydb_f0` from `users` where `users`.`id` = $p0 union all select `posts`.`title` as `__ydb_f0` from `posts` where `posts`.`user_id` = $p1 order by `__ydb_f0` limit $p2 offset $p3' + ) + assert.deepEqual(single.params, [1, 1, 2, 1]) + + let chained = dialect.sqlToQuery( + dialect.buildSetOperations(leftSelect, fields, selectionAliases, [ + { + type: 'union', + isAll: true, + rightSelect: rightUnionBuilder, + }, + { + type: 'except', + isAll: false, + rightSelect: rightExceptBuilder, + }, + ]) + ) + + assert.equal( + chained.sql, + 'select distinct `__ydb_left`.`__ydb_f0` as `__ydb_f0` from (select `users`.`name` as `__ydb_f0` from `users` where `users`.`id` = $p0 union all select `posts`.`title` as `__ydb_f0` from `posts` where `posts`.`user_id` = $p1) as `__ydb_left` left join (select `__ydb_right_input`.`__ydb_f0` as `__ydb_f0`, 1 as `__ydb_match` from (select `users`.`name` as `__ydb_f0` from `users` where `users`.`id` = $p2) as `__ydb_right_input`) as `__ydb_right` on `__ydb_left`.`__ydb_f0` = `__ydb_right`.`__ydb_f0` where `__ydb_right`.`__ydb_match` is null' + ) + assert.deepEqual(chained.params, [1, 1, 2]) +}) + +test('buildWithCTE, buildInsertQuery, buildUpdateSet, buildUpdateQuery and buildDeleteQuery', () => { + let ponyCte = new WithSubquery( + yql`select ${1} as ${yql.identifier('id')}`, + { id: users.id } as any, + 'pony_cte' + ) + let withQuery = dialect.sqlToQuery( + yql`${dialect.buildWithCTE([ponyCte])}select * from ${yql.raw('$pony_cte')} as ${yql.identifier('pony_cte')}` + ) + + assert.equal( + withQuery.sql, + '$pony_cte = (select $p0 as `id`); select * from $pony_cte as `pony_cte`' + ) + assert.deepEqual(withQuery.params, [1]) + + let insertQuery = dialect.sqlToQuery( + dialect.buildInsertQuery({ + table: users, + values: [{ id: 1, name: 'Twilight Sparkle' }], + }) + ) + assert.equal( + insertQuery.sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3)' + ) + assert.deepEqual(insertQuery.params, [1, 'Twilight Sparkle', 100, 200]) + + let insertSelectQuery = dialect.sqlToQuery( + dialect.buildInsertQuery({ + table: users, + select: true, + values: yql`select ${2}, ${'Pinkie Pie'}, ${100}, ${200}`, + }) + ) + assert.equal( + insertSelectQuery.sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) select $p0, $p1, $p2, $p3' + ) + assert.deepEqual(insertSelectQuery.params, [2, 'Pinkie Pie', 100, 200]) + + let upsertReturningQuery = dialect.sqlToQuery( + dialect.buildInsertQuery({ + table: users, + command: 'upsert', + values: [{ id: 3, name: 'Rarity' }], + returning: orderSelectedFields({ id: users.id, name: users.name }), + }) + ) + assert.equal( + upsertReturningQuery.sql, + 'upsert into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3) returning `id`, `name`' + ) + assert.deepEqual(upsertReturningQuery.params, [3, 'Rarity', 100, 200]) + + let setQuery = dialect.sqlToQuery(yql`${dialect.buildUpdateSet(users, { name: 'Fluttershy' })}`) + assert.equal(setQuery.sql, '`name` = $p0, `updated_at` = $p1') + assert.deepEqual(setQuery.params, ['Fluttershy', 200]) + + let updateQuery = dialect.sqlToQuery( + dialect.buildUpdateQuery({ + table: users, + set: { name: 'Fluttershy' }, + where: eq(users.id, 5), + }) + ) + assert.equal( + updateQuery.sql, + 'update `users` set `name` = $p0, `updated_at` = $p1 where `users`.`id` = $p2' + ) + assert.deepEqual(updateQuery.params, ['Fluttershy', 200, 5]) + + let updateReturningQuery = dialect.sqlToQuery( + dialect.buildUpdateQuery({ + table: users, + set: { name: 'Fluttershy' }, + where: eq(users.id, 5), + returning: orderSelectedFields({ id: users.id, name: users.name }), + }) + ) + assert.equal( + updateReturningQuery.sql, + 'update `users` set `name` = $p0, `updated_at` = $p1 where `users`.`id` = $p2 returning `id`, `name`' + ) + assert.deepEqual(updateReturningQuery.params, ['Fluttershy', 200, 5]) + + let updateOnQuery = dialect.sqlToQuery( + dialect.buildUpdateQuery({ + table: users, + on: yql`select ${1} as ${yql.identifier('id')}, ${'Twilight'} as ${yql.identifier('name')}`, + }) + ) + assert.equal(updateOnQuery.sql, 'update `users` on select $p0 as `id`, $p1 as `name`') + assert.deepEqual(updateOnQuery.params, [1, 'Twilight']) + + let deleteQuery = dialect.sqlToQuery( + dialect.buildDeleteQuery({ + table: users, + where: eq(users.id, 7), + }) + ) + assert.equal(deleteQuery.sql, 'delete from `users` where `users`.`id` = $p0') + assert.deepEqual(deleteQuery.params, [7]) + + let deleteReturningQuery = dialect.sqlToQuery( + dialect.buildDeleteQuery({ + table: users, + where: eq(users.id, 7), + returning: orderSelectedFields({ id: users.id, name: users.name }), + }) + ) + assert.equal( + deleteReturningQuery.sql, + 'delete from `users` where `users`.`id` = $p0 returning `id`, `name`' + ) + assert.deepEqual(deleteReturningQuery.params, [7]) + + let deleteOnQuery = dialect.sqlToQuery( + dialect.buildDeleteQuery({ + table: users, + on: yql`select ${1} as ${yql.identifier('id')}`, + }) + ) + assert.equal(deleteOnQuery.sql, 'delete from `users` on select $p0 as `id`') + assert.deepEqual(deleteOnQuery.params, [1]) +}) + +test('buildRelationalQueryWithoutPK builds flat schema-aware queries', () => { + let schema = { users } + let tablesConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers) + let relationalQuery = dialect.buildRelationalQueryWithoutPK({ + fullSchema: schema, + schema: tablesConfig.tables as any, + tableNamesMap: tablesConfig.tableNamesMap, + table: users, + tableConfig: (tablesConfig.tables as any).users, + queryConfig: { + columns: { id: true, name: true }, + where: (fields, operators) => operators.eq(fields['id'], 7), + orderBy: (fields, operators) => operators.desc(fields['id']), + limit: 1, + offset: 2, + }, + tableAlias: 'users_rel', + }) + + let query = dialect.sqlToQuery(relationalQuery.sql as any) + + assert.equal( + query.sql, + 'select `users_rel`.`id`, `users_rel`.`name` from `users` `users_rel` where `users_rel`.`id` = $p0 order by `users_rel`.`id` desc limit $p1 offset $p2' + ) + assert.deepEqual(query.params, [7, 1, 2]) + assert.deepEqual( + relationalQuery.selection.map(({ tsKey, dbKey }) => ({ tsKey, dbKey })), + [ + { tsKey: 'id', dbKey: 'id' }, + { tsKey: 'name', dbKey: 'name' }, + ] + ) +}) + +test('dialect.migrate bootstraps bookkeeping, skips applied hashes and records migration names', async () => { + let calls: string[] = [] + let rows: Array = [ + ['hash_1', 1, '0001_existing', 'applied', null, null, null, null, null, null], + ] + + let migrationSession = { + async execute(query: any) { + let built = dialect.sqlToQuery(query) + calls.push(built.sql) + + let upsertMatch = built.sql.match( + /UPSERT INTO .* VALUES \( '([^']+)', ([0-9]+), '([^']+)', '([^']+)',/u + ) + if (upsertMatch) { + let nextRow = [ + upsertMatch[1]!, + Number(upsertMatch[2]), + upsertMatch[3]!, + upsertMatch[4]!, + null, + null, + null, + null, + null, + null, + ] + let existingIndex = rows.findIndex((row) => row[0] === nextRow[0]) + if (existingIndex >= 0) { + rows[existingIndex] = nextRow + } else { + rows.unshift(nextRow) + } + } + + return { rows: [] } + }, + async values(query: any) { + let built = dialect.sqlToQuery(query) + calls.push(built.sql) + if (built.sql.startsWith('SELECT `status` FROM `')) { + return [] + } + + return rows.map((row) => [...row]) + }, + } + + await dialect.migrate( + [ + { + name: '0001_existing', + folderMillis: 1, + hash: 'hash_1', + bps: false, + sql: ['select 1'], + }, + { + name: '0002_new', + folderMillis: 2, + hash: 'hash_2', + bps: false, + sql: ['select 2', ''], + }, + ], + migrationSession as any, + { migrationsTable: '__dialect_migrations', migrationLock: false } + ) + + assert.ok(calls[0]?.startsWith('CREATE TABLE IF NOT EXISTS `__dialect_migrations`')) + assert.ok( + calls.some((call) => + call.startsWith( + 'SELECT `hash`, `created_at`, `name`, `status`, `started_at`, `finished_at`, `error`, `owner_id`, `statements_total`, `statements_applied` FROM `__dialect_migrations`' + ) + ) + ) + assert.ok(!calls.includes('select 1')) + assert.ok(calls.includes('select 2')) + assert.ok(calls.some((call) => call.includes("VALUES ( 'hash_2', 2, '0002_new', 'applied'"))) + assert.deepEqual(rows[0]?.slice(0, 4), ['hash_2', 2, '0002_new', 'applied']) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/dialect.test.ts b/third-parties/drizzle-adapter/tests/unit/dialect.test.ts new file mode 100644 index 00000000..47a1106d --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/dialect.test.ts @@ -0,0 +1,18 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { sql as yql } from 'drizzle-orm' +import { YdbDialect } from '../../src/ydb/dialect.ts' + +test('dialect', () => { + let dialect = new YdbDialect({ casing: 'snake_case' }) + + assert.equal(dialect.escapeName('pony`name'), '`pony``name`') + assert.equal(dialect.escapeParam(7), '$p7') + assert.equal(dialect.escapeString("Pinkie's pie"), "'Pinkie''s pie'") + assert.equal(dialect.prepareTyping(), 'none') + + let query = dialect.sqlToQuery(yql`select ${123} as ${yql.identifier('pony_id')}`) + assert.equal(query.sql, 'select $p0 as `pony_id`') + assert.deepEqual(query.params, [123]) + assert.deepEqual(query.typings, ['none']) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/driver.test.ts b/third-parties/drizzle-adapter/tests/unit/driver.test.ts new file mode 100644 index 00000000..117792a1 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/driver.test.ts @@ -0,0 +1,132 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { Driver } from '@ydbjs/core' +import { YdbDriver } from '../../src/index.ts' +import { createMockQueryFunction } from '../helpers/mock-driver.ts' + +test('borrowed driver', async () => { + let readyCalls = 0 + let closeCalls = 0 + let signal = new AbortController().signal + let borrowedDriver = Object.create(Driver.prototype) as Driver & { + ready(signal?: AbortSignal): Promise + close(): void + } + + borrowedDriver.ready = async (incomingSignal?: AbortSignal) => { + readyCalls++ + assert.equal(incomingSignal, signal) + } + borrowedDriver.close = () => { + closeCalls++ + } + + let driver = new YdbDriver(borrowedDriver) + + await driver.ready(signal) + driver.close() + + assert.equal(driver.driver, borrowedDriver) + assert.equal(readyCalls, 1) + assert.equal(closeCalls, 0) +}) + +test('execute', async () => { + let borrowedDriver = Object.create(Driver.prototype) as Driver + let driver = new YdbDriver(borrowedDriver) + let mockClient = createMockQueryFunction([{ pony: 'Twilight' }], [['Twilight', 7]]) + + ;(driver as any).client = mockClient.ql + + let executeResult = await driver.execute('select * from ponies', [1, 'Rainbow'], 'execute') + let valuesResult = await driver.execute('select * from ponies', [2], 'all', { + arrayMode: true, + typings: ['none'], + }) + + assert.deepEqual(executeResult.rows, [{ pony: 'Twilight' }]) + assert.equal(executeResult.rowCount, 1) + assert.equal(executeResult.command, 'execute') + assert.deepEqual(executeResult.meta, { + arrayMode: false, + typings: undefined, + }) + assert.deepEqual(valuesResult.rows, [['Twilight', 7]]) + assert.equal(valuesResult.rowCount, 1) + assert.equal(valuesResult.command, 'all') + assert.deepEqual(valuesResult.meta, { + arrayMode: true, + typings: ['none'], + }) + assert.equal(mockClient.calls.length, 2) + assert.equal(mockClient.calls[0]?.text, 'select * from ponies') + assert.deepEqual( + mockClient.calls[0]?.params.map(({ name }) => name), + ['p0', 'p1'] + ) + assert.deepEqual( + mockClient.calls[1]?.params.map(({ name }) => name), + ['p0'] + ) +}) + +test('transaction', async () => { + let borrowedDriver = Object.create(Driver.prototype) as Driver + let driver = new YdbDriver(borrowedDriver) + let txQuery = createMockQueryFunction([{ ok: true }]) + let beginCalls: unknown[][] = [] + + ;(driver as any).client = { + begin: async (...args: unknown[]) => { + beginCalls.push(args) + + let callback = (typeof args[0] === 'function' ? args[0] : args[1]) as ( + tx: any + ) => Promise + return callback(txQuery.ql) + }, + } + + let txResult = await driver.transaction( + async (tx) => { + let result = await tx.execute('select tx', [9], 'execute') + + return result.rows + }, + { accessMode: 'read only' } + ) + + assert.deepEqual(txResult, [{ ok: true }]) + assert.equal(beginCalls.length, 1) + assert.deepEqual(beginCalls[0]?.[0], { isolation: 'snapshotReadOnly', idempotent: true }) + assert.equal(txQuery.calls.length, 1) + + await driver.transaction(async () => 'ok', { + isolationLevel: 'serializableReadWrite', + idempotent: false, + }) + assert.deepEqual(beginCalls[1]?.[0], { isolation: 'serializableReadWrite', idempotent: false }) + + await driver.transaction(async () => 'no-config') + assert.equal(typeof beginCalls[2]?.[0], 'function') +}) + +test('fromCallback', async () => { + let calls: Array<{ sql: string; params: unknown[]; method: string; options: unknown }> = [] + let executor = YdbDriver.fromCallback(async (query, params, method, options) => { + calls.push({ sql: query, params, method, options }) + return { rows: [{ ok: true }] } + }) + + let result = await executor.execute('select 1', [1], 'execute', { arrayMode: true }) + + assert.deepEqual(result.rows, [{ ok: true }]) + assert.deepEqual(calls, [ + { + sql: 'select 1', + params: [1], + method: 'execute', + options: { arrayMode: true }, + }, + ]) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/migration-ddl.test.ts b/third-parties/drizzle-adapter/tests/unit/migration-ddl.test.ts new file mode 100644 index 00000000..9f0d6dea --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/migration-ddl.test.ts @@ -0,0 +1,780 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { + buildAddChangefeedSql, + buildAddColumnFamilySql, + buildAddColumnsSql, + buildAddIndexSql, + buildAlterAsyncReplicationSql, + buildAlterColumnFamilySql, + buildAlterColumnSetFamilySql, + buildAlterGroupSql, + buildAlterTableResetOptionsSql, + buildAlterTableSetOptionsSql, + buildAlterTableSql, + buildAlterTopicSql, + buildAlterTransferSql, + buildAlterUserSql, + buildAnalyzeSql, + buildCreateAsyncReplicationSql, + buildCreateGroupSql, + buildCreateSecretSql, + buildCreateTableSql, + buildCreateTopicSql, + buildCreateTransferSql, + buildCreateUserSql, + buildCreateViewSql, + buildDropAsyncReplicationSql, + buildDropChangefeedSql, + buildDropColumnsSql, + buildDropGroupSql, + buildDropIndexSql, + buildDropTableSql, + buildDropTopicSql, + buildDropTransferSql, + buildDropUserSql, + buildDropViewSql, + buildGrantSql, + buildMigrationSql, + buildRenameTableSql, + buildRevokeSql, + buildShowCreateSql, + bytes, + columnFamily, + index, + integer, + partitionByHash, + rawTableOption, + tableOptions, + text, + ttl, + uint32, + unique, + vectorIndex, + ydbTable, +} from '../../src/index.ts' + +test('migration DDL generates create table with inline indexes and unique constraints', () => { + let users = ydbTable( + 'migration_users', + { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + age: integer('age'), + }, + (table) => [ + index('migration_users_name_idx').on(table.name).cover(table.age), + unique('migration_users_name_unique').on(table.name), + ] + ) + + let ddl = buildCreateTableSql(users, { ifNotExists: true }) + + assert.match(ddl, /^CREATE TABLE IF NOT EXISTS `migration_users`/u) + assert.match(ddl, /`id` Int32 NOT NULL/u) + assert.match(ddl, /`name` Utf8 NOT NULL/u) + assert.match(ddl, /INDEX `migration_users_name_idx` GLOBAL SYNC ON \(`name`\) COVER \(`age`\)/u) + assert.match(ddl, /INDEX `migration_users_name_unique` GLOBAL UNIQUE SYNC ON \(`name`\)/u) + assert.match(ddl, /PRIMARY KEY \(`id`\)/u) +}) + +test('migration DDL generates table options, partitioning, TTL, and column families', () => { + let events = ydbTable( + 'migration_events', + { + id: integer('id').notNull().primaryKey(), + payload: text('payload').notNull(), + expiresAt: uint32('expires_at').notNull(), + }, + (table) => [ + columnFamily('cold', { data: 'rot', compression: 'lz4' }).columns(table.payload), + partitionByHash(table.id), + ttl(table.expiresAt, 'P7D', { unit: 'SECONDS' }), + tableOptions({ + STORE: 'COLUMN', + AUTO_PARTITIONING_BY_SIZE: 'ENABLED', + AUTO_PARTITIONING_PARTITION_SIZE_MB: 512, + }), + ] + ) + + let ddl = buildCreateTableSql(events) + + assert.match(ddl, /^CREATE TABLE `migration_events`/u) + assert.match(ddl, /`payload` Utf8 FAMILY `cold` NOT NULL/u) + assert.match(ddl, /FAMILY `cold` \(DATA = "rot", COMPRESSION = "lz4"\)/u) + assert.match(ddl, /PARTITION BY HASH\(`id`\)/u) + assert.match(ddl, /STORE = COLUMN/u) + assert.match(ddl, /AUTO_PARTITIONING_BY_SIZE = ENABLED/u) + assert.match(ddl, /AUTO_PARTITIONING_PARTITION_SIZE_MB = 512/u) + assert.match(ddl, /TTL = Interval\("P7D"\) ON `expires_at` AS SECONDS/u) +}) + +test('migration DDL generates vector k-means tree indexes', () => { + let articles = ydbTable( + 'user_articles', + { + articleId: uint32('article_id').notNull().primaryKey(), + user: text('user').notNull(), + title: text('title'), + body: text('body'), + embedding: bytes('embedding').notNull(), + }, + (table) => [ + vectorIndex('emb_cosine_idx', { + distance: 'cosine', + vectorType: 'float', + vectorDimension: 512, + clusters: 128, + levels: 2, + }) + .on(table.user, table.embedding) + .cover(table.title, table.body), + ] + ) + + let ddl = buildCreateTableSql(articles) + let similarityIndex = vectorIndex({ + similarity: 'inner_product', + vectorType: 'uint8', + vectorDimension: 256, + clusters: 64, + levels: 2, + }) + .on(articles.embedding) + .build(articles) + let methodIndex = index('emb_euclidean_idx') + .on(articles.embedding) + .vectorKMeansTree({ + distance: 'euclidean', + vectorType: 'int8', + vectorDimension: 128, + clusters: 32, + levels: 2, + }) + .build(articles) + + assert.match( + ddl, + /INDEX `emb_cosine_idx` GLOBAL SYNC USING vector_kmeans_tree ON \(`user`, `embedding`\) COVER \(`title`, `body`\) WITH \(distance = 'cosine', vector_type = 'float', vector_dimension = 512, clusters = 128, levels = 2\)/u + ) + assert.equal( + buildAddIndexSql(articles, similarityIndex), + "ALTER TABLE `user_articles` ADD INDEX `user_articles_embedding_idx` GLOBAL SYNC USING vector_kmeans_tree ON (`embedding`) WITH (similarity = 'inner_product', vector_type = 'uint8', vector_dimension = 256, clusters = 64, levels = 2)" + ) + assert.equal( + buildAddIndexSql(articles, methodIndex), + "ALTER TABLE `user_articles` ADD INDEX `emb_euclidean_idx` GLOBAL SYNC USING vector_kmeans_tree ON (`embedding`) WITH (distance = 'euclidean', vector_type = 'int8', vector_dimension = 128, clusters = 32, levels = 2)" + ) +}) + +test('migration DDL generates ALTER statements for table options and column families', () => { + let events = ydbTable('migration_events', { + id: integer('id').notNull().primaryKey(), + payload: text('payload').notNull(), + body: text('body'), + }) + + assert.equal( + buildAlterTableSetOptionsSql(events, { + STORE: 'ROW', + AUTO_PARTITIONING_BY_SIZE: 'DISABLED', + READ_REPLICAS_SETTINGS: rawTableOption("'PER_AZ: 2'"), + }), + "ALTER TABLE `migration_events` SET (STORE = ROW, AUTO_PARTITIONING_BY_SIZE = DISABLED, READ_REPLICAS_SETTINGS = 'PER_AZ: 2')" + ) + assert.equal( + buildAlterTableResetOptionsSql(events, ['TTL', 'AUTO_PARTITIONING_BY_SIZE']), + 'ALTER TABLE `migration_events` RESET (TTL, AUTO_PARTITIONING_BY_SIZE)' + ) + assert.equal( + buildAddColumnFamilySql(events, { + name: 'hot', + options: { data: 'ssd', compression: 'lz4' }, + }), + 'ALTER TABLE `migration_events` ADD FAMILY `hot` (DATA = "ssd", COMPRESSION = "lz4")' + ) + assert.equal( + buildAlterColumnFamilySql(events, 'hot', { + data: 'rot', + compression: 'zstd', + compressionLevel: 4, + }), + 'ALTER TABLE `migration_events` ALTER FAMILY `hot` SET DATA "rot", ALTER FAMILY `hot` SET COMPRESSION "zstd", ALTER FAMILY `hot` SET COMPRESSION_LEVEL 4' + ) + assert.deepEqual(buildAlterColumnSetFamilySql(events, [events.payload, events.body], 'hot'), [ + 'ALTER TABLE `migration_events` ALTER COLUMN `payload` SET FAMILY `hot`', + 'ALTER TABLE `migration_events` ALTER COLUMN `body` SET FAMILY `hot`', + ]) + assert.deepEqual( + buildMigrationSql([ + { kind: 'set_table_options', table: events, options: { STORE: 'ROW' } }, + { kind: 'reset_table_options', table: events, names: ['TTL'] }, + { + kind: 'add_column_family', + table: events, + family: { name: 'cold', options: { data: 'rot' } }, + }, + { + kind: 'alter_column_family', + table: events, + name: 'cold', + options: { compression: 'lz4' }, + }, + { + kind: 'set_column_family', + table: events, + columns: ['body'], + familyName: 'cold', + }, + ]), + [ + 'ALTER TABLE `migration_events` SET (STORE = ROW)', + 'ALTER TABLE `migration_events` RESET (TTL)', + 'ALTER TABLE `migration_events` ADD FAMILY `cold` (DATA = "rot")', + 'ALTER TABLE `migration_events` ALTER FAMILY `cold` SET COMPRESSION "lz4"', + 'ALTER TABLE `migration_events` ALTER COLUMN `body` SET FAMILY `cold`', + ] + ) +}) + +test('migration DDL generates alter and drop statements', () => { + let users = ydbTable('migration_users', { + id: integer('id').notNull().primaryKey(), + age: integer('age'), + score: integer('score').notNull(), + }) + let ageIndex = index('migration_users_age_idx').on(users.age).build(users) + + assert.deepEqual(buildAddColumnsSql(users, [users.age, users.score]), [ + 'ALTER TABLE `migration_users` ADD COLUMN `age` Int32', + 'ALTER TABLE `migration_users` ADD COLUMN `score` Int32 NOT NULL', + ]) + assert.deepEqual(buildDropColumnsSql(users, ['age', 'score']), [ + 'ALTER TABLE `migration_users` DROP COLUMN `age`', + 'ALTER TABLE `migration_users` DROP COLUMN `score`', + ]) + assert.equal( + buildAddIndexSql(users, ageIndex), + 'ALTER TABLE `migration_users` ADD INDEX `migration_users_age_idx` GLOBAL SYNC ON (`age`)' + ) + assert.equal( + buildDropIndexSql(users, 'migration_users_age_idx'), + 'ALTER TABLE `migration_users` DROP INDEX `migration_users_age_idx`' + ) + assert.equal( + buildDropTableSql(users, { ifExists: true }), + 'DROP TABLE IF EXISTS `migration_users`' + ) + assert.deepEqual( + buildMigrationSql([ + { kind: 'add_columns', table: users, columns: [users.age] }, + { kind: 'add_index', table: users, index: ageIndex }, + { kind: 'drop_columns', table: users, columns: ['age'] }, + ]), + [ + 'ALTER TABLE `migration_users` ADD COLUMN `age` Int32', + 'ALTER TABLE `migration_users` ADD INDEX `migration_users_age_idx` GLOBAL SYNC ON (`age`)', + 'ALTER TABLE `migration_users` DROP COLUMN `age`', + ] + ) +}) + +test('migration DDL generates ANALYZE, VIEW, TOPIC, CHANGEFEED, rename, and multi-action ALTER TABLE', () => { + let users = ydbTable('migration_admin_users', { + id: integer('id').notNull().primaryKey(), + age: integer('age'), + name: text('name'), + status: text('status'), + }) + let ageIndex = index('migration_admin_users_age_idx').on(users.age).build(users) + + assert.equal(buildAnalyzeSql(users), 'ANALYZE `migration_admin_users`') + assert.equal( + buildAnalyzeSql(users, [users.age, 'status']), + 'ANALYZE `migration_admin_users` (`age`, `status`)' + ) + assert.equal( + buildCreateViewSql( + 'migration_admin_view', + 'SELECT `id`, `name` FROM `migration_admin_users`', + { + ifNotExists: true, + } + ), + 'CREATE VIEW IF NOT EXISTS `migration_admin_view` WITH (security_invoker = TRUE) AS SELECT `id`, `name` FROM `migration_admin_users`' + ) + assert.equal( + buildDropViewSql('migration_admin_view', { ifExists: true }), + 'DROP VIEW IF EXISTS `migration_admin_view`' + ) + assert.equal( + buildCreateTopicSql('migration_admin_topic', { + consumers: [ + { + name: 'events', + settings: { + important: true, + read_from: rawTableOption("Timestamp('2024-01-01T00:00:00Z')"), + }, + }, + ], + settings: { retention_period: rawTableOption("Interval('P1D')") }, + }), + [ + 'CREATE TOPIC `migration_admin_topic` (', + " CONSUMER `events` WITH (important = TRUE, read_from = Timestamp('2024-01-01T00:00:00Z'))", + ') WITH (', + " retention_period = Interval('P1D')", + ')', + ].join('\n') + ) + assert.equal( + buildAlterTopicSql('migration_admin_topic', [ + { kind: 'add_consumer', consumer: { name: 'audit' } }, + { + kind: 'alter_consumer_set', + name: 'audit', + settings: { important: false }, + }, + { kind: 'drop_consumer', name: 'old_audit' }, + ]), + 'ALTER TOPIC `migration_admin_topic` ADD CONSUMER `audit`, ALTER CONSUMER `audit` SET (important = FALSE), DROP CONSUMER `old_audit`' + ) + assert.equal(buildDropTopicSql('migration_admin_topic'), 'DROP TOPIC `migration_admin_topic`') + assert.equal( + buildAddChangefeedSql(users, 'updates_feed', { + mode: 'NEW_IMAGE', + format: 'JSON', + retentionPeriod: 'PT1H', + virtualTimestamps: true, + }), + "ALTER TABLE `migration_admin_users` ADD CHANGEFEED `updates_feed` WITH (MODE = 'NEW_IMAGE', FORMAT = 'JSON', VIRTUAL_TIMESTAMPS = TRUE, RETENTION_PERIOD = Interval('PT1H'))" + ) + assert.equal( + buildDropChangefeedSql(users, 'updates_feed'), + 'ALTER TABLE `migration_admin_users` DROP CHANGEFEED `updates_feed`' + ) + assert.equal( + buildRenameTableSql('migration_admin_users', 'migration_admin_users_archive'), + 'ALTER TABLE `migration_admin_users` RENAME TO `migration_admin_users_archive`' + ) + assert.equal( + buildAlterTableSql(users, [ + { kind: 'add_column', column: users.status }, + { kind: 'add_index', index: ageIndex }, + { + kind: 'add_changefeed', + name: 'updates_feed', + options: { mode: 'UPDATES', format: 'JSON' }, + }, + { kind: 'drop_column', name: 'name' }, + ]), + "ALTER TABLE `migration_admin_users` ADD COLUMN `status` Utf8, ADD INDEX `migration_admin_users_age_idx` GLOBAL SYNC ON (`age`), ADD CHANGEFEED `updates_feed` WITH (MODE = 'UPDATES', FORMAT = 'JSON'), DROP COLUMN `name`" + ) + assert.deepEqual( + buildMigrationSql([ + { kind: 'analyze', table: users, columns: [users.id] }, + { + kind: 'create_view', + name: 'migration_admin_view', + query: 'SELECT 1 AS `id`', + }, + { kind: 'drop_view', name: 'migration_admin_view', ifExists: true }, + { kind: 'create_topic', name: 'migration_admin_topic' }, + { + kind: 'alter_topic', + name: 'migration_admin_topic', + actions: [ + { + kind: 'set_options', + settings: { retention_period: rawTableOption("Interval('PT1H')") }, + }, + ], + }, + { kind: 'drop_topic', name: 'migration_admin_topic' }, + { + kind: 'rename_table', + table: 'migration_admin_users', + to: 'migration_admin_users_archive', + }, + ]), + [ + 'ANALYZE `migration_admin_users` (`id`)', + 'CREATE VIEW `migration_admin_view` WITH (security_invoker = TRUE) AS SELECT 1 AS `id`', + 'DROP VIEW IF EXISTS `migration_admin_view`', + 'CREATE TOPIC `migration_admin_topic`', + "ALTER TOPIC `migration_admin_topic` SET (retention_period = Interval('PT1H'))", + 'DROP TOPIC `migration_admin_topic`', + 'ALTER TABLE `migration_admin_users` RENAME TO `migration_admin_users_archive`', + ] + ) +}) + +test('migration DDL generates temporary tables, replication, transfer, secrets, users, groups, grants, and SHOW CREATE', () => { + let tempTable = ydbTable('migration_temp_events', { + id: integer('id').notNull().primaryKey(), + payload: text('payload'), + }) + + assert.match( + buildCreateTableSql(tempTable, { temporary: true, ifNotExists: true }), + /^CREATE TEMPORARY TABLE IF NOT EXISTS `migration_temp_events`/u + ) + assert.equal( + buildCreateTableSql(tempTable, { temporary: 'temp' }).split('\n')[0], + 'CREATE TEMP TABLE `migration_temp_events` (' + ) + assert.equal( + buildCreateAsyncReplicationSql( + 'orders_replication', + [{ remote: '/Root/source/orders', local: 'orders_replica' }], + { + connectionString: 'grpcs://example.com:2135/?database=/Root/source', + tokenSecretName: 'replication_token', + consistencyLevel: 'GLOBAL', + commitInterval: 'PT1M', + } + ), + "CREATE ASYNC REPLICATION `orders_replication` FOR `/Root/source/orders` AS `orders_replica` WITH (CONNECTION_STRING = 'grpcs://example.com:2135/?database=/Root/source', TOKEN_SECRET_NAME = 'replication_token', CONSISTENCY_LEVEL = 'GLOBAL', COMMIT_INTERVAL = Interval('PT1M'))" + ) + assert.equal( + buildAlterAsyncReplicationSql('orders_replication', { + state: 'DONE', + failoverMode: 'FORCE', + }), + "ALTER ASYNC REPLICATION `orders_replication` SET (STATE = 'DONE', FAILOVER_MODE = 'FORCE')" + ) + assert.equal( + buildDropAsyncReplicationSql('orders_replication', { cascade: true }), + 'DROP ASYNC REPLICATION `orders_replication` CASCADE' + ) + assert.equal( + buildCreateTransferSql('orders_transfer', 'orders_topic', 'orders', '$lambda', { + consumer: 'orders_consumer', + batchSizeBytes: 1048576, + flushInterval: 'PT60S', + }), + "CREATE TRANSFER `orders_transfer` FROM `orders_topic` TO `orders` USING $lambda WITH (CONSUMER = 'orders_consumer', BATCH_SIZE_BYTES = 1048576, FLUSH_INTERVAL = Interval('PT60S'))" + ) + assert.equal( + buildAlterTransferSql('orders_transfer', { using: '$new_lambda' }), + 'ALTER TRANSFER `orders_transfer` SET USING $new_lambda' + ) + assert.equal( + buildAlterTransferSql('orders_transfer', { + options: { state: 'PAUSED', batchSizeBytes: 2048 }, + }), + "ALTER TRANSFER `orders_transfer` SET (STATE = 'PAUSED', BATCH_SIZE_BYTES = 2048)" + ) + assert.equal(buildDropTransferSql('orders_transfer'), 'DROP TRANSFER `orders_transfer`') + assert.equal( + buildCreateSecretSql('replication_token', 'token"value'), + 'CREATE OBJECT `replication_token` (TYPE SECRET) WITH value="token\\"value"' + ) + assert.equal( + buildCreateUserSql('app_user', { password: 'secret', login: true }), + "CREATE USER `app_user` PASSWORD 'secret' LOGIN" + ) + assert.equal( + buildAlterUserSql('app_user', { + password: null, + login: false, + withKeyword: true, + }), + 'ALTER USER `app_user` WITH PASSWORD NULL NOLOGIN' + ) + assert.equal( + buildDropUserSql(['app_user', 'old_user'], { ifExists: true }), + 'DROP USER IF EXISTS `app_user`, `old_user`' + ) + assert.equal( + buildCreateGroupSql('app_group', { users: ['app_user', 'audit_user'] }), + 'CREATE GROUP `app_group` WITH USER `app_user`, `audit_user`' + ) + assert.equal( + buildAlterGroupSql('app_group', 'add_user', ['new_user']), + 'ALTER GROUP `app_group` ADD USER `new_user`' + ) + assert.equal( + buildDropGroupSql(['app_group'], { ifExists: true }), + 'DROP GROUP IF EXISTS `app_group`' + ) + assert.equal( + buildGrantSql({ + permissions: ['SELECT ROW', 'ydb.generic.list'], + on: ['/Root/orders'], + to: ['app_group'], + withGrantOption: true, + }), + "GRANT SELECT ROW, 'ydb.generic.list' ON `/Root/orders` TO `app_group` WITH GRANT OPTION" + ) + assert.equal( + buildRevokeSql({ + permissions: { kind: 'all', privileges: true }, + on: ['/Root/orders'], + from: ['app_group'], + grantOptionFor: true, + }), + 'REVOKE GRANT OPTION FOR ALL PRIVILEGES ON `/Root/orders` FROM `app_group`' + ) + assert.equal(buildShowCreateSql('table', 'orders'), 'SHOW CREATE TABLE `orders`') + assert.deepEqual( + buildMigrationSql([ + { + kind: 'create_async_replication', + name: 'orders_replication', + targets: [{ remote: 'orders', local: 'orders_replica' }], + options: { connectionString: 'grpc://localhost', tokenSecretName: 't' }, + }, + { + kind: 'alter_transfer', + name: 'orders_transfer', + options: { state: 'ACTIVE' }, + }, + { kind: 'drop_transfer', name: 'orders_transfer' }, + { kind: 'create_secret', name: 't', value: 'token' }, + { kind: 'create_user', name: 'app_user', options: { login: false } }, + { + kind: 'grant', + permissions: 'SELECT', + on: ['/Root/orders'], + to: ['app_user'], + }, + { kind: 'show_create', objectType: 'table', name: 'orders' }, + ]), + [ + "CREATE ASYNC REPLICATION `orders_replication` FOR `orders` AS `orders_replica` WITH (CONNECTION_STRING = 'grpc://localhost', TOKEN_SECRET_NAME = 't')", + "ALTER TRANSFER `orders_transfer` SET (STATE = 'ACTIVE')", + 'DROP TRANSFER `orders_transfer`', + 'CREATE OBJECT `t` (TYPE SECRET) WITH value="token"', + 'CREATE USER `app_user` NOLOGIN', + 'GRANT SELECT ON `/Root/orders` TO `app_user`', + 'SHOW CREATE TABLE `orders`', + ] + ) +}) + +test('migration DDL rejects invalid YDB constructs', () => { + let users = ydbTable('users', { + id: integer('id').notNull().primaryKey(), + name: text('name'), + }) + let uniqueAge = unique('users_age_unique').on(users.name).build(users) + + assert.throws(() => buildAddIndexSql(users, uniqueAge), /cannot add UNIQUE indexes/u) +}) + +test('migration DDL rejects invalid table option and family definitions', () => { + let duplicateOptionTable = ydbTable( + 'duplicate_options', + { + id: integer('id').notNull().primaryKey(), + }, + () => [tableOptions({ STORE: 'ROW' }), tableOptions({ STORE: 'COLUMN' })] + ) + let duplicateTtlTable = ydbTable( + 'duplicate_ttl', + { + id: integer('id').notNull().primaryKey(), + expiresAt: uint32('expires_at').notNull(), + }, + (table) => [ttl(table.expiresAt, 'P1D'), ttl(table.expiresAt, 'P2D')] + ) + let duplicateFamilyTable = ydbTable( + 'duplicate_family', + { + id: integer('id').notNull().primaryKey(), + payload: text('payload'), + }, + (table) => [ + columnFamily('hot').columns(table.payload), + columnFamily('cold').columns(table.payload), + ] + ) + let duplicateFamilyNameTable = ydbTable( + 'duplicate_family_name', + { + id: integer('id').notNull().primaryKey(), + payload: text('payload'), + metadata: text('metadata'), + }, + (table) => [ + columnFamily('hot').columns(table.payload), + columnFamily('hot').columns(table.metadata), + ] + ) + + assert.throws( + () => buildCreateTableSql(duplicateOptionTable), + /duplicate table option "STORE"/u + ) + assert.throws(() => buildCreateTableSql(duplicateTtlTable), /supports only one TTL/u) + assert.throws( + () => buildCreateTableSql(duplicateFamilyTable), + /assigned to both "hot" and "cold"/u + ) + assert.throws( + () => buildCreateTableSql(duplicateFamilyNameTable), + /duplicate column family "hot"/u + ) + assert.throws( + () => buildAlterTableSetOptionsSql('duplicate_options', {}), + /requires at least one option/u + ) + assert.throws( + () => buildAlterColumnFamilySql('duplicate_family', 'hot', {}), + /requires at least one option/u + ) +}) + +test('migration DDL escapes identifiers and rejects unsafe option names', () => { + let users = ydbTable( + 'users` DROP TABLE audit; --', + { + id: integer('id`x').notNull().primaryKey(), + name: text('name`x'), + }, + (table) => [ + index('users`name`idx').on(table.name), + columnFamily('hot`family').columns(table.name), + tableOptions({ STORE: 'ROW' }), + ] + ) + + let ddl = buildCreateTableSql(users) + + assert.match(ddl, /CREATE TABLE `users`` DROP TABLE audit; --`/u) + assert.match(ddl, /`id``x` Int32 NOT NULL/u) + assert.match(ddl, /`name``x` Utf8 FAMILY `hot``family`/u) + assert.match(ddl, /INDEX `users``name``idx` GLOBAL SYNC ON \(`name``x`\)/u) + + assert.throws( + () => buildAlterTableSetOptionsSql('safe_table', { 'STORE) DROP TABLE users; --': 'ROW' }), + /invalid option name/u + ) + assert.throws( + () => buildAlterTableResetOptionsSql('safe_table', ['TTL) DROP TABLE users; --']), + /invalid option name/u + ) + assert.throws( + () => + buildAddChangefeedSql('safe_table', 'updates', { + mode: 'UPDATES', + options: { 'FORMAT) DROP TABLE users; --': 'JSON' }, + }), + /invalid option name/u + ) +}) + +test('migration DDL escapes service object identifiers and literal values', () => { + assert.equal( + buildDropTableSql('safe` DROP TABLE audit; --', { ifExists: true }), + 'DROP TABLE IF EXISTS `safe`` DROP TABLE audit; --`' + ) + assert.equal(buildAnalyzeSql('safe`table', ['col`umn']), 'ANALYZE `safe``table` (`col``umn`)') + assert.equal( + buildRenameTableSql('safe`table', 'renamed`table'), + 'ALTER TABLE `safe``table` RENAME TO `renamed``table`' + ) + assert.equal( + buildCreateSecretSql('secret`name', 'token"\\value'), + 'CREATE OBJECT `secret``name` (TYPE SECRET) WITH value="token\\"\\\\value"' + ) + assert.equal( + buildCreateUserSql('user`name', { + password: "pa'ss", + }), + "CREATE USER `user``name` PASSWORD 'pa''ss'" + ) + assert.equal( + buildGrantSql({ + permissions: 'SELECT', + on: ['folder`name/table'], + to: ['user`name'], + }), + 'GRANT SELECT ON `folder``name/table` TO `user``name`' + ) +}) + +test('migration DDL rejects invalid vector indexes', () => { + let articles = ydbTable('user_articles', { + id: uint32('id').notNull().primaryKey(), + embedding: bytes('embedding').notNull(), + }) + + assert.throws( + () => + vectorIndex({ + distance: 'cosine', + similarity: 'inner_product', + vectorType: 'float', + vectorDimension: 512, + clusters: 128, + levels: 2, + }) + .on(articles.embedding) + .build(articles), + /requires exactly one of distance or similarity/u + ) + assert.throws( + () => + vectorIndex({ + distance: 'cosine', + vectorType: 'float', + vectorDimension: 512, + clusters: 128, + levels: 0, + }) + .on(articles.embedding) + .build(articles), + /levels must be an integer between 1 and 16/u + ) + assert.throws( + () => + vectorIndex({ + distance: 'cosine', + vectorType: 'float', + vectorDimension: 512, + clusters: 128, + levels: 2, + }) + .on(articles.embedding) + .local() + .build(articles), + /support only GLOBAL/u + ) + assert.throws( + () => + vectorIndex({ + distance: 'cosine', + vectorType: 'float', + vectorDimension: 512, + clusters: 128, + levels: 2, + }) + .on(articles.embedding) + .async() + .build(articles), + /support only SYNC/u + ) +}) + +test('migration DDL includes inline column unique constraints', () => { + let users = ydbTable('migration_unique_users', { + id: integer('id').notNull().primaryKey(), + email: text('email').notNull().unique(), + externalId: text('external_id').unique('migration_unique_users_external_unique'), + }) + + let ddl = buildCreateTableSql(users) + + assert.match( + ddl, + /INDEX `migration_unique_users_email_unique` GLOBAL UNIQUE SYNC ON \(`email`\)/u + ) + assert.match( + ddl, + /INDEX `migration_unique_users_external_unique` GLOBAL UNIQUE SYNC ON \(`external_id`\)/u + ) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/migrator.test.ts b/third-parties/drizzle-adapter/tests/unit/migrator.test.ts new file mode 100644 index 00000000..44e6b365 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/migrator.test.ts @@ -0,0 +1,295 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { type YdbExecutor, drizzle, migrate } from '../../src/index.ts' + +function normalizeSql(query: string): string { + return query.replace(/\s+/gu, ' ').trim() +} + +function parseHistoryUpsert(query: string): [string, number, string] { + let match = normalizeSql(query).match(/VALUES \( '([^']+)', ([0-9]+), '([^']*)', '([^']*)',/u) + + if (!match) { + throw new Error(`Cannot parse migration bookkeeping query: ${query}`) + } + + return [match[1], Number(match[2]), match[3]] +} + +function createMigratorExecutor( + initialRows: Array<[string, number, string]> = [], + options: { + activeLock?: boolean + failQuery?: (query: string) => boolean + } = {} +) { + let calls: Array<{ query: string; method: string; arrayMode: boolean }> = [] + let appliedRows = initialRows.map( + (row) => [...row, 'applied', null, null, null, null, null, null] as unknown[] + ) + let lockRows = new Map() + if (options.activeLock) { + lockRows.set('migrate', { ownerId: 'other-unit-runner', expiresAt: Date.now() + 60_000 }) + } + + let executor: YdbExecutor & { + transaction(callback: (tx: YdbExecutor) => Promise): Promise + } = { + async execute(query, _params, method, execOptions) { + let normalized = normalizeSql(query) + calls.push({ query: normalized, method, arrayMode: execOptions?.arrayMode === true }) + + if (normalized.startsWith('SELECT `owner_id`, `expires_at` FROM `')) { + let key = normalized.match(/`lock_key` = '([^']+)'/u)?.[1] ?? 'migrate' + let lock = lockRows.get(key) + return { rows: lock ? [[lock.ownerId, lock.expiresAt]] : [] } + } + + if (normalized.startsWith('UPSERT INTO `') && normalized.includes('`lock_key`')) { + let match = normalized.match( + /VALUES \('([^']+)', '([^']+)', [0-9]+, [0-9]+, ([0-9]+)\)/u + ) + if (match) { + lockRows.set(match[1]!, { ownerId: match[2]!, expiresAt: Number(match[3]) }) + } + return { rows: [] } + } + + if (normalized.startsWith('DELETE FROM `') && normalized.includes('`lock_key`')) { + let key = normalized.match(/`lock_key` = '([^']+)'/u)?.[1] ?? 'migrate' + lockRows.delete(key) + return { rows: [] } + } + + if (normalized.startsWith('SELECT `hash`, `created_at`, `name` FROM `')) { + return { rows: appliedRows.map((row) => [...row]) } + } + + if (normalized.startsWith('SELECT `hash`, `created_at`, `name`, `status`, ')) { + return { rows: appliedRows.map((row) => [...row]) } + } + + if (normalized.startsWith('SELECT `status` FROM `')) { + return { rows: [] } + } + + if (normalized.startsWith('UPSERT INTO `')) { + let parsed = parseHistoryUpsert(normalized) + let existingIndex = appliedRows.findIndex((row) => row[0] === parsed[0]) + let nextRow = [ + parsed[0], + parsed[1], + parsed[2], + normalized.includes("'applied'") + ? 'applied' + : normalized.includes("'failed'") + ? 'failed' + : 'running', + null, + null, + null, + null, + null, + null, + ] + if (existingIndex >= 0) { + appliedRows[existingIndex] = nextRow + } else { + appliedRows.unshift(nextRow) + } + return { rows: [] } + } + + if (options.failQuery?.(normalized)) { + throw new Error(`forced failure: ${normalized}`) + } + + return { rows: [] } + }, + async transaction(callback) { + return callback(executor) + }, + } + + return { executor, calls, appliedRows } +} + +test('inline migrate bootstraps bookkeeping and skips already applied migrations', async () => { + let { executor, calls, appliedRows } = createMigratorExecutor() + let db = drizzle(executor) + let config = { + migrationsTable: '__unit_migrations', + migrations: [ + { + name: '0001_create', + folderMillis: 1, + sql: ['CREATE TABLE `unit_users` (`id` Int32 NOT NULL, PRIMARY KEY (`id`))'], + }, + { + name: '0002_alter', + folderMillis: 2, + sql: ['ALTER TABLE `unit_users` ADD COLUMN `age` Int32'], + }, + ], + } as const + + await migrate(db, config) + await migrate(db, config) + + assert.ok( + calls.some((call) => + call.query.startsWith('CREATE TABLE IF NOT EXISTS `__unit_migrations`') + ) + ) + assert.ok( + calls.some((call) => + call.query.startsWith('CREATE TABLE IF NOT EXISTS `__unit_migrations_lock`') + ) + ) + assert.equal( + calls.filter( + (call) => + call.query === 'CREATE TABLE `unit_users` (`id` Int32 NOT NULL, PRIMARY KEY (`id`))' + ).length, + 1 + ) + assert.equal( + calls.filter((call) => call.query === 'ALTER TABLE `unit_users` ADD COLUMN `age` Int32') + .length, + 1 + ) + assert.equal(appliedRows.length, 2) + assert.deepEqual(appliedRows.map((row) => row[2]).sort(), ['0001_create', '0002_alter']) +}) + +test('folder migrate reads drizzle migration journal format', async () => { + let { executor, calls, appliedRows } = createMigratorExecutor() + let db = drizzle(executor) + let migrationsFolder = fileURLToPath(new URL('../fixtures/migrations/basic', import.meta.url)) + + await migrate(db, { + migrationsFolder, + migrationsTable: '__folder_migrations', + }) + + assert.ok(calls.some((call) => call.query.includes('CREATE TABLE `folder_users`'))) + assert.ok( + calls.some((call) => + call.query.includes('ALTER TABLE `folder_users` ADD COLUMN `age` Int32') + ) + ) + assert.ok( + calls.some( + (call) => + call.query.startsWith('UPSERT INTO `__folder_migrations`') && + call.query.includes("'applied'") + ) + ) + assert.ok(appliedRows.every((row) => /^folder_[0-9]+_[a-f0-9]{12}$/u.test(String(row[2])))) +}) + +test('unnamed migration fallback uses timestamp and hash instead of current input index', async () => { + let targetMigration = { + folderMillis: 1_710_000_001_000, + hash: 'abcdef1234567890abcdef', + sql: ['select target'], + } as const + let precedingMigration = { + folderMillis: 1_710_000_000_000, + hash: '123456abcdef7890abcdef', + sql: ['select preceding'], + } as const + + let withPreceding = createMigratorExecutor() + await migrate(drizzle(withPreceding.executor), { + migrationsTable: '__stable_names_with_preceding', + migrations: [precedingMigration, targetMigration], + }) + + let standalone = createMigratorExecutor() + await migrate(drizzle(standalone.executor), { + migrationsTable: '__stable_names_standalone', + migrations: [targetMigration], + }) + + let withPrecedingName = withPreceding.appliedRows.find( + (row) => row[0] === targetMigration.hash + )?.[2] + let standaloneName = standalone.appliedRows.find((row) => row[0] === targetMigration.hash)?.[2] + + assert.equal(withPrecedingName, 'inline_1710000001000_abcdef123456') + assert.equal(standaloneName, withPrecedingName) +}) + +test('migrate fails fast when migration lock is held', async () => { + let { executor, calls } = createMigratorExecutor([], { activeLock: true }) + let db = drizzle(executor) + + await assert.rejects( + () => + migrate(db, { + migrationsTable: '__locked_migrations', + migrationLock: { + ownerId: 'unit-owner', + acquireTimeoutMs: 5, + retryIntervalMs: 1, + }, + migrations: [ + { + name: '0001_locked', + folderMillis: 1, + sql: ['select 1'], + }, + ], + }), + /could not acquire migration lock/u + ) + + assert.ok( + calls.some((call) => + call.query.startsWith('CREATE TABLE IF NOT EXISTS `__locked_migrations_lock`') + ) + ) + assert.ok(!calls.some((call) => call.query === 'select 1')) +}) + +test('migrate records failed state and retries only when recovery retry is enabled', async () => { + let failUnstable = true + let { executor, calls, appliedRows } = createMigratorExecutor([], { + failQuery(query) { + return failUnstable && query === 'unstable statement' + }, + }) + let db = drizzle(executor) + let config = { + migrationsTable: '__recovery_migrations', + migrationLock: { + ownerId: 'unit-recovery-owner', + }, + migrations: [ + { + name: '0001_recoverable', + folderMillis: 1, + hash: 'recovery_hash', + sql: ['select 1', 'unstable statement'], + }, + ], + } as const + + await assert.rejects(() => migrate(db, config), /failed after 1\/2 statements/u) + assert.equal(appliedRows[0]?.[3], 'failed') + assert.equal(calls.filter((call) => call.query === 'unstable statement').length, 1) + + await assert.rejects(() => migrate(db, config), /marked as failed/u) + assert.equal(calls.filter((call) => call.query === 'unstable statement').length, 1) + + failUnstable = false + await migrate(db, { + ...config, + migrationRecovery: { mode: 'retry' }, + }) + + assert.equal(appliedRows[0]?.[3], 'applied') + assert.equal(calls.filter((call) => call.query === 'unstable statement').length, 2) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/mutation-builders.test.ts b/third-parties/drizzle-adapter/tests/unit/mutation-builders.test.ts new file mode 100644 index 00000000..7be8cfef --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/mutation-builders.test.ts @@ -0,0 +1,373 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { eq, sql as yql } from 'drizzle-orm' +import { integer, text, ydbTable } from '../../src/index.ts' +import { + YdbBatchDeleteBuilder, + YdbBatchUpdateBuilder, + YdbDeleteBuilder, + YdbInsertBuilder, + YdbReplaceBuilder, + YdbUpdateBuilder, + YdbUpsertBuilder, +} from '../../src/ydb-core/query-builders/index.ts' +import { dialect, session, users } from '../helpers/unit-basic.ts' + +function toQuery(builder: { getSQL(): any }) { + return dialect.sqlToQuery(builder.getSQL()) +} + +test('delete sql', () => { + let query = toQuery(new YdbDeleteBuilder(users, session).where(eq(users.id, 7))) + + assert.equal(query.sql, 'delete from `users` where `users`.`id` = $p0') + assert.deepEqual(query.params, [7]) +}) + +test('delete returning', () => { + let query = toQuery( + new YdbDeleteBuilder(users, session) + .where(eq(users.id, 7)) + .returning({ id: users.id, name: users.name }) + ) + + assert.equal(query.sql, 'delete from `users` where `users`.`id` = $p0 returning `id`, `name`') + assert.deepEqual(query.params, [7]) +}) + +test('delete on select sql', () => { + let keyedUsers = ydbTable('keyed_users', { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + }) + + let query = toQuery( + new YdbDeleteBuilder(keyedUsers, session, dialect) + .on((qb) => + qb + .select({ + id: keyedUsers.id, + }) + .from(keyedUsers) + .where(eq(keyedUsers.id, 1)) + ) + .returning({ id: keyedUsers.id }) + ) + + assert.equal( + query.sql, + 'delete from `keyed_users` on select `keyed_users`.`id` from `keyed_users` where `keyed_users`.`id` = $p0 returning `id`' + ) + assert.deepEqual(query.params, [1]) +}) + +test('delete on validates target columns and primary key', () => { + let keyedUsers = ydbTable('keyed_users', { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + }) + + assert.throws( + () => + new YdbDeleteBuilder(keyedUsers, session, dialect).on((qb) => + qb + .select({ + name: keyedUsers.name, + }) + .from(keyedUsers) + ), + /requires primary key column "id"/ + ) + + assert.throws( + () => + new YdbDeleteBuilder(keyedUsers, session, dialect).on((qb) => + qb + .select({ + id: keyedUsers.id, + nope: keyedUsers.name, + }) + .from(keyedUsers) + ), + /selected field "nope"/ + ) + + assert.throws( + () => + new YdbDeleteBuilder(keyedUsers, session, dialect) + .on((qb) => + qb + .select({ + id: keyedUsers.id, + }) + .from(keyedUsers) + ) + .where(eq(keyedUsers.id, 1)), + /does not support where/ + ) + + assert.throws( + () => + new YdbDeleteBuilder(keyedUsers, session, dialect) + .on((qb) => + qb + .select({ + id: keyedUsers.id, + }) + .from(keyedUsers) + ) + .using(yql.identifier('posts')), + /does not support using/ + ) +}) + +test('insert defaults', () => { + let query = toQuery(new YdbInsertBuilder(users, session).values({ id: 1, name: 'Pinkie Pie' })) + + assert.equal( + query.sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3)' + ) + assert.deepEqual(query.params, [1, 'Pinkie Pie', 100, 200]) +}) + +test('insert order', () => { + let query = toQuery( + new YdbInsertBuilder(users, session).values([ + { id: 1, name: 'Twilight Sparkle', createdAt: 10, updatedAt: 11 }, + { name: 'Rainbow Dash', id: 2, updatedAt: 22 }, + ]) + ) + + assert.equal( + query.sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3), ($p4, $p5, $p6, $p7)' + ) + assert.deepEqual(query.params, [1, 'Twilight Sparkle', 10, 11, 2, 'Rainbow Dash', 100, 22]) +}) + +test('insert rejects unknown', () => { + assert.throws( + () => + toQuery( + new YdbInsertBuilder(users, session).values({ + id: 1, + name: 'Applejack', + nope: true, + } as any) + ), + /Unknown column "nope" in insert\(\)/ + ) +}) + +test('insert returning', () => { + let allColumns = toQuery( + new YdbInsertBuilder(users, session).values({ id: 1, name: 'Pinkie Pie' }).returning() + ) + + assert.equal( + allColumns.sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3) returning `id`, `name`, `created_at`, `updated_at`' + ) + assert.deepEqual(allColumns.params, [1, 'Pinkie Pie', 100, 200]) + + let selectedColumns = toQuery( + new YdbInsertBuilder(users, session) + .values({ id: 2, name: 'Rarity' }) + .returning({ id: users.id, label: yql`Upper(${users.name})`.as('label') }) + ) + + assert.equal( + selectedColumns.sql, + 'insert into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3) returning `id`, Upper(`users`.`name`) as `label`' + ) + assert.deepEqual(selectedColumns.params, [2, 'Rarity', 100, 200]) +}) + +test('native upsert values use provided columns', () => { + let query = toQuery( + new YdbUpsertBuilder(users, session) + .values({ id: 1, name: 'Starlight' }) + .returning({ id: users.id, name: users.name }) + ) + + assert.equal( + query.sql, + 'upsert into `users` (`id`, `name`) values ($p0, $p1) returning `id`, `name`' + ) + assert.deepEqual(query.params, [1, 'Starlight']) +}) + +test('native upsert rejects inconsistent value columns', () => { + assert.throws( + () => + toQuery( + new YdbUpsertBuilder(users, session).values([ + { id: 1, name: 'Twilight' }, + { id: 2 }, + ]) + ), + /same columns/ + ) +}) + +test('native replace values', () => { + let query = toQuery(new YdbReplaceBuilder(users, session).values({ id: 1, name: 'Fluttershy' })) + + assert.equal( + query.sql, + 'replace into `users` (`id`, `name`, `created_at`, `updated_at`) values ($p0, $p1, $p2, $p3)' + ) + assert.deepEqual(query.params, [1, 'Fluttershy', 100, 200]) +}) + +test('native replace returning is explicitly unsupported', () => { + assert.throws( + () => + new YdbReplaceBuilder(users, session).values({ id: 1, name: 'Fluttershy' }).returning(), + /not documented or supported/ + ) +}) + +test('update onUpdate', () => { + let query = toQuery( + new YdbUpdateBuilder(users, session).set({ name: 'Fluttershy' }).where(eq(users.id, 5)) + ) + + assert.equal( + query.sql, + 'update `users` set `name` = $p0, `updated_at` = $p1 where `users`.`id` = $p2' + ) + assert.deepEqual(query.params, ['Fluttershy', 200, 5]) +}) + +test('update returning', () => { + let query = toQuery( + new YdbUpdateBuilder(users, session) + .set({ name: 'Fluttershy' }) + .where(eq(users.id, 5)) + .returning({ id: users.id, name: users.name }) + ) + + assert.equal( + query.sql, + 'update `users` set `name` = $p0, `updated_at` = $p1 where `users`.`id` = $p2 returning `id`, `name`' + ) + assert.deepEqual(query.params, ['Fluttershy', 200, 5]) +}) + +test('update on select sql', () => { + let keyedUsers = ydbTable('keyed_users', { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + }) + + let query = toQuery( + new YdbUpdateBuilder(keyedUsers, session, dialect) + .on((qb) => + qb + .select({ + id: keyedUsers.id, + name: yql`${'updated'}`.as('name'), + }) + .from(keyedUsers) + .where(eq(keyedUsers.id, 1)) + ) + .returning({ id: keyedUsers.id, name: keyedUsers.name }) + ) + + assert.equal( + query.sql, + 'update `keyed_users` on select `keyed_users`.`id`, $p0 as `name` from `keyed_users` where `keyed_users`.`id` = $p1 returning `id`, `name`' + ) + assert.deepEqual(query.params, ['updated', 1]) +}) + +test('update on validates target columns and primary key', () => { + let keyedUsers = ydbTable('keyed_users', { + id: integer('id').notNull().primaryKey(), + name: text('name').notNull(), + }) + + assert.throws( + () => + new YdbUpdateBuilder(keyedUsers, session, dialect).on((qb) => + qb + .select({ + name: keyedUsers.name, + }) + .from(keyedUsers) + ), + /requires primary key column "id"/ + ) + + assert.throws( + () => + new YdbUpdateBuilder(keyedUsers, session, dialect).on((qb) => + qb + .select({ + id: keyedUsers.id, + nope: keyedUsers.name, + }) + .from(keyedUsers) + ), + /selected field "nope"/ + ) + + assert.throws( + () => + new YdbUpdateBuilder(keyedUsers, session, dialect) + .on((qb) => + qb + .select({ + id: keyedUsers.id, + name: keyedUsers.name, + }) + .from(keyedUsers) + ) + .where(eq(keyedUsers.id, 1)), + /does not support where/ + ) +}) + +test('update rejects unknown', () => { + assert.throws( + () => toQuery(new YdbUpdateBuilder(users, session).set({ nope: true } as any)), + /Unknown column "nope" in update\(\)/ + ) +}) + +test('update rejects empty', () => { + let tableWithoutUpdateHooks = ydbTable('plain_users', { + id: integer('id').notNull(), + name: text('name').notNull(), + }) + + assert.throws( + () => toQuery(new YdbUpdateBuilder(tableWithoutUpdateHooks, session).set({})), + /Update values are empty/ + ) +}) + +test('batch update and delete sql', () => { + let updateQuery = toQuery( + new YdbBatchUpdateBuilder(users, session).set({ name: 'Applejack' }).where(eq(users.id, 1)) + ) + let deleteQuery = toQuery(new YdbBatchDeleteBuilder(users, session).where(eq(users.id, 2))) + + assert.equal( + updateQuery.sql, + 'batch update `users` set `name` = $p0, `updated_at` = $p1 where `users`.`id` = $p2' + ) + assert.deepEqual(updateQuery.params, ['Applejack', 200, 1]) + assert.equal(deleteQuery.sql, 'batch delete from `users` where `users`.`id` = $p0') + assert.deepEqual(deleteQuery.params, [2]) +}) + +test('batch update and delete reject unsupported mutation clauses', () => { + assert.throws(() => new YdbBatchUpdateBuilder(users, session).returning(), /not supported/) + assert.throws(() => new YdbBatchUpdateBuilder(users, session).on(), /not supported/) + assert.throws(() => new YdbBatchDeleteBuilder(users, session).returning(), /not supported/) + assert.throws(() => new YdbBatchDeleteBuilder(users, session).using(), /not supported/) + assert.throws(() => new YdbBatchDeleteBuilder(users, session).on(), /not supported/) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/public-api.test.ts b/third-parties/drizzle-adapter/tests/unit/public-api.test.ts new file mode 100644 index 00000000..9cf28da8 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/public-api.test.ts @@ -0,0 +1,524 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import * as publicApi from '../../src/index.ts' +import { createMany, createOne, relations } from 'drizzle-orm' +import * as queryBuilders from '../../src/ydb-core/query-builders/index.ts' +import { YdbCountBuilder } from '../../src/ydb-core/query-builders/count.ts' +import { + YdbBatchDeleteBuilder, + YdbDeleteBuilder, +} from '../../src/ydb-core/query-builders/delete.ts' +import { + YdbInsertBuilder, + YdbReplaceBuilder, + YdbUpsertBuilder, +} from '../../src/ydb-core/query-builders/insert.ts' +import { YdbQueryBuilder } from '../../src/ydb-core/query-builders/query-builder.ts' +import { + YdbRelationalQuery, + YdbRelationalQueryBuilder, +} from '../../src/ydb-core/query-builders/query.ts' +import { + YdbSelectBuilder, + except, + intersect, + union, + unionAll, +} from '../../src/ydb-core/query-builders/select.ts' +import { + YdbBatchUpdateBuilder, + YdbUpdateBuilder, +} from '../../src/ydb-core/query-builders/update.ts' +import { + YdbAuthenticationError, + YdbCancelledQueryError, + YdbOverloadedQueryError, + YdbQueryExecutionError, + YdbRetryableQueryError, + YdbTimeoutQueryError, + YdbUnavailableQueryError, + YdbUniqueConstraintViolationError, +} from '../../src/ydb/errors.ts' +import { ydbTable } from '../../src/ydb-core/table.ts' +import { + buildAddChangefeedSql, + buildAddColumnFamilySql, + buildAlterAsyncReplicationSql, + buildAlterColumnFamilySql, + buildAlterColumnSetFamilySql, + buildAlterGroupSql, + buildAlterTableResetOptionsSql, + buildAlterTableSetOptionsSql, + buildAlterTableSql, + buildAlterTopicSql, + buildAlterTransferSql, + buildAlterUserSql, + buildAnalyzeSql, + buildCreateAsyncReplicationSql, + buildCreateGroupSql, + buildCreateSecretSql, + buildCreateTableSql, + buildCreateTopicSql, + buildCreateTransferSql, + buildCreateUserSql, + buildCreateViewSql, + buildDropAsyncReplicationSql, + buildDropChangefeedSql, + buildDropGroupSql, + buildDropTopicSql, + buildDropTransferSql, + buildDropUserSql, + buildDropViewSql, + buildGrantSql, + buildMigrationLockTableBootstrapSql, + buildRenameTableSql, + buildRevokeSql, + buildShowCreateSql, +} from '../../src/ydb/migration-ddl.ts' +import { migrate } from '../../src/ydb/migrator.ts' +import { customType } from '../../src/ydb-core/columns/custom.ts' +import { integer } from '../../src/ydb-core/columns/integer.ts' +import { text } from '../../src/ydb-core/columns/text.ts' +import { + index, + indexView, + uniqueIndex, + vectorIndex, + vectorIndexView, +} from '../../src/ydb-core/indexes.ts' +import { + bigint, + binary, + boolean, + bytes, + date, + date32, + datetime, + datetime64, + decimal, + double, + dyNumber, + float, + int16, + int8, + interval, + interval64, + json, + jsonDocument, + timestamp, + timestamp64, + uint16, + uint32, + uint64, + uint8, + uuid, + yson, +} from '../../src/ydb-core/columns/types.ts' +import { primaryKey } from '../../src/ydb-core/primary-keys.ts' +import { + columnFamily, + partitionByHash, + rawTableOption, + tableOptions, + ttl, +} from '../../src/ydb-core/table-options.ts' +import { unique } from '../../src/ydb-core/unique-constraint.ts' +import { createDrizzle, drizzle } from '../../src/ydb/createDrizzle.ts' +import { YdbDriver } from '../../src/ydb/driver.ts' +import { + asTable, + cube, + distinctHint, + groupKey, + grouping, + groupingSets, + hop, + hopEnd, + hopStart, + knnCosineDistance, + knnCosineSimilarity, + knnDistance, + knnEuclideanDistance, + knnInnerProductSimilarity, + knnManhattanDistance, + knnSimilarity, + matchRecognize, + rollup, + sessionStart, + sessionWindow, + uniqueHint, + values, + valuesTable, + windowDefinition, +} from '../../src/ydb-core/query-builders/select-syntax.ts' +import { + commit, + declareParam, + defineAction, + doAction, + doBlock, + intoResult, + kMeansTreeSearchTopSize, + pragma, + yqlScript, +} from '../../src/ydb-core/query-builders/yql-script.ts' + +let expectedRootRuntimeExports = [ + 'YdbAuthenticationError', + 'YdbCancelledQueryError', + 'YdbDriver', + 'YdbOverloadedQueryError', + 'YdbQueryExecutionError', + 'YdbRetryableQueryError', + 'YdbTimeoutQueryError', + 'YdbUnavailableQueryError', + 'YdbUniqueConstraintViolationError', + 'asTable', + 'bigint', + 'binary', + 'boolean', + 'buildAddChangefeedSql', + 'buildAddColumnFamilySql', + 'buildAddColumnsSql', + 'buildAddIndexSql', + 'buildAlterAsyncReplicationSql', + 'buildAlterColumnFamilySql', + 'buildAlterColumnSetFamilySql', + 'buildAlterGroupSql', + 'buildAlterTableResetOptionsSql', + 'buildAlterTableSetOptionsSql', + 'buildAlterTableSql', + 'buildAlterTopicSql', + 'buildAlterTransferSql', + 'buildAlterUserSql', + 'buildAnalyzeSql', + 'buildCreateAsyncReplicationSql', + 'buildCreateGroupSql', + 'buildCreateSecretSql', + 'buildCreateTableSql', + 'buildCreateTopicSql', + 'buildCreateTransferSql', + 'buildCreateUserSql', + 'buildCreateViewSql', + 'buildDropAsyncReplicationSql', + 'buildDropChangefeedSql', + 'buildDropColumnsSql', + 'buildDropGroupSql', + 'buildDropIndexSql', + 'buildDropTableSql', + 'buildDropTopicSql', + 'buildDropTransferSql', + 'buildDropUserSql', + 'buildDropViewSql', + 'buildGrantSql', + 'buildMigrationLockTableBootstrapSql', + 'buildMigrationSql', + 'buildRenameTableSql', + 'buildRevokeSql', + 'buildShowCreateSql', + 'bytes', + 'columnFamily', + 'commit', + 'createDrizzle', + 'cube', + 'customType', + 'date', + 'date32', + 'datetime', + 'datetime64', + 'decimal', + 'declareParam', + 'defineAction', + 'distinctHint', + 'doAction', + 'doBlock', + 'double', + 'drizzle', + 'dyNumber', + 'except', + 'float', + 'groupKey', + 'grouping', + 'groupingSets', + 'hop', + 'hopEnd', + 'hopStart', + 'index', + 'indexView', + 'int', + 'int16', + 'int8', + 'integer', + 'intersect', + 'interval', + 'interval64', + 'intoResult', + 'json', + 'jsonDocument', + 'kMeansTreeSearchTopSize', + 'knnCosineDistance', + 'knnCosineSimilarity', + 'knnDistance', + 'knnEuclideanDistance', + 'knnInnerProductSimilarity', + 'knnManhattanDistance', + 'knnSimilarity', + 'many', + 'matchRecognize', + 'migrate', + 'one', + 'partitionByHash', + 'pragma', + 'primaryKey', + 'rawTableOption', + 'relations', + 'rollup', + 'sessionStart', + 'sessionWindow', + 'tableOptions', + 'text', + 'timestamp', + 'timestamp64', + 'ttl', + 'uint16', + 'uint32', + 'uint64', + 'uint8', + 'union', + 'unionAll', + 'unique', + 'uniqueHint', + 'uniqueIndex', + 'uuid', + 'values', + 'valuesTable', + 'vectorIndex', + 'vectorIndexView', + 'windowDefinition', + 'ydbTable', + 'ydbTableCreator', + 'yqlScript', + 'yson', +] as const + +test('root public API exposes exactly the stable runtime surface', () => { + assert.deepEqual(Object.keys(publicApi).sort(), [...expectedRootRuntimeExports]) +}) + +test('root public API re-exports runtime entry points', () => { + assert.equal(publicApi.YdbDriver, YdbDriver) + assert.equal(publicApi.YdbAuthenticationError, YdbAuthenticationError) + assert.equal(publicApi.YdbCancelledQueryError, YdbCancelledQueryError) + assert.equal(publicApi.YdbOverloadedQueryError, YdbOverloadedQueryError) + assert.equal(publicApi.YdbQueryExecutionError, YdbQueryExecutionError) + assert.equal(publicApi.YdbRetryableQueryError, YdbRetryableQueryError) + assert.equal(publicApi.YdbTimeoutQueryError, YdbTimeoutQueryError) + assert.equal(publicApi.YdbUnavailableQueryError, YdbUnavailableQueryError) + assert.equal(publicApi.YdbUniqueConstraintViolationError, YdbUniqueConstraintViolationError) + assert.equal(publicApi.drizzle, drizzle) + assert.equal(publicApi.createDrizzle, createDrizzle) + assert.equal(publicApi.relations, relations) + assert.equal(publicApi.one, createOne) + assert.equal(publicApi.many, createMany) + assert.equal(publicApi.ydbTable, ydbTable) + assert.equal(publicApi.integer, integer) + assert.equal(publicApi.int, integer) + assert.equal(publicApi.text, text) + assert.equal(publicApi.customType, customType) + assert.equal(publicApi.index, index) + assert.equal(publicApi.indexView, indexView) + assert.equal(publicApi.uniqueIndex, uniqueIndex) + assert.equal(publicApi.vectorIndex, vectorIndex) + assert.equal(publicApi.vectorIndexView, vectorIndexView) + assert.equal(publicApi.boolean, boolean) + assert.equal(publicApi.bigint, bigint) + assert.equal(publicApi.int8, int8) + assert.equal(publicApi.int16, int16) + assert.equal(publicApi.uint8, uint8) + assert.equal(publicApi.uint16, uint16) + assert.equal(publicApi.uint32, uint32) + assert.equal(publicApi.uint64, uint64) + assert.equal(publicApi.float, float) + assert.equal(publicApi.double, double) + assert.equal(publicApi.dyNumber, dyNumber) + assert.equal(publicApi.bytes, bytes) + assert.equal(publicApi.binary, binary) + assert.equal(publicApi.date, date) + assert.equal(publicApi.date32, date32) + assert.equal(publicApi.datetime, datetime) + assert.equal(publicApi.datetime64, datetime64) + assert.equal(publicApi.timestamp, timestamp) + assert.equal(publicApi.timestamp64, timestamp64) + assert.equal(publicApi.interval, interval) + assert.equal(publicApi.interval64, interval64) + assert.equal(publicApi.json, json) + assert.equal(publicApi.jsonDocument, jsonDocument) + assert.equal(publicApi.uuid, uuid) + assert.equal(publicApi.yson, yson) + assert.equal(publicApi.decimal, decimal) + assert.equal(publicApi.primaryKey, primaryKey) + assert.equal(publicApi.unique, unique) + assert.equal(publicApi.buildCreateTableSql, buildCreateTableSql) + assert.equal(publicApi.buildAddColumnFamilySql, buildAddColumnFamilySql) + assert.equal(publicApi.buildAddChangefeedSql, buildAddChangefeedSql) + assert.equal(publicApi.buildAlterAsyncReplicationSql, buildAlterAsyncReplicationSql) + assert.equal(publicApi.buildAlterGroupSql, buildAlterGroupSql) + assert.equal(publicApi.buildAlterTableSql, buildAlterTableSql) + assert.equal(publicApi.buildAlterColumnFamilySql, buildAlterColumnFamilySql) + assert.equal(publicApi.buildAlterColumnSetFamilySql, buildAlterColumnSetFamilySql) + assert.equal(publicApi.buildAlterTableResetOptionsSql, buildAlterTableResetOptionsSql) + assert.equal(publicApi.buildAlterTableSetOptionsSql, buildAlterTableSetOptionsSql) + assert.equal(publicApi.buildAlterTopicSql, buildAlterTopicSql) + assert.equal(publicApi.buildAlterTransferSql, buildAlterTransferSql) + assert.equal(publicApi.buildAlterUserSql, buildAlterUserSql) + assert.equal(publicApi.buildAnalyzeSql, buildAnalyzeSql) + assert.equal(publicApi.buildCreateAsyncReplicationSql, buildCreateAsyncReplicationSql) + assert.equal(publicApi.buildCreateGroupSql, buildCreateGroupSql) + assert.equal(publicApi.buildCreateSecretSql, buildCreateSecretSql) + assert.equal(publicApi.buildCreateTopicSql, buildCreateTopicSql) + assert.equal(publicApi.buildCreateTransferSql, buildCreateTransferSql) + assert.equal(publicApi.buildCreateUserSql, buildCreateUserSql) + assert.equal(publicApi.buildCreateViewSql, buildCreateViewSql) + assert.equal(publicApi.buildDropAsyncReplicationSql, buildDropAsyncReplicationSql) + assert.equal(publicApi.buildDropChangefeedSql, buildDropChangefeedSql) + assert.equal(publicApi.buildDropGroupSql, buildDropGroupSql) + assert.equal(publicApi.buildDropTopicSql, buildDropTopicSql) + assert.equal(publicApi.buildDropTransferSql, buildDropTransferSql) + assert.equal(publicApi.buildDropUserSql, buildDropUserSql) + assert.equal(publicApi.buildDropViewSql, buildDropViewSql) + assert.equal(publicApi.buildGrantSql, buildGrantSql) + assert.equal(publicApi.buildMigrationLockTableBootstrapSql, buildMigrationLockTableBootstrapSql) + assert.equal(publicApi.buildRenameTableSql, buildRenameTableSql) + assert.equal(publicApi.buildRevokeSql, buildRevokeSql) + assert.equal(publicApi.buildShowCreateSql, buildShowCreateSql) + assert.equal(publicApi.migrate, migrate) + assert.equal(publicApi.tableOptions, tableOptions) + assert.equal(publicApi.rawTableOption, rawTableOption) + assert.equal(publicApi.partitionByHash, partitionByHash) + assert.equal(publicApi.ttl, ttl) + assert.equal(publicApi.columnFamily, columnFamily) + assert.equal(publicApi.union, union) + assert.equal(publicApi.unionAll, unionAll) + assert.equal(publicApi.intersect, intersect) + assert.equal(publicApi.except, except) + assert.equal(publicApi.asTable, asTable) + assert.equal(publicApi.commit, commit) + assert.equal(publicApi.cube, cube) + assert.equal(publicApi.declareParam, declareParam) + assert.equal(publicApi.defineAction, defineAction) + assert.equal(publicApi.distinctHint, distinctHint) + assert.equal(publicApi.doAction, doAction) + assert.equal(publicApi.doBlock, doBlock) + assert.equal(publicApi.groupKey, groupKey) + assert.equal(publicApi.grouping, grouping) + assert.equal(publicApi.groupingSets, groupingSets) + assert.equal(publicApi.hop, hop) + assert.equal(publicApi.hopEnd, hopEnd) + assert.equal(publicApi.hopStart, hopStart) + assert.equal(publicApi.intoResult, intoResult) + assert.equal(publicApi.kMeansTreeSearchTopSize, kMeansTreeSearchTopSize) + assert.equal(publicApi.knnCosineDistance, knnCosineDistance) + assert.equal(publicApi.knnCosineSimilarity, knnCosineSimilarity) + assert.equal(publicApi.knnDistance, knnDistance) + assert.equal(publicApi.knnEuclideanDistance, knnEuclideanDistance) + assert.equal(publicApi.knnInnerProductSimilarity, knnInnerProductSimilarity) + assert.equal(publicApi.knnManhattanDistance, knnManhattanDistance) + assert.equal(publicApi.knnSimilarity, knnSimilarity) + assert.equal(publicApi.values, values) + assert.equal(publicApi.valuesTable, valuesTable) + assert.equal(publicApi.matchRecognize, matchRecognize) + assert.equal(publicApi.pragma, pragma) + assert.equal(publicApi.rollup, rollup) + assert.equal(publicApi.sessionStart, sessionStart) + assert.equal(publicApi.sessionWindow, sessionWindow) + assert.equal(publicApi.uniqueHint, uniqueHint) + assert.equal(publicApi.windowDefinition, windowDefinition) + assert.equal(publicApi.yqlScript, yqlScript) +}) + +test('root public API does not expose implementation internals', () => { + let internalRuntimeNames = [ + 'YdbDialect', + 'YdbSession', + 'YdbTransaction', + 'YdbDatabase', + 'YdbCountBuilder', + 'YdbQueryBuilder', + 'YdbColumn', + 'YdbColumnBuilder', + ] as const + + for (let name of internalRuntimeNames) { + assert.equal(Object.hasOwn(publicApi, name), false) + } +}) + +test('internal migration SQL builders are not root public API', () => { + let internalMigrationSqlBuilders = [ + 'buildMigrationTableBootstrapSql', + 'buildMigrationHistoryMetadataProbeSql', + 'buildMigrationHistoryMetadataColumnSql', + 'buildMigrationHistorySelectSql', + 'buildMigrationHistoryInsertSql', + 'buildMigrationLockSelectSql', + 'buildMigrationLockUpsertSql', + 'buildMigrationLockRefreshSql', + 'buildMigrationLockReleaseSql', + ] as const + + for (let builderName of internalMigrationSqlBuilders) { + assert.equal(Object.hasOwn(publicApi, builderName), false) + } +}) + +test('query builder barrel re-exports concrete builder implementations', () => { + assert.equal(queryBuilders.YdbCountBuilder, YdbCountBuilder) + assert.equal(queryBuilders.YdbSelectBuilder, YdbSelectBuilder) + assert.equal(queryBuilders.YdbInsertBuilder, YdbInsertBuilder) + assert.equal(queryBuilders.YdbUpsertBuilder, YdbUpsertBuilder) + assert.equal(queryBuilders.YdbReplaceBuilder, YdbReplaceBuilder) + assert.equal(queryBuilders.YdbUpdateBuilder, YdbUpdateBuilder) + assert.equal(queryBuilders.YdbBatchUpdateBuilder, YdbBatchUpdateBuilder) + assert.equal(queryBuilders.YdbDeleteBuilder, YdbDeleteBuilder) + assert.equal(queryBuilders.YdbBatchDeleteBuilder, YdbBatchDeleteBuilder) + assert.equal(queryBuilders.YdbQueryBuilder, YdbQueryBuilder) + assert.equal(queryBuilders.YdbRelationalQueryBuilder, YdbRelationalQueryBuilder) + assert.equal(queryBuilders.YdbRelationalQuery, YdbRelationalQuery) + assert.equal(queryBuilders.union, union) + assert.equal(queryBuilders.unionAll, unionAll) + assert.equal(queryBuilders.intersect, intersect) + assert.equal(queryBuilders.except, except) + assert.equal(queryBuilders.asTable, asTable) + assert.equal(queryBuilders.commit, commit) + assert.equal(queryBuilders.cube, cube) + assert.equal(queryBuilders.declareParam, declareParam) + assert.equal(queryBuilders.defineAction, defineAction) + assert.equal(queryBuilders.distinctHint, distinctHint) + assert.equal(queryBuilders.doAction, doAction) + assert.equal(queryBuilders.doBlock, doBlock) + assert.equal(queryBuilders.groupKey, groupKey) + assert.equal(queryBuilders.grouping, grouping) + assert.equal(queryBuilders.groupingSets, groupingSets) + assert.equal(queryBuilders.hop, hop) + assert.equal(queryBuilders.hopEnd, hopEnd) + assert.equal(queryBuilders.hopStart, hopStart) + assert.equal(queryBuilders.intoResult, intoResult) + assert.equal(queryBuilders.kMeansTreeSearchTopSize, kMeansTreeSearchTopSize) + assert.equal(queryBuilders.knnCosineDistance, knnCosineDistance) + assert.equal(queryBuilders.knnCosineSimilarity, knnCosineSimilarity) + assert.equal(queryBuilders.knnDistance, knnDistance) + assert.equal(queryBuilders.knnEuclideanDistance, knnEuclideanDistance) + assert.equal(queryBuilders.knnInnerProductSimilarity, knnInnerProductSimilarity) + assert.equal(queryBuilders.knnManhattanDistance, knnManhattanDistance) + assert.equal(queryBuilders.knnSimilarity, knnSimilarity) + assert.equal(queryBuilders.values, values) + assert.equal(queryBuilders.valuesTable, valuesTable) + assert.equal(queryBuilders.matchRecognize, matchRecognize) + assert.equal(queryBuilders.pragma, pragma) + assert.equal(queryBuilders.rollup, rollup) + assert.equal(queryBuilders.sessionStart, sessionStart) + assert.equal(queryBuilders.sessionWindow, sessionWindow) + assert.equal(queryBuilders.uniqueHint, uniqueHint) + assert.equal(queryBuilders.windowDefinition, windowDefinition) + assert.equal(queryBuilders.yqlScript, yqlScript) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/query-builder-utils.test.ts b/third-parties/drizzle-adapter/tests/unit/query-builder-utils.test.ts new file mode 100644 index 00000000..d33900f5 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/query-builder-utils.test.ts @@ -0,0 +1,103 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { sql as yql } from 'drizzle-orm' +import { integer, text, ydbTable } from '../../src/index.ts' +import { + getInsertColumnEntries, + getTableColumns, + resolveInsertValue, + resolveUpdateValue, + validateTableColumnKeys, +} from '../../src/ydb-core/query-builders/utils.ts' +import { dialect, users } from '../helpers/unit-basic.ts' + +function fragmentToQuery(fragment: unknown) { + return dialect.sqlToQuery(yql`${fragment as any}`) +} + +test('getTableColumns and insert entries expose table metadata', () => { + let columns = getTableColumns(users) + + assert.deepEqual(Object.keys(columns), ['id', 'name', 'createdAt', 'updatedAt']) + assert.deepEqual( + getInsertColumnEntries(users).map(([key]) => key), + ['id', 'name', 'createdAt', 'updatedAt'] + ) +}) + +test('validateTableColumnKeys rejects unknown fields for insert and update', () => { + validateTableColumnKeys(users, { id: 1, name: 'Pinkie Pie' }, 'insert') + validateTableColumnKeys(users, { name: 'Rainbow Dash' }, 'update') + + assert.throws( + () => validateTableColumnKeys(users, { id: 1, nope: true }, 'insert'), + /Unknown column "nope" in insert\(\)/ + ) + assert.throws( + () => validateTableColumnKeys(users, { nope: true }, 'update'), + /Unknown column "nope" in update\(\)/ + ) +}) + +test('resolveInsertValue uses explicit values, defaults, onUpdate hooks and SQL default', () => { + let columns = getTableColumns(users) + let staticDefaultUsers = ydbTable('static_default_users', { + id: integer('id').notNull(), + score: integer('score').default(42), + }) + let plainUsers = ydbTable('plain_users', { + id: integer('id').notNull(), + name: text('name').notNull(), + }) + let staticDefaultColumns = getTableColumns(staticDefaultUsers) + let plainColumns = getTableColumns(plainUsers) + + assert.deepEqual(fragmentToQuery(resolveInsertValue(columns['name']!, 'Twilight Sparkle')), { + sql: '$p0', + params: ['Twilight Sparkle'], + typings: ['none'], + }) + assert.deepEqual(fragmentToQuery(resolveInsertValue(columns['createdAt']!, undefined)), { + sql: '$p0', + params: [100], + typings: ['none'], + }) + assert.deepEqual(fragmentToQuery(resolveInsertValue(columns['updatedAt']!, undefined)), { + sql: '$p0', + params: [200], + typings: ['none'], + }) + assert.deepEqual( + fragmentToQuery(resolveInsertValue(staticDefaultColumns['score']!, undefined)), + { + sql: '$p0', + params: [42], + typings: ['none'], + } + ) + assert.deepEqual(fragmentToQuery(resolveInsertValue(plainColumns['name']!, undefined)), { + sql: 'default', + params: [], + }) +}) + +test('resolveUpdateValue uses explicit values and onUpdate hooks', () => { + let columns = getTableColumns(users) + let plainUsers = ydbTable('plain_users', { + id: integer('id').notNull(), + name: text('name').notNull(), + }) + let plainColumns = getTableColumns(plainUsers) + + assert.deepEqual(fragmentToQuery(resolveUpdateValue(columns['name']!, 'Applejack')), { + sql: '$p0', + params: ['Applejack'], + typings: ['none'], + }) + assert.deepEqual(fragmentToQuery(resolveUpdateValue(columns['updatedAt']!, undefined)), { + sql: '$p0', + params: [200], + typings: ['none'], + }) + assert.equal(resolveUpdateValue(plainColumns['name']!, undefined), undefined) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/relational-query.test.ts b/third-parties/drizzle-adapter/tests/unit/relational-query.test.ts new file mode 100644 index 00000000..64c91d84 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/relational-query.test.ts @@ -0,0 +1,302 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { + createTableRelationsHelpers, + extractTablesRelationalConfig, + relations, +} from 'drizzle-orm/relations' +import { YdbRelationalQueryBuilder } from '../../src/ydb-core/query-builders/index.ts' +import { dialect, posts, users } from '../helpers/unit-basic.ts' + +function buildObjectRows(sqlText: string, rowValues: unknown[][]): Array> { + let aliases = Array.from(sqlText.matchAll(/ as `([^`]+)`/g), (match) => match[1]!) + return rowValues.map((values) => + Object.fromEntries(aliases.map((alias, index) => [alias, values[index]])) + ) +} + +function createRelationalSession(executedSql: string[]) { + return { + prepareQuery(query: any) { + let built = + 'sql' in query && Array.isArray(query.params) ? query : dialect.sqlToQuery(query) + executedSql.push(built.sql) + + return { + getQuery() { + return built + }, + async execute() { + if ( + built.sql.includes('from `users` `posts_author`') || + built.sql.includes('from `users` `users_posts_author`') + ) { + return buildObjectRows(built.sql, [[1, 'Pinkie Pie']]) + } + + if (built.sql.includes('from `posts` `users_posts`')) { + return buildObjectRows(built.sql, [ + [10, 'Cupcakes', 1], + [11, 'Sonic Rainboom', 1], + ]) + } + + if (built.sql.includes('from `users` `users`')) { + if (built.sql.includes('offset')) { + return buildObjectRows(built.sql, [ + [1, 'Pinkie Pie', undefined, undefined], + [2, 'Rainbow Dash', undefined, undefined], + ]) + } + + if (built.sql.includes('limit')) { + return buildObjectRows(built.sql, [ + [1, 'Pinkie Pie', undefined, undefined], + ]) + } + + return buildObjectRows(built.sql, [ + [1, 'Pinkie Pie', undefined, undefined], + [2, 'Rainbow Dash', undefined, undefined], + ]) + } + + if (built.sql.includes('from `posts` `posts`')) { + return buildObjectRows(built.sql, [[10, 'Cupcakes', 1]]) + } + + return [] + }, + async values() { + return [] + }, + } + }, + } as any +} + +function createChunkedRelationalSession( + executedQueries: Array<{ sql: string; params: unknown[] }> +) { + return { + prepareQuery(query: any) { + let built = + 'sql' in query && Array.isArray(query.params) ? query : dialect.sqlToQuery(query) + executedQueries.push({ sql: built.sql, params: [...built.params] }) + + return { + getQuery() { + return built + }, + async execute() { + if (built.sql.includes('from `users` `users`')) { + return buildObjectRows( + built.sql, + Array.from({ length: 260 }, (_, index) => [ + index + 1, + `User ${index + 1}`, + ]) + ) + } + + if (built.sql.includes('from `posts` `users_posts`')) { + return buildObjectRows( + built.sql, + built.params.map((userId) => [ + Number(userId) * 10, + `Post ${String(userId)}`, + userId, + ]) + ) + } + + return [] + }, + async values() { + return [] + }, + } + }, + } as any +} + +let schema = { + users, + posts, + usersRelations: relations(users, ({ many }) => ({ + posts: many(posts), + })), + postsRelations: relations(posts, ({ one }) => ({ + author: one(users, { + fields: [posts.userId], + references: [users.id], + }), + })), +} + +test('relational builder handles flat findMany/findFirst queries', async () => { + let executedSql: string[] = [] + let tablesConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers) + let relational = new YdbRelationalQueryBuilder( + schema, + tablesConfig.tables as any, + tablesConfig.tableNamesMap, + users, + (tablesConfig.tables as any).users, + dialect, + createRelationalSession(executedSql) + ) + + let many = await relational + .findMany({ + columns: { id: true, name: true }, + where: (fields, { eq }) => eq(fields['id'], 1), + orderBy: (fields, { desc }) => desc(fields['name']), + limit: 5, + offset: 2, + }) + .execute() + let first = await relational + .findFirst({ + where: (fields, { eq }) => eq(fields['id'], 1), + }) + .execute() + + assert.deepEqual(many, [ + { id: 1, name: 'Pinkie Pie' }, + { id: 2, name: 'Rainbow Dash' }, + ]) + assert.deepEqual(first, { + id: 1, + name: 'Pinkie Pie', + createdAt: undefined, + updatedAt: undefined, + }) + assert.match( + executedSql[0] ?? '', + /^select `users`\.`id` as `__ydb_c0`, `users`\.`name` as `__ydb_c1` from `users` `users` where `users`\.`id` = \$p0 order by `users`\.`name` desc limit \$p1 offset \$p2$/ + ) + assert.match( + executedSql[1] ?? '', + /^select `users`\.`id` as `__ydb_c0`, `users`\.`name` as `__ydb_c1`, `users`\.`created_at` as `__ydb_c2`, `users`\.`updated_at` as `__ydb_c3` from `users` `users` where `users`\.`id` = \$p0 limit \$p1$/ + ) +}) + +test('relational builder hydrates many/one relations through schema metadata', async () => { + let executedSql: string[] = [] + let tablesConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers) + let usersRelational = new YdbRelationalQueryBuilder( + schema, + tablesConfig.tables as any, + tablesConfig.tableNamesMap, + users, + (tablesConfig.tables as any).users, + dialect, + createRelationalSession(executedSql) + ) + let postsRelational = new YdbRelationalQueryBuilder( + schema, + tablesConfig.tables as any, + tablesConfig.tableNamesMap, + posts, + (tablesConfig.tables as any).posts, + dialect, + createRelationalSession(executedSql) + ) + + let usersWithPosts = await usersRelational + .findMany({ + columns: { id: true, name: true }, + with: { + posts: { + columns: { id: true, title: true }, + }, + }, + }) + .execute() + let postsWithAuthor = await postsRelational + .findMany({ + columns: { id: true, title: true }, + with: { + author: { + columns: { id: true, name: true }, + }, + }, + }) + .execute() + + assert.deepEqual(usersWithPosts, [ + { + id: 1, + name: 'Pinkie Pie', + posts: [ + { id: 10, title: 'Cupcakes' }, + { id: 11, title: 'Sonic Rainboom' }, + ], + }, + { + id: 2, + name: 'Rainbow Dash', + posts: [], + }, + ]) + assert.deepEqual(postsWithAuthor, [ + { + id: 10, + title: 'Cupcakes', + author: { + id: 1, + name: 'Pinkie Pie', + }, + }, + ]) + assert.ok(executedSql.some((query) => query.includes('from `posts` `users_posts`'))) + assert.ok(executedSql.some((query) => query.includes('from `users` `posts_author`'))) + assert.ok(executedSql.some((query) => query.includes('from `users` `users`'))) +}) + +test('relational builder chunks relation filters for large parent sets', async () => { + let executedQueries: Array<{ sql: string; params: unknown[] }> = [] + let tablesConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers) + let usersRelational = new YdbRelationalQueryBuilder( + schema, + tablesConfig.tables as any, + tablesConfig.tableNamesMap, + users, + (tablesConfig.tables as any).users, + dialect, + createChunkedRelationalSession(executedQueries) + ) + + let rows = await usersRelational + .findMany({ + columns: { id: true, name: true }, + with: { + posts: { + columns: { id: true, title: true }, + }, + }, + }) + .execute() + let relationQueries = executedQueries.filter(({ sql }) => + sql.includes('from `posts` `users_posts`') + ) + + assert.equal(rows.length, 260) + assert.deepEqual(rows[0], { + id: 1, + name: 'User 1', + posts: [{ id: 10, title: 'Post 1' }], + }) + assert.deepEqual(rows[259], { + id: 260, + name: 'User 260', + posts: [{ id: 2600, title: 'Post 260' }], + }) + assert.equal(relationQueries.length, 2) + assert.ok(relationQueries.every(({ params }) => params.length <= 256)) + assert.deepEqual( + relationQueries.map(({ params }) => params.length), + [256, 4] + ) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/select-builder.runtime.test.ts b/third-parties/drizzle-adapter/tests/unit/select-builder.runtime.test.ts new file mode 100644 index 00000000..6a5b4ad8 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/select-builder.runtime.test.ts @@ -0,0 +1,90 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { desc, eq } from 'drizzle-orm' +import { YdbSelectBuilder } from '../../src/ydb-core/query-builders/index.ts' +import { dialect, posts, users } from '../helpers/unit-basic.ts' + +test('select builder maps joins and advanced queries', async () => { + let executedSql: string[] = [] + let advancedSession = { + prepareQuery( + query: any, + _fields: unknown, + _name?: string, + _isResponseInArrayMode = false, + customResultMapper?: (rows: unknown[][]) => unknown + ) { + let built = + 'sql' in query && Array.isArray(query.params) ? query : dialect.sqlToQuery(query) + executedSql.push(built.sql) + + return { + getQuery() { + return built + }, + async execute() { + let rows = built.sql.includes('__ydb_row_number') + ? [ + [1, 'Zecora'], + [2, 'Rainbow Dash'], + ] + : [ + [1, 'Twilight Sparkle', 100, 200, 11, 1, 'Lesson Zero'], + [2, 'Pinkie Pie', 100, 200, null, null, null], + ] + + return customResultMapper ? customResultMapper(rows) : rows + }, + } + }, + } as any + + let joinedRows = (await new YdbSelectBuilder(advancedSession) + .from(users) + .leftJoin(posts, eq(users.id, posts.userId)) + .orderBy(users.id, posts.id) + .execute()) as Array> + + let distinctOnRows = (await new YdbSelectBuilder(advancedSession, { + userId: posts.userId, + title: posts.title, + }) + .from(posts) + .distinctOn(posts.userId) + .orderBy(posts.userId, desc(posts.title)) + .execute()) as Array<{ userId: number; title: string }> + + assert.deepEqual(joinedRows, [ + { + users: { + id: 1, + name: 'Twilight Sparkle', + createdAt: 100, + updatedAt: 200, + }, + posts: { + id: 11, + userId: 1, + title: 'Lesson Zero', + }, + }, + { + users: { + id: 2, + name: 'Pinkie Pie', + createdAt: 100, + updatedAt: 200, + }, + posts: null, + }, + ]) + assert.deepEqual(distinctOnRows, [ + { userId: 1, title: 'Zecora' }, + { userId: 2, title: 'Rainbow Dash' }, + ]) + assert.match( + executedSql[0] ?? '', + /^select `users`\.`id` as `__ydb_f0`, `users`\.`name` as `__ydb_f1`, `users`\.`created_at` as `__ydb_f2`, `users`\.`updated_at` as `__ydb_f3`, `posts`\.`id` as `__ydb_f4`, `posts`\.`user_id` as `__ydb_f5`, `posts`\.`title` as `__ydb_f6` from `users` left join `posts` on `users`\.`id` = `posts`\.`user_id` order by `users`\.`id`, `posts`\.`id`$/ + ) + assert.match(executedSql[1] ?? '', /row_number\(\) over \(/) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/select-builder.sql.test.ts b/third-parties/drizzle-adapter/tests/unit/select-builder.sql.test.ts new file mode 100644 index 00000000..20f86261 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/select-builder.sql.test.ts @@ -0,0 +1,475 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { desc, eq, sql as yql } from 'drizzle-orm' +import { + asTable, + commit, + cube, + declareParam, + defineAction, + distinctHint, + doAction, + doBlock, + except, + groupKey, + grouping, + groupingSets, + hop, + hopEnd, + hopStart, + indexView, + intersect, + intoResult, + kMeansTreeSearchTopSize, + knnCosineSimilarity, + pragma, + rollup, + sessionStart, + sessionWindow, + unionAll, + uniqueHint, + values, + vectorIndexView, + windowDefinition, + yqlScript, +} from '../../src/index.ts' +import { YdbSelectBuilder } from '../../src/ydb-core/query-builders/index.ts' +import { dialect, posts, session, users } from '../helpers/unit-basic.ts' + +function toQuery(builder: { getSQL(): any }) { + return dialect.sqlToQuery(builder.getSQL()) +} + +test('select sql', () => { + let query = toQuery(new YdbSelectBuilder(session).from(users).where(eq(users.id, 7))) + + assert.equal( + query.sql, + 'select `users`.`id`, `users`.`name`, `users`.`created_at`, `users`.`updated_at` from `users` where `users`.`id` = $p0' + ) + assert.deepEqual(query.params, [7]) +}) + +test('select without from sql', () => { + let query = toQuery(new YdbSelectBuilder(session, { value: yql`${1}` })) + + assert.equal(query.sql, 'select $p0') + assert.deepEqual(query.params, [1]) +}) + +test('select advanced clauses sql', () => { + let query = toQuery( + new YdbSelectBuilder(session) + .from(users) + .distinct() + .groupBy(users.id, users.name, users.createdAt, users.updatedAt) + .having(yql`count(*) > ${1}`) + .orderBy(desc(users.name), users.id) + .limit(5) + .offset(2) + ) + + assert.equal( + query.sql, + 'select distinct `users`.`id`, `users`.`name`, `users`.`created_at`, `users`.`updated_at` from `users` group by `users`.`id`, `users`.`name`, `users`.`created_at`, `users`.`updated_at` having count(*) > $p0 order by `users`.`name` desc, `users`.`id` limit $p1 offset $p2' + ) + assert.deepEqual(query.params, [1, 5, 2]) +}) + +test('join sql', () => { + let leftJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id, postId: posts.id }) + .from(users) + .leftJoin(posts, eq(users.id, posts.userId)) + .orderBy(users.id, posts.id) + ) + let innerJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id, postId: posts.id }) + .from(users) + .innerJoin(posts, eq(users.id, posts.userId)) + ) + let rightJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id, postId: posts.id }) + .from(users) + .rightJoin(posts, eq(users.id, posts.userId)) + ) + let fullJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id, postId: posts.id }) + .from(users) + .fullJoin(posts, eq(users.id, posts.userId)) + ) + let crossJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id, postId: posts.id }) + .from(users) + .crossJoin(posts) + ) + let leftSemiJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id }) + .from(users) + .leftSemiJoin(posts, eq(users.id, posts.userId)) + ) + let rightOnlyJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id }) + .from(users) + .rightOnlyJoin(posts, eq(users.id, posts.userId)) + ) + let exclusionJoinQuery = toQuery( + new YdbSelectBuilder(session, { userId: users.id }) + .from(users) + .exclusionJoin(posts, eq(users.id, posts.userId)) + ) + + assert.equal( + leftJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0`, `posts`.`id` as `__ydb_f1` from `users` left join `posts` on `users`.`id` = `posts`.`user_id` order by `users`.`id`, `posts`.`id`' + ) + assert.equal( + innerJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0`, `posts`.`id` as `__ydb_f1` from `users` inner join `posts` on `users`.`id` = `posts`.`user_id`' + ) + assert.equal( + rightJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0`, `posts`.`id` as `__ydb_f1` from `users` right join `posts` on `users`.`id` = `posts`.`user_id`' + ) + assert.equal( + fullJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0`, `posts`.`id` as `__ydb_f1` from `users` full join `posts` on `users`.`id` = `posts`.`user_id`' + ) + assert.equal( + crossJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0`, `posts`.`id` as `__ydb_f1` from `users` cross join `posts`' + ) + assert.equal( + leftSemiJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0` from `users` left semi join `posts` on `users`.`id` = `posts`.`user_id`' + ) + assert.equal( + rightOnlyJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0` from `users` right only join `posts` on `users`.`id` = `posts`.`user_id`' + ) + assert.equal( + exclusionJoinQuery.sql, + 'select `users`.`id` as `__ydb_f0` from `users` exclusion join `posts` on `users`.`id` = `posts`.`user_id`' + ) +}) + +test('index view table source sql', () => { + let query = toQuery( + new YdbSelectBuilder(session, { + id: yql`${yql.identifier('u')}.${yql.identifier('id')}`, + }).from(indexView(users, 'users_name_idx', 'u')) + ) + + assert.equal(query.sql, 'select `u`.`id` from `users` view `users_name_idx` as `u`') +}) + +test('YDB SELECT source helpers render AS_TABLE, VALUES, and top-level VALUES', () => { + let asTableQuery = toQuery( + new YdbSelectBuilder(session, { + id: yql`${yql.identifier('r')}.${yql.identifier('id')}`, + }).from(asTable('$rows', 'r')) + ) + let fromAsTableQuery = toQuery( + new YdbSelectBuilder(session, { + id: yql`${yql.identifier('r')}.${yql.identifier('id')}`, + }).fromAsTable('rows', 'r') + ) + let fromValuesQuery = toQuery( + new YdbSelectBuilder(session, { + id: yql`${yql.identifier('v')}.${yql.identifier('id')}`, + name: yql`${yql.identifier('v')}.${yql.identifier('name')}`, + }).fromValues( + [ + [1, 'one'], + [2, 'two'], + ], + { alias: 'v', columns: ['id', 'name'] } + ) + ) + let topLevelValues = dialect.sqlToQuery( + values([ + { id: 1, name: 'one' }, + { id: 2, name: 'two' }, + ]) + ) + + assert.equal(asTableQuery.sql, 'select `r`.`id` from AS_TABLE($rows) as `r`') + assert.equal(fromAsTableQuery.sql, 'select `r`.`id` from AS_TABLE($rows) as `r`') + assert.equal( + fromValuesQuery.sql, + 'select `v`.`id`, `v`.`name` from (VALUES ($p0, $p1), ($p2, $p3)) as `v`(`id`, `name`)' + ) + assert.deepEqual(fromValuesQuery.params, [1, 'one', 2, 'two']) + assert.equal(topLevelValues.sql, 'VALUES ($p0, $p1), ($p2, $p3)') + assert.deepEqual(topLevelValues.params, [1, 'one', 2, 'two']) +}) + +test('YDB SELECT gaps render WITHOUT, FLATTEN, SAMPLE, TABLESAMPLE, and MATCH_RECOGNIZE', () => { + let withoutQuery = toQuery( + new YdbSelectBuilder(session).from(users).without(users.name).where(eq(users.id, 7)) + ) + let flattenQuery = toQuery( + new YdbSelectBuilder(session, { item: yql`${yql.identifier('items')}` }) + .from(yql.raw('(select AsList(1, 2) as items)')) + .flattenListBy(yql.identifier('items')) + .orderBy(yql.identifier('items')) + ) + let sampleQuery = toQuery( + new YdbSelectBuilder(session, { id: users.id }).from(users).sample(0.25) + ) + let tableSampleQuery = toQuery( + new YdbSelectBuilder(session, { id: users.id }).from(users).tableSample('bernoulli', 10, 7) + ) + let matchRecognizeQuery = toQuery( + new YdbSelectBuilder(session, { bTs: yql`${yql.identifier('b_ts')}` }) + .from(yql.raw('(select 1 as ts, 1 as button)')) + .matchRecognize({ + orderBy: [yql.identifier('ts')], + measures: { + b_ts: yql.raw('LAST(B.ts)'), + }, + rowsPerMatch: 'ONE ROW PER MATCH', + pattern: '(A B)', + define: { + A: yql.raw('A.button = 1'), + B: yql.raw('B.button = 2'), + }, + }) + ) + + assert.equal(withoutQuery.sql, 'select * WITHOUT `name` from `users` where `users`.`id` = $p0') + assert.deepEqual(withoutQuery.params, [7]) + assert.equal( + flattenQuery.sql, + 'select `items` from (select AsList(1, 2) as items) flatten list by `items` order by `items`' + ) + assert.equal(sampleQuery.sql, 'select `users`.`id` from `users` sample $p0') + assert.deepEqual(sampleQuery.params, [0.25]) + assert.equal( + tableSampleQuery.sql, + 'select `users`.`id` from `users` tablesample bernoulli($p0) repeatable($p1)' + ) + assert.deepEqual(tableSampleQuery.params, [10, 7]) + assert.equal( + matchRecognizeQuery.sql, + 'select `b_ts` from (select 1 as ts, 1 as button) match_recognize (ORDER BY `ts` MEASURES LAST(B.ts) AS `b_ts` ONE ROW PER MATCH PATTERN (A B) DEFINE A AS A.button = 1, B AS B.button = 2)' + ) +}) + +test('YDB SELECT renders unique/distinct hints, ASSUME ORDER BY, WINDOW, INTO RESULT, and advanced GROUP BY', () => { + let uniqueDistinctQuery = toQuery( + new YdbSelectBuilder(session, { id: users.id }) + .from(users) + .uniqueDistinct(uniqueHint('id'), distinctHint('name')) + ) + let assumeOrderQuery = toQuery( + new YdbSelectBuilder(session, { id: users.id, name: users.name }) + .from(users) + .assumeOrderBy('id', yql.raw('name DESC')) + ) + let windowQuery = toQuery( + new YdbSelectBuilder(session, { + id: users.id, + rn: yql`row_number() over w`, + }) + .from(users) + .window( + 'w', + windowDefinition({ + partitionBy: [users.name], + orderBy: [users.id], + frame: 'ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + }) + ) + ) + let groupQuery = toQuery( + new YdbSelectBuilder(session, { + mask: grouping(users.name, users.id), + count: yql`count(*)`, + }) + .from(users) + .groupCompactBy( + rollup(users.name, users.id), + cube(users.id), + groupingSets([users.name], [users.id]) + ) + ) + let sessionWindowQuery = toQuery( + new YdbSelectBuilder(session, { + sessionStart: sessionStart(), + count: yql`count(*)`, + }) + .from(users) + .groupBy(groupKey(sessionWindow(users.createdAt, 'PT1H'), 'session_start')) + ) + let hopQuery = toQuery( + new YdbSelectBuilder(session, { + hopStart: hopStart(), + hopEnd: hopEnd(), + count: yql`count(*)`, + }) + .from(users) + .groupBy(hop(users.createdAt, 'PT10S', 'PT1M', 'PT30S'), users.id) + ) + let intoResultQuery = toQuery( + new YdbSelectBuilder(session, { id: users.id }) + .from(users) + .limit(1) + .intoResult('selected_users') + ) + let knnQuery = toQuery( + new YdbSelectBuilder(session, { + similarity: knnCosineSimilarity(yql.identifier('embedding'), yql.raw('$target')), + }) + .from(vectorIndexView('articles', 'emb_idx', 'a')) + .orderBy(yql.raw('similarity DESC')) + .limit(10) + ) + + assert.equal( + uniqueDistinctQuery.sql, + 'select /*+ unique(id) distinct(name) */ `users`.`id` from `users`' + ) + assert.equal( + assumeOrderQuery.sql, + 'select `users`.`id`, `users`.`name` from `users` assume order by `id`, name DESC' + ) + assert.equal( + windowQuery.sql, + 'select `users`.`id`, row_number() over w from `users` window `w` AS (PARTITION BY `users`.`name` ORDER BY `users`.`id` ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)' + ) + assert.equal( + groupQuery.sql, + 'select GROUPING(`users`.`name`, `users`.`id`), count(*) from `users` group compact by ROLLUP(`users`.`name`, `users`.`id`), CUBE(`users`.`id`), GROUPING SETS((`users`.`name`), (`users`.`id`))' + ) + assert.equal( + sessionWindowQuery.sql, + 'select SessionStart(), count(*) from `users` group by SessionWindow(`users`.`created_at`, $p0) AS `session_start`' + ) + assert.deepEqual(sessionWindowQuery.params, ['PT1H']) + assert.equal( + hopQuery.sql, + 'select HOP_START(), HOP_END(), count(*) from `users` group by HOP(`users`.`created_at`, $p0, $p1, $p2), `users`.`id`' + ) + assert.deepEqual(hopQuery.params, ['PT10S', 'PT1M', 'PT30S']) + assert.equal( + intoResultQuery.sql, + 'select `users`.`id` from `users` limit $p0 into result `selected_users`' + ) + assert.deepEqual(intoResultQuery.params, [1]) + assert.equal( + knnQuery.sql, + 'select Knn::CosineSimilarity(`embedding`, $target) from `articles` view `emb_idx` as `a` order by similarity DESC limit $p0' + ) + assert.deepEqual(knnQuery.params, [10]) +}) + +test('YDB script helpers render PRAGMA, DECLARE, ACTION, COMMIT, and INTO RESULT', () => { + let scriptQuery = dialect.sqlToQuery( + yqlScript( + declareParam('$name', 'Utf8'), + pragma('TablePathPrefix', '/Root'), + pragma('Warning', ['disable', '1101']), + kMeansTreeSearchTopSize(10), + defineAction( + '$hello', + ['$name', { name: 'suffix', optional: true }], + ['SELECT "Hello";'] + ), + doAction('$hello', [yql.raw('$name')]), + doBlock(['SELECT 1;']), + commit() + ) + ) + let intoResultQuery = dialect.sqlToQuery( + intoResult( + new YdbSelectBuilder(session, { id: users.id }).from(users).getSQL(), + 'Result name' + ) + ) + + assert.equal( + scriptQuery.sql, + [ + 'DECLARE $name AS Utf8;', + 'PRAGMA TablePathPrefix = "/Root";', + 'PRAGMA Warning("disable", "1101");', + 'PRAGMA ydb.KMeansTreeSearchTopSize = "10";', + 'DEFINE ACTION $hello($name, $suffix?) AS', + 'SELECT "Hello";', + 'END DEFINE;', + 'DO $hello($name);', + 'DO BEGIN', + 'SELECT 1;', + 'END DO;', + 'COMMIT;', + ].join('\n') + ) + assert.equal(intoResultQuery.sql, 'select `users`.`id` from `users` INTO RESULT `Result name`;') +}) + +test('distinctOn and set operators sql', () => { + let distinctOnQuery = toQuery( + new YdbSelectBuilder(session, { userId: posts.userId, title: posts.title }) + .from(posts) + .distinctOn(posts.userId) + .orderBy(posts.userId, desc(posts.title)) + ) + let variadicDistinctOnQuery = toQuery( + new YdbSelectBuilder(session, { userId: posts.userId, title: posts.title }) + .from(posts) + .distinctOn(posts.userId, posts.title) + ) + + let unionQuery = toQuery( + unionAll( + new YdbSelectBuilder(session, { value: users.name }).from(users).where(eq(users.id, 1)), + new YdbSelectBuilder(session, { value: posts.title }) + .from(posts) + .where(eq(posts.userId, 1)) + ) + .orderBy((fields: { value: unknown }) => fields.value as any) + .limit(3) + ) + + let intersectQuery = toQuery( + intersect( + new YdbSelectBuilder(session, { value: users.name }).from(users).where(eq(users.id, 1)), + new YdbSelectBuilder(session, { value: posts.title }) + .from(posts) + .where(eq(posts.userId, 1)) + ) + ) + + let exceptQuery = toQuery( + except( + new YdbSelectBuilder(session, { value: users.name }).from(users).where(eq(users.id, 1)), + new YdbSelectBuilder(session, { value: posts.title }) + .from(posts) + .where(eq(posts.userId, 1)) + ) + ) + + assert.match( + distinctOnQuery.sql, + /^select `__ydb_f0`, `__ydb_f1` from \(select `posts`\.`user_id` as `__ydb_f0`, `posts`\.`title` as `__ydb_f1`, row_number\(\) over \(\s+partition by `posts`\.`user_id`\s+ order by `posts`\.`user_id`, `posts`\.`title` desc\s+\) as `__ydb_row_number` from `posts`\) as `__ydb_distinct_on` where `__ydb_distinct_on`\.`__ydb_row_number` = 1 order by `__ydb_f0`, `__ydb_f1` desc$/ + ) + assert.ok( + variadicDistinctOnQuery.sql.includes('partition by `posts`.`user_id`, `posts`.`title`') + ) + assert.equal( + unionQuery.sql, + 'select `users`.`name` as `__ydb_f0` from `users` where `users`.`id` = $p0 union all select `posts`.`title` as `__ydb_f0` from `posts` where `posts`.`user_id` = $p1 order by `__ydb_f0` limit $p2' + ) + assert.equal( + intersectQuery.sql, + 'select distinct `__ydb_left`.`__ydb_f0` as `__ydb_f0` from (select `users`.`name` as `__ydb_f0` from `users` where `users`.`id` = $p0) as `__ydb_left` inner join (select `__ydb_right_input`.`__ydb_f0` as `__ydb_f0`, 1 as `__ydb_match` from (select `posts`.`title` as `__ydb_f0` from `posts` where `posts`.`user_id` = $p1) as `__ydb_right_input`) as `__ydb_right` on `__ydb_left`.`__ydb_f0` = `__ydb_right`.`__ydb_f0`' + ) + assert.equal( + exceptQuery.sql, + 'select distinct `__ydb_left`.`__ydb_f0` as `__ydb_f0` from (select `users`.`name` as `__ydb_f0` from `users` where `users`.`id` = $p0) as `__ydb_left` left join (select `__ydb_right_input`.`__ydb_f0` as `__ydb_f0`, 1 as `__ydb_match` from (select `posts`.`title` as `__ydb_f0` from `posts` where `posts`.`user_id` = $p1) as `__ydb_right_input`) as `__ydb_right` on `__ydb_left`.`__ydb_f0` = `__ydb_right`.`__ydb_f0` where `__ydb_right`.`__ydb_match` is null' + ) + assert.deepEqual(unionQuery.params, [1, 1, 3]) + assert.deepEqual(intersectQuery.params, [1, 1]) + assert.deepEqual(exceptQuery.params, [1, 1]) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/session-prepared.test.ts b/third-parties/drizzle-adapter/tests/unit/session-prepared.test.ts new file mode 100644 index 00000000..cce2fe27 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/session-prepared.test.ts @@ -0,0 +1,576 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { StatusIds_StatusCode } from '@ydbjs/api/operation' +import { DrizzleQueryError, TransactionRollbackError } from 'drizzle-orm/errors' +import { sql as yql } from 'drizzle-orm' +import { + YdbAuthenticationError, + YdbCancelledQueryError, + YdbOverloadedQueryError, + YdbRetryableQueryError, + YdbTimeoutQueryError, + YdbUnavailableQueryError, + YdbUniqueConstraintViolationError, + customType, + drizzle, + integer, + text, + ydbTable, +} from '../../src/index.ts' +import { YdbDialect } from '../../src/ydb/dialect.ts' +import { YdbSession } from '../../src/ydb-core/session.ts' +import { orderSelectedFields } from '../../src/ydb-core/result-mapping.ts' + +let dialect = new YdbDialect() +let users = ydbTable('users', { + id: integer('id').notNull(), + name: text('name').notNull(), +}) + +test('prepareQuery', () => { + let logs: Array<{ query: string; params: unknown[] }> = [] + let session = new YdbSession( + { + async execute() { + return { rows: [] } + }, + }, + dialect, + { + logger: { + logQuery(query, params) { + logs.push({ query, params: [...params] }) + }, + }, + } + ) + + let fields = orderSelectedFields({ id: users.id, name: users.name }) + let prepared = session.prepareQuery( + yql`select ${1} as ${yql.identifier('id')}`, + fields, + 'select_users', + true, + (rows) => rows.length + ) + + assert.equal(prepared.getQuery().sql, 'select $p0 as `id`') + assert.deepEqual(prepared.getQuery().params, [1]) + assert.equal(prepared.isResponseInArrayMode(), true) + assert.equal(prepared.mapResult([[1, 'Twilight']]), 1) + assert.deepEqual(logs, []) +}) + +test('prepared rows', async () => { + let logs: Array<{ query: string; params: unknown[] }> = [] + let calls: Array<{ method: string; options: unknown }> = [] + let fields = orderSelectedFields({ id: users.id, name: users.name }) + let session = new YdbSession( + { + async execute(_query, _params, method, options) { + calls.push({ method, options }) + return { rows: [[1, 'Twilight Sparkle']] } + }, + }, + dialect, + { + logger: { + logQuery(query, params) { + logs.push({ query, params: [...params] }) + }, + }, + } + ) + + let prepared = session.prepareQuery( + yql`select ${1} as id, ${'Twilight Sparkle'} as name`, + fields, + undefined, + false + ) + + let allRows = await prepared.all() + let oneRow = await prepared.get() + let valueRows = await prepared.values() + + assert.deepEqual(allRows, [{ id: 1, name: 'Twilight Sparkle' }]) + assert.deepEqual(oneRow, { id: 1, name: 'Twilight Sparkle' }) + assert.deepEqual(valueRows, [[1, 'Twilight Sparkle']]) + assert.equal(calls.length, 3) + assert.deepEqual( + calls.map(({ method }) => method), + ['all', 'all', 'all'] + ) + assert.equal(logs.length, 3) + assert.ok(logs.every(({ query }) => query === 'select $p0 as id, $p1 as name')) +}) + +test('prepared execute', async () => { + let session = new YdbSession( + { + async execute(_query, _params, method, options) { + return { + rows: options?.arrayMode ? [[1, 'Rarity']] : [{ id: 1, name: 'Rarity' }], + rowCount: 1, + command: method, + meta: { arrayMode: options?.arrayMode === true }, + } + }, + }, + dialect + ) + + let rawPrepared = session.prepareQuery(yql`select ${1}`, undefined, undefined, false) + let arrayPrepared = session.prepareQuery(yql`select ${1}`, undefined, undefined, true) + + assert.deepEqual(await rawPrepared.execute(), [{ id: 1, name: 'Rarity' }]) + type RowsWithMeta = unknown[][] & { + rowCount?: number + command?: string + meta?: { arrayMode: boolean } + } + let arrayRows: RowsWithMeta = (await arrayPrepared.execute()) as any + + assert.equal(arrayRows.rowCount, 1) + assert.equal(arrayRows.command, 'execute') + assert.deepEqual(arrayRows.meta, { arrayMode: true }) + assert.deepEqual(arrayRows, [[1, 'Rarity']]) +}) + +test('prepared query errors preserve query context and map unique constraint failures', async () => { + let uniqueCause = new Error('Unique constraint violation: duplicate key users_email_unique') + let uniqueSession = new YdbSession( + { + async execute() { + throw uniqueCause + }, + }, + dialect + ) + + await assert.rejects( + () => uniqueSession.prepareQuery(yql`insert into users values (${1})`).execute(), + (error) => { + assert.ok(error instanceof YdbUniqueConstraintViolationError) + assert.equal(error.query, 'insert into users values ($p0)') + assert.deepEqual(error.params, [1]) + assert.equal(error.cause, uniqueCause) + return true + } + ) + + let genericCause = new Error('YDB unavailable') + let genericSession = new YdbSession( + { + async execute() { + throw genericCause + }, + }, + dialect + ) + + await assert.rejects( + () => genericSession.prepareQuery(yql`select ${1}`).execute(), + (error) => { + assert.ok(error instanceof DrizzleQueryError) + assert.ok(!(error instanceof YdbUniqueConstraintViolationError)) + assert.equal(error.query, 'select $p0') + assert.deepEqual(error.params, [1]) + assert.equal(error.cause, genericCause) + return true + } + ) +}) + +test('prepared query errors inspect YDB issue payloads for unique violations', async () => { + let ydbCause = Object.assign(new Error('PRECONDITION_FAILED'), { + code: StatusIds_StatusCode.PRECONDITION_FAILED, + issues: [ + { + message: 'Conflict with unique index users_email_unique', + }, + ], + }) + let session = new YdbSession( + { + async execute() { + throw ydbCause + }, + }, + dialect + ) + + await assert.rejects( + () => session.prepareQuery(yql`insert into users values (${1})`).execute(), + (error) => { + assert.ok(error instanceof YdbUniqueConstraintViolationError) + assert.equal(error.query, 'insert into users values ($p0)') + assert.deepEqual(error.params, [1]) + assert.equal(error.cause, ydbCause) + return true + } + ) +}) + +test('prepared query errors map common YDB runtime failures to typed errors', async () => { + let cases = [ + { + name: 'auth', + cause: Object.assign(new Error('UNAUTHORIZED'), { + code: StatusIds_StatusCode.UNAUTHORIZED, + }), + errorClass: YdbAuthenticationError, + kind: 'authentication', + retryable: false, + }, + { + name: 'cancelled', + cause: Object.assign(new Error('CANCELLED'), { + code: StatusIds_StatusCode.CANCELLED, + }), + errorClass: YdbCancelledQueryError, + kind: 'cancelled', + retryable: false, + }, + { + name: 'timeout', + cause: Object.assign(new Error('TIMEOUT'), { + code: StatusIds_StatusCode.TIMEOUT, + }), + errorClass: YdbTimeoutQueryError, + kind: 'timeout', + retryable: true, + }, + { + name: 'unavailable', + cause: Object.assign(new Error('UNAVAILABLE'), { + code: StatusIds_StatusCode.UNAVAILABLE, + }), + errorClass: YdbUnavailableQueryError, + kind: 'unavailable', + retryable: true, + }, + { + name: 'overloaded', + cause: Object.assign(new Error('OVERLOADED'), { + code: StatusIds_StatusCode.OVERLOADED, + }), + errorClass: YdbOverloadedQueryError, + kind: 'overloaded', + retryable: true, + }, + { + name: 'retryable', + cause: Object.assign(new Error('ABORTED'), { + code: StatusIds_StatusCode.ABORTED, + }), + errorClass: YdbRetryableQueryError, + kind: 'retryable', + retryable: true, + }, + ] as const + + await Promise.all( + cases.map(async (item) => { + let session = new YdbSession( + { + async execute() { + throw item.cause + }, + }, + dialect + ) + + await assert.rejects( + () => session.prepareQuery(yql`select ${item.name}`).execute(), + (error) => { + assert.ok(error instanceof item.errorClass) + if (item.retryable) { + assert.ok(error instanceof YdbRetryableQueryError) + } + assert.equal((error as any).kind, item.kind) + assert.equal((error as any).retryable, item.retryable) + assert.equal((error as any).statusCode, item.cause.code) + assert.equal((error as any).code, item.cause.code) + assert.equal(error.cause, item.cause) + return true + } + ) + }) + ) +}) + +test('prepared query errors classify grpc-shaped retryable failures', async () => { + let cause = Object.assign(new Error('deadline exceeded'), { + code: 4, + }) + let session = new YdbSession( + { + async execute() { + throw cause + }, + }, + dialect + ) + + await assert.rejects( + () => session.prepareQuery(yql`select ${1}`).execute(), + (error) => { + assert.ok(error instanceof YdbTimeoutQueryError) + assert.ok(error instanceof YdbRetryableQueryError) + assert.equal((error as any).kind, 'timeout') + assert.equal((error as any).retryable, true) + assert.equal((error as any).statusCode, 4) + return true + } + ) +}) + +test('prepared query errors preserve YDB diagnostic fields on Drizzle errors', async () => { + let issues = [ + { + message: 'BATCH operations are not supported at the current time.', + }, + ] + let ydbCause = Object.assign(new Error('PRECONDITION_FAILED'), { + code: StatusIds_StatusCode.PRECONDITION_FAILED, + issues, + retryable: false, + }) + let session = new YdbSession( + { + async execute() { + throw ydbCause + }, + }, + dialect + ) + + await assert.rejects( + () => session.prepareQuery(yql`select ${1}`).execute(), + (error) => { + assert.ok(error instanceof DrizzleQueryError) + assert.equal((error as any).code, StatusIds_StatusCode.PRECONDITION_FAILED) + assert.equal((error as any).issues, issues) + assert.equal((error as any).retryable, false) + assert.equal(error.cause, ydbCause) + return true + } + ) +}) + +test('prepared get', async () => { + let session = new YdbSession( + { + async execute() { + return { rows: [[1, 'Pinkie Pie']] } + }, + }, + dialect + ) + + let prepared = session.prepareQuery( + yql`select ${1} as id, ${'Pinkie Pie'} as name`, + orderSelectedFields({ id: users.id, name: users.name }), + undefined, + true, + (rows) => ({ id: rows[0]?.[0], name: rows[0]?.[1] }) + ) + + assert.deepEqual(await prepared.get(), { id: 1, name: 'Pinkie Pie' }) +}) + +test('prepared rows decode object results with column codecs', async () => { + let slugType = customType<{ data: string; driverData: string }>({ + dataType() { + return 'Utf8' + }, + fromDriver(value) { + return value.toLowerCase() + }, + }) + let customUsers = ydbTable('custom_users', { + id: integer('id').notNull(), + slug: slugType('slug').notNull(), + }) + let fields = orderSelectedFields({ id: customUsers.id, slug: customUsers.slug }) + let session = new YdbSession( + { + async execute() { + return { rows: [{ id: 1, slug: 'RAINBOW-DASH' }] } + }, + }, + dialect + ) + + let prepared = session.prepareQuery(yql.raw('select 1'), fields, undefined, false) + + assert.deepEqual(await prepared.all(), [{ id: 1, slug: 'rainbow-dash' }]) +}) + +test('prepareQuery passes ordered rows and mapColumnValue to customResultMapper', async () => { + let fields = orderSelectedFields({ id: users.id, name: users.name }) + let session = new YdbSession( + { + async execute() { + return { rows: [{ id: 1, name: 'Fluttershy' }] } + }, + }, + dialect + ) + + let prepared = session.prepareQuery( + yql.raw('select 1'), + fields, + undefined, + false, + (rows, mapColumnValue) => ({ + rows, + mapped: mapColumnValue?.('Fluttershy'), + }) + ) + + assert.deepEqual(await prepared.execute(), { + rows: [[1, 'Fluttershy']], + mapped: 'Fluttershy', + }) +}) + +test('session helpers', async () => { + let calls: Array<{ method: string; arrayMode?: boolean; query: string }> = [] + let rowsQuery = yql`select ${7} as ${yql.identifier('id')}, ${'Applejack'} as ${yql.identifier('name')}` + let countQuery = yql`select count(*) as ${yql.identifier('count')} from ${yql.identifier('users')}` + let session = new YdbSession( + { + async execute(query, _params, method, options) { + calls.push({ method, arrayMode: options?.arrayMode, query }) + + if (query.includes('count')) { + return { rows: [[3]] } + } + + if (options?.arrayMode) { + return { rows: [[7, 'Applejack']] } + } + + return { rows: [{ id: 7, name: 'Applejack' }] } + }, + }, + dialect + ) + + let executeResult = await session.execute(rowsQuery) + let allResult = await session.all(rowsQuery) + let getResult = await session.get(rowsQuery) + let valuesResult = await session.values(rowsQuery) + let countResult = await session.count(countQuery) + + assert.deepEqual(executeResult, [{ id: 7, name: 'Applejack' }]) + assert.deepEqual(allResult, [{ id: 7, name: 'Applejack' }]) + assert.deepEqual(getResult, { id: 7, name: 'Applejack' }) + assert.deepEqual(valuesResult, [[7, 'Applejack']]) + assert.equal(countResult, 3) + assert.deepEqual( + calls.map(({ method, arrayMode }) => ({ method, arrayMode })), + [ + { method: 'execute', arrayMode: false }, + { method: 'all', arrayMode: false }, + { method: 'all', arrayMode: false }, + { method: 'all', arrayMode: true }, + { method: 'all', arrayMode: true }, + ] + ) +}) + +test('session helpers with builders', async () => { + let db = drizzle({ + async execute(query, _params, _method, options) { + if (query.startsWith('insert into `users`')) { + return { rows: [] } + } + + if (options?.arrayMode) { + return { rows: [[1, 'Rainbow Dash']] } + } + + return { rows: [[1, 'Rainbow Dash']] } + }, + }) + + let selectBuilder = db + .select() + .from(users) + .where(yql`${users.id} = ${1}`) + let insertBuilder = db.insert(users).values({ id: 1, name: 'Rainbow Dash' }) + + assert.deepEqual(await db.execute>(selectBuilder), [ + { id: 1, name: 'Rainbow Dash' }, + ]) + assert.deepEqual(await db.all<{ id: number; name: string }>(selectBuilder), [ + { id: 1, name: 'Rainbow Dash' }, + ]) + assert.deepEqual(await db.get<{ id: number; name: string }>(selectBuilder), { + id: 1, + name: 'Rainbow Dash', + }) + assert.deepEqual(await db.values<[number, string]>(selectBuilder), [[1, 'Rainbow Dash']]) + assert.deepEqual(await db.execute(insertBuilder), []) +}) + +test('session transaction', async () => { + let transactionConfigs: unknown[] = [] + let sessionWithoutTransactions = new YdbSession( + { + async execute() { + return { rows: [] } + }, + }, + dialect + ) + + await assert.rejects( + () => sessionWithoutTransactions.transaction(async () => 'nope'), + /Transactions are not supported/ + ) + + let session = new YdbSession( + { + async execute() { + return { rows: [] } + }, + async transaction(callback, config) { + transactionConfigs.push(config) + + try { + return await callback({ + async execute() { + return { rows: [] } + }, + }) + } catch (error) { + throw new Error('wrapped', { cause: error }) + } + }, + }, + dialect + ) + + let committed = await session.transaction( + async (tx) => { + await tx.execute(yql`select ${1}`) + return 'ok' + }, + { accessMode: 'read only' } + ) + + assert.equal(committed, 'ok') + assert.deepEqual(transactionConfigs, [{ accessMode: 'read only' }]) + + await assert.rejects( + () => + session.transaction(async (tx) => { + tx.rollback() + }), + TransactionRollbackError + ) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/table-columns.test.ts b/third-parties/drizzle-adapter/tests/unit/table-columns.test.ts new file mode 100644 index 00000000..ee7be93a --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/table-columns.test.ts @@ -0,0 +1,170 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { Table, getTableName } from 'drizzle-orm/table' +import { sql as yql } from 'drizzle-orm' +import { customType, integer, text, uuid, ydbTable, ydbTableCreator } from '../../src/index.ts' +import { getYdbColumnBuilders, ydbColumnBuilders } from '../../src/ydb-core/columns/all.ts' +import { YdbColumn } from '../../src/ydb-core/columns/common.ts' +import { getTableConfig } from '../../src/ydb-core/table.utils.ts' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false + +type Assert = T + +let typedTable = ydbTable('typed_table', { + id: integer('id').notNull().$type<1 | 2>(), + payload: text('payload').$type<{ pony: string }>(), + meta: text('meta').notNull().$type<{ level: number }>(), +}) + +type _TypedTableAssertions = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert< + Equal, { pony: string } | null> + >, +] + +void typedTable + +test('table columns', () => { + let table = ydbTable('ponies', { + id: uuid('id').notNull(), + price: integer('price').notNull(), + name: text('name').notNull(), + }) + + let columns = (table as any)[(Table as any).Symbol.Columns] + let extraColumns = (table as any)[(Table as any).Symbol.ExtraConfigColumns] + + assert.equal(getTableName(table), 'ponies') + assert.equal(table.id.name, 'id') + assert.equal(table.name.name, 'name') + assert.equal(columns.id, table.id) + assert.equal(columns.name, table.name) + assert.equal(extraColumns.id, table.id) + assert.equal(extraColumns.name, table.name) +}) + +test('table callback', () => { + let callbackTable = ydbTable('mares', ({ integer: int, text: textType }) => ({ + id: int('id').notNull(), + name: textType('name').notNull(), + })) + let prefixedTable = ydbTableCreator((name) => `app_${name}`)('users', { + id: integer('id').notNull(), + }) + + assert.equal(getTableName(callbackTable), 'mares') + assert.equal(getTableName(prefixedTable), 'app_users') + assert.equal(callbackTable.id.name, 'id') + assert.equal(callbackTable.name.name, 'name') +}) + +test('column builders', () => { + let firstRegistry = getYdbColumnBuilders() + let secondRegistry = getYdbColumnBuilders() + let callbackCalls: unknown[] = [] + + let table = ydbTable('registry_check', (builders) => { + callbackCalls.push(builders) + + assert.equal(builders, ydbColumnBuilders) + assert.equal(builders, firstRegistry) + assert.equal(firstRegistry, secondRegistry) + assert.equal(builders.int, builders.integer) + + return { + id: builders.int('id').notNull(), + payload: builders.text('payload'), + } + }) + + assert.equal(callbackCalls.length, 1) + assert.equal(table.id.name, 'id') + assert.equal(table.payload.name, 'payload') +}) + +test('column builder metadata', () => { + let table = ydbTable('builder_columns', { + id: integer('id') + .notNull() + .default(1) + .$defaultFn(() => 2) + .$onUpdateFn(() => 3) + .primaryKey(), + plain: text('plain'), + }) + + let idColumn = table.id + let plainColumn = table.plain as YdbColumn + + assert.equal(idColumn.notNull, true) + assert.equal(idColumn.default, 1) + assert.equal(idColumn.defaultFn?.(), 2) + assert.equal(idColumn.onUpdateFn?.(), 3) + assert.equal(idColumn.primary, true) + assert.equal(plainColumn.getSQLType(), 'Utf8') + assert.equal(new YdbColumn(table, (plainColumn as any).config).getSQLType(), 'unknown') + assert.equal(plainColumn.mapToDriverValue('Rarity'), 'Rarity') + assert.equal(plainColumn.mapFromDriverValue('Rarity'), 'Rarity') +}) + +test('generatedAlwaysAs is rejected for YDB columns', () => { + assert.throws( + () => integer('id').generatedAlwaysAs(() => yql`1`), + /generatedAlwaysAs\(\) is not supported/u + ) +}) + +test('custom columns', () => { + let uppercase = customType<{ data: string; driverData: string }>({ + dataType() { + return 'Utf8' + }, + toDriver(value) { + return value.toUpperCase() + }, + fromDriver(value) { + return value.toLowerCase() + }, + }) + let withConfig = customType<{ data: string; driverData: string; config: { prefix: string } }>({ + dataType(config) { + return config?.prefix ? 'Utf8' : 'String' + }, + toDriver(value) { + return `cfg:${value}` + }, + }) + + let table = ydbTable('custom_columns', { + upper: uppercase('upper'), + configured: withConfig('configured', { prefix: 'pony' }), + }) + + assert.equal(table.upper.getSQLType(), 'Utf8') + assert.equal(table.upper.mapToDriverValue('pinkie'), 'PINKIE') + assert.equal(table.upper.mapFromDriverValue('PINKIE'), 'pinkie') + assert.equal(table.configured.getSQLType(), 'Utf8') + assert.equal(table.configured.mapToDriverValue('dash'), 'cfg:dash') +}) + +test('column unique metadata', () => { + let users = ydbTable('constraint_users', { + id: integer('id').notNull().primaryKey(), + email: text('email').notNull().unique(), + externalId: text('external_id').unique('constraint_users_external_unique'), + }) + + let usersConfig = getTableConfig(users) + + assert.deepEqual( + usersConfig.uniqueConstraints.map((constraint) => constraint.config.name).sort(), + ['constraint_users_email_unique', 'constraint_users_external_unique'] + ) +}) diff --git a/third-parties/drizzle-adapter/tests/unit/transaction.test.ts b/third-parties/drizzle-adapter/tests/unit/transaction.test.ts new file mode 100644 index 00000000..9faee219 --- /dev/null +++ b/third-parties/drizzle-adapter/tests/unit/transaction.test.ts @@ -0,0 +1,17 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { TransactionRollbackError } from 'drizzle-orm/errors' +import { YdbDialect } from '../../src/ydb/dialect.ts' +import { YdbTransaction } from '../../src/ydb-core/transaction.ts' + +test('transaction rollback throws Drizzle rollback error', () => { + let tx = new YdbTransaction(new YdbDialect(), {} as any) + + assert.throws(() => tx.rollback(), TransactionRollbackError) +}) + +test('transaction rejects nested transactions explicitly', () => { + let tx = new YdbTransaction(new YdbDialect(), {} as any) + + assert.throws(() => tx.transaction(), /Nested YDB transactions are not supported/u) +}) diff --git a/third-parties/drizzle-adapter/tsconfig.json b/third-parties/drizzle-adapter/tsconfig.json new file mode 100644 index 00000000..1406ba35 --- /dev/null +++ b/third-parties/drizzle-adapter/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["dist/**/*", "src/tests/**/*", "tests/**/*", "vitest.config.ts"] +} diff --git a/third-parties/drizzle-adapter/vitest.config.ts b/third-parties/drizzle-adapter/vitest.config.ts new file mode 100644 index 00000000..54f52916 --- /dev/null +++ b/third-parties/drizzle-adapter/vitest.config.ts @@ -0,0 +1,32 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + projects: [ + { + test: { + name: { + label: 'uni', + color: 'yellow', + }, + include: ['tests/unit/**/*.test.ts'], + environment: 'node', + }, + }, + { + test: { + name: { + label: 'int', + color: 'blue', + }, + include: ['tests/live/**/*.test.ts'], + environment: 'node', + globalSetup: '../../vitest.setup.ydb.ts', + testTimeout: 60000, + hookTimeout: 30000, + maxConcurrency: 1, + }, + }, + ], + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts index 0880799c..22bc20f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,11 @@ export default defineConfig({ label: 'uni', color: 'yellow', }, - include: ['packages/*/src/**/*.test.ts', 'third-parties/*/src/**/*.test.ts'], + include: [ + 'packages/*/src/**/*.test.ts', + 'packages/*/tests/unit/**/*.test.ts', + 'third-parties/*/src/**/*.test.ts', + ], environment: 'node', benchmark: { include: [