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.
- package/README.md +15 -0
- package/package.json +51 -0
- package/runtime/bindings/ai.ts +132 -0
- package/runtime/bindings/analytics-engine.ts +96 -0
- package/runtime/bindings/browser.ts +64 -0
- package/runtime/bindings/cache.ts +179 -0
- package/runtime/bindings/cf-streams.ts +56 -0
- package/runtime/bindings/container-docker.ts +225 -0
- package/runtime/bindings/container.ts +662 -0
- package/runtime/bindings/crypto-extras.ts +89 -0
- package/runtime/bindings/d1.ts +315 -0
- package/runtime/bindings/do-executor-inprocess.ts +140 -0
- package/runtime/bindings/do-executor-worker.ts +368 -0
- package/runtime/bindings/do-executor.ts +45 -0
- package/runtime/bindings/do-websocket-bridge.ts +70 -0
- package/runtime/bindings/do-worker-entry.ts +220 -0
- package/runtime/bindings/do-worker-env.ts +74 -0
- package/runtime/bindings/durable-object.ts +992 -0
- package/runtime/bindings/email.ts +180 -0
- package/runtime/bindings/html-rewriter.ts +84 -0
- package/runtime/bindings/hyperdrive.ts +130 -0
- package/runtime/bindings/images.ts +381 -0
- package/runtime/bindings/kv.ts +359 -0
- package/runtime/bindings/queue.ts +507 -0
- package/runtime/bindings/r2.ts +759 -0
- package/runtime/bindings/rpc-stub.ts +267 -0
- package/runtime/bindings/scheduled.ts +172 -0
- package/runtime/bindings/service-binding.ts +217 -0
- package/runtime/bindings/static-assets.ts +481 -0
- package/runtime/bindings/websocket-pair.ts +182 -0
- package/runtime/bindings/workflow.ts +858 -0
- package/runtime/bunflare-config.ts +56 -0
- package/runtime/cli/cache.ts +39 -0
- package/runtime/cli/context.ts +105 -0
- package/runtime/cli/d1.ts +163 -0
- package/runtime/cli/dev.ts +392 -0
- package/runtime/cli/kv.ts +84 -0
- package/runtime/cli/queues.ts +109 -0
- package/runtime/cli/r2.ts +140 -0
- package/runtime/cli/traces.ts +251 -0
- package/runtime/cli.ts +102 -0
- package/runtime/config.ts +148 -0
- package/runtime/d1-migrate.ts +37 -0
- package/runtime/dashboard/api.ts +174 -0
- package/runtime/dashboard/app.tsx +220 -0
- package/runtime/dashboard/components/breadcrumb.tsx +16 -0
- package/runtime/dashboard/components/buttons.tsx +13 -0
- package/runtime/dashboard/components/code-block.tsx +5 -0
- package/runtime/dashboard/components/detail-field.tsx +8 -0
- package/runtime/dashboard/components/empty-state.tsx +8 -0
- package/runtime/dashboard/components/filter-input.tsx +11 -0
- package/runtime/dashboard/components/index.ts +16 -0
- package/runtime/dashboard/components/key-value-table.tsx +23 -0
- package/runtime/dashboard/components/modal.tsx +23 -0
- package/runtime/dashboard/components/page-header.tsx +11 -0
- package/runtime/dashboard/components/pill-button.tsx +14 -0
- package/runtime/dashboard/components/refresh-button.tsx +7 -0
- package/runtime/dashboard/components/service-info.tsx +45 -0
- package/runtime/dashboard/components/status-badge.tsx +7 -0
- package/runtime/dashboard/components/table-link.tsx +5 -0
- package/runtime/dashboard/components/table.tsx +26 -0
- package/runtime/dashboard/components.tsx +19 -0
- package/runtime/dashboard/index.html +23 -0
- package/runtime/dashboard/lib.ts +45 -0
- package/runtime/dashboard/rpc/client.ts +20 -0
- package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
- package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
- package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
- package/runtime/dashboard/rpc/handlers/config.ts +137 -0
- package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
- package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
- package/runtime/dashboard/rpc/handlers/do.ts +117 -0
- package/runtime/dashboard/rpc/handlers/email.ts +82 -0
- package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
- package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
- package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
- package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
- package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
- package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
- package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
- package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
- package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
- package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
- package/runtime/dashboard/rpc/hooks.ts +132 -0
- package/runtime/dashboard/rpc/server.ts +70 -0
- package/runtime/dashboard/rpc/types.ts +396 -0
- package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
- package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
- package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
- package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
- package/runtime/dashboard/sql-browser/hooks.ts +137 -0
- package/runtime/dashboard/sql-browser/index.ts +4 -0
- package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
- package/runtime/dashboard/sql-browser/modals.tsx +116 -0
- package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
- package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
- package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
- package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
- package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
- package/runtime/dashboard/sql-browser/types.ts +61 -0
- package/runtime/dashboard/sql-browser/utils.ts +167 -0
- package/runtime/dashboard/style.css +177 -0
- package/runtime/dashboard/views/ai.tsx +152 -0
- package/runtime/dashboard/views/analytics-engine.tsx +169 -0
- package/runtime/dashboard/views/cache.tsx +93 -0
- package/runtime/dashboard/views/containers.tsx +197 -0
- package/runtime/dashboard/views/d1.tsx +81 -0
- package/runtime/dashboard/views/do.tsx +168 -0
- package/runtime/dashboard/views/email.tsx +235 -0
- package/runtime/dashboard/views/errors.tsx +558 -0
- package/runtime/dashboard/views/home.tsx +287 -0
- package/runtime/dashboard/views/kv.tsx +273 -0
- package/runtime/dashboard/views/queue.tsx +193 -0
- package/runtime/dashboard/views/r2.tsx +202 -0
- package/runtime/dashboard/views/scheduled.tsx +89 -0
- package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
- package/runtime/dashboard/views/traces.tsx +768 -0
- package/runtime/dashboard/views/workers.tsx +55 -0
- package/runtime/dashboard/views/workflows.tsx +473 -0
- package/runtime/db.ts +258 -0
- package/runtime/env.ts +362 -0
- package/runtime/error-page/app.tsx +394 -0
- package/runtime/error-page/build.ts +269 -0
- package/runtime/error-page/index.html +16 -0
- package/runtime/error-page/style.css +31 -0
- package/runtime/execution-context.ts +18 -0
- package/runtime/file-watcher.ts +57 -0
- package/runtime/generation-manager.ts +230 -0
- package/runtime/generation.ts +411 -0
- package/runtime/plugin.ts +292 -0
- package/runtime/request-cf.ts +28 -0
- package/runtime/rpc-validate.ts +154 -0
- package/runtime/tracing/context.ts +40 -0
- package/runtime/tracing/db.ts +73 -0
- package/runtime/tracing/frames.ts +75 -0
- package/runtime/tracing/instrument.ts +186 -0
- package/runtime/tracing/span.ts +138 -0
- package/runtime/tracing/store.ts +499 -0
- package/runtime/tracing/types.ts +47 -0
- package/runtime/vite-plugin/config-plugin.ts +68 -0
- package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
- package/runtime/vite-plugin/dist/index.mjs +52333 -0
- package/runtime/vite-plugin/globals-plugin.ts +94 -0
- package/runtime/vite-plugin/index.ts +43 -0
- package/runtime/vite-plugin/modules-plugin.ts +88 -0
- package/runtime/vite-plugin/react-router-plugin.ts +95 -0
- 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
|
+
}
|