@gobing-ai/ts-db 0.1.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 (55) hide show
  1. package/README.md +326 -0
  2. package/dist/adapter.d.ts +86 -0
  3. package/dist/adapter.d.ts.map +1 -0
  4. package/dist/adapter.js +18 -0
  5. package/dist/adapters/bun-sqlite.d.ts +40 -0
  6. package/dist/adapters/bun-sqlite.d.ts.map +1 -0
  7. package/dist/adapters/bun-sqlite.js +70 -0
  8. package/dist/adapters/d1.d.ts +48 -0
  9. package/dist/adapters/d1.d.ts.map +1 -0
  10. package/dist/adapters/d1.js +45 -0
  11. package/dist/base-dao.d.ts +27 -0
  12. package/dist/base-dao.d.ts.map +1 -0
  13. package/dist/base-dao.js +34 -0
  14. package/dist/embedded-migrations.d.ts +15 -0
  15. package/dist/embedded-migrations.d.ts.map +1 -0
  16. package/dist/embedded-migrations.js +25 -0
  17. package/dist/entity-dao.d.ts +143 -0
  18. package/dist/entity-dao.d.ts.map +1 -0
  19. package/dist/entity-dao.js +218 -0
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +10 -0
  23. package/dist/index.js.map +9 -0
  24. package/dist/migrate.d.ts +38 -0
  25. package/dist/migrate.d.ts.map +1 -0
  26. package/dist/migrate.js +131 -0
  27. package/dist/queue-job-dao.d.ts +95 -0
  28. package/dist/queue-job-dao.d.ts.map +1 -0
  29. package/dist/queue-job-dao.js +211 -0
  30. package/dist/schema/common.d.ts +87 -0
  31. package/dist/schema/common.d.ts.map +1 -0
  32. package/dist/schema/common.js +76 -0
  33. package/dist/schema/index.d.ts +3 -0
  34. package/dist/schema/index.d.ts.map +1 -0
  35. package/dist/schema/index.js +2 -0
  36. package/dist/schema/queue-jobs.d.ts +225 -0
  37. package/dist/schema/queue-jobs.d.ts.map +1 -0
  38. package/dist/schema/queue-jobs.js +18 -0
  39. package/dist/span-context.d.ts +2 -0
  40. package/dist/span-context.d.ts.map +1 -0
  41. package/dist/span-context.js +0 -0
  42. package/package.json +47 -0
  43. package/src/adapter.ts +109 -0
  44. package/src/adapters/bun-sqlite.ts +108 -0
  45. package/src/adapters/d1.ts +76 -0
  46. package/src/base-dao.ts +37 -0
  47. package/src/embedded-migrations.ts +32 -0
  48. package/src/entity-dao.ts +290 -0
  49. package/src/index.ts +19 -0
  50. package/src/migrate.ts +163 -0
  51. package/src/queue-job-dao.ts +317 -0
  52. package/src/schema/common.ts +94 -0
  53. package/src/schema/index.ts +2 -0
  54. package/src/schema/queue-jobs.ts +23 -0
  55. package/src/span-context.ts +1 -0
@@ -0,0 +1,317 @@
1
+ import { and, eq, inArray, sql } from 'drizzle-orm';
2
+ import type { DbClient } from './adapter';
3
+ import { EntityDao } from './entity-dao';
4
+ import { queueJobs } from './schema/queue-jobs';
5
+
6
+ /**
7
+ * Aggregate queue statistics by job status.
8
+ */
9
+ export interface QueueStats {
10
+ pending: number;
11
+ processing: number;
12
+ completed: number;
13
+ failed: number;
14
+ }
15
+
16
+ /**
17
+ * Row type inferred from the queue_jobs Drizzle schema.
18
+ */
19
+ export type QueueJobRecord = typeof queueJobs.$inferSelect;
20
+
21
+ /**
22
+ * DAO for the queue_jobs table.
23
+ *
24
+ * Extends EntityDao for generic CRUD operations. Adds queue-specific
25
+ * methods for job lifecycle management (enqueue, process, retry, fail).
26
+ */
27
+ export class QueueJobDao extends EntityDao<typeof queueJobs, typeof queueJobs.id> {
28
+ constructor(db: DbClient) {
29
+ super(db, queueJobs, queueJobs.id, 'queue_jobs');
30
+ }
31
+
32
+ /**
33
+ * Enqueue a new job.
34
+ */
35
+ async enqueue(
36
+ type: string,
37
+ payload: unknown,
38
+ options?: { maxRetries?: number; delay?: number; ttlMs?: number },
39
+ ): Promise<string> {
40
+ const now = this.now();
41
+ const id = crypto.randomUUID();
42
+
43
+ await this.create({
44
+ id,
45
+ type,
46
+ payload: JSON.stringify(payload),
47
+ status: 'pending',
48
+ attempts: 0,
49
+ maxRetries: options?.maxRetries ?? 3,
50
+ nextRetryAt: options?.delay !== undefined ? now + options.delay : now,
51
+ ...(options?.ttlMs !== undefined ? { expiresAt: now + options.ttlMs } : {}),
52
+ });
53
+
54
+ return id;
55
+ }
56
+
57
+ /**
58
+ * Enqueue multiple jobs in a single batch.
59
+ */
60
+ async enqueueBatch(
61
+ jobs: Array<{ type: string; payload: unknown } & { maxRetries?: number; delay?: number; ttlMs?: number }>,
62
+ ): Promise<string[]> {
63
+ const now = this.now();
64
+ const ids: string[] = [];
65
+
66
+ const rows = jobs.map((job) => {
67
+ const id = crypto.randomUUID();
68
+ ids.push(id);
69
+
70
+ return {
71
+ id,
72
+ type: job.type,
73
+ payload: JSON.stringify(job.payload),
74
+ status: 'pending' as const,
75
+ attempts: 0,
76
+ maxRetries: job.maxRetries ?? 3,
77
+ nextRetryAt: job.delay !== undefined ? now + job.delay : now,
78
+ ...(job.ttlMs !== undefined ? { expiresAt: now + job.ttlMs } : {}),
79
+ };
80
+ });
81
+
82
+ if (rows.length > 0) {
83
+ await this.withTransaction(async (tx) => {
84
+ for (const row of rows) {
85
+ await tx.insert(queueJobs).values(row);
86
+ }
87
+ });
88
+ }
89
+
90
+ return ids;
91
+ }
92
+
93
+ /**
94
+ * Get a job by ID.
95
+ */
96
+ async getById(id: string): Promise<QueueJobRecord | undefined> {
97
+ return this.findBy(queueJobs.id, id);
98
+ }
99
+
100
+ /**
101
+ * Get aggregate job counts by status.
102
+ */
103
+ 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
+ )
109
+ .select({
110
+ status: queueJobs.status,
111
+ count: sql`count(*)`,
112
+ })
113
+ .from(queueJobs)
114
+ .groupBy(queueJobs.status);
115
+
116
+ const rows = result as { status: string; count: unknown }[];
117
+ const map = Object.fromEntries(rows.map((r) => [r.status, Number(r.count ?? 0)]));
118
+
119
+ return {
120
+ pending: map.pending ?? 0,
121
+ processing: map.processing ?? 0,
122
+ completed: map.completed ?? 0,
123
+ failed: map.failed ?? 0,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Count jobs by status.
129
+ */
130
+ 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
+ )
136
+ .select({ value: sql`count(*)` })
137
+ .from(queueJobs)
138
+ .where(sql`${queueJobs.status} = ${status}`);
139
+
140
+ return (result as { value: number }[])[0]?.value ?? 0;
141
+ }
142
+
143
+ /**
144
+ * Find pending jobs that are ready for processing (nextRetryAt <= now).
145
+ */
146
+ async findPending(batchSize: number): Promise<QueueJobRecord[]> {
147
+ const now = this.now();
148
+
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
+ )
160
+ .select()
161
+ .from(queueJobs)
162
+ .where(
163
+ sql`${queueJobs.status} = 'pending' AND (${queueJobs.nextRetryAt} IS NULL OR ${queueJobs.nextRetryAt} <= ${now})`,
164
+ )
165
+ .orderBy(queueJobs.createdAt)
166
+ .limit(batchSize);
167
+
168
+ return result as QueueJobRecord[];
169
+ }
170
+
171
+ /**
172
+ * Atomically claim ready pending jobs for processing.
173
+ *
174
+ * The update and selection happen in one SQLite statement so competing
175
+ * consumers only receive rows they actually transitioned to processing.
176
+ */
177
+ async claimReady(batchSize: number): Promise<QueueJobRecord[]> {
178
+ const limit = Math.floor(batchSize);
179
+ if (limit <= 0) return [];
180
+
181
+ const now = this.now();
182
+
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
+ )
194
+ .update(queueJobs)
195
+ .set({ status: 'processing', processingAt: now, updatedAt: now })
196
+ .where(
197
+ sql`${queueJobs.id} IN (
198
+ SELECT id
199
+ FROM ${queueJobs}
200
+ WHERE status = 'pending'
201
+ AND (next_retry_at IS NULL OR next_retry_at <= ${now})
202
+ ORDER BY created_at
203
+ LIMIT ${limit}
204
+ )
205
+ AND ${queueJobs.status} = 'pending'`,
206
+ )
207
+ .returning();
208
+
209
+ return result as QueueJobRecord[];
210
+ }
211
+
212
+ /**
213
+ * Mark jobs as processing.
214
+ */
215
+ async markProcessing(ids: string[]): Promise<void> {
216
+ if (ids.length === 0) return;
217
+
218
+ const now = this.now();
219
+
220
+ await (
221
+ this.db as unknown as {
222
+ update: (t: unknown) => { set: (v: unknown) => { where: (w: unknown) => Promise<unknown> } };
223
+ }
224
+ )
225
+ .update(queueJobs)
226
+ .set({ status: 'processing', processingAt: now, updatedAt: now })
227
+ .where(and(inArray(queueJobs.id, ids), eq(queueJobs.status, 'pending')));
228
+ }
229
+
230
+ /**
231
+ * Mark a job as completed.
232
+ */
233
+ async markCompleted(id: string): Promise<void> {
234
+ await this.update(id, {
235
+ status: 'completed',
236
+ processingAt: null,
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Mark a job as failed.
242
+ */
243
+ async markFailed(id: string, attempts: number, error: string): Promise<void> {
244
+ await this.update(id, {
245
+ status: 'failed',
246
+ attempts,
247
+ lastError: error,
248
+ processingAt: null,
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Reset a job to pending for retry with backoff.
254
+ */
255
+ async markForRetry(id: string, attempts: number, errorMessage: string, nextRetryAt: number): Promise<void> {
256
+ await this.update(id, {
257
+ status: 'pending',
258
+ attempts,
259
+ lastError: errorMessage,
260
+ nextRetryAt,
261
+ processingAt: null,
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Reset stuck processing jobs (processing beyond visibility timeout).
267
+ */
268
+ async resetStuckJobs(visibilityTimeout: number): Promise<number> {
269
+ const cutoff = this.now() - visibilityTimeout;
270
+
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
+ )
278
+ .update(queueJobs)
279
+ .set({ status: 'pending', processingAt: null, updatedAt: this.now() })
280
+ .where(
281
+ sql`${queueJobs.status} = 'processing' AND ${queueJobs.processingAt} IS NOT NULL AND ${queueJobs.processingAt} <= ${cutoff}`,
282
+ );
283
+
284
+ return (result as { changes: number }).changes;
285
+ }
286
+
287
+ /**
288
+ * Mark expired pending jobs as failed.
289
+ *
290
+ * Jobs where `expires_at IS NOT NULL AND expires_at <= now` are
291
+ * transitioned to `failed` with an expiry error message.
292
+ */
293
+ async failExpiredJobs(): Promise<number> {
294
+ const now = this.now();
295
+
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
+ )
303
+ .update(queueJobs)
304
+ .set({
305
+ status: 'failed',
306
+ lastError: 'Job expired — not processed before TTL deadline',
307
+ updatedAt: now,
308
+ attempts: sql`${queueJobs.attempts} + 1`,
309
+ processingAt: null,
310
+ })
311
+ .where(
312
+ sql`${queueJobs.status} = 'pending' AND ${queueJobs.expiresAt} IS NOT NULL AND ${queueJobs.expiresAt} <= ${now}`,
313
+ );
314
+
315
+ return (result as { changes: number }).changes;
316
+ }
317
+ }
@@ -0,0 +1,94 @@
1
+ import { integer } from 'drizzle-orm/sqlite-core';
2
+
3
+ /**
4
+ * Returns the current timestamp in milliseconds.
5
+ * Extracted for testability and V8 coverage tracking.
6
+ */
7
+ export function nowTimestamp(): number {
8
+ return Date.now();
9
+ }
10
+
11
+ /**
12
+ * Standard columns shared across all entity tables.
13
+ *
14
+ * Uses plain `integer` (returns `number`) to match the existing codebase
15
+ * convention where `nowMs()` returns `number` and all timestamp comparisons
16
+ * use numeric operators.
17
+ *
18
+ * Usage in schema definitions:
19
+ * ```ts
20
+ * import { standardColumns } from './common';
21
+ *
22
+ * export const myTable = sqliteTable('my_table', {
23
+ * id: text('id').primaryKey(),
24
+ * name: text('name').notNull(),
25
+ * ...standardColumns,
26
+ * });
27
+ * ```
28
+ */
29
+ export function buildStandardColumns() {
30
+ return {
31
+ createdAt: integer('created_at').notNull().$defaultFn(nowTimestamp).default(0),
32
+ updatedAt: integer('updated_at').notNull().$defaultFn(nowTimestamp).default(0),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Standard columns (createdAt, updatedAt) for use in Drizzle table definitions via spread.
38
+ */
39
+ export const standardColumns = buildStandardColumns();
40
+
41
+ /**
42
+ * Standard columns with soft-delete support.
43
+ *
44
+ * Adds an `inUsed` column (1 = active, 0 = soft-deleted).
45
+ * EntityDao automatically filters by `inUsed = 1` when the table has this column.
46
+ */
47
+ export function buildStandardColumnsWithSoftDelete() {
48
+ return {
49
+ ...buildStandardColumns(),
50
+ inUsed: integer('in_used').notNull().default(1),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Standard columns with soft-delete `inUsed` flag for use in Drizzle table definitions via spread.
56
+ */
57
+ export const standardColumnsWithSoftDelete = buildStandardColumnsWithSoftDelete();
58
+
59
+ /**
60
+ * Type helper for tables that use standard columns.
61
+ * Provides the `inUsed`, `updatedAt`, `createdAt` column types.
62
+ */
63
+ export type StandardColumns = typeof standardColumns;
64
+
65
+ /**
66
+ * Type helper for tables that use standard columns with soft delete.
67
+ */
68
+ export type StandardColumnsWithSoftDelete = typeof standardColumnsWithSoftDelete;
69
+
70
+ /**
71
+ * Build append-only columns (createdAt only, no updatedAt).
72
+ *
73
+ * Enforces D-013 at the schema level: tables using this helper
74
+ * cannot support update operations because there is no `updatedAt`
75
+ * column to track mutations.
76
+ */
77
+ export function buildAppendOnlyColumns() {
78
+ return {
79
+ createdAt: integer('created_at').notNull().$defaultFn(nowTimestamp).default(0),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Append-only columns for Drizzle table definitions via spread.
85
+ *
86
+ * Usage:
87
+ * ```ts
88
+ * export const runEvents = sqliteTable('run_event', {
89
+ * id: text('id').primaryKey(),
90
+ * ...appendOnlyColumns,
91
+ * });
92
+ * ```
93
+ */
94
+ export const appendOnlyColumns = buildAppendOnlyColumns();
@@ -0,0 +1,2 @@
1
+ export * from './common';
2
+ export { queueJobs } from './queue-jobs';
@@ -0,0 +1,23 @@
1
+ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
2
+ import { standardColumns } from './common';
3
+
4
+ /**
5
+ * Drizzle schema definition for the queue_jobs table.
6
+ */
7
+ export const queueJobs = sqliteTable(
8
+ 'queue_jobs',
9
+ {
10
+ id: text('id').primaryKey(),
11
+ type: text('type').notNull(),
12
+ payload: text('payload').notNull(),
13
+ status: text('status').notNull().default('pending'),
14
+ attempts: integer('attempts').notNull().default(0),
15
+ maxRetries: integer('max_retries').notNull().default(3),
16
+ ...standardColumns,
17
+ nextRetryAt: integer('next_retry_at'),
18
+ lastError: text('last_error'),
19
+ processingAt: integer('processing_at'),
20
+ expiresAt: integer('expires_at'),
21
+ },
22
+ (table) => [index('queue_jobs_ready_idx').on(table.status, table.nextRetryAt, table.createdAt)],
23
+ );
@@ -0,0 +1 @@
1
+ export type { SpanContext } from '@gobing-ai/ts-runtime';