@gobing-ai/ts-db 0.1.7 → 0.2.1

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/src/entity-dao.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { and, count as countFn, eq, type SQL } from 'drizzle-orm';
2
2
  import type { SQLiteColumn, SQLiteTable } from 'drizzle-orm/sqlite-core';
3
- import type { DbClient } from './adapter';
3
+ import type { DbAdapter } from './adapter';
4
4
  import { BaseDao } from './base-dao';
5
+ import { compilePredicate, type OrderTerm, type Predicate } from './query-spec';
5
6
 
6
7
  /**
7
8
  * Type for tables compatible with EntityDao.
@@ -12,63 +13,119 @@ export type EntityTable = SQLiteTable & {
12
13
  updatedAt: SQLiteColumn;
13
14
  };
14
15
 
15
- /**
16
- * Type for tables with soft delete support.
17
- */
16
+ /** Type for tables with soft delete support. */
18
17
  export type SoftDeletableTable = EntityTable & {
19
18
  inUsed: SQLiteColumn;
20
19
  };
21
20
 
22
- /**
23
- * Type for primary key columns.
24
- */
21
+ /** Type for primary key columns (single column or a tuple for composite keys). */
25
22
  export type PKColumn = SQLiteColumn;
26
23
 
24
+ /** A primary key value: a single value, or a tuple for composite keys. */
25
+ export type PKValue = string | number | (string | number)[];
26
+
27
+ /** Options for the structured `list` operation. */
28
+ export interface EntityListSpec {
29
+ where?: Predicate;
30
+ orderBy?: readonly OrderTerm[];
31
+ limit?: number;
32
+ offset?: number;
33
+ includeDeleted?: boolean;
34
+ }
35
+
36
+ /** Options for keyset/cursor pagination. */
37
+ export interface CursorListSpec {
38
+ /** Opaque cursor from a previous page (the last row's keyset value). */
39
+ cursor?: string | number;
40
+ /** Column the cursor walks (must be unique + ordered, e.g. the primary key). */
41
+ cursorColumn: SQLiteColumn;
42
+ /** Page size. */
43
+ limit: number;
44
+ where?: Predicate;
45
+ direction?: 'asc' | 'desc';
46
+ includeDeleted?: boolean;
47
+ }
48
+
27
49
  /**
28
- * Generic CRUD base class for entity DAOs.
50
+ * Generic CRUD base class for entity DAOs — the STRUCTURED tier of the facade.
29
51
  *
30
- * Provides standard create, read, update, delete operations with:
31
- * - Type-safe public API using Drizzle's inference types
32
- * - Automatic soft delete filtering (if table has `inUsed` column)
52
+ * Extends {@link BaseDao} (raw tier) with typed create/read/update/delete over a
53
+ * single table, using RETURNING, drizzle-free predicate filters, soft-delete
54
+ * auto-filtering, batch insert, upsert, and cursor pagination. drizzle is hidden
55
+ * entirely — consumers use ts-db vocabulary. (G1/G3)
33
56
  *
34
- * @typeParam TTable - The table type (must extend EntityTable)
35
- * @typeParam TPK - The primary key column type
57
+ * @typeParam TTable - The table type (must extend EntityTable).
58
+ * @typeParam TPK - The primary key column type.
36
59
  *
37
60
  * @example
38
61
  * ```ts
39
- * export class UsersDao extends EntityDao<typeof users, typeof users.id> {
40
- * constructor(db: DbClient) {
41
- * super(db, users, users.id, 'users');
62
+ * class UsersDao extends EntityDao<typeof users, typeof users.id> {
63
+ * constructor(adapter: DbAdapter) {
64
+ * super(adapter, users, [users.id], 'users');
42
65
  * }
43
- *
44
- * // Add entity-specific methods here
45
- * async findByEmail(email: string) {
66
+ * findByEmail(email: string) {
46
67
  * return this.findBy(users.email, email);
47
68
  * }
48
69
  * }
49
70
  * ```
50
71
  */
72
+ /**
73
+ * A structural validator (e.g. a zod schema). Kept structural so EntityDao never
74
+ * imports zod/drizzle-zod — consumers wire `defineTable().insertSchema` here when
75
+ * they want boundary validation.
76
+ */
77
+ export interface DaoValidator {
78
+ parse(input: unknown): unknown;
79
+ }
80
+
81
+ /** Optional EntityDao configuration. */
82
+ export interface EntityDaoOptions {
83
+ /** Schema used to validate input before `create`/`createMany`/`upsert`. */
84
+ insertSchema?: DaoValidator;
85
+ /** Schema used to validate input before `update`. Falls back to `insertSchema` when absent. */
86
+ updateSchema?: DaoValidator;
87
+ /** Which write operations validate. Default: all (`create`, `createMany`, `upsert`, `update`) when a schema is present. */
88
+ validateOn?: ('create' | 'createMany' | 'upsert' | 'update')[];
89
+ }
90
+
51
91
  export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> extends BaseDao {
92
+ readonly table: TTable;
93
+ /** Primary key columns. A single-element array for single-PK tables, multiple for composite. */
94
+ protected readonly primaryKey: SQLiteColumn[];
95
+ protected readonly collectionName: string;
96
+ private readonly validation: EntityDaoOptions;
97
+
52
98
  constructor(
53
- db: DbClient,
54
- public readonly table: TTable,
55
- protected readonly primaryKey: TPK,
56
- protected readonly collectionName: string,
99
+ adapter: DbAdapter,
100
+ table: TTable,
101
+ primaryKey: TPK | SQLiteColumn[],
102
+ collectionName: string,
103
+ options: EntityDaoOptions = {},
57
104
  ) {
58
- super(db);
105
+ super(adapter);
106
+ this.table = table;
107
+ this.primaryKey = Array.isArray(primaryKey) ? primaryKey : [primaryKey];
108
+ this.collectionName = collectionName;
109
+ this.validation = options;
59
110
  }
60
111
 
61
- /**
62
- * Check if the table has soft delete support (inUsed column).
63
- */
112
+ /** Validate input against the configured schema for an operation, when enabled. */
113
+ private validate(op: 'create' | 'createMany' | 'upsert' | 'update', input: unknown): void {
114
+ // `update` is partial, so it only validates against an explicit partial
115
+ // `updateSchema`; the full insertSchema would reject a partial payload.
116
+ const schema = op === 'update' ? this.validation.updateSchema : this.validation.insertSchema;
117
+ if (schema === undefined) return;
118
+ const enabled = this.validation.validateOn ?? ['create', 'createMany', 'upsert', 'update'];
119
+ if (!enabled.includes(op)) return;
120
+ schema.parse(input);
121
+ }
122
+
123
+ /** Check if the table has soft delete support (inUsed column). */
64
124
  protected get hasSoftDelete(): boolean {
65
125
  return 'inUsed' in this.table;
66
126
  }
67
127
 
68
- /**
69
- * Build a where condition that filters out soft-deleted records.
70
- * Returns undefined if the table doesn't support soft delete.
71
- */
128
+ /** Condition filtering out soft-deleted rows, or undefined when unsupported. */
72
129
  protected get activeCondition(): SQL | undefined {
73
130
  if (this.hasSoftDelete) {
74
131
  return eq((this.table as unknown as SoftDeletableTable).inUsed, 1);
@@ -76,215 +133,217 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
76
133
  return undefined;
77
134
  }
78
135
 
136
+ /** Build the where-condition matching a primary key value (single or composite). */
137
+ private pkCondition(id: PKValue): SQL {
138
+ const values = Array.isArray(id) ? id : [id];
139
+ if (values.length !== this.primaryKey.length) {
140
+ throw new Error(
141
+ `${this.collectionName}: primary key expects ${this.primaryKey.length} value(s), got ${values.length}`,
142
+ );
143
+ }
144
+ const parts = this.primaryKey.map((col, i) => eq(col, values[i]));
145
+ return parts.length === 1 ? (parts[0] as SQL) : (and(...parts) as SQL);
146
+ }
147
+
148
+ private get insertBuilder() {
149
+ return this.db.insert(this.table as unknown as Parameters<typeof this.db.insert>[0]);
150
+ }
151
+
79
152
  /**
80
- * Create a new record.
81
- *
82
- * `createdAt` and `updatedAt` are auto-filled if not provided.
83
- *
84
- * @param data - The data to insert (createdAt/updatedAt optional).
85
- * @returns The created record.
153
+ * Create a new record. `createdAt`/`updatedAt` are auto-filled if absent.
154
+ * Returns the row as written by the database (RETURNING).
86
155
  */
87
156
  async create(
88
157
  data: Omit<TTable['$inferInsert'], 'createdAt' | 'updatedAt'> & { createdAt?: number; updatedAt?: number },
89
158
  ): Promise<TTable['$inferSelect']> {
90
159
  const now = this.now();
91
- const record = {
92
- ...data,
93
- createdAt: (data as Record<string, unknown>).createdAt ?? now,
94
- updatedAt: (data as Record<string, unknown>).updatedAt ?? now,
95
- };
96
-
97
- await this.db.insert(this.table).values(record);
98
-
99
- return record as TTable['$inferSelect'];
100
- }
101
-
102
- /**
103
- * Find a record by its primary key.
104
- *
105
- * @param id - The primary key value.
106
- * @param includeDeleted - Whether to include soft-deleted records.
107
- * @returns The record if found, otherwise undefined.
108
- */
109
- async findById(id: string | number, includeDeleted = false): Promise<TTable['$inferSelect'] | undefined> {
110
- const conditions = [eq(this.primaryKey, id)];
111
- if (!includeDeleted && this.activeCondition) {
112
- conditions.push(this.activeCondition);
113
- }
114
-
115
- const result = await this.db
116
- .select()
117
- .from(this.table)
118
- .where(and(...conditions));
119
-
120
- return (result as TTable['$inferSelect'][])[0];
160
+ const record = { createdAt: now, updatedAt: now, ...data };
161
+ this.validate('create', record);
162
+ const rows = (await (
163
+ this.insertBuilder.values(record) as unknown as { returning: () => Promise<unknown[]> }
164
+ ).returning()) as TTable['$inferSelect'][];
165
+ return rows[0] as TTable['$inferSelect'];
121
166
  }
122
167
 
123
168
  /**
124
- * Find all records.
125
- *
126
- * @param includeDeleted - Whether to include soft-deleted records.
127
- * @returns Array of records.
169
+ * Insert many records in a single multi-VALUES statement (efficient for ETL).
170
+ * Returns the written rows (RETURNING).
128
171
  */
129
- async findAll(includeDeleted = false): Promise<TTable['$inferSelect'][]> {
130
- const query = this.db.select().from(this.table);
131
-
132
- if (!includeDeleted && this.activeCondition) {
133
- return query.where(this.activeCondition);
134
- }
135
-
136
- return query;
172
+ async createMany(
173
+ data: (Omit<TTable['$inferInsert'], 'createdAt' | 'updatedAt'> & {
174
+ createdAt?: number;
175
+ updatedAt?: number;
176
+ })[],
177
+ ): Promise<TTable['$inferSelect'][]> {
178
+ if (data.length === 0) return [];
179
+ const now = this.now();
180
+ const records = data.map((d) => ({ createdAt: now, updatedAt: now, ...d }));
181
+ for (const record of records) this.validate('createMany', record);
182
+ return (await (
183
+ this.insertBuilder.values(records) as unknown as { returning: () => Promise<unknown[]> }
184
+ ).returning()) as TTable['$inferSelect'][];
137
185
  }
138
186
 
139
187
  /**
140
- * Update a record by its primary key.
141
- *
142
- * @param id - The primary key value.
143
- * @param data - The data to update.
144
- * @returns The updated record if found, otherwise undefined.
188
+ * Insert or update on conflict. `conflictColumns` identify the unique target;
189
+ * `update` (or all non-conflict columns) are written on conflict. Returns the row.
145
190
  */
146
- async update(
147
- id: string | number,
148
- data: Partial<TTable['$inferInsert']>,
149
- ): Promise<TTable['$inferSelect'] | undefined> {
191
+ async upsert(
192
+ data: Omit<TTable['$inferInsert'], 'createdAt' | 'updatedAt'> & { createdAt?: number; updatedAt?: number },
193
+ conflictColumns: SQLiteColumn[],
194
+ updateColumns?: Partial<TTable['$inferInsert']>,
195
+ ): Promise<TTable['$inferSelect']> {
150
196
  const now = this.now();
151
- const updateData = {
152
- ...data,
153
- updatedAt: now,
154
- };
155
-
156
- await this.db.update(this.table).set(updateData).where(eq(this.primaryKey, id));
157
-
158
- return this.findById(id);
197
+ const record = { createdAt: now, updatedAt: now, ...data };
198
+ this.validate('upsert', record);
199
+ // Default conflict-update = the supplied data minus identity columns
200
+ // (conflict target + primary key), so a conflict updates the row in place
201
+ // without rewriting the key it matched on. Identity columns are matched by
202
+ // their table property key (not DB column name) to handle snake_case columns.
203
+ const identityCols = new Set<SQLiteColumn>([...conflictColumns, ...this.primaryKey]);
204
+ const identityProps = new Set(
205
+ Object.entries(this.table as unknown as Record<string, SQLiteColumn>)
206
+ .filter(([, col]) => identityCols.has(col))
207
+ .map(([key]) => key),
208
+ );
209
+ const defaultSet = Object.fromEntries(
210
+ Object.entries(data as Record<string, unknown>).filter(([key]) => !identityProps.has(key)),
211
+ );
212
+ const setOnConflict = { ...(updateColumns ?? defaultSet), updatedAt: now };
213
+ const rows = (await (
214
+ this.insertBuilder.values(record) as unknown as {
215
+ onConflictDoUpdate: (cfg: { target: SQLiteColumn[]; set: unknown }) => {
216
+ returning: () => Promise<unknown[]>;
217
+ };
218
+ }
219
+ )
220
+ .onConflictDoUpdate({ target: conflictColumns, set: setOnConflict })
221
+ .returning()) as TTable['$inferSelect'][];
222
+ return rows[0] as TTable['$inferSelect'];
159
223
  }
160
224
 
161
- /**
162
- * Delete a record by its primary key.
163
- *
164
- * @param id - The primary key value.
165
- * @param soft - Whether to perform a soft delete (default: true if table supports it).
166
- * @returns The deleted record (for soft delete), otherwise undefined.
167
- */
168
- async delete(id: string | number, soft?: boolean): Promise<TTable['$inferSelect'] | undefined> {
169
- const useSoftDelete = soft ?? this.hasSoftDelete;
170
-
171
- if (useSoftDelete && this.hasSoftDelete) {
172
- const now = this.now();
173
- await this.db
174
- .update(this.table)
175
- .set({ inUsed: 0, updatedAt: now } as Partial<TTable['$inferInsert']>)
176
- .where(eq(this.primaryKey, id));
177
-
178
- return this.findById(id, true);
179
- }
180
-
181
- await this.db.delete(this.table).where(eq(this.primaryKey, id));
225
+ /** Find a record by its primary key (single value or composite tuple). */
226
+ async findById(id: PKValue, includeDeleted = false): Promise<TTable['$inferSelect'] | undefined> {
227
+ const conditions: SQL[] = [this.pkCondition(id)];
228
+ if (!includeDeleted && this.activeCondition) conditions.push(this.activeCondition);
229
+ const result = (await this.db
230
+ .select()
231
+ .from(this.table)
232
+ .where(and(...conditions))) as TTable['$inferSelect'][];
233
+ return result[0];
234
+ }
182
235
 
183
- return undefined;
236
+ /** Find all records (optionally including soft-deleted). */
237
+ async findAll(includeDeleted = false): Promise<TTable['$inferSelect'][]> {
238
+ return this.list({ includeDeleted });
184
239
  }
185
240
 
186
- /**
187
- * Find a record by a specific column value.
188
- *
189
- * @param column - The column to search.
190
- * @param value - The value to match.
191
- * @param includeDeleted - Whether to include soft-deleted records.
192
- * @returns The record if found, otherwise undefined.
193
- */
241
+ /** Find the first record matching a column value. */
194
242
  async findBy<TCol extends SQLiteColumn>(
195
243
  column: TCol,
196
244
  value: TCol['_']['data'],
197
245
  includeDeleted = false,
198
246
  ): Promise<TTable['$inferSelect'] | undefined> {
199
- const conditions = [eq(column, value)];
200
- if (!includeDeleted && this.activeCondition) {
201
- conditions.push(this.activeCondition);
202
- }
203
-
204
- const result = await this.db
205
- .select()
206
- .from(this.table)
207
- .where(and(...conditions));
208
-
209
- return (result as TTable['$inferSelect'][])[0];
247
+ const rows = await this.list({ where: { col: column, op: 'eq', value }, limit: 1, includeDeleted });
248
+ return rows[0];
210
249
  }
211
250
 
212
- /**
213
- * Find all records matching a specific column value.
214
- *
215
- * @param column - The column to search.
216
- * @param value - The value to match.
217
- * @param includeDeleted - Whether to include soft-deleted records.
218
- * @returns Array of matching records.
219
- */
251
+ /** Find all records matching a column value. */
220
252
  async findAllBy<TCol extends SQLiteColumn>(
221
253
  column: TCol,
222
254
  value: TCol['_']['data'],
223
255
  includeDeleted = false,
224
256
  ): Promise<TTable['$inferSelect'][]> {
225
- const conditions = [eq(column, value)];
226
- if (!includeDeleted && this.activeCondition) {
227
- conditions.push(this.activeCondition);
228
- }
229
-
230
- return this.db
231
- .select()
232
- .from(this.table)
233
- .where(and(...conditions));
257
+ return this.list({ where: { col: column, op: 'eq', value }, includeDeleted });
234
258
  }
235
259
 
236
- /**
237
- * List records with pagination and optional filtering.
238
- *
239
- * @param options - List options (limit, offset, where).
240
- * @returns Array of records.
241
- */
242
- async list(
243
- options: { limit?: number; offset?: number; where?: SQL; includeDeleted?: boolean } = {},
244
- ): Promise<TTable['$inferSelect'][]> {
245
- const { limit = 100, offset = 0, where, includeDeleted = false } = options;
246
- const conditions: SQL[] = [];
247
-
248
- if (!includeDeleted && this.activeCondition) {
249
- conditions.push(this.activeCondition);
250
- }
251
-
252
- if (where) {
253
- conditions.push(where);
254
- }
255
-
256
- const query = this.db.select().from(this.table);
260
+ /** Update a record by primary key. `updatedAt` is refreshed. Returns the updated row. */
261
+ async update(id: PKValue, data: Partial<TTable['$inferInsert']>): Promise<TTable['$inferSelect'] | undefined> {
262
+ const updateData = { ...data, updatedAt: this.now() };
263
+ this.validate('update', updateData);
264
+ const rows = (await (
265
+ this.db
266
+ .update(this.table as unknown as Parameters<typeof this.db.update>[0])
267
+ .set(updateData) as unknown as {
268
+ where: (c: SQL) => { returning: () => Promise<unknown[]> };
269
+ }
270
+ )
271
+ .where(this.pkCondition(id))
272
+ .returning()) as TTable['$inferSelect'][];
273
+ return rows[0];
274
+ }
257
275
 
258
- if (conditions.length > 0) {
259
- return query
260
- .where(and(...conditions))
261
- .limit(limit)
262
- .offset(offset);
276
+ /** Delete a record by primary key. Soft-deletes when the table supports it (default). */
277
+ async delete(id: PKValue, soft?: boolean): Promise<TTable['$inferSelect'] | undefined> {
278
+ const useSoftDelete = soft ?? this.hasSoftDelete;
279
+ if (useSoftDelete && this.hasSoftDelete) {
280
+ return this.update(id, { inUsed: 0 } as Partial<TTable['$inferInsert']>);
263
281
  }
282
+ await (
283
+ this.db.delete(this.table as unknown as Parameters<typeof this.db.delete>[0]) as unknown as {
284
+ where: (c: SQL) => Promise<unknown>;
285
+ }
286
+ ).where(this.pkCondition(id));
287
+ return undefined;
288
+ }
264
289
 
265
- return query.limit(limit).offset(offset);
290
+ /** List records with predicate filter, ordering, and offset pagination. */
291
+ async list(spec: EntityListSpec = {}): Promise<TTable['$inferSelect'][]> {
292
+ const where = this.withActive(spec.where, spec.includeDeleted);
293
+ return this.query<TTable['$inferSelect']>(this.table, {
294
+ ...(where ? { where } : {}),
295
+ ...(spec.orderBy ? { orderBy: spec.orderBy } : {}),
296
+ ...(spec.limit !== undefined ? { limit: spec.limit } : {}),
297
+ ...(spec.offset !== undefined ? { offset: spec.offset } : {}),
298
+ });
266
299
  }
267
300
 
268
- /**
269
- * Count records in the table.
270
- *
271
- * @param where - Optional filter condition.
272
- * @param includeDeleted - Whether to include soft-deleted records.
273
- * @returns The count of matching records.
274
- */
275
- async count(where?: SQL, includeDeleted = false): Promise<number> {
276
- const conditions: SQL[] = [];
277
- if (!includeDeleted && this.activeCondition) {
278
- conditions.push(this.activeCondition);
301
+ /** List one keyset page; returns the rows and the cursor for the next page. */
302
+ async listByCursor(
303
+ spec: CursorListSpec,
304
+ ): Promise<{ rows: TTable['$inferSelect'][]; nextCursor?: string | number }> {
305
+ const dir = spec.direction ?? 'asc';
306
+ const filters: Predicate[] = [];
307
+ if (spec.where) filters.push(spec.where);
308
+ if (spec.cursor !== undefined) {
309
+ filters.push({ col: spec.cursorColumn, op: dir === 'asc' ? 'gt' : 'lt', value: spec.cursor });
279
310
  }
280
- if (where) {
281
- conditions.push(where);
282
- }
283
-
284
- const query = this.db.select<{ value: number }>({ value: countFn() }).from(this.table);
285
-
286
- const result = conditions.length > 0 ? await query.where(and(...conditions)) : await query;
311
+ const where = this.withActive(filters.length > 0 ? { and: filters } : undefined, spec.includeDeleted);
312
+ const rows = await this.query<TTable['$inferSelect']>(this.table, {
313
+ ...(where ? { where } : {}),
314
+ orderBy: [{ col: spec.cursorColumn, dir }],
315
+ limit: spec.limit,
316
+ });
317
+ const last = rows[rows.length - 1] as Record<string, unknown> | undefined;
318
+ const nextCursor =
319
+ rows.length === spec.limit && last
320
+ ? (last[(spec.cursorColumn as unknown as { name: string }).name] as string | number)
321
+ : undefined;
322
+ return nextCursor !== undefined ? { rows, nextCursor } : { rows };
323
+ }
287
324
 
325
+ /** Count records matching an optional predicate. */
326
+ async count(where?: Predicate, includeDeleted = false): Promise<number> {
327
+ const condition = this.withActive(where, includeDeleted);
328
+ const compiled = condition ? compilePredicate(condition) : undefined;
329
+ const base = (
330
+ this.db as unknown as {
331
+ select: (p: unknown) => { from: (t: unknown) => { where: (c: SQL) => Promise<unknown[]> } };
332
+ }
333
+ )
334
+ .select({ value: countFn() })
335
+ .from(this.table);
336
+ const result = (await (compiled ? base.where(compiled) : (base as unknown as Promise<unknown[]>))) as {
337
+ value: number;
338
+ }[];
288
339
  return result[0]?.value ?? 0;
289
340
  }
341
+
342
+ /** Combine a caller predicate with the soft-delete active filter. */
343
+ private withActive(where: Predicate | undefined, includeDeleted?: boolean): Predicate | undefined {
344
+ if (includeDeleted || !this.hasSoftDelete) return where;
345
+ const active: Predicate = { col: (this.table as unknown as SoftDeletableTable).inUsed, op: 'eq', value: 1 };
346
+ if (!where) return active;
347
+ return { and: [where, active] };
348
+ }
290
349
  }
package/src/index.ts CHANGED
@@ -1,10 +1,30 @@
1
- export { createDbAdapter, type DbAdapter, type DbAdapterConfig, type DbClient, type DbTable } from './adapter';
1
+ export { createDbAdapter, type DbAdapter, type DbAdapterConfig, type InternalDb } from './adapter';
2
2
  export { BunSqliteAdapter, type BunSqliteOptions } from './adapters/bun-sqlite';
3
3
  export { D1Adapter } from './adapters/d1';
4
- export { BaseDao } from './base-dao';
4
+ export { BaseDao, type TxHandle } from './base-dao';
5
+ export { type DefinedTable, defineTable } from './define-table';
5
6
  export { type EmbeddedMigration, embeddedMigrations } from './embedded-migrations';
6
- export { EntityDao, type EntityTable, type PKColumn, type SoftDeletableTable } from './entity-dao';
7
+ export {
8
+ type CursorListSpec,
9
+ type DaoValidator,
10
+ EntityDao,
11
+ type EntityDaoOptions,
12
+ type EntityListSpec,
13
+ type EntityTable,
14
+ type PKColumn,
15
+ type PKValue,
16
+ type SoftDeletableTable,
17
+ } from './entity-dao';
7
18
  export { applyMigrations, type MigrationOptions } from './migrate';
19
+ export {
20
+ type ColRef,
21
+ type ComparisonOp,
22
+ compileOrderBy,
23
+ compilePredicate,
24
+ type ListSpec,
25
+ type OrderTerm,
26
+ type Predicate,
27
+ } from './query-spec';
8
28
  export { QueueJobDao, type QueueJobRecord, type QueueStats } from './queue-job-dao';
9
29
  export {
10
30
  appendOnlyColumns,
@@ -0,0 +1,84 @@
1
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, or, type SQL } from 'drizzle-orm';
2
+ import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
3
+
4
+ /**
5
+ * A column reference for the ts-db predicate spec.
6
+ *
7
+ * Consumers obtain these from their own ts-db table definitions (e.g. `users.email`),
8
+ * never by importing from `drizzle-orm` — keeping the drizzle dependency internal.
9
+ */
10
+ export type ColRef = SQLiteColumn;
11
+
12
+ /** Binary comparison operators supported by the predicate spec. */
13
+ export type ComparisonOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'like';
14
+
15
+ /**
16
+ * Drizzle-free predicate vocabulary for `where`/`count` filters.
17
+ *
18
+ * Deliberately small (the "lean facade"): enough for the common 90% of filters.
19
+ * Anything beyond (joins, aggregates, window functions) belongs in a named DAO
20
+ * method that uses drizzle privately, never in this public spec.
21
+ */
22
+ export type Predicate =
23
+ | { col: ColRef; op: ComparisonOp; value: unknown }
24
+ | { col: ColRef; op: 'in'; values: readonly unknown[] }
25
+ | { col: ColRef; op: 'isNull' | 'isNotNull' }
26
+ | { and: readonly Predicate[] }
27
+ | { or: readonly Predicate[] };
28
+
29
+ /** A single ordering term: a column and an optional direction (default `asc`). */
30
+ export interface OrderTerm {
31
+ col: ColRef;
32
+ dir?: 'asc' | 'desc';
33
+ }
34
+
35
+ /** Options accepted by the structured `list` operation. */
36
+ export interface ListSpec {
37
+ where?: Predicate;
38
+ orderBy?: readonly OrderTerm[];
39
+ limit?: number;
40
+ offset?: number;
41
+ includeDeleted?: boolean;
42
+ }
43
+
44
+ const COMPARISON_BUILDERS: Record<ComparisonOp, (col: ColRef, value: unknown) => SQL> = {
45
+ eq: (col, value) => eq(col, value),
46
+ ne: (col, value) => ne(col, value),
47
+ gt: (col, value) => gt(col, value),
48
+ gte: (col, value) => gte(col, value),
49
+ lt: (col, value) => lt(col, value),
50
+ lte: (col, value) => lte(col, value),
51
+ like: (col, value) => like(col, value as string),
52
+ };
53
+
54
+ /**
55
+ * Compile a ts-db {@link Predicate} into a drizzle `SQL` condition.
56
+ *
57
+ * Internal to ts-db — consumers never see drizzle's `SQL` type. Returns
58
+ * `undefined` for an empty `and`/`or` group so callers can omit the filter.
59
+ */
60
+ export function compilePredicate(predicate: Predicate): SQL | undefined {
61
+ if ('and' in predicate) {
62
+ const parts = predicate.and.map(compilePredicate).filter((p): p is SQL => p !== undefined);
63
+ return parts.length > 0 ? and(...parts) : undefined;
64
+ }
65
+ if ('or' in predicate) {
66
+ const parts = predicate.or.map(compilePredicate).filter((p): p is SQL => p !== undefined);
67
+ return parts.length > 0 ? or(...parts) : undefined;
68
+ }
69
+ switch (predicate.op) {
70
+ case 'isNull':
71
+ return isNull(predicate.col);
72
+ case 'isNotNull':
73
+ return isNotNull(predicate.col);
74
+ case 'in':
75
+ return inArray(predicate.col, predicate.values as unknown[]);
76
+ default:
77
+ return COMPARISON_BUILDERS[predicate.op](predicate.col, predicate.value);
78
+ }
79
+ }
80
+
81
+ /** Compile a list of {@link OrderTerm}s into drizzle order-by `SQL` clauses. */
82
+ export function compileOrderBy(orderBy: readonly OrderTerm[]): SQL[] {
83
+ return orderBy.map((term) => (term.dir === 'desc' ? desc(term.col) : asc(term.col)));
84
+ }