lopata 0.0.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.
Files changed (147) hide show
  1. package/README.md +15 -0
  2. package/package.json +51 -0
  3. package/runtime/bindings/ai.ts +132 -0
  4. package/runtime/bindings/analytics-engine.ts +96 -0
  5. package/runtime/bindings/browser.ts +64 -0
  6. package/runtime/bindings/cache.ts +179 -0
  7. package/runtime/bindings/cf-streams.ts +56 -0
  8. package/runtime/bindings/container-docker.ts +225 -0
  9. package/runtime/bindings/container.ts +662 -0
  10. package/runtime/bindings/crypto-extras.ts +89 -0
  11. package/runtime/bindings/d1.ts +315 -0
  12. package/runtime/bindings/do-executor-inprocess.ts +140 -0
  13. package/runtime/bindings/do-executor-worker.ts +368 -0
  14. package/runtime/bindings/do-executor.ts +45 -0
  15. package/runtime/bindings/do-websocket-bridge.ts +70 -0
  16. package/runtime/bindings/do-worker-entry.ts +220 -0
  17. package/runtime/bindings/do-worker-env.ts +74 -0
  18. package/runtime/bindings/durable-object.ts +992 -0
  19. package/runtime/bindings/email.ts +180 -0
  20. package/runtime/bindings/html-rewriter.ts +84 -0
  21. package/runtime/bindings/hyperdrive.ts +130 -0
  22. package/runtime/bindings/images.ts +381 -0
  23. package/runtime/bindings/kv.ts +359 -0
  24. package/runtime/bindings/queue.ts +507 -0
  25. package/runtime/bindings/r2.ts +759 -0
  26. package/runtime/bindings/rpc-stub.ts +267 -0
  27. package/runtime/bindings/scheduled.ts +172 -0
  28. package/runtime/bindings/service-binding.ts +217 -0
  29. package/runtime/bindings/static-assets.ts +481 -0
  30. package/runtime/bindings/websocket-pair.ts +182 -0
  31. package/runtime/bindings/workflow.ts +858 -0
  32. package/runtime/bunflare-config.ts +56 -0
  33. package/runtime/cli/cache.ts +39 -0
  34. package/runtime/cli/context.ts +105 -0
  35. package/runtime/cli/d1.ts +163 -0
  36. package/runtime/cli/dev.ts +392 -0
  37. package/runtime/cli/kv.ts +84 -0
  38. package/runtime/cli/queues.ts +109 -0
  39. package/runtime/cli/r2.ts +140 -0
  40. package/runtime/cli/traces.ts +251 -0
  41. package/runtime/cli.ts +102 -0
  42. package/runtime/config.ts +148 -0
  43. package/runtime/d1-migrate.ts +37 -0
  44. package/runtime/dashboard/api.ts +174 -0
  45. package/runtime/dashboard/app.tsx +220 -0
  46. package/runtime/dashboard/components/breadcrumb.tsx +16 -0
  47. package/runtime/dashboard/components/buttons.tsx +13 -0
  48. package/runtime/dashboard/components/code-block.tsx +5 -0
  49. package/runtime/dashboard/components/detail-field.tsx +8 -0
  50. package/runtime/dashboard/components/empty-state.tsx +8 -0
  51. package/runtime/dashboard/components/filter-input.tsx +11 -0
  52. package/runtime/dashboard/components/index.ts +16 -0
  53. package/runtime/dashboard/components/key-value-table.tsx +23 -0
  54. package/runtime/dashboard/components/modal.tsx +23 -0
  55. package/runtime/dashboard/components/page-header.tsx +11 -0
  56. package/runtime/dashboard/components/pill-button.tsx +14 -0
  57. package/runtime/dashboard/components/refresh-button.tsx +7 -0
  58. package/runtime/dashboard/components/service-info.tsx +45 -0
  59. package/runtime/dashboard/components/status-badge.tsx +7 -0
  60. package/runtime/dashboard/components/table-link.tsx +5 -0
  61. package/runtime/dashboard/components/table.tsx +26 -0
  62. package/runtime/dashboard/components.tsx +19 -0
  63. package/runtime/dashboard/index.html +23 -0
  64. package/runtime/dashboard/lib.ts +45 -0
  65. package/runtime/dashboard/rpc/client.ts +20 -0
  66. package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
  67. package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
  68. package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
  69. package/runtime/dashboard/rpc/handlers/config.ts +137 -0
  70. package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
  71. package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
  72. package/runtime/dashboard/rpc/handlers/do.ts +117 -0
  73. package/runtime/dashboard/rpc/handlers/email.ts +82 -0
  74. package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
  75. package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
  76. package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
  77. package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
  78. package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
  79. package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
  80. package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
  81. package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
  82. package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
  83. package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
  84. package/runtime/dashboard/rpc/hooks.ts +132 -0
  85. package/runtime/dashboard/rpc/server.ts +70 -0
  86. package/runtime/dashboard/rpc/types.ts +396 -0
  87. package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
  88. package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
  89. package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
  90. package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
  91. package/runtime/dashboard/sql-browser/hooks.ts +137 -0
  92. package/runtime/dashboard/sql-browser/index.ts +4 -0
  93. package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
  94. package/runtime/dashboard/sql-browser/modals.tsx +116 -0
  95. package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
  96. package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
  97. package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
  98. package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
  99. package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
  100. package/runtime/dashboard/sql-browser/types.ts +61 -0
  101. package/runtime/dashboard/sql-browser/utils.ts +167 -0
  102. package/runtime/dashboard/style.css +177 -0
  103. package/runtime/dashboard/views/ai.tsx +152 -0
  104. package/runtime/dashboard/views/analytics-engine.tsx +169 -0
  105. package/runtime/dashboard/views/cache.tsx +93 -0
  106. package/runtime/dashboard/views/containers.tsx +197 -0
  107. package/runtime/dashboard/views/d1.tsx +81 -0
  108. package/runtime/dashboard/views/do.tsx +168 -0
  109. package/runtime/dashboard/views/email.tsx +235 -0
  110. package/runtime/dashboard/views/errors.tsx +558 -0
  111. package/runtime/dashboard/views/home.tsx +287 -0
  112. package/runtime/dashboard/views/kv.tsx +273 -0
  113. package/runtime/dashboard/views/queue.tsx +193 -0
  114. package/runtime/dashboard/views/r2.tsx +202 -0
  115. package/runtime/dashboard/views/scheduled.tsx +89 -0
  116. package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
  117. package/runtime/dashboard/views/traces.tsx +768 -0
  118. package/runtime/dashboard/views/workers.tsx +55 -0
  119. package/runtime/dashboard/views/workflows.tsx +473 -0
  120. package/runtime/db.ts +258 -0
  121. package/runtime/env.ts +362 -0
  122. package/runtime/error-page/app.tsx +394 -0
  123. package/runtime/error-page/build.ts +269 -0
  124. package/runtime/error-page/index.html +16 -0
  125. package/runtime/error-page/style.css +31 -0
  126. package/runtime/execution-context.ts +18 -0
  127. package/runtime/file-watcher.ts +57 -0
  128. package/runtime/generation-manager.ts +230 -0
  129. package/runtime/generation.ts +411 -0
  130. package/runtime/plugin.ts +292 -0
  131. package/runtime/request-cf.ts +28 -0
  132. package/runtime/rpc-validate.ts +154 -0
  133. package/runtime/tracing/context.ts +40 -0
  134. package/runtime/tracing/db.ts +73 -0
  135. package/runtime/tracing/frames.ts +75 -0
  136. package/runtime/tracing/instrument.ts +186 -0
  137. package/runtime/tracing/span.ts +138 -0
  138. package/runtime/tracing/store.ts +499 -0
  139. package/runtime/tracing/types.ts +47 -0
  140. package/runtime/vite-plugin/config-plugin.ts +68 -0
  141. package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
  142. package/runtime/vite-plugin/dist/index.mjs +52333 -0
  143. package/runtime/vite-plugin/globals-plugin.ts +94 -0
  144. package/runtime/vite-plugin/index.ts +43 -0
  145. package/runtime/vite-plugin/modules-plugin.ts +88 -0
  146. package/runtime/vite-plugin/react-router-plugin.ts +95 -0
  147. package/runtime/worker-registry.ts +52 -0
@@ -0,0 +1,507 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { randomUUIDv7 } from "bun";
3
+ import { ExecutionContext } from "../execution-context";
4
+ import crypto from "node:crypto";
5
+ import { startSpan, persistError } from "../tracing/span";
6
+
7
+ // --- Types ---
8
+
9
+ interface SendOptions {
10
+ contentType?: "json" | "text" | "bytes" | "v8";
11
+ delaySeconds?: number;
12
+ }
13
+
14
+ interface BatchMessage {
15
+ body: unknown;
16
+ contentType?: "json" | "text" | "bytes" | "v8";
17
+ delaySeconds?: number;
18
+ }
19
+
20
+ interface Message {
21
+ id: string;
22
+ timestamp: Date;
23
+ body: unknown;
24
+ attempts: number;
25
+ ack(): void;
26
+ retry(options?: { delaySeconds?: number }): void;
27
+ }
28
+
29
+ interface MessageBatch {
30
+ readonly queue: string;
31
+ readonly messages: readonly Message[];
32
+ ackAll(): void;
33
+ retryAll(options?: { delaySeconds?: number }): void;
34
+ }
35
+
36
+ interface ConsumerConfig {
37
+ queue: string;
38
+ maxBatchSize: number;
39
+ maxBatchTimeout: number;
40
+ maxRetries: number;
41
+ deadLetterQueue: string | null;
42
+ retentionPeriodSeconds?: number; // default 345600 (4 days), matching CF default
43
+ }
44
+
45
+ type QueueHandler = (batch: MessageBatch, env: Record<string, unknown>, ctx: ExecutionContext) => Promise<void>;
46
+
47
+ // --- Limits ---
48
+
49
+ export interface QueueLimits {
50
+ maxMessageSize?: number; // default 128 * 1024 (128 KB)
51
+ maxBatchMessages?: number; // default 100
52
+ maxBatchSize?: number; // default 256 * 1024 (256 KB)
53
+ maxDelaySeconds?: number; // default 43200 (12 hours)
54
+ }
55
+
56
+ const QUEUE_DEFAULTS: Required<QueueLimits> = {
57
+ maxMessageSize: 128 * 1024,
58
+ maxBatchMessages: 100,
59
+ maxBatchSize: 256 * 1024,
60
+ maxDelaySeconds: 43200,
61
+ };
62
+
63
+ // --- Encoding / Decoding ---
64
+
65
+ function encodeBody(message: unknown, contentType: string): Uint8Array {
66
+ switch (contentType) {
67
+ case "bytes": {
68
+ if (message instanceof ArrayBuffer) {
69
+ return new Uint8Array(message);
70
+ }
71
+ if (message instanceof Uint8Array) {
72
+ return message;
73
+ }
74
+ if (ArrayBuffer.isView(message)) {
75
+ return new Uint8Array(message.buffer, message.byteOffset, message.byteLength);
76
+ }
77
+ throw new Error("bytes content type requires ArrayBuffer or Uint8Array");
78
+ }
79
+ case "text":
80
+ return new TextEncoder().encode(String(message));
81
+ case "v8":
82
+ // Use JSON serialization as a v8-structured-clone approximation
83
+ return new TextEncoder().encode(JSON.stringify(message));
84
+ case "json":
85
+ default:
86
+ return new TextEncoder().encode(JSON.stringify(message));
87
+ }
88
+ }
89
+
90
+ function decodeBody(raw: Uint8Array | Buffer, contentType: string): unknown {
91
+ switch (contentType) {
92
+ case "bytes":
93
+ return raw instanceof Uint8Array ? raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) : new Uint8Array(raw).buffer;
94
+ case "text":
95
+ return new TextDecoder().decode(raw);
96
+ case "v8":
97
+ return JSON.parse(new TextDecoder().decode(raw));
98
+ case "json":
99
+ default:
100
+ return JSON.parse(new TextDecoder().decode(raw));
101
+ }
102
+ }
103
+
104
+ // --- Producer ---
105
+
106
+ export class SqliteQueueProducer {
107
+ private db: Database;
108
+ private queueName: string;
109
+ private defaultDelay: number;
110
+ private limits: Required<QueueLimits>;
111
+
112
+ constructor(db: Database, queueName: string, defaultDelay: number = 0, limits?: QueueLimits) {
113
+ this.db = db;
114
+ this.queueName = queueName;
115
+ this.defaultDelay = defaultDelay;
116
+ this.limits = { ...QUEUE_DEFAULTS, ...limits };
117
+ }
118
+
119
+ async send(message: unknown, options?: SendOptions): Promise<void> {
120
+ const contentType = options?.contentType ?? "json";
121
+ const delaySeconds = options?.delaySeconds ?? this.defaultDelay;
122
+
123
+ if (delaySeconds < 0 || delaySeconds > this.limits.maxDelaySeconds) {
124
+ throw new Error(`delaySeconds must be between 0 and ${this.limits.maxDelaySeconds}`);
125
+ }
126
+
127
+ const encoded = encodeBody(message, contentType);
128
+
129
+ if (encoded.byteLength > this.limits.maxMessageSize) {
130
+ throw new Error(`Message exceeds max size of ${this.limits.maxMessageSize} bytes`);
131
+ }
132
+
133
+ const now = Date.now();
134
+ const visibleAt = now + delaySeconds * 1000;
135
+
136
+ this.db.run(
137
+ "INSERT INTO queue_messages (id, queue, body, content_type, attempts, visible_at, created_at) VALUES (?, ?, ?, ?, 0, ?, ?)",
138
+ [randomUUIDv7(), this.queueName, encoded, contentType, visibleAt, now],
139
+ );
140
+ }
141
+
142
+ async sendBatch(messages: BatchMessage[], options?: SendOptions): Promise<void> {
143
+ if (messages.length > this.limits.maxBatchMessages) {
144
+ throw new Error(`Batch exceeds max message count of ${this.limits.maxBatchMessages}`);
145
+ }
146
+
147
+ const stmt = this.db.prepare(
148
+ "INSERT INTO queue_messages (id, queue, body, content_type, attempts, visible_at, created_at) VALUES (?, ?, ?, ?, 0, ?, ?)",
149
+ );
150
+ const now = Date.now();
151
+
152
+ // Pre-encode all messages and validate total size
153
+ const encoded: { data: Uint8Array; contentType: string; delaySeconds: number }[] = [];
154
+ let totalSize = 0;
155
+
156
+ for (const msg of messages) {
157
+ const contentType = msg.contentType ?? options?.contentType ?? "json";
158
+ const delaySeconds = msg.delaySeconds ?? options?.delaySeconds ?? this.defaultDelay;
159
+
160
+ if (delaySeconds < 0 || delaySeconds > this.limits.maxDelaySeconds) {
161
+ throw new Error(`delaySeconds must be between 0 and ${this.limits.maxDelaySeconds}`);
162
+ }
163
+
164
+ const data = encodeBody(msg.body, contentType);
165
+
166
+ if (data.byteLength > this.limits.maxMessageSize) {
167
+ throw new Error(`Message exceeds max size of ${this.limits.maxMessageSize} bytes`);
168
+ }
169
+
170
+ totalSize += data.byteLength;
171
+ encoded.push({ data, contentType, delaySeconds });
172
+ }
173
+
174
+ if (totalSize > this.limits.maxBatchSize) {
175
+ throw new Error(`Batch exceeds max total size of ${this.limits.maxBatchSize} bytes`);
176
+ }
177
+
178
+ const tx = this.db.transaction(() => {
179
+ for (const { data, contentType, delaySeconds } of encoded) {
180
+ const visibleAt = now + delaySeconds * 1000;
181
+ stmt.run(randomUUIDv7(), this.queueName, data, contentType, visibleAt, now);
182
+ }
183
+ });
184
+ tx();
185
+ }
186
+ }
187
+
188
+ // --- Consumer (poll loop) ---
189
+
190
+ export class QueueConsumer {
191
+ private db: Database;
192
+ private config: ConsumerConfig;
193
+ private handler: QueueHandler;
194
+ private env: Record<string, unknown>;
195
+ private workerName: string | undefined;
196
+ private timer: ReturnType<typeof setInterval> | null = null;
197
+ private batchBuffer: { id: string; body: Uint8Array | Buffer; content_type: string; attempts: number; created_at: number }[] = [];
198
+ private batchTimer: ReturnType<typeof setTimeout> | null = null;
199
+ private polling = false;
200
+
201
+ constructor(
202
+ db: Database,
203
+ config: ConsumerConfig,
204
+ handler: QueueHandler,
205
+ env: Record<string, unknown>,
206
+ workerName?: string,
207
+ ) {
208
+ this.db = db;
209
+ this.config = config;
210
+ this.handler = handler;
211
+ this.env = env;
212
+ this.workerName = workerName;
213
+ }
214
+
215
+ start(intervalMs: number = 1000): void {
216
+ if (this.timer) return;
217
+ this.timer = setInterval(() => this.poll(), intervalMs);
218
+ // Run first poll immediately
219
+ this.poll();
220
+ }
221
+
222
+ stop(): void {
223
+ if (this.timer) {
224
+ clearInterval(this.timer);
225
+ this.timer = null;
226
+ }
227
+ if (this.batchTimer) {
228
+ clearTimeout(this.batchTimer);
229
+ this.batchTimer = null;
230
+ }
231
+ }
232
+
233
+ async poll(): Promise<void> {
234
+ if (this.polling) return;
235
+ this.polling = true;
236
+ try {
237
+ const now = Date.now();
238
+
239
+ // Periodically clean up completed messages beyond retention period
240
+ const retentionMs = (this.config.retentionPeriodSeconds ?? 345600) * 1000;
241
+ this.db.run(
242
+ "DELETE FROM queue_messages WHERE queue = ? AND created_at < ?",
243
+ [this.config.queue, now - retentionMs],
244
+ );
245
+
246
+ const rows = this.db.query<
247
+ { id: string; body: Uint8Array | Buffer; content_type: string; attempts: number; created_at: number },
248
+ [string, number, number]
249
+ >(
250
+ "SELECT id, body, content_type, attempts, created_at FROM queue_messages WHERE queue = ? AND status = 'pending' AND visible_at <= ? ORDER BY visible_at LIMIT ?",
251
+ ).all(this.config.queue, now, this.config.maxBatchSize);
252
+
253
+ if (rows.length === 0) return;
254
+
255
+ await this.deliverBatch(rows);
256
+ } finally {
257
+ this.polling = false;
258
+ }
259
+ }
260
+
261
+ private async deliverBatch(rows: { id: string; body: Uint8Array | Buffer; content_type: string; attempts: number; created_at: number }[]): Promise<void> {
262
+ // Increment attempts for all fetched messages
263
+ const ids = rows.map((r) => r.id);
264
+ const placeholders = ids.map(() => "?").join(",");
265
+ this.db.run(`UPDATE queue_messages SET attempts = attempts + 1 WHERE id IN (${placeholders})`, ids);
266
+
267
+ // Track per-message decisions — last call wins (matching CF behavior)
268
+ type Decision = { type: 'ack' } | { type: 'retry'; delaySeconds: number | undefined };
269
+ const messageDecisions = new Map<string, Decision>();
270
+ let batchDecision: Decision | null = null;
271
+
272
+ const messages: Message[] = rows.map((row) => {
273
+ const body = decodeBody(row.body, row.content_type);
274
+ return {
275
+ id: row.id,
276
+ timestamp: new Date(row.created_at),
277
+ body,
278
+ attempts: row.attempts + 1, // CF behavior: starts at 1 on first delivery
279
+ ack() {
280
+ messageDecisions.set(row.id, { type: 'ack' });
281
+ },
282
+ retry(options?: { delaySeconds?: number }) {
283
+ messageDecisions.set(row.id, { type: 'retry', delaySeconds: options?.delaySeconds });
284
+ },
285
+ };
286
+ });
287
+
288
+ const batch: MessageBatch = {
289
+ queue: this.config.queue,
290
+ messages,
291
+ ackAll() {
292
+ batchDecision = { type: 'ack' };
293
+ },
294
+ retryAll(options?: { delaySeconds?: number }) {
295
+ batchDecision = { type: 'retry', delaySeconds: options?.delaySeconds };
296
+ },
297
+ };
298
+
299
+ const ctx = new ExecutionContext();
300
+
301
+ let handlerError = false;
302
+ await startSpan({
303
+ name: `queue ${this.config.queue}`,
304
+ kind: "server",
305
+ attributes: { "messaging.queue": this.config.queue, "messaging.batch_size": messages.length },
306
+ workerName: this.workerName,
307
+ }, async () => {
308
+ try {
309
+ await this.handler(batch, this.env, ctx);
310
+ } catch (err) {
311
+ console.error(`[bunflare] Queue consumer error (${this.config.queue}):`, err);
312
+ persistError(err, "queue", this.workerName);
313
+ // On handler error, retry all messages
314
+ handlerError = true;
315
+ }
316
+
317
+ // Wait for all waitUntil promises to settle (best-effort)
318
+ await ctx._awaitAll();
319
+ });
320
+
321
+ // Process message outcomes — per-message decision overrides batch decision
322
+ for (const row of rows) {
323
+ const currentAttempts = row.attempts + 1;
324
+ const decision: Decision | null = handlerError
325
+ ? { type: 'retry', delaySeconds: undefined }
326
+ : messageDecisions.get(row.id) ?? batchDecision;
327
+
328
+ if (!decision || decision.type === 'ack') {
329
+ // Ack (explicit or default) — mark as acked
330
+ this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [Date.now(), row.id]);
331
+ } else {
332
+ // Retry
333
+ const delay = decision.delaySeconds ?? 0;
334
+ if (currentAttempts >= this.config.maxRetries) {
335
+ // Max retries exceeded — move to DLQ or mark as failed
336
+ if (this.config.deadLetterQueue) {
337
+ this.db.run(
338
+ "UPDATE queue_messages SET queue = ?, visible_at = ?, status = 'pending' WHERE id = ?",
339
+ [this.config.deadLetterQueue, Date.now(), row.id],
340
+ );
341
+ } else {
342
+ console.warn(`[bunflare] Queue message ${row.id} exceeded max retries (${this.config.maxRetries}), discarding`);
343
+ this.db.run("UPDATE queue_messages SET status = 'failed', completed_at = ? WHERE id = ?", [Date.now(), row.id]);
344
+ }
345
+ } else {
346
+ // Retry with delay
347
+ this.db.run(
348
+ "UPDATE queue_messages SET visible_at = ? WHERE id = ?",
349
+ [Date.now() + delay * 1000, row.id],
350
+ );
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ // --- Pull Consumer ---
358
+
359
+ export interface PullMessage {
360
+ lease_id: string;
361
+ id: string;
362
+ timestamp: string; // ISO 8601
363
+ body: unknown;
364
+ attempts: number;
365
+ }
366
+
367
+ export interface PullResponse {
368
+ messages: PullMessage[];
369
+ }
370
+
371
+ export interface AckRequest {
372
+ acks?: { lease_id: string }[];
373
+ retries?: { lease_id: string; delay_seconds?: number }[];
374
+ }
375
+
376
+ export interface PullRequest {
377
+ batch_size?: number;
378
+ visibility_timeout_ms?: number;
379
+ }
380
+
381
+ const DEFAULT_VISIBILITY_TIMEOUT_MS = 30_000;
382
+ const DEFAULT_PULL_BATCH_SIZE = 10;
383
+
384
+ export class QueuePullConsumer {
385
+ private db: Database;
386
+ private queueName: string;
387
+
388
+ constructor(db: Database, queueName: string) {
389
+ this.db = db;
390
+ this.queueName = queueName;
391
+ }
392
+
393
+ pull(options?: PullRequest): PullResponse {
394
+ const batchSize = options?.batch_size ?? DEFAULT_PULL_BATCH_SIZE;
395
+ const visibilityTimeoutMs = options?.visibility_timeout_ms ?? DEFAULT_VISIBILITY_TIMEOUT_MS;
396
+ const now = Date.now();
397
+
398
+ // Clean up expired leases — make messages visible again
399
+ this.db.run(
400
+ "DELETE FROM queue_leases WHERE queue = ? AND expires_at <= ?",
401
+ [this.queueName, now],
402
+ );
403
+
404
+ // Select visible pending messages that don't have an active lease
405
+ const rows = this.db.query<
406
+ { id: string; body: Uint8Array | Buffer; content_type: string; attempts: number; created_at: number },
407
+ [string, number, string, number, number]
408
+ >(
409
+ `SELECT id, body, content_type, attempts, created_at FROM queue_messages
410
+ WHERE queue = ? AND status = 'pending' AND visible_at <= ?
411
+ AND id NOT IN (SELECT message_id FROM queue_leases WHERE queue = ? AND expires_at > ?)
412
+ ORDER BY visible_at LIMIT ?`,
413
+ ).all(this.queueName, now, this.queueName, now, batchSize);
414
+
415
+ if (rows.length === 0) {
416
+ return { messages: [] };
417
+ }
418
+
419
+ // Reject v8 content type
420
+ const v8Messages = rows.filter(r => r.content_type === "v8");
421
+ const validRows = rows.filter(r => r.content_type !== "v8");
422
+
423
+ const messages: PullMessage[] = [];
424
+
425
+ const insertLease = this.db.prepare(
426
+ "INSERT INTO queue_leases (lease_id, message_id, queue, expires_at) VALUES (?, ?, ?, ?)",
427
+ );
428
+ const updateAttempts = this.db.prepare(
429
+ "UPDATE queue_messages SET attempts = attempts + 1 WHERE id = ?",
430
+ );
431
+
432
+ const tx = this.db.transaction(() => {
433
+ for (const row of validRows) {
434
+ const leaseId = crypto.randomUUID();
435
+ const expiresAt = now + visibilityTimeoutMs;
436
+
437
+ insertLease.run(leaseId, row.id, this.queueName, expiresAt);
438
+ updateAttempts.run(row.id);
439
+
440
+ const body = decodeBody(row.body, row.content_type);
441
+
442
+ messages.push({
443
+ lease_id: leaseId,
444
+ id: row.id,
445
+ timestamp: new Date(row.created_at).toISOString(),
446
+ body,
447
+ attempts: row.attempts + 1,
448
+ });
449
+ }
450
+ });
451
+ tx();
452
+
453
+ return { messages };
454
+ }
455
+
456
+ ack(request: AckRequest): { acked: number; retried: number } {
457
+ let acked = 0;
458
+ let retried = 0;
459
+ const now = Date.now();
460
+
461
+ const tx = this.db.transaction(() => {
462
+ // Process acks
463
+ if (request.acks) {
464
+ for (const { lease_id } of request.acks) {
465
+ // Find the lease
466
+ const lease = this.db.query<
467
+ { message_id: string },
468
+ [string, string, number]
469
+ >(
470
+ "SELECT message_id FROM queue_leases WHERE lease_id = ? AND queue = ? AND expires_at > ?",
471
+ ).get(lease_id, this.queueName, now);
472
+
473
+ if (lease) {
474
+ this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [Date.now(), lease.message_id]);
475
+ this.db.run("DELETE FROM queue_leases WHERE lease_id = ?", [lease_id]);
476
+ acked++;
477
+ }
478
+ }
479
+ }
480
+
481
+ // Process retries
482
+ if (request.retries) {
483
+ for (const { lease_id, delay_seconds } of request.retries) {
484
+ const lease = this.db.query<
485
+ { message_id: string },
486
+ [string, string, number]
487
+ >(
488
+ "SELECT message_id FROM queue_leases WHERE lease_id = ? AND queue = ? AND expires_at > ?",
489
+ ).get(lease_id, this.queueName, now);
490
+
491
+ if (lease) {
492
+ const delay = delay_seconds ?? 0;
493
+ this.db.run(
494
+ "UPDATE queue_messages SET visible_at = ? WHERE id = ?",
495
+ [now + delay * 1000, lease.message_id],
496
+ );
497
+ this.db.run("DELETE FROM queue_leases WHERE lease_id = ?", [lease_id]);
498
+ retried++;
499
+ }
500
+ }
501
+ }
502
+ });
503
+ tx();
504
+
505
+ return { acked, retried };
506
+ }
507
+ }