@gobing-ai/ts-db 0.2.2 → 0.2.4

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.
Files changed (40) hide show
  1. package/README.md +4 -3
  2. package/dist/adapters/bun-sqlite.d.ts +1 -1
  3. package/dist/adapters/bun-sqlite.d.ts.map +1 -1
  4. package/dist/adapters/bun-sqlite.js +2 -2
  5. package/dist/adapters/d1.d.ts +1 -1
  6. package/dist/adapters/d1.d.ts.map +1 -1
  7. package/dist/adapters/d1.js +1 -1
  8. package/dist/base-dao.d.ts.map +1 -1
  9. package/dist/base-dao.js +4 -1
  10. package/dist/entity-dao.d.ts +1 -0
  11. package/dist/entity-dao.d.ts.map +1 -1
  12. package/dist/entity-dao.js +20 -12
  13. package/dist/index.d.ts +0 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +0 -2
  16. package/dist/queue-job-dao.d.ts.map +1 -1
  17. package/dist/schema/ddl.d.ts +19 -0
  18. package/dist/schema/ddl.d.ts.map +1 -0
  19. package/dist/schema/ddl.js +156 -0
  20. package/dist/{define-table.d.ts → schema/define-table.d.ts} +18 -9
  21. package/dist/schema/define-table.d.ts.map +1 -0
  22. package/dist/{define-table.js → schema/define-table.js} +12 -1
  23. package/dist/schema/index.d.ts +3 -0
  24. package/dist/schema/index.d.ts.map +1 -1
  25. package/dist/schema/index.js +3 -0
  26. package/dist/schema/runtime.d.ts +2 -0
  27. package/dist/schema/runtime.d.ts.map +1 -0
  28. package/dist/schema/runtime.js +1 -0
  29. package/package.json +14 -2
  30. package/src/adapters/bun-sqlite.ts +2 -2
  31. package/src/adapters/d1.ts +1 -1
  32. package/src/base-dao.ts +18 -10
  33. package/src/entity-dao.ts +59 -42
  34. package/src/index.ts +1 -2
  35. package/src/queue-job-dao.ts +59 -51
  36. package/src/schema/ddl.ts +174 -0
  37. package/src/{define-table.ts → schema/define-table.ts} +26 -9
  38. package/src/schema/index.ts +3 -0
  39. package/src/schema/runtime.ts +1 -0
  40. package/dist/define-table.d.ts.map +0 -1
package/src/base-dao.ts CHANGED
@@ -2,6 +2,21 @@ import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
2
2
  import type { DbAdapter, InternalDb } from './adapter';
3
3
  import { compileOrderBy, compilePredicate, type ListSpec, type Predicate } from './query-spec';
4
4
 
5
+ type TransactionalDb = {
6
+ transaction: <T>(cb: (tx: TxHandle) => Promise<T>) => Promise<T>;
7
+ };
8
+
9
+ type SelectQuery<T> = Promise<T[]> & {
10
+ where: (condition: unknown) => SelectQuery<T>;
11
+ orderBy: (...order: unknown[]) => SelectQuery<T>;
12
+ limit: (limit: number) => SelectQuery<T>;
13
+ offset: (offset: number) => SelectQuery<T>;
14
+ };
15
+
16
+ function asSelectQuery<T>(query: object): SelectQuery<T> {
17
+ return query as SelectQuery<T>;
18
+ }
19
+
5
20
  /**
6
21
  * A transaction-scoped handle passed to {@link BaseDao.tx} callbacks.
7
22
  *
@@ -42,9 +57,7 @@ export abstract class BaseDao {
42
57
  * The callback receives a transaction-scoped db handle.
43
58
  */
44
59
  protected async tx<T>(fn: (tx: TxHandle) => Promise<T>): Promise<T> {
45
- return (this.db as { transaction: (cb: (tx: TxHandle) => Promise<T>) => Promise<T> }).transaction((tx) =>
46
- fn(tx),
47
- );
60
+ return (this.db as TransactionalDb).transaction((tx) => fn(tx));
48
61
  }
49
62
 
50
63
  /**
@@ -58,17 +71,12 @@ export abstract class BaseDao {
58
71
  const order = spec.orderBy ? compileOrderBy(spec.orderBy) : [];
59
72
 
60
73
  // drizzle's fluent builder is internal here; the public input is the spec.
61
- let q = (this.db as InternalDb).select().from(table) as unknown as {
62
- where: (c: unknown) => typeof q;
63
- orderBy: (...o: unknown[]) => typeof q;
64
- limit: (n: number) => typeof q;
65
- offset: (n: number) => typeof q;
66
- };
74
+ let q = asSelectQuery<T>(this.db.select().from(table));
67
75
  if (condition) q = q.where(condition);
68
76
  if (order.length > 0) q = q.orderBy(...order);
69
77
  if (spec.limit !== undefined) q = q.limit(spec.limit);
70
78
  if (spec.offset !== undefined) q = q.offset(spec.offset);
71
- return (await (q as unknown as Promise<T[]>)) ?? [];
79
+ return (await q) ?? [];
72
80
  }
73
81
 
74
82
  /** Run a SELECT and return the first matching row, or undefined. */
package/src/entity-dao.ts CHANGED
@@ -4,6 +4,36 @@ import type { DbAdapter } from './adapter';
4
4
  import { BaseDao } from './base-dao';
5
5
  import { compilePredicate, type OrderTerm, type Predicate } from './query-spec';
6
6
 
7
+ type ReturningRows = {
8
+ returning: () => Promise<unknown[]>;
9
+ };
10
+
11
+ type InsertBuilder = {
12
+ values: (record: unknown) => ReturningRows & {
13
+ onConflictDoUpdate: (cfg: { target: SQLiteColumn[]; set: unknown }) => ReturningRows;
14
+ };
15
+ };
16
+
17
+ type UpdateBuilder = {
18
+ set: (data: unknown) => {
19
+ where: (condition: SQL) => ReturningRows;
20
+ };
21
+ };
22
+
23
+ type DeleteBuilder = {
24
+ where: (condition: SQL) => Promise<unknown>;
25
+ };
26
+
27
+ type CountQuery = Promise<unknown[]> & {
28
+ where: (condition: SQL) => Promise<unknown[]>;
29
+ };
30
+
31
+ type CountDb = {
32
+ select: (projection: unknown) => {
33
+ from: (table: unknown) => CountQuery;
34
+ };
35
+ };
36
+
7
37
  /**
8
38
  * Type for tables compatible with EntityDao.
9
39
  * Must have standard columns: createdAt, updatedAt.
@@ -127,8 +157,9 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
127
157
 
128
158
  /** Condition filtering out soft-deleted rows, or undefined when unsupported. */
129
159
  protected get activeCondition(): SQL | undefined {
130
- if (this.hasSoftDelete) {
131
- return eq((this.table as unknown as SoftDeletableTable).inUsed, 1);
160
+ const table = this.table;
161
+ if ('inUsed' in table) {
162
+ return eq(table.inUsed as SQLiteColumn, 1);
132
163
  }
133
164
  return undefined;
134
165
  }
@@ -146,7 +177,7 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
146
177
  }
147
178
 
148
179
  private get insertBuilder() {
149
- return this.db.insert(this.table as unknown as Parameters<typeof this.db.insert>[0]);
180
+ return this.db.insert(this.table as Parameters<typeof this.db.insert>[0]) as InsertBuilder;
150
181
  }
151
182
 
152
183
  /**
@@ -159,9 +190,7 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
159
190
  const now = this.now();
160
191
  const record = { createdAt: now, updatedAt: now, ...data };
161
192
  this.validate('create', record);
162
- const rows = (await (
163
- this.insertBuilder.values(record) as unknown as { returning: () => Promise<unknown[]> }
164
- ).returning()) as TTable['$inferSelect'][];
193
+ const rows = (await this.insertBuilder.values(record).returning()) as TTable['$inferSelect'][];
165
194
  return rows[0] as TTable['$inferSelect'];
166
195
  }
167
196
 
@@ -179,9 +208,7 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
179
208
  const now = this.now();
180
209
  const records = data.map((d) => ({ createdAt: now, updatedAt: now, ...d }));
181
210
  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'][];
211
+ return (await this.insertBuilder.values(records).returning()) as TTable['$inferSelect'][];
185
212
  }
186
213
 
187
214
  /**
@@ -202,21 +229,16 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
202
229
  // their table property key (not DB column name) to handle snake_case columns.
203
230
  const identityCols = new Set<SQLiteColumn>([...conflictColumns, ...this.primaryKey]);
204
231
  const identityProps = new Set(
205
- Object.entries(this.table as unknown as Record<string, SQLiteColumn>)
206
- .filter(([, col]) => identityCols.has(col))
232
+ Object.entries(this.table)
233
+ .filter(([, col]) => identityCols.has(col as SQLiteColumn))
207
234
  .map(([key]) => key),
208
235
  );
209
236
  const defaultSet = Object.fromEntries(
210
237
  Object.entries(data as Record<string, unknown>).filter(([key]) => !identityProps.has(key)),
211
238
  );
212
239
  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
- )
240
+ const rows = (await this.insertBuilder
241
+ .values(record)
220
242
  .onConflictDoUpdate({ target: conflictColumns, set: setOnConflict })
221
243
  .returning()) as TTable['$inferSelect'][];
222
244
  return rows[0] as TTable['$inferSelect'];
@@ -261,13 +283,8 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
261
283
  async update(id: PKValue, data: Partial<TTable['$inferInsert']>): Promise<TTable['$inferSelect'] | undefined> {
262
284
  const updateData = { ...data, updatedAt: this.now() };
263
285
  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
- )
286
+ const rows = (await (this.db.update(this.table as Parameters<typeof this.db.update>[0]) as UpdateBuilder)
287
+ .set(updateData)
271
288
  .where(this.pkCondition(id))
272
289
  .returning()) as TTable['$inferSelect'][];
273
290
  return rows[0];
@@ -279,11 +296,9 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
279
296
  if (useSoftDelete && this.hasSoftDelete) {
280
297
  return this.update(id, { inUsed: 0 } as Partial<TTable['$inferInsert']>);
281
298
  }
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));
299
+ await (this.db.delete(this.table as Parameters<typeof this.db.delete>[0]) as DeleteBuilder).where(
300
+ this.pkCondition(id),
301
+ );
287
302
  return undefined;
288
303
  }
289
304
 
@@ -315,25 +330,25 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
315
330
  limit: spec.limit,
316
331
  });
317
332
  const last = rows[rows.length - 1] as Record<string, unknown> | undefined;
333
+ const cursorResultKey = this.resultKeyForColumn(spec.cursorColumn);
318
334
  const nextCursor =
319
- rows.length === spec.limit && last
320
- ? (last[(spec.cursorColumn as unknown as { name: string }).name] as string | number)
321
- : undefined;
335
+ rows.length === spec.limit && last ? (last[cursorResultKey] as string | number | undefined) : undefined;
322
336
  return nextCursor !== undefined ? { rows, nextCursor } : { rows };
323
337
  }
324
338
 
339
+ private resultKeyForColumn(column: SQLiteColumn): string {
340
+ for (const [key, value] of Object.entries(this.table)) {
341
+ if (value === column) return key;
342
+ }
343
+ return (column as { name: string }).name;
344
+ }
345
+
325
346
  /** Count records matching an optional predicate. */
326
347
  async count(where?: Predicate, includeDeleted = false): Promise<number> {
327
348
  const condition = this.withActive(where, includeDeleted);
328
349
  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 {
350
+ const base = (this.db as CountDb).select({ value: countFn() }).from(this.table);
351
+ const result = (await (compiled ? base.where(compiled) : base)) as {
337
352
  value: number;
338
353
  }[];
339
354
  return result[0]?.value ?? 0;
@@ -342,7 +357,9 @@ export class EntityDao<TTable extends EntityTable, TPK extends SQLiteColumn> ext
342
357
  /** Combine a caller predicate with the soft-delete active filter. */
343
358
  private withActive(where: Predicate | undefined, includeDeleted?: boolean): Predicate | undefined {
344
359
  if (includeDeleted || !this.hasSoftDelete) return where;
345
- const active: Predicate = { col: (this.table as unknown as SoftDeletableTable).inUsed, op: 'eq', value: 1 };
360
+ const table = this.table;
361
+ if (!('inUsed' in table)) return where;
362
+ const active: Predicate = { col: table.inUsed as SQLiteColumn, op: 'eq', value: 1 };
346
363
  if (!where) return active;
347
364
  return { and: [where, active] };
348
365
  }
package/src/index.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  export { createDbAdapter, type DbAdapter, type DbAdapterConfig, type InternalDb } from './adapter';
2
- export { BunSqliteAdapter, type BunSqliteOptions } from './adapters/bun-sqlite';
3
2
  export { D1Adapter } from './adapters/d1';
4
3
  export { BaseDao, type TxHandle } from './base-dao';
5
- export { type DefinedTable, defineTable } from './define-table';
4
+
6
5
  export { type EmbeddedMigration, embeddedMigrations } from './embedded-migrations';
7
6
  export {
8
7
  type CursorListSpec,
@@ -3,6 +3,58 @@ import type { DbAdapter } from './adapter';
3
3
  import { EntityDao } from './entity-dao';
4
4
  import { queueJobs } from './schema/queue-jobs';
5
5
 
6
+ type SelectGroupByQuery = {
7
+ groupBy: (group: unknown) => Promise<unknown[]>;
8
+ };
9
+
10
+ type SelectWhereQuery = {
11
+ where: (where: unknown) => Promise<unknown[]>;
12
+ };
13
+
14
+ type SelectReadyQuery = {
15
+ where: (where: unknown) => {
16
+ orderBy: (order: unknown) => { limit: (limit: number) => Promise<unknown[]> };
17
+ };
18
+ };
19
+
20
+ type QueueSelectDb = {
21
+ select: (projection: unknown) => {
22
+ from: (table: unknown) => SelectGroupByQuery & SelectWhereQuery;
23
+ };
24
+ };
25
+
26
+ type QueueReadyDb = {
27
+ select: () => {
28
+ from: (table: unknown) => SelectReadyQuery;
29
+ };
30
+ };
31
+
32
+ type QueueUpdateReturningDb = {
33
+ update: (table: unknown) => {
34
+ set: (value: unknown) => {
35
+ where: (where: unknown) => {
36
+ returning: () => Promise<unknown[]>;
37
+ };
38
+ };
39
+ };
40
+ };
41
+
42
+ type QueueUpdateVoidDb = {
43
+ update: (table: unknown) => {
44
+ set: (value: unknown) => {
45
+ where: (where: unknown) => Promise<unknown>;
46
+ };
47
+ };
48
+ };
49
+
50
+ type QueueUpdateChangesDb = {
51
+ update: (table: unknown) => {
52
+ set: (value: unknown) => {
53
+ where: (where: unknown) => Promise<{ changes: number }>;
54
+ };
55
+ };
56
+ };
57
+
6
58
  /**
7
59
  * Aggregate queue statistics by job status.
8
60
  */
@@ -101,11 +153,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
101
153
  * Get aggregate job counts by status.
102
154
  */
103
155
  async getStats(): Promise<QueueStats> {
104
- const result = await (
105
- this.db as unknown as {
106
- select: (fn: unknown) => { from: (t: unknown) => { groupBy: (g: unknown) => Promise<unknown[]> } };
107
- }
108
- )
156
+ const result = await (this.db as QueueSelectDb)
109
157
  .select({
110
158
  status: queueJobs.status,
111
159
  count: sql`count(*)`,
@@ -128,11 +176,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
128
176
  * Count jobs by status.
129
177
  */
130
178
  async countByStatus(status: string): Promise<number> {
131
- const result = await (
132
- this.db as unknown as {
133
- select: (fn: unknown) => { from: (t: unknown) => { where: (w: unknown) => Promise<unknown[]> } };
134
- }
135
- )
179
+ const result = await (this.db as QueueSelectDb)
136
180
  .select({ value: sql`count(*)` })
137
181
  .from(queueJobs)
138
182
  .where(sql`${queueJobs.status} = ${status}`);
@@ -146,17 +190,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
146
190
  async findPending(batchSize: number): Promise<QueueJobRecord[]> {
147
191
  const now = this.now();
148
192
 
149
- const result = await (
150
- this.db as unknown as {
151
- select: () => {
152
- from: (t: unknown) => {
153
- where: (w: unknown) => {
154
- orderBy: (o: unknown) => { limit: (l: number) => Promise<unknown[]> };
155
- };
156
- };
157
- };
158
- }
159
- )
193
+ const result = await (this.db as QueueReadyDb)
160
194
  .select()
161
195
  .from(queueJobs)
162
196
  .where(
@@ -180,17 +214,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
180
214
 
181
215
  const now = this.now();
182
216
 
183
- const result = await (
184
- this.db as unknown as {
185
- update: (t: unknown) => {
186
- set: (v: unknown) => {
187
- where: (w: unknown) => {
188
- returning: () => Promise<unknown[]>;
189
- };
190
- };
191
- };
192
- }
193
- )
217
+ const result = await (this.db as QueueUpdateReturningDb)
194
218
  .update(queueJobs)
195
219
  .set({ status: 'processing', processingAt: now, updatedAt: now })
196
220
  .where(
@@ -217,11 +241,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
217
241
 
218
242
  const now = this.now();
219
243
 
220
- await (
221
- this.db as unknown as {
222
- update: (t: unknown) => { set: (v: unknown) => { where: (w: unknown) => Promise<unknown> } };
223
- }
224
- )
244
+ await (this.db as QueueUpdateVoidDb)
225
245
  .update(queueJobs)
226
246
  .set({ status: 'processing', processingAt: now, updatedAt: now })
227
247
  .where(and(inArray(queueJobs.id, ids), eq(queueJobs.status, 'pending')));
@@ -268,13 +288,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
268
288
  async resetStuckJobs(visibilityTimeout: number): Promise<number> {
269
289
  const cutoff = this.now() - visibilityTimeout;
270
290
 
271
- const result = await (
272
- this.db as unknown as {
273
- update: (t: unknown) => {
274
- set: (v: unknown) => { where: (w: unknown) => Promise<{ changes: number }> };
275
- };
276
- }
277
- )
291
+ const result = await (this.db as QueueUpdateChangesDb)
278
292
  .update(queueJobs)
279
293
  .set({ status: 'pending', processingAt: null, updatedAt: this.now() })
280
294
  .where(
@@ -293,13 +307,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
293
307
  async failExpiredJobs(): Promise<number> {
294
308
  const now = this.now();
295
309
 
296
- const result = await (
297
- this.db as unknown as {
298
- update: (t: unknown) => {
299
- set: (v: unknown) => { where: (w: unknown) => Promise<{ changes: number }> };
300
- };
301
- }
302
- )
310
+ const result = await (this.db as QueueUpdateChangesDb)
303
311
  .update(queueJobs)
304
312
  .set({
305
313
  status: 'failed',
@@ -0,0 +1,174 @@
1
+ import { getTableConfig, type SQLiteTable } from 'drizzle-orm/sqlite-core';
2
+
3
+ /**
4
+ * Quote an identifier for use in SQL (double-quoted for SQLite compatibility).
5
+ */
6
+ function quoteIdent(name: string): string {
7
+ return `"${name.replace(/"/g, '""')}"`;
8
+ }
9
+
10
+ /**
11
+ * Extract the SQL string from a drizzle-orm SQL expression by walking its
12
+ * internal `queryChunks` — StringChunk values plus Param placeholders.
13
+ */
14
+ function sqlToString(chunks: Array<{ value?: unknown; input?: unknown }>): string {
15
+ return chunks
16
+ .map((chunk) => {
17
+ // StringChunk — the literal SQL fragment
18
+ if ('value' in chunk && typeof chunk.value === 'string') {
19
+ return chunk.value;
20
+ }
21
+ // Param — use the input value if available
22
+ if ('input' in chunk && chunk.input !== undefined) {
23
+ return String(chunk.input);
24
+ }
25
+ return String(chunk.value ?? '?');
26
+ })
27
+ .join('');
28
+ }
29
+
30
+ /**
31
+ * Map a column default value to its SQL literal representation.
32
+ *
33
+ * drizzle-orm defaults can be:
34
+ * - primitives: number, string, boolean, null
35
+ * - SQL expressions (sql\`...\` template results)
36
+ * - undefined (no default, or runtime-only $defaultFn)
37
+ */
38
+ function defaultToSql(value: unknown): string | undefined {
39
+ if (value == null) {
40
+ return value === null ? 'NULL' : undefined;
41
+ }
42
+ if (typeof value === 'number') {
43
+ return String(value);
44
+ }
45
+ if (typeof value === 'string') {
46
+ return `'${value.replace(/'/g, "''")}'`;
47
+ }
48
+ if (typeof value === 'boolean') {
49
+ return value ? '1' : '0';
50
+ }
51
+ // drizzle-orm SQL expression (has queryChunks)
52
+ if (typeof value === 'object' && value !== null && 'queryChunks' in value) {
53
+ const chunks = (value as Record<string, unknown>).queryChunks as
54
+ | Array<{ value?: unknown; input?: unknown }>
55
+ | undefined;
56
+ if (chunks) {
57
+ return sqlToString(chunks);
58
+ }
59
+ }
60
+ return String(value);
61
+ }
62
+
63
+ /**
64
+ * Resolve a drizzle table object to its string name.
65
+ */
66
+ function getTableName(table: Record<string, unknown>): string {
67
+ // Drizzle tables store name at Symbol.for('drizzle:Name')
68
+ const nameSym = Symbol.for('drizzle:Name');
69
+ return String((table as unknown as Record<symbol, unknown>)[nameSym]);
70
+ }
71
+
72
+ /**
73
+ * Generate a `CREATE TABLE IF NOT EXISTS` DDL statement from a Drizzle SQLite table.
74
+ *
75
+ * Uses `getTableConfig` (drizzle-orm runtime introspection) to extract columns,
76
+ * types, constraints, and foreign keys — no drizzle-kit CLI required.
77
+ *
78
+ * The output is deterministic: columns are emitted in definition order, identifiers
79
+ * are double-quoted, and table constraints follow column definitions.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const users = sqliteTable('users', { id: text('id').primaryKey() });
84
+ * const ddl = generateCreateTableSql(users);
85
+ * // CREATE TABLE IF NOT EXISTS "users" ("id" text PRIMARY KEY NOT NULL)
86
+ * ```
87
+ */
88
+ export function generateCreateTableSql(table: SQLiteTable): string {
89
+ const config = getTableConfig(table);
90
+
91
+ const columnDefs: string[] = [];
92
+ const tableConstraints: string[] = [];
93
+
94
+ // Track which columns participate in composite unique constraints
95
+ const compositeUniqueCols = new Set<string>();
96
+ for (const uc of config.uniqueConstraints) {
97
+ if (uc.columns.length > 1) {
98
+ for (const col of uc.columns) {
99
+ compositeUniqueCols.add(col.name);
100
+ }
101
+ }
102
+ }
103
+
104
+ // Track which columns participate in composite primary keys
105
+ const compositePkCols = new Set<string>();
106
+ for (const pk of config.primaryKeys) {
107
+ if (pk.columns.length > 1) {
108
+ for (const col of pk.columns) {
109
+ compositePkCols.add(col.name);
110
+ }
111
+ }
112
+ }
113
+
114
+ for (const col of config.columns) {
115
+ const parts: string[] = [quoteIdent(col.name), col.getSQLType()];
116
+
117
+ // Column-level PRIMARY KEY only for single-column PKs
118
+ if (col.primary && !compositePkCols.has(col.name)) {
119
+ parts.push('PRIMARY KEY');
120
+ }
121
+ if (col.notNull) {
122
+ parts.push('NOT NULL');
123
+ }
124
+ // DEFAULT — only for SQL-level defaults (not runtime $defaultFn)
125
+ if (col.hasDefault && col.default !== undefined) {
126
+ const sqlDefault = defaultToSql(col.default);
127
+ if (sqlDefault !== undefined) {
128
+ parts.push(`DEFAULT ${sqlDefault}`);
129
+ }
130
+ }
131
+ // UNIQUE at column level only when it's a single-column unique constraint
132
+ if (col.isUnique && !compositeUniqueCols.has(col.name)) {
133
+ parts.push('UNIQUE');
134
+ }
135
+
136
+ columnDefs.push(parts.join(' '));
137
+ }
138
+
139
+ // Composite PRIMARY KEY
140
+ for (const pk of config.primaryKeys) {
141
+ if (pk.columns.length > 1) {
142
+ const pkCols = pk.columns.map((c) => quoteIdent(c.name)).join(', ');
143
+ tableConstraints.push(`PRIMARY KEY (${pkCols})`);
144
+ }
145
+ }
146
+
147
+ // Composite UNIQUE constraints
148
+ for (const uc of config.uniqueConstraints) {
149
+ if (uc.columns.length > 1) {
150
+ const cols = uc.columns.map((c) => quoteIdent(c.name)).join(', ');
151
+ tableConstraints.push(`UNIQUE (${cols})`);
152
+ }
153
+ }
154
+
155
+ // Foreign keys
156
+ for (const fk of config.foreignKeys) {
157
+ const ref = fk.reference();
158
+ const localCols = ref.columns.map((c) => quoteIdent(c.name)).join(', ');
159
+ const foreignCols = ref.foreignColumns.map((c) => quoteIdent(c.name)).join(', ');
160
+ const foreignTableName = getTableName(ref.foreignTable as unknown as Record<string, symbol | unknown>);
161
+
162
+ let constraint = `FOREIGN KEY (${localCols}) REFERENCES ${quoteIdent(foreignTableName)} (${foreignCols})`;
163
+ if (fk.onDelete) {
164
+ constraint += ` ON DELETE ${fk.onDelete}`;
165
+ }
166
+ if (fk.onUpdate) {
167
+ constraint += ` ON UPDATE ${fk.onUpdate}`;
168
+ }
169
+ tableConstraints.push(constraint);
170
+ }
171
+
172
+ const allDefs = [...columnDefs, ...tableConstraints];
173
+ return `CREATE TABLE IF NOT EXISTS ${quoteIdent(config.name)} (\n ${allDefs.join(',\n ')}\n)`;
174
+ }
@@ -1,20 +1,25 @@
1
1
  import { type SQLiteColumnBuilderBase, sqliteTable } from 'drizzle-orm/sqlite-core';
2
2
  import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
3
3
  import type { ZodType } from 'zod';
4
+ import { generateCreateTableSql } from './ddl';
4
5
 
5
6
  /**
6
- * A table definition bundled with its drizzle-zod validation schemas.
7
+ * A table definition bundled with its drizzle-zod validation schemas and DDL.
7
8
  *
8
9
  * The single source of truth (G2): one table authored once yields the drizzle
9
- * table (for queries/migrations) plus insert/select zod schemas (for boundary
10
- * validation), with no parallel re-authoring.
10
+ * table (for queries/migrations), insert/select zod schemas (for boundary
11
+ * validation), and CREATE TABLE DDL (for migrations) — with no parallel
12
+ * re-authoring.
11
13
  *
12
- * The zod schemas are derived lazily — only materialised the first time they are
13
- * read — so a consumer that only needs the table pays nothing extra.
14
+ * The zod schemas and DDL are derived lazily — only materialised the first
15
+ * time they are read — so a consumer that only needs the table pays nothing
16
+ * extra.
14
17
  *
15
- * `defineTable`, `insertSchema`, and `selectSchema` require the optional peers
16
- * `zod` and `drizzle-zod`. Consumers that never validate need not install them
17
- * and simply use `createDbAdapter` + raw `sqliteTable` + the column helpers.
18
+ * `defineTable`, `insertSchema`, `selectSchema`, and `createTableSql` require
19
+ * the optional peers `zod` and `drizzle-zod`. Consumers that never validate and
20
+ * don't need generated DDL can use `createDbAdapter` + raw `sqliteTable` +
21
+ * column helpers without installing those peers. Import from
22
+ * `@gobing-ai/ts-db/schema` to opt in.
18
23
  */
19
24
  export interface DefinedTable<TTable> {
20
25
  /** The underlying drizzle table — pass to DAOs, migrations, queries. */
@@ -23,13 +28,17 @@ export interface DefinedTable<TTable> {
23
28
  readonly insertSchema: ZodType;
24
29
  /** Zod schema validating a selected row. */
25
30
  readonly selectSchema: ZodType;
31
+ /** `CREATE TABLE IF NOT EXISTS` DDL generated from the table definition (lazy). */
32
+ readonly createTableSql: string;
26
33
  }
27
34
 
28
35
  /**
29
- * Define a SQLite table and derive its validation schemas in one place (G2).
36
+ * Define a SQLite table and derive its validation schemas and DDL in one place (G2).
30
37
  *
31
38
  * @example
32
39
  * ```ts
40
+ * import { defineTable } from '@gobing-ai/ts-db/schema';
41
+ *
33
42
  * export const users = defineTable('users', {
34
43
  * id: text('id').primaryKey(),
35
44
  * email: text('email').notNull().unique(),
@@ -37,6 +46,7 @@ export interface DefinedTable<TTable> {
37
46
  * });
38
47
  * users.table // drizzle table for DAOs/migrations
39
48
  * users.insertSchema // zod schema derived from the table
49
+ * users.createTableSql // CREATE TABLE IF NOT EXISTS "users" (...)
40
50
  * ```
41
51
  */
42
52
  export function defineTable<TName extends string, TColumns extends Record<string, SQLiteColumnBuilderBase>>(
@@ -47,6 +57,7 @@ export function defineTable<TName extends string, TColumns extends Record<string
47
57
 
48
58
  let insert: ZodType | undefined;
49
59
  let select: ZodType | undefined;
60
+ let ddl: string | undefined;
50
61
 
51
62
  return {
52
63
  table,
@@ -62,5 +73,11 @@ export function defineTable<TName extends string, TColumns extends Record<string
62
73
  }
63
74
  return select;
64
75
  },
76
+ get createTableSql(): string {
77
+ if (ddl === undefined) {
78
+ ddl = generateCreateTableSql(table);
79
+ }
80
+ return ddl;
81
+ },
65
82
  };
66
83
  }
@@ -1,2 +1,5 @@
1
+ export { index, integer, text } from 'drizzle-orm/sqlite-core';
1
2
  export * from './common';
3
+ export { generateCreateTableSql } from './ddl';
4
+ export { type DefinedTable, defineTable } from './define-table';
2
5
  export { queueJobs } from './queue-jobs';
@@ -0,0 +1 @@
1
+ export { queueJobs } from './queue-jobs';
@@ -1 +0,0 @@
1
- {"version":3,"file":"define-table.d.ts","sourceRoot":"","sources":["../src/define-table.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,uBAAuB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEpF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC;AAEnC;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAY,CAAC,MAAM;IAChC,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,4CAA4C;IAC5C,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,EACtG,IAAI,EAAE,KAAK,EACX,OAAO,EAAE,QAAQ,GAClB,YAAY,CAAC,UAAU,CAAC,OAAO,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,CAqB/D"}