@c4t4/heyamigo 0.8.15 → 0.9.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.
@@ -0,0 +1,185 @@
1
+ // Outbound queue helpers. Producers (chat workers, async workers,
2
+ // crons, external triggers) call enqueueOutbound. The sender worker
3
+ // drains via claimNextOutbound + markOutbound{Done,Failed,Retry}.
4
+ //
5
+ // All mutations preserve the claimed_by safety check on completion:
6
+ // only the holder of a claim can mark it done/failed. A slow worker
7
+ // that comes back after TTL-reclaim will harmlessly no-op.
8
+ import { and, asc, eq, isNull, lte, or, sql } from 'drizzle-orm';
9
+ import { getDb } from '../db/index.js';
10
+ import { outbound } from '../db/schema.js';
11
+ // Insert a row, or no-op when the same idempotency_key already exists.
12
+ // Returns the row either way so callers can log/observe.
13
+ export function enqueueOutbound(input) {
14
+ const db = getDb();
15
+ const now = Math.floor(Date.now() / 1000);
16
+ // Idempotency: look up first. SQLite has no INSERT ... ON CONFLICT
17
+ // returning the previous row, so we serve it in two queries inside
18
+ // a transaction.
19
+ if (input.idempotencyKey) {
20
+ const found = db
21
+ .select()
22
+ .from(outbound)
23
+ .where(eq(outbound.idempotencyKey, input.idempotencyKey))
24
+ .get();
25
+ if (found)
26
+ return { inserted: false, row: found };
27
+ }
28
+ const inserted = db
29
+ .insert(outbound)
30
+ .values({
31
+ address: input.address,
32
+ kind: input.kind,
33
+ text: input.text ?? null,
34
+ mediaPath: input.mediaPath ?? null,
35
+ mediaMime: input.mediaMime ?? null,
36
+ mediaBytes: input.mediaBytes ?? null,
37
+ quoteMsgId: input.quoteMsgId ?? null,
38
+ idempotencyKey: input.idempotencyKey ?? null,
39
+ status: 'pending',
40
+ attempts: 0,
41
+ nextAttemptAt: null,
42
+ lastError: null,
43
+ claimedBy: null,
44
+ claimedAt: null,
45
+ createdAt: now,
46
+ updatedAt: now,
47
+ })
48
+ .returning()
49
+ .get();
50
+ return { inserted: true, row: inserted };
51
+ }
52
+ // Atomic claim. Returns the row or null if nothing's ready.
53
+ // Reserves rows whose nextAttemptAt is null (ready immediately) OR in
54
+ // the past (backoff elapsed). Single-statement so two workers can't
55
+ // claim the same row.
56
+ export function claimNextOutbound(workerId) {
57
+ const db = getDb();
58
+ const now = Math.floor(Date.now() / 1000);
59
+ // SQLite supports UPDATE ... RETURNING since 3.35.
60
+ return db.transaction((tx) => {
61
+ const target = tx
62
+ .select({ id: outbound.id })
63
+ .from(outbound)
64
+ .where(and(eq(outbound.status, 'pending'), or(isNull(outbound.nextAttemptAt), lte(outbound.nextAttemptAt, now))))
65
+ .orderBy(asc(outbound.id))
66
+ .limit(1)
67
+ .get();
68
+ if (!target)
69
+ return null;
70
+ const claimed = tx
71
+ .update(outbound)
72
+ .set({
73
+ status: 'sending',
74
+ claimedBy: workerId,
75
+ claimedAt: now,
76
+ updatedAt: now,
77
+ })
78
+ .where(and(eq(outbound.id, target.id), eq(outbound.status, 'pending')))
79
+ .returning()
80
+ .get();
81
+ return claimed ?? null;
82
+ });
83
+ }
84
+ // Mark done — succeeds only when the row is still owned by the caller.
85
+ // Returns whether the update actually applied.
86
+ export function markOutboundDone(id, workerId) {
87
+ const db = getDb();
88
+ const now = Math.floor(Date.now() / 1000);
89
+ const result = db
90
+ .update(outbound)
91
+ .set({ status: 'done', updatedAt: now })
92
+ .where(and(eq(outbound.id, id), eq(outbound.status, 'sending'), eq(outbound.claimedBy, workerId)))
93
+ .returning({ id: outbound.id })
94
+ .all();
95
+ return result.length > 0;
96
+ }
97
+ // Backoff schedule: 1s, 5s, 30s, 2min, give up.
98
+ const BACKOFF_SECONDS = [1, 5, 30, 120];
99
+ const MAX_ATTEMPTS = BACKOFF_SECONDS.length;
100
+ // Transient failure: return to pending with next_attempt_at set, or
101
+ // move to DLQ if attempts exceeded. Caller-owned check applies.
102
+ export function markOutboundRetryOrDlq(id, workerId, errorMessage) {
103
+ const db = getDb();
104
+ return db.transaction((tx) => {
105
+ const row = tx
106
+ .select()
107
+ .from(outbound)
108
+ .where(eq(outbound.id, id))
109
+ .get();
110
+ if (!row || row.status !== 'sending' || row.claimedBy !== workerId) {
111
+ return { retried: false, deadLettered: false };
112
+ }
113
+ const now = Math.floor(Date.now() / 1000);
114
+ const nextAttempts = row.attempts + 1;
115
+ if (nextAttempts > MAX_ATTEMPTS) {
116
+ tx.update(outbound)
117
+ .set({
118
+ status: 'dlq',
119
+ attempts: nextAttempts,
120
+ lastError: errorMessage,
121
+ claimedBy: null,
122
+ claimedAt: null,
123
+ updatedAt: now,
124
+ })
125
+ .where(eq(outbound.id, id))
126
+ .run();
127
+ return { retried: false, deadLettered: true };
128
+ }
129
+ const backoff = BACKOFF_SECONDS[Math.min(row.attempts, BACKOFF_SECONDS.length - 1)];
130
+ tx.update(outbound)
131
+ .set({
132
+ status: 'pending',
133
+ attempts: nextAttempts,
134
+ nextAttemptAt: now + backoff,
135
+ lastError: errorMessage,
136
+ claimedBy: null,
137
+ claimedAt: null,
138
+ updatedAt: now,
139
+ })
140
+ .where(eq(outbound.id, id))
141
+ .run();
142
+ return { retried: true, deadLettered: false };
143
+ });
144
+ }
145
+ // Permanent failure (no retry). Used when the error is unrecoverable
146
+ // — e.g. media file missing, malformed address.
147
+ export function markOutboundFailed(id, workerId, errorMessage) {
148
+ const db = getDb();
149
+ const now = Math.floor(Date.now() / 1000);
150
+ const result = db
151
+ .update(outbound)
152
+ .set({
153
+ status: 'failed',
154
+ lastError: errorMessage,
155
+ claimedBy: null,
156
+ claimedAt: null,
157
+ updatedAt: now,
158
+ })
159
+ .where(and(eq(outbound.id, id), eq(outbound.status, 'sending'), eq(outbound.claimedBy, workerId)))
160
+ .returning({ id: outbound.id })
161
+ .all();
162
+ return result.length > 0;
163
+ }
164
+ // Orchestrator helper: reclaim rows whose worker died mid-send.
165
+ // "Stuck in sending past TTL" → return to pending so another worker
166
+ // can pick them up. attempts NOT incremented (the worker may have
167
+ // died before even talking to the channel).
168
+ const CLAIM_TTL_SECONDS = 60;
169
+ export function reclaimStuckOutbound() {
170
+ const db = getDb();
171
+ const cutoff = Math.floor(Date.now() / 1000) - CLAIM_TTL_SECONDS;
172
+ const result = db
173
+ .update(outbound)
174
+ .set({
175
+ status: 'pending',
176
+ claimedBy: null,
177
+ claimedAt: null,
178
+ // intentionally leaving updatedAt as-is so observability can spot reclaims
179
+ updatedAt: sql `${outbound.updatedAt}`,
180
+ })
181
+ .where(and(eq(outbound.status, 'sending'), lte(outbound.claimedAt, cutoff)))
182
+ .returning({ id: outbound.id })
183
+ .all();
184
+ return result.length;
185
+ }
@@ -0,0 +1,199 @@
1
+ // Sender worker. Drains the outbound queue and pushes each row to the
2
+ // matching channel adapter. One per process (no concurrency) so
3
+ // per-address ordering is preserved naturally and rate-limiting lives
4
+ // in one place.
5
+ import { hostname } from 'os';
6
+ import { resolve } from 'path';
7
+ import { eq } from 'drizzle-orm';
8
+ import { config } from '../config.js';
9
+ import { getDb } from '../db/index.js';
10
+ import { parseAddress } from '../db/address.js';
11
+ import { workers } from '../db/schema.js';
12
+ import { getChannelAdapter, PermanentChannelError, TransientChannelError, } from '../channels/index.js';
13
+ import { logger } from '../logger.js';
14
+ import { claimNextOutbound, markOutboundDone, markOutboundFailed, markOutboundRetryOrDlq, } from './outbound.js';
15
+ import { afterSend } from './outbound-postsend.js';
16
+ const HEARTBEAT_INTERVAL_MS = 5_000;
17
+ const IDLE_POLL_INTERVAL_MS = 500; // when queue empty
18
+ const BUSY_POLL_INTERVAL_MS = 50; // immediately fetch next after a successful send
19
+ let workerId = null;
20
+ let stopping = false;
21
+ let heartbeatTimer = null;
22
+ function newWorkerId() {
23
+ return `${hostname()}-${process.pid}-sender-0`;
24
+ }
25
+ function registerWorker(id) {
26
+ const db = getDb();
27
+ const now = Math.floor(Date.now() / 1000);
28
+ db.insert(workers)
29
+ .values({
30
+ id,
31
+ kind: 'sender',
32
+ status: 'idle',
33
+ currentJob: null,
34
+ lastSeen: now,
35
+ startedAt: now,
36
+ })
37
+ .onConflictDoUpdate({
38
+ target: workers.id,
39
+ set: { status: 'idle', currentJob: null, lastSeen: now, startedAt: now },
40
+ })
41
+ .run();
42
+ }
43
+ function setWorkerStatus(id, status, currentJob = null) {
44
+ const db = getDb();
45
+ db.update(workers)
46
+ .set({
47
+ status,
48
+ currentJob,
49
+ lastSeen: Math.floor(Date.now() / 1000),
50
+ })
51
+ .where(eq(workers.id, id))
52
+ .run();
53
+ }
54
+ function heartbeat(id) {
55
+ const db = getDb();
56
+ db.update(workers)
57
+ .set({ lastSeen: Math.floor(Date.now() / 1000) })
58
+ .where(eq(workers.id, id))
59
+ .run();
60
+ }
61
+ // Translate an outbound row into the channel-agnostic message shape.
62
+ // Media paths are stored relative to the project root in the row;
63
+ // resolved to absolute here so the adapter can readFileSync directly.
64
+ function rowToMessage(row) {
65
+ return {
66
+ kind: row.kind,
67
+ text: row.text ?? undefined,
68
+ mediaPath: row.mediaPath ? resolve(process.cwd(), row.mediaPath) : undefined,
69
+ mediaMime: row.mediaMime ?? undefined,
70
+ quoteMsgId: row.quoteMsgId ?? undefined,
71
+ };
72
+ }
73
+ // Enforce media-size cap. mediaBytes is stored on the row by the
74
+ // producer; if missing, we trust the channel to enforce its own limit.
75
+ function tooLarge(row) {
76
+ const cap = config.reply.maxOutboundMediaBytes ?? null;
77
+ if (cap === null)
78
+ return null;
79
+ if (row.mediaBytes !== null && row.mediaBytes > cap) {
80
+ return `media too large: ${row.mediaBytes} > ${cap} bytes`;
81
+ }
82
+ return null;
83
+ }
84
+ async function processOne(id, row) {
85
+ setWorkerStatus(id, 'busy', `outbound:${row.id}`);
86
+ // Size cap → permanent fail; no point retrying same payload.
87
+ const sizeError = tooLarge(row);
88
+ if (sizeError) {
89
+ markOutboundFailed(row.id, id, sizeError);
90
+ logger.warn({ id: row.id, address: row.address }, sizeError);
91
+ setWorkerStatus(id, 'idle');
92
+ return;
93
+ }
94
+ let address;
95
+ try {
96
+ address = parseAddress(row.address);
97
+ }
98
+ catch (err) {
99
+ markOutboundFailed(row.id, id, `bad address: ${row.address}`);
100
+ logger.warn({ id: row.id, err }, 'outbound row has unparseable address');
101
+ setWorkerStatus(id, 'idle');
102
+ return;
103
+ }
104
+ // system:* addresses are bot-internal; not real channels. Drop them
105
+ // with a friendly log so future cron-emitted system messages don't
106
+ // accidentally try to "send" anywhere.
107
+ if (address.channel === 'system') {
108
+ markOutboundFailed(row.id, id, 'system addresses are not sendable');
109
+ logger.warn({ id: row.id, address: row.address }, 'system address routed to sender');
110
+ setWorkerStatus(id, 'idle');
111
+ return;
112
+ }
113
+ let adapter;
114
+ try {
115
+ adapter = getChannelAdapter(address.channel);
116
+ }
117
+ catch (err) {
118
+ markOutboundFailed(row.id, id, err.message);
119
+ logger.error({ id: row.id, channel: address.channel }, 'no adapter for channel');
120
+ setWorkerStatus(id, 'idle');
121
+ return;
122
+ }
123
+ try {
124
+ const result = await adapter.send(address.externalId, rowToMessage(row));
125
+ const ok = markOutboundDone(row.id, id);
126
+ if (!ok) {
127
+ // Lost the claim — orchestrator reclaimed it as stuck, or
128
+ // status changed under us. The send already happened though
129
+ // (channel returned a msg_id), so we just log and move on. The
130
+ // reclaimed copy may re-send → that's why idempotency_key
131
+ // matters at the producer side.
132
+ logger.warn({ id: row.id, msgId: result.msgId }, 'outbound sent but markDone failed (claim lost?)');
133
+ }
134
+ else {
135
+ await afterSend(row, result.msgId).catch((err) => {
136
+ logger.error({ err, id: row.id }, 'afterSend hook failed');
137
+ });
138
+ logger.info({ id: row.id, address: row.address, kind: row.kind, msgId: result.msgId }, 'outbound sent');
139
+ }
140
+ }
141
+ catch (err) {
142
+ if (err instanceof PermanentChannelError) {
143
+ markOutboundFailed(row.id, id, err.message);
144
+ logger.error({ id: row.id, err: err.message }, 'outbound permanent failure');
145
+ }
146
+ else if (err instanceof TransientChannelError) {
147
+ const result = markOutboundRetryOrDlq(row.id, id, err.message);
148
+ if (result.deadLettered) {
149
+ logger.error({ id: row.id }, 'outbound dead-lettered after max attempts');
150
+ }
151
+ else if (result.retried) {
152
+ logger.warn({ id: row.id, err: err.message }, 'outbound transient fail, will retry');
153
+ }
154
+ }
155
+ else {
156
+ // Unexpected throw — treat as transient (safer to retry than to
157
+ // give up on something we don't understand).
158
+ const result = markOutboundRetryOrDlq(row.id, id, `unexpected error: ${err.message}`);
159
+ logger.error({ id: row.id, err, retried: result.retried, dlq: result.deadLettered }, 'outbound unexpected error');
160
+ }
161
+ }
162
+ finally {
163
+ setWorkerStatus(id, 'idle');
164
+ }
165
+ }
166
+ async function loop(id) {
167
+ while (!stopping) {
168
+ let processed = false;
169
+ try {
170
+ const row = claimNextOutbound(id);
171
+ if (row) {
172
+ await processOne(id, row);
173
+ processed = true;
174
+ }
175
+ }
176
+ catch (err) {
177
+ logger.error({ err }, 'sender worker loop error');
178
+ }
179
+ const delay = processed ? BUSY_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
180
+ await new Promise((res) => setTimeout(res, delay));
181
+ }
182
+ setWorkerStatus(id, 'dead');
183
+ }
184
+ export function startSenderWorker() {
185
+ if (workerId) {
186
+ logger.warn('sender worker already started; ignoring');
187
+ return;
188
+ }
189
+ workerId = newWorkerId();
190
+ registerWorker(workerId);
191
+ heartbeatTimer = setInterval(() => workerId && heartbeat(workerId), HEARTBEAT_INTERVAL_MS);
192
+ void loop(workerId).catch((err) => logger.fatal({ err }, 'sender worker loop crashed'));
193
+ logger.info({ workerId }, 'sender worker started');
194
+ }
195
+ export function stopSenderWorker() {
196
+ stopping = true;
197
+ if (heartbeatTimer)
198
+ clearInterval(heartbeatTimer);
199
+ }
@@ -0,0 +1,34 @@
1
+ CREATE TABLE `control` (
2
+ `key` text PRIMARY KEY NOT NULL,
3
+ `value` text,
4
+ `requested_by` text,
5
+ `requested_at` integer NOT NULL
6
+ );
7
+ --> statement-breakpoint
8
+ CREATE TABLE `identities` (
9
+ `person_id` text NOT NULL,
10
+ `address` text NOT NULL,
11
+ `added_at` integer NOT NULL,
12
+ PRIMARY KEY(`person_id`, `address`),
13
+ FOREIGN KEY (`person_id`) REFERENCES `persons`(`id`) ON UPDATE no action ON DELETE no action
14
+ );
15
+ --> statement-breakpoint
16
+ CREATE UNIQUE INDEX `identities_address_unique` ON `identities` (`address`);--> statement-breakpoint
17
+ CREATE TABLE `persons` (
18
+ `id` text PRIMARY KEY NOT NULL,
19
+ `display_name` text,
20
+ `timezone` text,
21
+ `created_at` integer NOT NULL
22
+ );
23
+ --> statement-breakpoint
24
+ CREATE TABLE `workers` (
25
+ `id` text PRIMARY KEY NOT NULL,
26
+ `kind` text NOT NULL,
27
+ `status` text NOT NULL,
28
+ `current_job` text,
29
+ `last_seen` integer NOT NULL,
30
+ `started_at` integer NOT NULL
31
+ );
32
+ --> statement-breakpoint
33
+ CREATE INDEX `workers_by_kind_status` ON `workers` (`kind`,`status`);--> statement-breakpoint
34
+ CREATE INDEX `workers_by_last_seen` ON `workers` (`last_seen`);
@@ -0,0 +1,23 @@
1
+ CREATE TABLE `outbound` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `address` text NOT NULL,
4
+ `kind` text NOT NULL,
5
+ `text` text,
6
+ `media_path` text,
7
+ `media_mime` text,
8
+ `media_bytes` integer,
9
+ `quote_msg_id` text,
10
+ `idempotency_key` text,
11
+ `status` text NOT NULL,
12
+ `attempts` integer DEFAULT 0 NOT NULL,
13
+ `next_attempt_at` integer,
14
+ `last_error` text,
15
+ `claimed_by` text,
16
+ `claimed_at` integer,
17
+ `created_at` integer NOT NULL,
18
+ `updated_at` integer NOT NULL
19
+ );
20
+ --> statement-breakpoint
21
+ CREATE INDEX `outbound_by_status_next` ON `outbound` (`status`,`next_attempt_at`);--> statement-breakpoint
22
+ CREATE INDEX `outbound_by_address` ON `outbound` (`address`);--> statement-breakpoint
23
+ CREATE UNIQUE INDEX `outbound_idempotency_key_uq` ON `outbound` (`idempotency_key`) WHERE "outbound"."idempotency_key" IS NOT NULL;
@@ -0,0 +1,223 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "16f29db1-6a21-41fb-b7e2-933ea7384dad",
5
+ "prevId": "00000000-0000-0000-0000-000000000000",
6
+ "tables": {
7
+ "control": {
8
+ "name": "control",
9
+ "columns": {
10
+ "key": {
11
+ "name": "key",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "value": {
18
+ "name": "value",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": false,
22
+ "autoincrement": false
23
+ },
24
+ "requested_by": {
25
+ "name": "requested_by",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": false,
29
+ "autoincrement": false
30
+ },
31
+ "requested_at": {
32
+ "name": "requested_at",
33
+ "type": "integer",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ }
38
+ },
39
+ "indexes": {},
40
+ "foreignKeys": {},
41
+ "compositePrimaryKeys": {},
42
+ "uniqueConstraints": {},
43
+ "checkConstraints": {}
44
+ },
45
+ "identities": {
46
+ "name": "identities",
47
+ "columns": {
48
+ "person_id": {
49
+ "name": "person_id",
50
+ "type": "text",
51
+ "primaryKey": false,
52
+ "notNull": true,
53
+ "autoincrement": false
54
+ },
55
+ "address": {
56
+ "name": "address",
57
+ "type": "text",
58
+ "primaryKey": false,
59
+ "notNull": true,
60
+ "autoincrement": false
61
+ },
62
+ "added_at": {
63
+ "name": "added_at",
64
+ "type": "integer",
65
+ "primaryKey": false,
66
+ "notNull": true,
67
+ "autoincrement": false
68
+ }
69
+ },
70
+ "indexes": {
71
+ "identities_address_unique": {
72
+ "name": "identities_address_unique",
73
+ "columns": [
74
+ "address"
75
+ ],
76
+ "isUnique": true
77
+ }
78
+ },
79
+ "foreignKeys": {
80
+ "identities_person_id_persons_id_fk": {
81
+ "name": "identities_person_id_persons_id_fk",
82
+ "tableFrom": "identities",
83
+ "tableTo": "persons",
84
+ "columnsFrom": [
85
+ "person_id"
86
+ ],
87
+ "columnsTo": [
88
+ "id"
89
+ ],
90
+ "onDelete": "no action",
91
+ "onUpdate": "no action"
92
+ }
93
+ },
94
+ "compositePrimaryKeys": {
95
+ "identities_person_id_address_pk": {
96
+ "columns": [
97
+ "person_id",
98
+ "address"
99
+ ],
100
+ "name": "identities_person_id_address_pk"
101
+ }
102
+ },
103
+ "uniqueConstraints": {},
104
+ "checkConstraints": {}
105
+ },
106
+ "persons": {
107
+ "name": "persons",
108
+ "columns": {
109
+ "id": {
110
+ "name": "id",
111
+ "type": "text",
112
+ "primaryKey": true,
113
+ "notNull": true,
114
+ "autoincrement": false
115
+ },
116
+ "display_name": {
117
+ "name": "display_name",
118
+ "type": "text",
119
+ "primaryKey": false,
120
+ "notNull": false,
121
+ "autoincrement": false
122
+ },
123
+ "timezone": {
124
+ "name": "timezone",
125
+ "type": "text",
126
+ "primaryKey": false,
127
+ "notNull": false,
128
+ "autoincrement": false
129
+ },
130
+ "created_at": {
131
+ "name": "created_at",
132
+ "type": "integer",
133
+ "primaryKey": false,
134
+ "notNull": true,
135
+ "autoincrement": false
136
+ }
137
+ },
138
+ "indexes": {},
139
+ "foreignKeys": {},
140
+ "compositePrimaryKeys": {},
141
+ "uniqueConstraints": {},
142
+ "checkConstraints": {}
143
+ },
144
+ "workers": {
145
+ "name": "workers",
146
+ "columns": {
147
+ "id": {
148
+ "name": "id",
149
+ "type": "text",
150
+ "primaryKey": true,
151
+ "notNull": true,
152
+ "autoincrement": false
153
+ },
154
+ "kind": {
155
+ "name": "kind",
156
+ "type": "text",
157
+ "primaryKey": false,
158
+ "notNull": true,
159
+ "autoincrement": false
160
+ },
161
+ "status": {
162
+ "name": "status",
163
+ "type": "text",
164
+ "primaryKey": false,
165
+ "notNull": true,
166
+ "autoincrement": false
167
+ },
168
+ "current_job": {
169
+ "name": "current_job",
170
+ "type": "text",
171
+ "primaryKey": false,
172
+ "notNull": false,
173
+ "autoincrement": false
174
+ },
175
+ "last_seen": {
176
+ "name": "last_seen",
177
+ "type": "integer",
178
+ "primaryKey": false,
179
+ "notNull": true,
180
+ "autoincrement": false
181
+ },
182
+ "started_at": {
183
+ "name": "started_at",
184
+ "type": "integer",
185
+ "primaryKey": false,
186
+ "notNull": true,
187
+ "autoincrement": false
188
+ }
189
+ },
190
+ "indexes": {
191
+ "workers_by_kind_status": {
192
+ "name": "workers_by_kind_status",
193
+ "columns": [
194
+ "kind",
195
+ "status"
196
+ ],
197
+ "isUnique": false
198
+ },
199
+ "workers_by_last_seen": {
200
+ "name": "workers_by_last_seen",
201
+ "columns": [
202
+ "last_seen"
203
+ ],
204
+ "isUnique": false
205
+ }
206
+ },
207
+ "foreignKeys": {},
208
+ "compositePrimaryKeys": {},
209
+ "uniqueConstraints": {},
210
+ "checkConstraints": {}
211
+ }
212
+ },
213
+ "views": {},
214
+ "enums": {},
215
+ "_meta": {
216
+ "schemas": {},
217
+ "tables": {},
218
+ "columns": {}
219
+ },
220
+ "internal": {
221
+ "indexes": {}
222
+ }
223
+ }