@mevdragon/vidfarm-devcli 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.
package/dist/src/db.js ADDED
@@ -0,0 +1,588 @@
1
+ import Database from "better-sqlite3";
2
+ import { config } from "./config.js";
3
+ import { parseJson, stringifyJson } from "./lib/json.js";
4
+ import { nowIso } from "./lib/time.js";
5
+ const db = new Database(config.VIDFARM_DB_PATH);
6
+ db.pragma("journal_mode = WAL");
7
+ db.pragma("foreign_keys = ON");
8
+ db.exec(`
9
+ create table if not exists customers (
10
+ id text primary key,
11
+ email text not null unique,
12
+ name text,
13
+ default_webhook_url text,
14
+ created_at text not null,
15
+ updated_at text not null
16
+ );
17
+
18
+ create table if not exists api_keys (
19
+ id text primary key,
20
+ customer_id text not null references customers(id) on delete cascade,
21
+ key_hash text not null unique,
22
+ label text,
23
+ status text not null,
24
+ last_used_at text,
25
+ created_at text not null,
26
+ updated_at text not null
27
+ );
28
+
29
+ create table if not exists otp_challenges (
30
+ id text primary key,
31
+ email text not null,
32
+ code_hash text not null,
33
+ expires_at text not null,
34
+ consumed_at text,
35
+ created_at text not null
36
+ );
37
+
38
+ create table if not exists customer_provider_keys (
39
+ id text primary key,
40
+ customer_id text not null references customers(id) on delete cascade,
41
+ provider text not null,
42
+ label text,
43
+ encrypted_secret text not null,
44
+ status text not null,
45
+ weight integer not null default 1,
46
+ last_used_at text,
47
+ cooldown_until text,
48
+ disabled_reason text,
49
+ created_at text not null,
50
+ updated_at text not null
51
+ );
52
+
53
+ create table if not exists provider_key_leases (
54
+ key_id text primary key references customer_provider_keys(id) on delete cascade,
55
+ lease_token text not null,
56
+ worker_id text not null,
57
+ job_id text not null,
58
+ leased_at text not null,
59
+ expires_at text not null
60
+ );
61
+
62
+ create table if not exists provider_key_usage_events (
63
+ id text primary key,
64
+ key_id text not null references customer_provider_keys(id) on delete cascade,
65
+ job_id text not null,
66
+ provider text not null,
67
+ model text,
68
+ event_type text not null,
69
+ input_tokens integer,
70
+ output_tokens integer,
71
+ cost_usd real,
72
+ metadata text,
73
+ created_at text not null
74
+ );
75
+
76
+ create table if not exists template_configs (
77
+ id text primary key,
78
+ customer_id text not null references customers(id) on delete cascade,
79
+ template_id text not null,
80
+ config_json text not null,
81
+ created_at text not null,
82
+ updated_at text not null,
83
+ unique(customer_id, template_id)
84
+ );
85
+
86
+ create table if not exists jobs (
87
+ id text primary key,
88
+ template_id text not null,
89
+ operation_name text not null,
90
+ workflow_name text not null,
91
+ tracer text not null,
92
+ status text not null,
93
+ customer_id text not null references customers(id) on delete cascade,
94
+ payload_json text not null,
95
+ result_json text,
96
+ error_json text,
97
+ progress real not null default 0,
98
+ webhook_url text,
99
+ parent_job_id text references jobs(id) on delete set null,
100
+ priority integer not null default 100,
101
+ attempt_count integer not null default 0,
102
+ max_attempts integer not null default 6,
103
+ run_after text not null,
104
+ created_at text not null,
105
+ updated_at text not null,
106
+ started_at text,
107
+ completed_at text
108
+ );
109
+
110
+ create index if not exists idx_jobs_runnable on jobs(status, run_after, priority, created_at);
111
+
112
+ create table if not exists job_events (
113
+ id text primary key,
114
+ job_id text not null references jobs(id) on delete cascade,
115
+ level text not null,
116
+ message text not null,
117
+ metadata_json text not null,
118
+ progress real,
119
+ artifact_key text,
120
+ created_at text not null
121
+ );
122
+
123
+ create index if not exists idx_job_events_job_time on job_events(job_id, created_at);
124
+
125
+ create table if not exists artifacts (
126
+ id text primary key,
127
+ job_id text not null references jobs(id) on delete cascade,
128
+ customer_id text not null references customers(id) on delete cascade,
129
+ template_id text not null,
130
+ kind text not null,
131
+ storage_key text not null,
132
+ public_url text,
133
+ metadata_json text not null,
134
+ created_at text not null
135
+ );
136
+
137
+ create table if not exists billing_events (
138
+ id text primary key,
139
+ customer_id text not null references customers(id) on delete cascade,
140
+ job_id text not null references jobs(id) on delete cascade,
141
+ template_id text not null,
142
+ type text not null,
143
+ cost_usd real not null,
144
+ charge_usd real not null,
145
+ metadata_json text not null,
146
+ created_at text not null
147
+ );
148
+
149
+ create table if not exists webhook_deliveries (
150
+ id text primary key,
151
+ job_id text not null references jobs(id) on delete cascade,
152
+ customer_id text not null references customers(id) on delete cascade,
153
+ event_type text not null,
154
+ destination_url text not null,
155
+ payload_json text not null,
156
+ status text not null,
157
+ attempt_count integer not null default 0,
158
+ next_attempt_at text not null,
159
+ last_error text,
160
+ created_at text not null,
161
+ updated_at text not null
162
+ );
163
+ `);
164
+ function mapJob(row) {
165
+ return {
166
+ id: String(row.id),
167
+ templateId: String(row.template_id),
168
+ operationName: String(row.operation_name),
169
+ workflowName: String(row.workflow_name),
170
+ tracer: String(row.tracer),
171
+ status: row.status,
172
+ customerId: String(row.customer_id),
173
+ payload: parseJson(row.payload_json, {}),
174
+ result: parseJson(row.result_json, null),
175
+ error: parseJson(row.error_json, null),
176
+ progress: Number(row.progress),
177
+ webhookUrl: row.webhook_url ? String(row.webhook_url) : null,
178
+ parentJobId: row.parent_job_id ? String(row.parent_job_id) : null,
179
+ priority: Number(row.priority),
180
+ attemptCount: Number(row.attempt_count),
181
+ maxAttempts: Number(row.max_attempts),
182
+ runAfter: String(row.run_after),
183
+ createdAt: String(row.created_at),
184
+ updatedAt: String(row.updated_at),
185
+ startedAt: row.started_at ? String(row.started_at) : null,
186
+ completedAt: row.completed_at ? String(row.completed_at) : null
187
+ };
188
+ }
189
+ export const database = {
190
+ raw: db,
191
+ upsertCustomer(input) {
192
+ const timestamp = nowIso();
193
+ db.prepare(`
194
+ insert into customers (id, email, name, default_webhook_url, created_at, updated_at)
195
+ values (@id, @email, @name, @default_webhook_url, @created_at, @updated_at)
196
+ on conflict(id) do update set
197
+ email = excluded.email,
198
+ name = excluded.name,
199
+ default_webhook_url = excluded.default_webhook_url,
200
+ updated_at = excluded.updated_at
201
+ `).run({
202
+ id: input.id,
203
+ email: input.email,
204
+ name: input.name ?? null,
205
+ default_webhook_url: input.defaultWebhookUrl ?? null,
206
+ created_at: timestamp,
207
+ updated_at: timestamp
208
+ });
209
+ return this.getCustomerById(input.id);
210
+ },
211
+ getCustomerById(id) {
212
+ const row = db.prepare(`select * from customers where id = ?`).get(id);
213
+ if (!row) {
214
+ return null;
215
+ }
216
+ return {
217
+ id: String(row.id),
218
+ email: String(row.email),
219
+ name: row.name ? String(row.name) : null,
220
+ defaultWebhookUrl: row.default_webhook_url ? String(row.default_webhook_url) : null
221
+ };
222
+ },
223
+ getCustomerByEmail(email) {
224
+ const row = db.prepare(`select * from customers where lower(email) = lower(?)`).get(email);
225
+ return row ? this.getCustomerById(String(row.id)) : null;
226
+ },
227
+ listProviderKeys(customerId) {
228
+ return db.prepare(`
229
+ select id, provider, label, status, weight, last_used_at, cooldown_until, disabled_reason, created_at, updated_at
230
+ from customer_provider_keys
231
+ where customer_id = ?
232
+ order by provider asc, created_at desc
233
+ `).all(customerId);
234
+ },
235
+ insertOtpChallenge(record) {
236
+ db.prepare(`
237
+ insert into otp_challenges (id, email, code_hash, expires_at, created_at)
238
+ values (@id, @email, @code_hash, @expires_at, @created_at)
239
+ `).run({
240
+ id: record.id,
241
+ email: record.email,
242
+ code_hash: record.codeHash,
243
+ expires_at: record.expiresAt,
244
+ created_at: nowIso()
245
+ });
246
+ },
247
+ findLatestOtpChallenge(email) {
248
+ return db.prepare(`
249
+ select * from otp_challenges
250
+ where lower(email) = lower(?)
251
+ order by created_at desc
252
+ limit 1
253
+ `).get(email);
254
+ },
255
+ consumeOtpChallenge(id) {
256
+ db.prepare(`update otp_challenges set consumed_at = ? where id = ?`).run(nowIso(), id);
257
+ },
258
+ insertApiKey(record) {
259
+ const timestamp = nowIso();
260
+ db.prepare(`
261
+ insert into api_keys (id, customer_id, key_hash, label, status, created_at, updated_at)
262
+ values (@id, @customer_id, @key_hash, @label, 'active', @created_at, @updated_at)
263
+ `).run({
264
+ id: record.id,
265
+ customer_id: record.customerId,
266
+ key_hash: record.keyHash,
267
+ label: record.label,
268
+ created_at: timestamp,
269
+ updated_at: timestamp
270
+ });
271
+ },
272
+ findApiKeyByHash(keyHash) {
273
+ return db.prepare(`
274
+ select a.*, c.email, c.name, c.default_webhook_url
275
+ from api_keys a
276
+ join customers c on c.id = a.customer_id
277
+ where a.key_hash = ? and a.status = 'active'
278
+ `).get(keyHash);
279
+ },
280
+ touchApiKey(id) {
281
+ db.prepare(`update api_keys set last_used_at = ?, updated_at = ? where id = ?`).run(nowIso(), nowIso(), id);
282
+ },
283
+ upsertTemplateConfig(record) {
284
+ const timestamp = nowIso();
285
+ db.prepare(`
286
+ insert into template_configs (id, customer_id, template_id, config_json, created_at, updated_at)
287
+ values (@id, @customer_id, @template_id, @config_json, @created_at, @updated_at)
288
+ on conflict(customer_id, template_id) do update set
289
+ config_json = excluded.config_json,
290
+ updated_at = excluded.updated_at
291
+ `).run({
292
+ id: record.id,
293
+ customer_id: record.customerId,
294
+ template_id: record.templateId,
295
+ config_json: stringifyJson(record.config),
296
+ created_at: timestamp,
297
+ updated_at: timestamp
298
+ });
299
+ },
300
+ getTemplateConfig(customerId, templateId) {
301
+ const row = db.prepare(`
302
+ select config_json from template_configs
303
+ where customer_id = ? and template_id = ?
304
+ `).get(customerId, templateId);
305
+ return parseJson(row?.config_json, {});
306
+ },
307
+ createJob(record) {
308
+ const timestamp = nowIso();
309
+ db.prepare(`
310
+ insert into jobs (
311
+ id, template_id, operation_name, workflow_name, tracer, status, customer_id,
312
+ payload_json, result_json, error_json, progress, webhook_url, parent_job_id,
313
+ priority, attempt_count, max_attempts, run_after, created_at, updated_at, started_at, completed_at
314
+ )
315
+ values (
316
+ @id, @template_id, @operation_name, @workflow_name, @tracer, @status, @customer_id,
317
+ @payload_json, @result_json, @error_json, @progress, @webhook_url, @parent_job_id,
318
+ @priority, @attempt_count, @max_attempts, @run_after, @created_at, @updated_at, @started_at, @completed_at
319
+ )
320
+ `).run({
321
+ id: record.id,
322
+ template_id: record.templateId,
323
+ operation_name: record.operationName,
324
+ workflow_name: record.workflowName,
325
+ tracer: record.tracer,
326
+ status: record.status,
327
+ customer_id: record.customerId,
328
+ payload_json: stringifyJson(record.payload),
329
+ result_json: stringifyJson(record.result ?? null),
330
+ error_json: stringifyJson(record.error ?? null),
331
+ progress: record.progress,
332
+ webhook_url: record.webhookUrl,
333
+ parent_job_id: record.parentJobId,
334
+ priority: record.priority,
335
+ attempt_count: record.attemptCount,
336
+ max_attempts: record.maxAttempts,
337
+ run_after: record.runAfter,
338
+ created_at: timestamp,
339
+ updated_at: timestamp,
340
+ started_at: record.startedAt ?? null,
341
+ completed_at: record.completedAt ?? null
342
+ });
343
+ return this.getJob(record.id);
344
+ },
345
+ getJob(id) {
346
+ const row = db.prepare(`select * from jobs where id = ?`).get(id);
347
+ return row ? mapJob(row) : null;
348
+ },
349
+ listRunnableJobs(limit) {
350
+ const rows = db.prepare(`
351
+ select * from jobs
352
+ where status = 'queued' and run_after <= ?
353
+ order by priority asc, created_at asc
354
+ limit ?
355
+ `).all(nowIso(), limit);
356
+ return rows.map(mapJob);
357
+ },
358
+ markJobRunning(id) {
359
+ const timestamp = nowIso();
360
+ db.prepare(`
361
+ update jobs
362
+ set status = 'running', started_at = coalesce(started_at, ?), updated_at = ?, attempt_count = attempt_count + 1
363
+ where id = ?
364
+ `).run(timestamp, timestamp, id);
365
+ },
366
+ updateJobStatus(input) {
367
+ const current = this.getJob(input.id);
368
+ db.prepare(`
369
+ update jobs
370
+ set status = @status,
371
+ progress = @progress,
372
+ result_json = @result_json,
373
+ error_json = @error_json,
374
+ run_after = @run_after,
375
+ completed_at = @completed_at,
376
+ updated_at = @updated_at
377
+ where id = @id
378
+ `).run({
379
+ id: input.id,
380
+ status: input.status,
381
+ progress: input.progress ?? current?.progress ?? 0,
382
+ result_json: input.result === undefined ? stringifyJson(current?.result ?? null) : stringifyJson(input.result),
383
+ error_json: input.error === undefined ? stringifyJson(current?.error ?? null) : stringifyJson(input.error),
384
+ run_after: input.runAfter ?? current?.runAfter ?? nowIso(),
385
+ completed_at: input.completedAt ?? current?.completedAt ?? null,
386
+ updated_at: nowIso()
387
+ });
388
+ },
389
+ listJobsForCustomer(customerId, templateId) {
390
+ const rows = templateId
391
+ ? db.prepare(`select * from jobs where customer_id = ? and template_id = ? order by created_at desc limit 100`).all(customerId, templateId)
392
+ : db.prepare(`select * from jobs where customer_id = ? order by created_at desc limit 100`).all(customerId);
393
+ return rows.map(mapJob);
394
+ },
395
+ addJobEvent(event) {
396
+ db.prepare(`
397
+ insert into job_events (id, job_id, level, message, metadata_json, progress, artifact_key, created_at)
398
+ values (@id, @job_id, @level, @message, @metadata_json, @progress, @artifact_key, @created_at)
399
+ `).run({
400
+ id: event.id,
401
+ job_id: event.jobId,
402
+ level: event.level,
403
+ message: event.message,
404
+ metadata_json: stringifyJson(event.metadata),
405
+ progress: event.progress,
406
+ artifact_key: event.artifactKey,
407
+ created_at: nowIso()
408
+ });
409
+ },
410
+ getJobEvents(jobId, since, limit = 100) {
411
+ const rows = since
412
+ ? db.prepare(`
413
+ select * from job_events where job_id = ? and created_at > ? order by created_at asc limit ?
414
+ `).all(jobId, since, limit)
415
+ : db.prepare(`
416
+ select * from job_events where job_id = ? order by created_at asc limit ?
417
+ `).all(jobId, limit);
418
+ return rows.map((row) => ({
419
+ id: String(row.id),
420
+ jobId: String(row.job_id),
421
+ level: row.level,
422
+ message: String(row.message),
423
+ metadata: parseJson(row.metadata_json, {}),
424
+ progress: row.progress === null ? null : Number(row.progress),
425
+ artifactKey: row.artifact_key ? String(row.artifact_key) : null,
426
+ createdAt: String(row.created_at)
427
+ }));
428
+ },
429
+ insertArtifact(record) {
430
+ db.prepare(`
431
+ insert into artifacts (id, job_id, customer_id, template_id, kind, storage_key, public_url, metadata_json, created_at)
432
+ values (@id, @job_id, @customer_id, @template_id, @kind, @storage_key, @public_url, @metadata_json, @created_at)
433
+ `).run({
434
+ id: record.id,
435
+ job_id: record.jobId,
436
+ customer_id: record.customerId,
437
+ template_id: record.templateId,
438
+ kind: record.kind,
439
+ storage_key: record.storageKey,
440
+ public_url: record.publicUrl ?? null,
441
+ metadata_json: stringifyJson(record.metadata ?? {}),
442
+ created_at: nowIso()
443
+ });
444
+ },
445
+ insertBillingEvent(record) {
446
+ db.prepare(`
447
+ insert into billing_events (id, customer_id, job_id, template_id, type, cost_usd, charge_usd, metadata_json, created_at)
448
+ values (@id, @customer_id, @job_id, @template_id, @type, @cost_usd, @charge_usd, @metadata_json, @created_at)
449
+ `).run({
450
+ id: record.id,
451
+ customer_id: record.customerId,
452
+ job_id: record.jobId,
453
+ template_id: record.templateId,
454
+ type: record.type,
455
+ cost_usd: record.costUsd,
456
+ charge_usd: record.chargeUsd,
457
+ metadata_json: stringifyJson(record.metadata ?? {}),
458
+ created_at: nowIso()
459
+ });
460
+ },
461
+ queueWebhookDelivery(record) {
462
+ const timestamp = nowIso();
463
+ db.prepare(`
464
+ insert into webhook_deliveries (
465
+ id, job_id, customer_id, event_type, destination_url, payload_json, status,
466
+ attempt_count, next_attempt_at, created_at, updated_at
467
+ ) values (
468
+ @id, @job_id, @customer_id, @event_type, @destination_url, @payload_json, 'pending',
469
+ 0, @next_attempt_at, @created_at, @updated_at
470
+ )
471
+ `).run({
472
+ id: record.id,
473
+ job_id: record.jobId,
474
+ customer_id: record.customerId,
475
+ event_type: record.eventType,
476
+ destination_url: record.destinationUrl,
477
+ payload_json: stringifyJson(record.payload),
478
+ next_attempt_at: timestamp,
479
+ created_at: timestamp,
480
+ updated_at: timestamp
481
+ });
482
+ },
483
+ listPendingWebhookDeliveries(limit) {
484
+ return db.prepare(`
485
+ select * from webhook_deliveries
486
+ where status in ('pending', 'retrying') and next_attempt_at <= ?
487
+ order by next_attempt_at asc
488
+ limit ?
489
+ `).all(nowIso(), limit);
490
+ },
491
+ markWebhookDelivery(record) {
492
+ db.prepare(`
493
+ update webhook_deliveries
494
+ set status = @status,
495
+ attempt_count = @attempt_count,
496
+ next_attempt_at = coalesce(@next_attempt_at, next_attempt_at),
497
+ last_error = @last_error,
498
+ updated_at = @updated_at
499
+ where id = @id
500
+ `).run({
501
+ id: record.id,
502
+ status: record.status,
503
+ attempt_count: record.attemptCount,
504
+ next_attempt_at: record.nextAttemptAt ?? null,
505
+ last_error: record.lastError ?? null,
506
+ updated_at: nowIso()
507
+ });
508
+ },
509
+ acquireProviderKeyLease(input) {
510
+ const transaction = db.transaction(() => {
511
+ db.prepare(`delete from provider_key_leases where expires_at <= ?`).run(nowIso());
512
+ const row = db.prepare(`
513
+ select k.id, k.encrypted_secret
514
+ from customer_provider_keys k
515
+ left join provider_key_leases l on l.key_id = k.id and l.expires_at > ?
516
+ where k.customer_id = ?
517
+ and k.provider = ?
518
+ and k.status = 'active'
519
+ and (k.cooldown_until is null or k.cooldown_until <= ?)
520
+ and l.key_id is null
521
+ order by case when k.last_used_at is null then 0 else 1 end asc, k.last_used_at asc, k.weight desc
522
+ limit 1
523
+ `).get(nowIso(), input.customerId, input.provider, nowIso());
524
+ if (!row) {
525
+ return null;
526
+ }
527
+ db.prepare(`
528
+ insert into provider_key_leases (key_id, lease_token, worker_id, job_id, leased_at, expires_at)
529
+ values (?, ?, ?, ?, ?, ?)
530
+ `).run(row.id, input.leaseToken, input.workerId, input.jobId, nowIso(), input.expiresAt);
531
+ return { keyId: row.id, encryptedSecret: row.encrypted_secret };
532
+ });
533
+ return transaction();
534
+ },
535
+ releaseProviderKeyLease(input) {
536
+ db.prepare(`delete from provider_key_leases where key_id = ? and lease_token = ?`).run(input.keyId, input.leaseToken);
537
+ },
538
+ recordProviderKeyUsage(input) {
539
+ db.prepare(`
540
+ insert into provider_key_usage_events (
541
+ id, key_id, job_id, provider, model, event_type, input_tokens, output_tokens, cost_usd, metadata, created_at
542
+ ) values (
543
+ @id, @key_id, @job_id, @provider, @model, @event_type, @input_tokens, @output_tokens, @cost_usd, @metadata, @created_at
544
+ )
545
+ `).run({
546
+ id: input.id,
547
+ key_id: input.keyId,
548
+ job_id: input.jobId,
549
+ provider: input.provider,
550
+ model: input.model ?? null,
551
+ event_type: input.eventType,
552
+ input_tokens: input.inputTokens ?? null,
553
+ output_tokens: input.outputTokens ?? null,
554
+ cost_usd: input.costUsd ?? null,
555
+ metadata: stringifyJson(input.metadata ?? {}),
556
+ created_at: nowIso()
557
+ });
558
+ },
559
+ touchProviderKey(keyId) {
560
+ db.prepare(`update customer_provider_keys set last_used_at = ?, updated_at = ? where id = ?`).run(nowIso(), nowIso(), keyId);
561
+ },
562
+ setProviderKeyCooldown(keyId, cooldownUntil, status = "active", disabledReason = null) {
563
+ db.prepare(`
564
+ update customer_provider_keys
565
+ set cooldown_until = ?, status = ?, disabled_reason = ?, updated_at = ?
566
+ where id = ?
567
+ `).run(cooldownUntil, status, disabledReason, nowIso(), keyId);
568
+ },
569
+ createProviderKey(record) {
570
+ const timestamp = nowIso();
571
+ db.prepare(`
572
+ insert into customer_provider_keys (
573
+ id, customer_id, provider, label, encrypted_secret, status, weight, created_at, updated_at
574
+ ) values (
575
+ @id, @customer_id, @provider, @label, @encrypted_secret, 'active', @weight, @created_at, @updated_at
576
+ )
577
+ `).run({
578
+ id: record.id,
579
+ customer_id: record.customerId,
580
+ provider: record.provider,
581
+ label: record.label,
582
+ encrypted_secret: record.encryptedSecret,
583
+ weight: record.weight,
584
+ created_at: timestamp,
585
+ updated_at: timestamp
586
+ });
587
+ }
588
+ };