@gobing-ai/ts-db 0.2.8 → 0.3.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.
Files changed (48) hide show
  1. package/README.md +52 -1
  2. package/dist/drizzle-builders.d.ts +96 -0
  3. package/dist/drizzle-builders.d.ts.map +1 -0
  4. package/dist/drizzle-builders.js +17 -0
  5. package/dist/embedded-migrations.d.ts.map +1 -1
  6. package/dist/embedded-migrations.js +10 -0
  7. package/dist/entity-dao.d.ts.map +1 -1
  8. package/dist/inbox-message-dao.d.ts +19 -0
  9. package/dist/inbox-message-dao.d.ts.map +1 -0
  10. package/dist/inbox-message-dao.js +75 -0
  11. package/dist/inbox.d.ts +3 -0
  12. package/dist/inbox.d.ts.map +1 -0
  13. package/dist/inbox.js +2 -0
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/migrate.d.ts +12 -5
  18. package/dist/migrate.d.ts.map +1 -1
  19. package/dist/migrate.js +19 -25
  20. package/dist/queue-job-dao.d.ts.map +1 -1
  21. package/dist/schema/ddl.d.ts.map +1 -1
  22. package/dist/schema/ddl.js +4 -36
  23. package/dist/schema/drizzle-internals.d.ts +25 -0
  24. package/dist/schema/drizzle-internals.d.ts.map +1 -0
  25. package/dist/schema/drizzle-internals.js +47 -0
  26. package/dist/schema/inbox-messages.d.ts +212 -0
  27. package/dist/schema/inbox-messages.d.ts.map +1 -0
  28. package/dist/schema/inbox-messages.js +17 -0
  29. package/dist/schema/index.d.ts +1 -0
  30. package/dist/schema/index.d.ts.map +1 -1
  31. package/dist/schema/index.js +1 -0
  32. package/dist/schema/runtime.d.ts +1 -0
  33. package/dist/schema/runtime.d.ts.map +1 -1
  34. package/dist/schema/runtime.js +1 -0
  35. package/package.json +7 -2
  36. package/src/drizzle-builders.ts +98 -0
  37. package/src/embedded-migrations.ts +10 -0
  38. package/src/entity-dao.ts +2 -21
  39. package/src/inbox-message-dao.ts +91 -0
  40. package/src/inbox.ts +2 -0
  41. package/src/index.ts +3 -1
  42. package/src/migrate.ts +34 -22
  43. package/src/queue-job-dao.ts +14 -59
  44. package/src/schema/ddl.ts +4 -40
  45. package/src/schema/drizzle-internals.ts +55 -0
  46. package/src/schema/inbox-messages.ts +22 -0
  47. package/src/schema/index.ts +1 -0
  48. package/src/schema/runtime.ts +1 -0
package/src/migrate.ts CHANGED
@@ -5,13 +5,16 @@ import type { DbAdapter } from './adapter';
5
5
  import { embeddedMigrations } from './embedded-migrations';
6
6
 
7
7
  /**
8
- * Find project root by walking up looking for bun.lock.
9
- * @deprecated Use `FileSystem.getProjectRoot()` instead.
10
- * @internal only used for backward compatibility.
8
+ * Minimal structural logger for migration progress.
9
+ *
10
+ * Structurally compatible with `@gobing-ai/ts-infra`'s `Logger` so consumers can
11
+ * pass theirs directly — ts-db never imports ts-infra (keeps the package
12
+ * boundary). Defaults to `console` when absent.
11
13
  */
12
- /** @internal */
13
- export function findProjectRoot(_startDir: string): string {
14
- return process.cwd();
14
+ export interface MigrationLogger {
15
+ info(msg: string): void;
16
+ warn(msg: string): void;
17
+ error(msg: string): void;
15
18
  }
16
19
 
17
20
  /**
@@ -24,6 +27,8 @@ export interface MigrationOptions {
24
27
  migrationsTable?: string;
25
28
  /** File system abstraction for path resolution. */
26
29
  fs?: FileSystem;
30
+ /** Logger for migration progress. Default: `console`. */
31
+ logger?: MigrationLogger;
27
32
  }
28
33
 
29
34
  /**
@@ -56,7 +61,11 @@ function validateMigrationTableName(table: string): string {
56
61
  * Checks the journal table and applies only migrations that haven't run yet.
57
62
  * Each migration is executed with adapter.exec() for file-based or adapter.run() for journal tracking.
58
63
  */
59
- async function applyEmbeddedMigrations(adapter: DbAdapter, journalTable: string): Promise<void> {
64
+ async function applyEmbeddedMigrations(
65
+ adapter: DbAdapter,
66
+ journalTable: string,
67
+ logger: MigrationLogger,
68
+ ): Promise<void> {
60
69
  // Validate journal table name — this is an internal constant, never user input.
61
70
  if (!/^__[a-z_]+$/.test(journalTable)) {
62
71
  throw new Error(`Invalid migration journal table name: ${journalTable}`);
@@ -76,7 +85,7 @@ async function applyEmbeddedMigrations(adapter: DbAdapter, journalTable: string)
76
85
  for (const migration of embeddedMigrations) {
77
86
  if (appliedHashes.has(migration.hash)) continue;
78
87
 
79
- console.info(`Applying embedded migration: ${migration.tag}`);
88
+ logger.info(`Applying embedded migration: ${migration.tag}`);
80
89
 
81
90
  // Split on semicolons and execute each non-empty statement
82
91
  const statements = migration.sql
@@ -94,7 +103,7 @@ async function applyEmbeddedMigrations(adapter: DbAdapter, journalTable: string)
94
103
  }
95
104
 
96
105
  if (applied > 0) {
97
- console.info(`Applied ${applied} embedded migration(s)`);
106
+ logger.info(`Applied ${applied} embedded migration(s)`);
98
107
  }
99
108
  }
100
109
 
@@ -115,9 +124,10 @@ async function applyEmbeddedMigrations(adapter: DbAdapter, journalTable: string)
115
124
  * @param options - Optional migration folder and table name overrides.
116
125
  */
117
126
  export async function applyMigrations(adapter: DbAdapter, options?: MigrationOptions): Promise<void> {
127
+ const logger = options?.logger ?? console;
118
128
  const { BunSqliteAdapter } = await import('./adapters/bun-sqlite');
119
129
  if (!(adapter instanceof BunSqliteAdapter)) {
120
- console.warn('Skipping in-app migrations: only supported for bun-sqlite adapter');
130
+ logger.warn('Skipping in-app migrations: only supported for bun-sqlite adapter');
121
131
  return;
122
132
  }
123
133
 
@@ -125,39 +135,41 @@ export async function applyMigrations(adapter: DbAdapter, options?: MigrationOpt
125
135
 
126
136
  await ensureJournalTable(adapter, table);
127
137
 
128
- const folder = options?.migrationsFolder ?? resolve(findProjectRoot(process.cwd()), 'drizzle');
138
+ const folder = options?.migrationsFolder ?? resolve(process.cwd(), 'drizzle');
129
139
 
130
- // File-based migrations: attempt if drizzle/ folder is accessible.
131
- // Use FileSystem.exists when available, otherwise try and fall back to embedded.
140
+ // File-based migrations: attempt only if the drizzle/ folder is present.
141
+ // With an injected fs we get a definitive answer (await the Promise — a bare
142
+ // `fs.exists(...)` is always truthy and silently disables the check); without
143
+ // one we attempt optimistically and fall back on the migrator's own error.
132
144
  const fs = options?.fs;
133
- const tryFileBased = fs?.exists(folder) ?? true; // optimistic when no fs
145
+ const tryFileBased = fs ? await fs.exists(folder) : true;
134
146
 
135
147
  if (tryFileBased) {
136
148
  try {
137
149
  const { migrate: drizzleMigrate } = await import('drizzle-orm/bun-sqlite/migrator');
138
150
 
139
- console.info(`Applying database migrations from ${folder}`);
151
+ logger.info(`Applying database migrations from ${folder}`);
140
152
 
141
153
  await drizzleMigrate(adapter.getDrizzleDb(), {
142
154
  migrationsFolder: folder,
143
155
  ...(options?.migrationsTable !== undefined ? { migrationsTable: options.migrationsTable } : {}),
144
156
  });
145
- console.info('Database migrations complete');
157
+ logger.info('Database migrations complete');
146
158
  return;
147
159
  } catch (error) {
148
- // If folder doesn't exist, fall through to embedded migrations.
149
- // Any other error should be thrown.
160
+ // A missing/empty migrations folder is expected in compiled binaries
161
+ // fall through to embedded. Any other failure is real; rethrow it.
150
162
  const message = error instanceof Error ? error.message : String(error);
151
163
  if (message.includes('journal') || message.includes('ENOENT') || message.includes('meta')) {
152
- console.info(`File-based migrations unavailable, using embedded: ${message}`);
164
+ logger.info(`File-based migrations unavailable, using embedded: ${message}`);
153
165
  } else {
154
- console.error(`[MIGRATE] drizzleMigrate failed: ${message}`);
166
+ logger.error(`[MIGRATE] drizzleMigrate failed: ${message}`);
155
167
  throw error;
156
168
  }
157
169
  }
158
170
  }
159
171
 
160
172
  // Fallback: embedded migrations (for compiled binaries)
161
- console.info('No drizzle/ folder found — applying embedded migrations');
162
- await applyEmbeddedMigrations(adapter, table);
173
+ logger.info('No drizzle/ folder found — applying embedded migrations');
174
+ await applyEmbeddedMigrations(adapter, table, logger);
163
175
  }
@@ -1,60 +1,15 @@
1
1
  import { and, eq, inArray, sql } from 'drizzle-orm';
2
2
  import type { DbAdapter } from './adapter';
3
+ import type {
4
+ SelectOrderedLimitDb,
5
+ SelectProjectionDb,
6
+ UpdateChangesDb,
7
+ UpdateReturningDb,
8
+ UpdateVoidDb,
9
+ } from './drizzle-builders';
3
10
  import { EntityDao } from './entity-dao';
4
11
  import { queueJobs } from './schema/queue-jobs';
5
12
 
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
-
58
13
  /**
59
14
  * Aggregate queue statistics by job status.
60
15
  */
@@ -153,7 +108,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
153
108
  * Get aggregate job counts by status.
154
109
  */
155
110
  async getStats(): Promise<QueueStats> {
156
- const result = await (this.db as QueueSelectDb)
111
+ const result = await (this.db as SelectProjectionDb)
157
112
  .select({
158
113
  status: queueJobs.status,
159
114
  count: sql`count(*)`,
@@ -176,7 +131,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
176
131
  * Count jobs by status.
177
132
  */
178
133
  async countByStatus(status: string): Promise<number> {
179
- const result = await (this.db as QueueSelectDb)
134
+ const result = await (this.db as SelectProjectionDb)
180
135
  .select({ value: sql`count(*)` })
181
136
  .from(queueJobs)
182
137
  .where(sql`${queueJobs.status} = ${status}`);
@@ -190,7 +145,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
190
145
  async findPending(batchSize: number): Promise<QueueJobRecord[]> {
191
146
  const now = this.now();
192
147
 
193
- const result = await (this.db as QueueReadyDb)
148
+ const result = await (this.db as SelectOrderedLimitDb)
194
149
  .select()
195
150
  .from(queueJobs)
196
151
  .where(
@@ -214,7 +169,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
214
169
 
215
170
  const now = this.now();
216
171
 
217
- const result = await (this.db as QueueUpdateReturningDb)
172
+ const result = await (this.db as UpdateReturningDb)
218
173
  .update(queueJobs)
219
174
  .set({ status: 'processing', processingAt: now, updatedAt: now })
220
175
  .where(
@@ -241,7 +196,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
241
196
 
242
197
  const now = this.now();
243
198
 
244
- await (this.db as QueueUpdateVoidDb)
199
+ await (this.db as UpdateVoidDb)
245
200
  .update(queueJobs)
246
201
  .set({ status: 'processing', processingAt: now, updatedAt: now })
247
202
  .where(and(inArray(queueJobs.id, ids), eq(queueJobs.status, 'pending')));
@@ -288,7 +243,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
288
243
  async resetStuckJobs(visibilityTimeout: number): Promise<number> {
289
244
  const cutoff = this.now() - visibilityTimeout;
290
245
 
291
- const result = await (this.db as QueueUpdateChangesDb)
246
+ const result = await (this.db as UpdateChangesDb)
292
247
  .update(queueJobs)
293
248
  .set({ status: 'pending', processingAt: null, updatedAt: this.now() })
294
249
  .where(
@@ -307,7 +262,7 @@ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id
307
262
  async failExpiredJobs(): Promise<number> {
308
263
  const now = this.now();
309
264
 
310
- const result = await (this.db as QueueUpdateChangesDb)
265
+ const result = await (this.db as UpdateChangesDb)
311
266
  .update(queueJobs)
312
267
  .set({
313
268
  status: 'failed',
package/src/schema/ddl.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getTableConfig, type SQLiteTable } from 'drizzle-orm/sqlite-core';
2
+ import { getDrizzleTableName, sqlExpressionToText } from './drizzle-internals';
2
3
 
3
4
  /**
4
5
  * Quote an identifier for use in SQL (double-quoted for SQLite compatibility).
@@ -7,26 +8,6 @@ function quoteIdent(name: string): string {
7
8
  return `"${name.replace(/"/g, '""')}"`;
8
9
  }
9
10
 
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
11
  /**
31
12
  * Map a column default value to its SQL literal representation.
32
13
  *
@@ -48,25 +29,8 @@ function defaultToSql(value: unknown): string | undefined {
48
29
  if (typeof value === 'boolean') {
49
30
  return value ? '1' : '0';
50
31
  }
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]);
32
+ // drizzle-orm SQL expression (sql`...`) — rendered via the internals quarantine.
33
+ return sqlExpressionToText(value) ?? String(value);
70
34
  }
71
35
 
72
36
  /**
@@ -157,7 +121,7 @@ export function generateCreateTableSql(table: SQLiteTable): string {
157
121
  const ref = fk.reference();
158
122
  const localCols = ref.columns.map((c) => quoteIdent(c.name)).join(', ');
159
123
  const foreignCols = ref.foreignColumns.map((c) => quoteIdent(c.name)).join(', ');
160
- const foreignTableName = getTableName(ref.foreignTable as unknown as Record<string, symbol | unknown>);
124
+ const foreignTableName = getDrizzleTableName(ref.foreignTable);
161
125
 
162
126
  let constraint = `FOREIGN KEY (${localCols}) REFERENCES ${quoteIdent(foreignTableName)} (${foreignCols})`;
163
127
  if (fk.onDelete) {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Quarantine for the *unversioned* drizzle-orm internal shapes ts-db reaches into
3
+ * for DDL generation.
4
+ *
5
+ * Everything else in ts-db consumes drizzle's public API (`getTableConfig`,
6
+ * column accessors). These two helpers are the only places that touch private
7
+ * structure — the SQL-expression `queryChunks` array and the
8
+ * `Symbol.for('drizzle:Name')` table-name slot — neither of which is covered by
9
+ * drizzle's semver contract. Keeping both here means a drizzle bump that changes
10
+ * an internal shape breaks in ONE file with ONE focused test, instead of silently
11
+ * mis-generating DDL across `ddl.ts`. If this module starts failing after an
12
+ * upgrade, the fix lives here.
13
+ */
14
+
15
+ /** A drizzle SQL expression's internal chunk: a literal fragment or a bound param. */
16
+ interface SqlChunk {
17
+ value?: unknown;
18
+ input?: unknown;
19
+ }
20
+
21
+ /**
22
+ * Render a drizzle `sql\`...\`` expression to its literal SQL text by walking its
23
+ * internal `queryChunks`. Used to emit SQL-level column defaults
24
+ * (e.g. `DEFAULT (unixepoch())`) into generated DDL.
25
+ *
26
+ * Returns `undefined` when `value` is not a drizzle SQL expression with chunks,
27
+ * so callers can fall through to their primitive/`String()` handling.
28
+ */
29
+ export function sqlExpressionToText(value: unknown): string | undefined {
30
+ if (typeof value !== 'object' || value === null || !('queryChunks' in value)) {
31
+ return undefined;
32
+ }
33
+ const chunks = (value as Record<string, unknown>).queryChunks as SqlChunk[] | undefined;
34
+ if (!chunks) return undefined;
35
+
36
+ return chunks
37
+ .map((chunk) => {
38
+ // StringChunk — the literal SQL fragment
39
+ if ('value' in chunk && typeof chunk.value === 'string') {
40
+ return chunk.value;
41
+ }
42
+ // Param — use the input value if available
43
+ if ('input' in chunk && chunk.input !== undefined) {
44
+ return String(chunk.input);
45
+ }
46
+ return String(chunk.value ?? '?');
47
+ })
48
+ .join('');
49
+ }
50
+
51
+ /** Resolve a drizzle table object to its declared name (stored at a private symbol). */
52
+ export function getDrizzleTableName(table: object): string {
53
+ const nameSym = Symbol.for('drizzle:Name');
54
+ return String((table as Record<symbol, unknown>)[nameSym]);
55
+ }
@@ -0,0 +1,22 @@
1
+ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
2
+
3
+ /**
4
+ * Drizzle schema definition for durable inter-agent inbox messages.
5
+ */
6
+ export const inboxMessages = sqliteTable(
7
+ 'inbox_messages',
8
+ {
9
+ id: text('id').primaryKey(),
10
+ fromId: text('from_id'),
11
+ toId: text('to_id').notNull(),
12
+ body: text('body').notNull(),
13
+ status: text('status').notNull().default('queued'),
14
+ inReplyTo: text('in_reply_to'),
15
+ createdAt: integer('created_at').notNull(),
16
+ updatedAt: integer('updated_at').notNull(),
17
+ deliveredAt: integer('delivered_at'),
18
+ injectAttempts: integer('inject_attempts').notNull().default(0),
19
+ injectError: text('inject_error'),
20
+ },
21
+ (table) => [index('idx_inbox_messages_to_status').on(table.toId, table.status)],
22
+ );
@@ -2,4 +2,5 @@ export { index, integer, text } from 'drizzle-orm/sqlite-core';
2
2
  export * from './common';
3
3
  export { generateCreateTableSql } from './ddl';
4
4
  export { type DefinedTable, defineTable } from './define-table';
5
+ export { inboxMessages } from './inbox-messages';
5
6
  export { queueJobs } from './queue-jobs';
@@ -1 +1,2 @@
1
+ export { inboxMessages } from './inbox-messages';
1
2
  export { queueJobs } from './queue-jobs';