@gobing-ai/ts-db 0.1.8 → 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/README.md +44 -21
- package/dist/adapter.d.ts +26 -46
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapters/bun-sqlite.d.ts +3 -2
- package/dist/adapters/bun-sqlite.d.ts.map +1 -1
- package/dist/adapters/bun-sqlite.js +2 -1
- package/dist/adapters/d1.d.ts +7 -2
- package/dist/adapters/d1.d.ts.map +1 -1
- package/dist/adapters/d1.js +6 -1
- package/dist/base-dao.d.ts +36 -16
- package/dist/base-dao.d.ts.map +1 -1
- package/dist/base-dao.js +46 -20
- package/dist/define-table.d.ts +40 -0
- package/dist/define-table.d.ts.map +1 -0
- package/dist/define-table.js +36 -0
- package/dist/entity-dao.d.ts +99 -96
- package/dist/entity-dao.d.ts.map +1 -1
- package/dist/entity-dao.js +145 -170
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/query-spec.d.ts +57 -0
- package/dist/query-spec.d.ts.map +1 -0
- package/dist/query-spec.js +40 -0
- package/dist/queue-job-dao.d.ts +2 -2
- package/dist/queue-job-dao.d.ts.map +1 -1
- package/dist/queue-job-dao.js +3 -3
- package/package.json +17 -5
- package/src/adapter.ts +27 -54
- package/src/adapters/bun-sqlite.ts +4 -3
- package/src/adapters/d1.ts +9 -3
- package/src/base-dao.ts +62 -20
- package/src/define-table.ts +66 -0
- package/src/entity-dao.ts +258 -199
- package/src/index.ts +23 -3
- package/src/query-spec.ts +84 -0
- package/src/queue-job-dao.ts +4 -4
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 {
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* -
|
|
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
|
-
*
|
|
40
|
-
* constructor(
|
|
41
|
-
* super(
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
99
|
+
adapter: DbAdapter,
|
|
100
|
+
table: TTable,
|
|
101
|
+
primaryKey: TPK | SQLiteColumn[],
|
|
102
|
+
collectionName: string,
|
|
103
|
+
options: EntityDaoOptions = {},
|
|
57
104
|
) {
|
|
58
|
-
super(
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
*
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
*
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
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
|
|
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 {
|
|
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
|
+
}
|