sqlite-zod-orm 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
4
  "description": "Type-safe SQLite ORM for Bun — Zod schemas, fluent queries, auto relationships, zero SQL",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,8 +13,10 @@
13
13
  }
14
14
  },
15
15
  "scripts": {
16
- "build": "bun run ./src/build.ts",
16
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun --format esm",
17
+ "clean": "rm -rf dist",
17
18
  "test": "bun test",
19
+ "bench": "bun bench/triggers-vs-naive.ts && bun bench/poll-strategy.ts && bun bench/indexes.ts",
18
20
  "prepublishOnly": "bun run build"
19
21
  },
20
22
  "files": [
@@ -52,4 +54,4 @@
52
54
  "engines": {
53
55
  "bun": ">=1.0.0"
54
56
  }
55
- }
57
+ }
package/src/ast.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { z } from 'zod';
1
+
2
2
 
3
3
  // ==========================================
4
4
  // AST Node Types
package/src/builder.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * builder.ts — Fluent QueryBuilder class
3
+ *
4
+ * Accumulates query state via chaining and executes when a
5
+ * terminal method (.all(), .get(), .count()) is called.
6
+ */
7
+
8
+ import {
9
+ type ASTNode, type WhereCallback, type TypedColumnProxy, type FunctionProxy, type Operators,
10
+ createColumnProxy, createFunctionProxy, op,
11
+ } from './ast';
12
+ import {
13
+ type IQO, type WhereCondition, type WhereOperator, type OrderDirection,
14
+ OPERATOR_MAP, compileIQO,
15
+ } from './iqo';
16
+
17
+ // =============================================================================
18
+ // QueryBuilder Class
19
+ // =============================================================================
20
+
21
+ /**
22
+ * A Fluent Query Builder that accumulates query state via chaining
23
+ * and only executes when a terminal method is called (.all(), .get())
24
+ * or when it is `await`-ed (thenable).
25
+ *
26
+ * Supports two WHERE styles:
27
+ * - Object-style: `.where({ name: 'Alice', age: { $gt: 18 } })`
28
+ * - Callback-style (AST): `.where((c, f, op) => op.and(op.eq(c.name, 'Alice'), op.gt(c.age, 18)))`
29
+ */
30
+ export class QueryBuilder<T extends Record<string, any>> {
31
+ private iqo: IQO;
32
+ private tableName: string;
33
+ private executor: (sql: string, params: any[], raw: boolean) => any[];
34
+ private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
35
+ private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
36
+ private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
37
+ private eagerLoader: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null;
38
+
39
+ constructor(
40
+ tableName: string,
41
+ executor: (sql: string, params: any[], raw: boolean) => any[],
42
+ singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
43
+ joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
44
+ conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
45
+ eagerLoader?: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null,
46
+ ) {
47
+ this.tableName = tableName;
48
+ this.executor = executor;
49
+ this.singleExecutor = singleExecutor;
50
+ this.joinResolver = joinResolver ?? null;
51
+ this.conditionResolver = conditionResolver ?? null;
52
+ this.eagerLoader = eagerLoader ?? null;
53
+ this.iqo = {
54
+ selects: [],
55
+ wheres: [],
56
+ whereOrs: [],
57
+ whereAST: null,
58
+ joins: [],
59
+ groupBy: [],
60
+ limit: null,
61
+ offset: null,
62
+ orderBy: [],
63
+ includes: [],
64
+ raw: false,
65
+ };
66
+ }
67
+
68
+ /** Specify which columns to select. If called with no arguments, defaults to `*`. */
69
+ select(...cols: (keyof T & string)[]): this {
70
+ this.iqo.selects.push(...cols);
71
+ return this;
72
+ }
73
+
74
+ /**
75
+ * Add WHERE conditions. Two calling styles:
76
+ *
77
+ * **Object-style** (simple equality and operators):
78
+ * ```ts
79
+ * .where({ name: 'Alice' })
80
+ * .where({ age: { $gt: 18 } })
81
+ * ```
82
+ *
83
+ * **Callback-style** (AST-based, full SQL expression power):
84
+ * ```ts
85
+ * .where((c, f, op) => op.and(
86
+ * op.eq(f.lower(c.name), 'alice'),
87
+ * op.gt(c.age, 18)
88
+ * ))
89
+ * ```
90
+ */
91
+ where(criteriaOrCallback: (Partial<Record<keyof T & string, any>> & { $or?: Partial<Record<keyof T & string, any>>[] }) | WhereCallback<T>): this {
92
+ if (typeof criteriaOrCallback === 'function') {
93
+ const ast = (criteriaOrCallback as WhereCallback<T>)(
94
+ createColumnProxy<T>(),
95
+ createFunctionProxy(),
96
+ op,
97
+ );
98
+ if (this.iqo.whereAST) {
99
+ this.iqo.whereAST = { type: 'operator', op: 'AND', left: this.iqo.whereAST, right: ast };
100
+ } else {
101
+ this.iqo.whereAST = ast;
102
+ }
103
+ } else {
104
+ const resolved = this.conditionResolver
105
+ ? this.conditionResolver(criteriaOrCallback as Record<string, any>)
106
+ : criteriaOrCallback;
107
+
108
+ for (const [key, value] of Object.entries(resolved)) {
109
+ if (key === '$or' && Array.isArray(value)) {
110
+ const orConditions: WhereCondition[] = [];
111
+ for (const branch of value as Record<string, any>[]) {
112
+ const resolvedBranch = this.conditionResolver
113
+ ? this.conditionResolver(branch)
114
+ : branch;
115
+ for (const [bKey, bValue] of Object.entries(resolvedBranch)) {
116
+ if (typeof bValue === 'object' && bValue !== null && !Array.isArray(bValue) && !(bValue instanceof Date)) {
117
+ for (const [opKey, operand] of Object.entries(bValue)) {
118
+ const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
119
+ if (sqlOp) orConditions.push({ field: bKey, operator: sqlOp as WhereCondition['operator'], value: operand });
120
+ }
121
+ } else {
122
+ orConditions.push({ field: bKey, operator: '=', value: bValue });
123
+ }
124
+ }
125
+ }
126
+ if (orConditions.length > 0) this.iqo.whereOrs.push(orConditions);
127
+ continue;
128
+ }
129
+
130
+ if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
131
+ for (const [opKey, operand] of Object.entries(value)) {
132
+ const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
133
+ if (!sqlOp) throw new Error(`Unsupported query operator: '${opKey}' on field '${key}'.`);
134
+ if (opKey === '$between') {
135
+ if (!Array.isArray(operand) || operand.length !== 2) throw new Error(`$between for '${key}' requires [min, max]`);
136
+ }
137
+ this.iqo.wheres.push({
138
+ field: key,
139
+ operator: sqlOp as WhereCondition['operator'],
140
+ value: operand,
141
+ });
142
+ }
143
+ } else {
144
+ this.iqo.wheres.push({ field: key, operator: '=', value });
145
+ }
146
+ }
147
+ }
148
+ return this;
149
+ }
150
+
151
+ /** Set the maximum number of rows to return. */
152
+ limit(n: number): this {
153
+ this.iqo.limit = n;
154
+ return this;
155
+ }
156
+
157
+ /** Set the offset for pagination. */
158
+ offset(n: number): this {
159
+ this.iqo.offset = n;
160
+ return this;
161
+ }
162
+
163
+ /** Add ORDER BY clauses. */
164
+ orderBy(field: keyof T & string, direction: OrderDirection = 'asc'): this {
165
+ this.iqo.orderBy.push({ field, direction });
166
+ return this;
167
+ }
168
+
169
+ /**
170
+ * Join another table. Two calling styles:
171
+ *
172
+ * **Accessor-based** (auto-infers FK from relationships):
173
+ * ```ts
174
+ * db.trees.select('name').join(db.forests, ['name']).all()
175
+ * ```
176
+ *
177
+ * **String-based** (manual FK):
178
+ * ```ts
179
+ * db.trees.select('name').join('forests', 'forestId', ['name']).all()
180
+ * ```
181
+ */
182
+ join(accessor: { _tableName: string }, columns?: string[]): this;
183
+ join(table: string, fk: string, columns?: string[], pk?: string): this;
184
+ join(tableOrAccessor: string | { _tableName: string }, fkOrCols?: string | string[], colsOrPk?: string[] | string, pk?: string): this {
185
+ let table: string;
186
+ let fromCol: string;
187
+ let toCol: string;
188
+ let columns: string[];
189
+
190
+ if (typeof tableOrAccessor === 'object' && '_tableName' in tableOrAccessor) {
191
+ table = tableOrAccessor._tableName;
192
+ columns = Array.isArray(fkOrCols) ? fkOrCols : [];
193
+ if (!this.joinResolver) throw new Error(`Cannot auto-resolve join: no relationship data available`);
194
+ const resolved = this.joinResolver(this.tableName, table);
195
+ if (!resolved) throw new Error(`No relationship found between '${this.tableName}' and '${table}'`);
196
+ fromCol = resolved.fk;
197
+ toCol = resolved.pk;
198
+ } else {
199
+ table = tableOrAccessor;
200
+ fromCol = fkOrCols as string;
201
+ columns = Array.isArray(colsOrPk) ? colsOrPk : [];
202
+ toCol = (typeof colsOrPk === 'string' ? colsOrPk : pk) ?? 'id';
203
+ }
204
+
205
+ this.iqo.joins.push({ table, fromCol, toCol, columns });
206
+ this.iqo.raw = true;
207
+ return this;
208
+ }
209
+
210
+ /** Skip Zod parsing and return raw SQLite row objects. */
211
+ raw(): this {
212
+ this.iqo.raw = true;
213
+ return this;
214
+ }
215
+
216
+ /**
217
+ * Eagerly load a related entity and attach as an array property.
218
+ *
219
+ * Runs a single batched query (WHERE fk IN (...)) per relation,
220
+ * avoiding the N+1 problem of lazy navigation.
221
+ */
222
+ with(...relations: string[]): this {
223
+ this.iqo.includes.push(...relations);
224
+ return this;
225
+ }
226
+
227
+ /** Internal: apply eager loads to a set of results */
228
+ private _applyEagerLoads(results: T[]): T[] {
229
+ if (this.iqo.includes.length === 0 || !this.eagerLoader || results.length === 0) {
230
+ return results;
231
+ }
232
+
233
+ const parentIds = results.map((r: any) => r.id).filter((id: any) => typeof id === 'number');
234
+ if (parentIds.length === 0) return results;
235
+
236
+ for (const relation of this.iqo.includes) {
237
+ const loaded = this.eagerLoader(this.tableName, relation, parentIds);
238
+ if (!loaded) continue;
239
+
240
+ for (const row of results as any[]) {
241
+ row[loaded.key] = loaded.groups.get(row.id) ?? [];
242
+ }
243
+ }
244
+
245
+ return results;
246
+ }
247
+
248
+ // ---------- Terminal / Execution Methods ----------
249
+
250
+ /** Execute the query and return all matching rows. */
251
+ all(): T[] {
252
+ const { sql, params } = compileIQO(this.tableName, this.iqo);
253
+ const results = this.executor(sql, params, this.iqo.raw);
254
+ return this._applyEagerLoads(results);
255
+ }
256
+
257
+ /** Execute the query and return the first matching row, or null. */
258
+ get(): T | null {
259
+ this.iqo.limit = 1;
260
+ const { sql, params } = compileIQO(this.tableName, this.iqo);
261
+ const result = this.singleExecutor(sql, params, this.iqo.raw);
262
+ if (!result) return null;
263
+ const [loaded] = this._applyEagerLoads([result]);
264
+ return loaded ?? null;
265
+ }
266
+
267
+ /** Execute the query and return the count of matching rows. */
268
+ count(): number {
269
+ // Reuse compileIQO to avoid duplicating WHERE logic
270
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
271
+ // Replace "SELECT ... FROM" with "SELECT COUNT(*) as count FROM"
272
+ const countSql = selectSql.replace(/^SELECT .+? FROM/, 'SELECT COUNT(*) as count FROM');
273
+ const results = this.executor(countSql, params, true);
274
+ return (results[0] as any)?.count ?? 0;
275
+ }
276
+
277
+ /** Alias for get() — returns the first matching row or null. */
278
+ first(): T | null {
279
+ return this.get();
280
+ }
281
+
282
+ /** Returns true if at least one row matches the query. */
283
+ exists(): boolean {
284
+ const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
285
+ const existsSql = selectSql.replace(/^SELECT .+? FROM/, 'SELECT 1 FROM').replace(/ LIMIT \d+/, '') + ' LIMIT 1';
286
+ const results = this.executor(existsSql, params, true);
287
+ return results.length > 0;
288
+ }
289
+
290
+ /** Group results by one or more columns. */
291
+ groupBy(...fields: string[]): this {
292
+ this.iqo.groupBy.push(...fields);
293
+ return this;
294
+ }
295
+
296
+
297
+
298
+ // ---------- Thenable (async/await support) ----------
299
+
300
+ then<TResult1 = T[], TResult2 = never>(
301
+ onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | null,
302
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
303
+ ): Promise<TResult1 | TResult2> {
304
+ try {
305
+ const result = this.all();
306
+ return Promise.resolve(result).then(onfulfilled, onrejected);
307
+ } catch (err) {
308
+ return Promise.reject(err).then(onfulfilled, onrejected);
309
+ }
310
+ }
311
+ }
package/src/context.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * context.ts — Shared context interface for Database internals.
3
+ *
4
+ * Provides a slim interface so extracted modules (crud.ts, entity.ts, etc.)
5
+ * can access the Database's internals without importing the full class.
6
+ */
7
+ import type { Database as SqliteDatabase } from 'bun:sqlite';
8
+ import type { SchemaMap, Relationship, AugmentedEntity } from './types';
9
+
10
+ export interface DatabaseContext {
11
+ /** The raw bun:sqlite Database handle. */
12
+ db: SqliteDatabase;
13
+
14
+ /** All registered Zod schemas, keyed by entity name. */
15
+ schemas: SchemaMap;
16
+
17
+ /** Parsed relationship descriptors. */
18
+ relationships: Relationship[];
19
+
20
+ /** Augment a raw row with .update()/.delete()/nav methods + auto-persist proxy. */
21
+ attachMethods<T extends Record<string, any>>(entityName: string, entity: T): AugmentedEntity<any>;
22
+
23
+ /** Build a WHERE clause from a conditions object. */
24
+ buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] };
25
+ }
package/src/crud.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * crud.ts — CRUD operations extracted from Database class.
3
+ *
4
+ * Each function accepts a `DatabaseContext` so it can access
5
+ * the db handle, schemas, and entity methods without tight coupling.
6
+ */
7
+ import type { AugmentedEntity, UpdateBuilder, DeleteBuilder } from './types';
8
+ import { asZodObject } from './types';
9
+ import { transformForStorage, transformFromStorage } from './schema';
10
+ import type { DatabaseContext } from './context';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Read helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export function getById(ctx: DatabaseContext, entityName: string, id: number): AugmentedEntity<any> | null {
17
+ const row = ctx.db.query(`SELECT * FROM "${entityName}" WHERE id = ?`).get(id) as any;
18
+ if (!row) return null;
19
+ return ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]!));
20
+ }
21
+
22
+ export function getOne(ctx: DatabaseContext, entityName: string, conditions: Record<string, any>): AugmentedEntity<any> | null {
23
+ const { clause, values } = ctx.buildWhereClause(conditions);
24
+ const row = ctx.db.query(`SELECT * FROM "${entityName}" ${clause} LIMIT 1`).get(...values) as any;
25
+ if (!row) return null;
26
+ return ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]!));
27
+ }
28
+
29
+ export function findMany(ctx: DatabaseContext, entityName: string, conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
30
+ const { clause, values } = ctx.buildWhereClause(conditions);
31
+ const rows = ctx.db.query(`SELECT * FROM "${entityName}" ${clause}`).all(...values);
32
+ return rows.map((row: any) =>
33
+ ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]!))
34
+ );
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Write operations
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, data: Omit<T, 'id'>): AugmentedEntity<any> {
42
+ const schema = ctx.schemas[entityName]!;
43
+ const validatedData = asZodObject(schema).passthrough().parse(data);
44
+ const transformed = transformForStorage(validatedData);
45
+ const columns = Object.keys(transformed);
46
+
47
+ const quotedCols = columns.map(c => `"${c}"`);
48
+ const sql = columns.length === 0
49
+ ? `INSERT INTO "${entityName}" DEFAULT VALUES`
50
+ : `INSERT INTO "${entityName}" (${quotedCols.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
51
+
52
+ const result = ctx.db.query(sql).run(...Object.values(transformed));
53
+ const newEntity = getById(ctx, entityName, result.lastInsertRowid as number);
54
+ if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
55
+
56
+ return newEntity;
57
+ }
58
+
59
+ export function update<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, id: number, data: Partial<Omit<T, 'id'>>): AugmentedEntity<any> | null {
60
+ const schema = ctx.schemas[entityName]!;
61
+ const validatedData = asZodObject(schema).partial().parse(data);
62
+ const transformed = transformForStorage(validatedData);
63
+ if (Object.keys(transformed).length === 0) return getById(ctx, entityName, id);
64
+
65
+ const setClause = Object.keys(transformed).map(key => `"${key}" = ?`).join(', ');
66
+ ctx.db.query(`UPDATE "${entityName}" SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
67
+
68
+ return getById(ctx, entityName, id);
69
+ }
70
+
71
+ export function updateWhere(ctx: DatabaseContext, entityName: string, data: Record<string, any>, conditions: Record<string, any>): number {
72
+ const schema = ctx.schemas[entityName]!;
73
+ const validatedData = asZodObject(schema).partial().parse(data);
74
+ const transformed = transformForStorage(validatedData);
75
+ if (Object.keys(transformed).length === 0) return 0;
76
+
77
+ const { clause, values: whereValues } = ctx.buildWhereClause(conditions);
78
+ if (!clause) throw new Error('update().where() requires at least one condition');
79
+
80
+ const setCols = Object.keys(transformed);
81
+ const setClause = setCols.map(key => `"${key}" = ?`).join(', ');
82
+ const result = ctx.db.query(`UPDATE "${entityName}" SET ${setClause} ${clause}`).run(
83
+ ...setCols.map(key => transformed[key]),
84
+ ...whereValues
85
+ );
86
+
87
+ return (result as any).changes ?? 0;
88
+ }
89
+
90
+ export function createUpdateBuilder(ctx: DatabaseContext, entityName: string, data: Record<string, any>): UpdateBuilder<any> {
91
+ let _conditions: Record<string, any> = {};
92
+ const builder: UpdateBuilder<any> = {
93
+ where: (conditions) => { _conditions = { ..._conditions, ...conditions }; return builder; },
94
+ exec: () => updateWhere(ctx, entityName, data, _conditions),
95
+ };
96
+ return builder;
97
+ }
98
+
99
+ export function upsert<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, data: any, conditions: any = {}): AugmentedEntity<any> {
100
+ const hasId = data?.id && typeof data.id === 'number';
101
+ const existing = hasId
102
+ ? getById(ctx, entityName, data.id)
103
+ : Object.keys(conditions ?? {}).length > 0
104
+ ? getOne(ctx, entityName, conditions)
105
+ : null;
106
+
107
+ if (existing) {
108
+ const updateData = { ...data };
109
+ delete updateData.id;
110
+ return update(ctx, entityName, existing.id, updateData) as AugmentedEntity<any>;
111
+ }
112
+ const insertData = { ...(conditions ?? {}), ...(data ?? {}) };
113
+ delete insertData.id;
114
+ return insert(ctx, entityName, insertData);
115
+ }
116
+
117
+ export function deleteEntity(ctx: DatabaseContext, entityName: string, id: number): void {
118
+ ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
119
+ }
120
+
121
+ /** Delete all rows matching the given conditions. Returns the number of rows deleted. */
122
+ export function deleteWhere(ctx: DatabaseContext, entityName: string, conditions: Record<string, any>): number {
123
+ const { clause, values } = ctx.buildWhereClause(conditions);
124
+ if (!clause) throw new Error('delete().where() requires at least one condition');
125
+ const result = ctx.db.query(`DELETE FROM "${entityName}" ${clause}`).run(...values);
126
+ return (result as any).changes ?? 0;
127
+ }
128
+
129
+ /** Create a fluent delete builder: db.table.delete().where({...}).exec() */
130
+ export function createDeleteBuilder(ctx: DatabaseContext, entityName: string): DeleteBuilder<any> {
131
+ let _conditions: Record<string, any> = {};
132
+ const builder: DeleteBuilder<any> = {
133
+ where: (conditions) => { _conditions = { ..._conditions, ...conditions }; return builder; },
134
+ exec: () => deleteWhere(ctx, entityName, _conditions),
135
+ };
136
+ return builder;
137
+ }
138
+
139
+ /** Insert multiple rows in a single transaction for better performance. */
140
+ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, rows: Omit<T, 'id'>[]): AugmentedEntity<any>[] {
141
+ if (rows.length === 0) return [];
142
+ const schema = ctx.schemas[entityName]!;
143
+ const zodSchema = asZodObject(schema).passthrough();
144
+
145
+ const txn = ctx.db.transaction(() => {
146
+ const ids: number[] = [];
147
+ for (const data of rows) {
148
+ const validatedData = zodSchema.parse(data);
149
+ const transformed = transformForStorage(validatedData);
150
+ const columns = Object.keys(transformed);
151
+ const quotedCols = columns.map(c => `"${c}"`);
152
+ const sql = columns.length === 0
153
+ ? `INSERT INTO "${entityName}" DEFAULT VALUES`
154
+ : `INSERT INTO "${entityName}" (${quotedCols.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
155
+ const result = ctx.db.query(sql).run(...Object.values(transformed));
156
+ ids.push(result.lastInsertRowid as number);
157
+ }
158
+ return ids;
159
+ });
160
+
161
+ const ids = txn();
162
+ return ids.map((id: number) => getById(ctx, entityName, id)!).filter(Boolean);
163
+ }