@repogo/plugins 0.1.33 → 0.1.35

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,1979 @@
1
+ // @bun
2
+ import {
3
+ MethodError,
4
+ toCallError
5
+ } from "./index-83w1fk10.js";
6
+
7
+ // src/testing.ts
8
+ import { mkdir as mkdir4, mkdtemp, rm as rm3 } from "fs/promises";
9
+ import { tmpdir } from "os";
10
+ import { join as join4 } from "path";
11
+
12
+ // src/logs.ts
13
+ import { AsyncLocalStorage } from "async_hooks";
14
+ import { format } from "util";
15
+ var logContext = new AsyncLocalStorage;
16
+ var activeRuns = [];
17
+ async function runWith(context, work) {
18
+ activeRuns.push(context);
19
+ try {
20
+ return await logContext.run(context, work);
21
+ } finally {
22
+ const i = activeRuns.lastIndexOf(context);
23
+ if (i >= 0)
24
+ activeRuns.splice(i, 1);
25
+ }
26
+ }
27
+ var textLimit = 8 * 1024;
28
+ var levels = {
29
+ log: "info",
30
+ info: "info",
31
+ debug: "debug",
32
+ warn: "warn",
33
+ error: "error"
34
+ };
35
+ var original = { log: console.log, info: console.info, debug: console.debug, warn: console.warn, error: console.error };
36
+ var sink = null;
37
+ function captureConsole(to, options = {}) {
38
+ sink = to;
39
+ for (const name of Object.keys(levels)) {
40
+ console[name] = (...args) => {
41
+ if (options.passThrough)
42
+ original[name](...args);
43
+ record(levels[name], args);
44
+ };
45
+ }
46
+ }
47
+ function releaseConsole() {
48
+ sink = null;
49
+ Object.assign(console, original);
50
+ }
51
+ function record(level, args, context) {
52
+ const where = context ?? logContext.getStore() ?? activeRuns.at(-1);
53
+ if (!sink || !where) {
54
+ original[level === "info" ? "log" : level](...args);
55
+ return;
56
+ }
57
+ const text = format(...args);
58
+ sink({
59
+ at: Date.now(),
60
+ plugin: where.plugin,
61
+ level,
62
+ method: where.method ?? "",
63
+ caller: where.caller ?? "",
64
+ callId: where.callId ?? "",
65
+ chatId: where.chatId ?? "",
66
+ text: text.length > textLimit ? text.slice(0, textLimit) : text,
67
+ data: args.map(carryable)
68
+ });
69
+ }
70
+ function carryable(value) {
71
+ if (value instanceof Error) {
72
+ return { name: value.name, message: value.message, stack: value.stack ?? "" };
73
+ }
74
+ if (typeof value === "bigint")
75
+ return value.toString();
76
+ if (typeof value === "function" || typeof value === "symbol" || value === undefined)
77
+ return String(value);
78
+ try {
79
+ JSON.stringify(value);
80
+ return value;
81
+ } catch {
82
+ return format(value);
83
+ }
84
+ }
85
+
86
+ // src/runtime.ts
87
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
88
+ import { mkdir as mkdir3, readdir as readdir3 } from "fs/promises";
89
+ import { join as join3 } from "path";
90
+ import { z as z2 } from "zod";
91
+
92
+ // src/buckets.ts
93
+ import { watch } from "fs";
94
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
95
+ import { join } from "path";
96
+
97
+ // src/store.ts
98
+ class Store {
99
+ options;
100
+ kind;
101
+ name;
102
+ tag;
103
+ plugins = new Set;
104
+ constructor(options, kind, name) {
105
+ this.options = options;
106
+ this.kind = kind;
107
+ this.name = name;
108
+ this.tag = `${options.namespace}.${kind}.${name}`;
109
+ }
110
+ close() {}
111
+ get sql() {
112
+ return this.options.database.sql;
113
+ }
114
+ async emit(event, payload) {
115
+ for (const plugin of this.plugins)
116
+ await this.options.events.emit(`${plugin}.${this.name}.${event}`, payload);
117
+ }
118
+ }
119
+ function tableName(kind, name) {
120
+ return `${kind}_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
121
+ }
122
+ var safeName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
123
+
124
+ // src/tracking.ts
125
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
126
+ var current = new AsyncLocalStorage2;
127
+ function currentAccess() {
128
+ return current.getStore();
129
+ }
130
+ async function tracked(readOnly, announce, work) {
131
+ const outer = current.getStore();
132
+ const outermost = !outer;
133
+ const access = outer && (outer.readOnly || !readOnly) ? outer : outer ? { ...outer, readOnly: true } : { reads: new Set, writes: new Set, readOnly, done: { value: false }, announce };
134
+ try {
135
+ const value = access === outer ? await work() : await current.run(access, work);
136
+ return { ok: true, value, access, outermost };
137
+ } catch (error) {
138
+ return { ok: false, error, access, outermost };
139
+ } finally {
140
+ if (outermost)
141
+ access.done.value = true;
142
+ }
143
+ }
144
+ function recordRead(tag) {
145
+ current.getStore()?.reads.add(tag);
146
+ }
147
+ function assertWritable(tag) {
148
+ if (current.getStore()?.readOnly) {
149
+ throw new MethodError("FORBIDDEN", `A query can't change data (it tried to write ${tag}).`);
150
+ }
151
+ }
152
+ function recordWrite(tag) {
153
+ const access = current.getStore();
154
+ if (!access)
155
+ return;
156
+ if (access.done.value)
157
+ access.announce([tag]);
158
+ else
159
+ access.writes.add(tag);
160
+ }
161
+
162
+ // src/buckets.ts
163
+ var metaFile = "meta.json";
164
+ var tmpFolder = ".tmp";
165
+ var watchSettleMs = 200;
166
+ var scanEveryMs = 60000;
167
+
168
+ class Bucket extends Store {
169
+ schema;
170
+ table;
171
+ searchTable;
172
+ folder;
173
+ watcher = null;
174
+ settling = null;
175
+ scanning = Promise.resolve();
176
+ lastScan = 0;
177
+ constructor(options, name, schema) {
178
+ super(options, "buckets", name);
179
+ this.schema = schema;
180
+ if ("folder" in schema.shape)
181
+ throw new Error(`bucket ${name}: "folder" is the file's place, not a meta field`);
182
+ this.table = tableName("buckets", name);
183
+ this.searchTable = `${this.table}_search`;
184
+ this.folder = join(options.dir, "buckets", name);
185
+ }
186
+ get path() {
187
+ return this.folder;
188
+ }
189
+ async setup() {
190
+ await mkdir(join(this.folder, tmpFolder), { recursive: true });
191
+ await this.sql.unsafe(`create table if not exists "${this.table}" (
192
+ id text primary key,
193
+ name text not null,
194
+ folder text not null,
195
+ size integer not null,
196
+ type text not null,
197
+ meta text not null,
198
+ status text not null,
199
+ problem text,
200
+ revision integer not null,
201
+ file_print text not null,
202
+ meta_print text not null,
203
+ created_at text not null,
204
+ updated_at text not null
205
+ )`);
206
+ await this.sql.unsafe(`create virtual table if not exists "${this.searchTable}" using fts5(id unindexed, name, folder, text)`);
207
+ await this.scan();
208
+ this.watcher = watch(this.folder, { recursive: true }, (_, file) => {
209
+ if (String(file ?? "").startsWith(tmpFolder))
210
+ return;
211
+ if (this.settling)
212
+ clearTimeout(this.settling);
213
+ this.settling = setTimeout(() => void this.scan(), watchSettleMs);
214
+ });
215
+ }
216
+ async put(file) {
217
+ assertWritable(this.tag);
218
+ checkName(file.name);
219
+ const meta = this.schema.parse(file.meta ?? {});
220
+ const id = Bun.randomUUIDv7();
221
+ const staging = join(this.folder, tmpFolder, id);
222
+ await mkdir(staging, { recursive: true });
223
+ await Bun.write(join(staging, file.name), file.body);
224
+ await writeMeta(staging, { ...meta, ...file.folder ? { folder: file.folder } : {} });
225
+ await rename(staging, join(this.folder, id));
226
+ const { after } = await this.reindex(id);
227
+ return after;
228
+ }
229
+ async get(id) {
230
+ recordRead(this.tag);
231
+ const rows = await this.sql.unsafe(`select * from "${this.table}" where id = $1`, [id]);
232
+ return rows[0] ? this.toFile(rows[0]) : null;
233
+ }
234
+ async file(id) {
235
+ const found = await this.get(id);
236
+ if (!found)
237
+ throw new MethodError("NOT_FOUND", `${this.name} has no file ${id}.`);
238
+ return Bun.file(found.path);
239
+ }
240
+ async list(options = {}) {
241
+ recordRead(this.tag);
242
+ const rows = await this.sql.unsafe(`select * from "${this.table}"
243
+ where ($1 is null or folder = $1) and ($2 is null or status = $2)
244
+ order by created_at desc, id desc limit $3`, [options.folder ?? null, options.status ?? null, options.limit ?? 100]);
245
+ return rows.map((row) => this.toFile(row));
246
+ }
247
+ async search(query, options = {}) {
248
+ recordRead(this.tag);
249
+ const words = query.match(/[\p{L}\p{N}]+/gu) ?? [];
250
+ if (words.length === 0)
251
+ return [];
252
+ const rows = await this.sql.unsafe(`select t.* from "${this.searchTable}" s join "${this.table}" t on t.id = s.id
253
+ where "${this.searchTable}" match $1 order by s.rank limit $2`, [words.map((word) => `"${word}"*`).join(" "), options.limit ?? 50]);
254
+ return rows.map((row) => this.toFile(row));
255
+ }
256
+ async update(id, meta, options = {}) {
257
+ assertWritable(this.tag);
258
+ return this.rewrite(id, options.revision, async (folder, current2) => {
259
+ const merged = Object.fromEntries(Object.entries({ ...current2, ...meta }).filter(([, value]) => value !== null));
260
+ const { folder: place, ...fields } = merged;
261
+ return { ...this.schema.parse(fields), ...place ? { folder: place } : {} };
262
+ });
263
+ }
264
+ async move(id, to, options = {}) {
265
+ assertWritable(this.tag);
266
+ if (to.name !== undefined)
267
+ checkName(to.name);
268
+ return this.rewrite(id, options.revision, async (folder, current2, name) => {
269
+ if (to.name !== undefined && to.name !== name)
270
+ await rename(join(folder, name), join(folder, to.name));
271
+ const { folder: _, ...fields } = current2;
272
+ return { ...fields, ...to.folder ?? current2.folder ? { folder: to.folder ?? current2.folder } : {} };
273
+ });
274
+ }
275
+ async delete(id) {
276
+ assertWritable(this.tag);
277
+ checkID(id);
278
+ await rm(join(this.folder, id), { recursive: true, force: true });
279
+ await this.reindex(id);
280
+ }
281
+ async tick(now) {
282
+ if (now - this.lastScan >= scanEveryMs)
283
+ await this.scan();
284
+ }
285
+ close() {
286
+ this.watcher?.close();
287
+ if (this.settling)
288
+ clearTimeout(this.settling);
289
+ }
290
+ scan() {
291
+ this.scanning = this.scanning.then(() => this.scanNow()).catch((error) => {
292
+ console.error(`[buckets] ${this.name}: scan failed:`, toCallError(error).message);
293
+ });
294
+ return this.scanning;
295
+ }
296
+ async scanNow() {
297
+ this.lastScan = Date.now();
298
+ const entries = await readdir(this.folder, { withFileTypes: true });
299
+ const ids = entries.filter((entry) => entry.isDirectory() && safeName.test(entry.name)).map((entry) => entry.name);
300
+ const rows = await this.sql.unsafe(`select id, file_print, meta_print from "${this.table}"`);
301
+ const known = new Map(rows.map((row) => [row.id, row]));
302
+ const present = new Set(ids);
303
+ const changed = [];
304
+ for (const id of ids) {
305
+ const row = known.get(id);
306
+ const prints = await fingerprints(join(this.folder, id));
307
+ if (!row || row.file_print !== prints.file || row.meta_print !== prints.meta)
308
+ changed.push(id);
309
+ }
310
+ const gone = rows.map((row) => row.id).filter((id) => !present.has(id));
311
+ for (const id of [...changed, ...gone])
312
+ await this.reindex(id, { announce: true });
313
+ }
314
+ async reindex(id, options = {}) {
315
+ const result = await this.options.database.transaction(async () => {
316
+ const before2 = (await this.sql.unsafe(`select * from "${this.table}" where id = $1`, [id]))[0];
317
+ const disk = await this.read(id);
318
+ if (!disk) {
319
+ if (!before2)
320
+ return { before: null, after: null };
321
+ await this.sql.unsafe(`delete from "${this.table}" where id = $1`, [id]);
322
+ await this.sql.unsafe(`delete from "${this.searchTable}" where id = $1`, [id]);
323
+ recordWrite(this.tag);
324
+ return { before: before2, after: null };
325
+ }
326
+ if (before2 && before2.file_print === disk.file_print && before2.meta_print === disk.meta_print) {
327
+ return { before: before2, after: before2 };
328
+ }
329
+ const row = {
330
+ ...disk,
331
+ revision: (before2?.revision ?? 0) + 1,
332
+ created_at: before2?.created_at ?? disk.createdAt,
333
+ updated_at: disk.updatedAt
334
+ };
335
+ await this.sql.unsafe(`insert or replace into "${this.table}"
336
+ (id, name, folder, size, type, meta, status, problem, revision, file_print, meta_print, created_at, updated_at)
337
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, [
338
+ row.id,
339
+ row.name,
340
+ row.folder,
341
+ row.size,
342
+ row.type,
343
+ row.meta,
344
+ row.status,
345
+ row.problem,
346
+ row.revision,
347
+ row.file_print,
348
+ row.meta_print,
349
+ row.created_at,
350
+ row.updated_at
351
+ ]);
352
+ await this.sql.unsafe(`delete from "${this.searchTable}" where id = $1`, [id]);
353
+ await this.sql.unsafe(`insert into "${this.searchTable}" (id, name, folder, text) values ($1, $2, $3, $4)`, [
354
+ id,
355
+ row.name,
356
+ row.folder,
357
+ searchText(JSON.parse(row.meta))
358
+ ]);
359
+ recordWrite(this.tag);
360
+ return { before: before2, after: row };
361
+ });
362
+ const { before, after } = result;
363
+ if (before === after)
364
+ return { before: before && this.toFile(before), after: after && this.toFile(after) };
365
+ if (options.announce)
366
+ this.options.announce([this.tag]);
367
+ const file = after && this.toFile(after);
368
+ if (!before)
369
+ await this.emit("created", file);
370
+ else if (!after)
371
+ await this.emit("deleted", this.toFile(before));
372
+ else {
373
+ if (before.file_print !== after.file_print)
374
+ await this.emit("changed", file);
375
+ if (before.meta_print !== after.meta_print)
376
+ await this.emit("metaChanged", file);
377
+ }
378
+ return { before: before && this.toFile(before), after: file };
379
+ }
380
+ async read(id) {
381
+ const folder = join(this.folder, id);
382
+ const names = await readdir(folder).catch(() => null);
383
+ if (!names)
384
+ return null;
385
+ const files = names.filter((name) => name !== metaFile && !name.startsWith("."));
386
+ const prints = await fingerprints(folder);
387
+ const info = await stat(folder);
388
+ const file = files.length === 1 ? await stat(join(folder, files[0])) : null;
389
+ const disk = {
390
+ id,
391
+ name: files[0] ?? "",
392
+ folder: "",
393
+ size: file?.size ?? 0,
394
+ type: files.length === 1 ? Bun.file(join(folder, files[0])).type : "",
395
+ meta: "{}",
396
+ status: "empty",
397
+ problem: null,
398
+ file_print: prints.file,
399
+ meta_print: prints.meta,
400
+ createdAt: info.birthtime.toISOString(),
401
+ updatedAt: new Date(Math.max(info.mtimeMs, file?.mtimeMs ?? 0, prints.metaMtimeMs)).toISOString()
402
+ };
403
+ const raw = await readFile(join(folder, metaFile), "utf8").catch(() => null);
404
+ let meta = {};
405
+ if (raw !== null) {
406
+ try {
407
+ const parsed = JSON.parse(raw);
408
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
409
+ throw new Error("meta.json is not an object");
410
+ meta = parsed;
411
+ } catch (error) {
412
+ return { ...disk, status: "malformed", problem: `meta.json: ${error.message}` };
413
+ }
414
+ }
415
+ const { folder: place, ...fields } = meta;
416
+ disk.folder = typeof place === "string" ? place : "";
417
+ disk.meta = JSON.stringify(fields);
418
+ if (files.length !== 1) {
419
+ return { ...disk, status: "malformed", problem: `A file's folder holds one file and meta.json; found ${files.length}.` };
420
+ }
421
+ if (Object.keys(fields).length === 0)
422
+ return disk;
423
+ const checked = this.schema.safeParse(fields);
424
+ if (!checked.success)
425
+ return { ...disk, status: "invalid", problem: toCallError(checked.error).message };
426
+ return { ...disk, status: "ok" };
427
+ }
428
+ async rewrite(id, revision, change) {
429
+ checkID(id);
430
+ const folder = join(this.folder, id);
431
+ await this.options.database.transaction(async () => {
432
+ const row = (await this.sql.unsafe(`select * from "${this.table}" where id = $1`, [id]))[0];
433
+ if (!row)
434
+ throw new MethodError("NOT_FOUND", `${this.name} has no file ${id}.`);
435
+ if (revision !== undefined && row.revision !== revision) {
436
+ throw new MethodError("VALIDATION", `${this.name} ${id} changed since it was read (revision ${row.revision}, not ${revision}).`);
437
+ }
438
+ const raw = await readFile(join(folder, metaFile), "utf8").catch(() => "{}");
439
+ await writeMeta(folder, await change(folder, JSON.parse(raw), row.name));
440
+ });
441
+ const { after } = await this.reindex(id);
442
+ return after;
443
+ }
444
+ toFile(row) {
445
+ return {
446
+ id: row.id,
447
+ name: row.name,
448
+ folder: row.folder,
449
+ path: join(this.folder, row.id, row.name),
450
+ size: row.size,
451
+ type: row.type,
452
+ meta: JSON.parse(row.meta),
453
+ status: row.status,
454
+ problem: row.problem,
455
+ revision: row.revision,
456
+ createdAt: row.created_at,
457
+ updatedAt: row.updated_at
458
+ };
459
+ }
460
+ }
461
+ async function writeMeta(folder, meta) {
462
+ const temp = join(folder, `.${metaFile}.${crypto.randomUUID()}`);
463
+ await writeFile(temp, JSON.stringify(meta, null, 2) + `
464
+ `);
465
+ await rename(temp, join(folder, metaFile));
466
+ }
467
+ async function fingerprints(folder) {
468
+ const names = (await readdir(folder).catch(() => [])).filter((name) => !name.startsWith(".")).sort();
469
+ const file = [];
470
+ let meta = "";
471
+ let metaMtimeMs = 0;
472
+ for (const name of names) {
473
+ const info = await stat(join(folder, name), { bigint: true }).catch(() => null);
474
+ if (!info)
475
+ continue;
476
+ const print = `${name}:${info.size}:${info.mtimeNs}`;
477
+ if (name === metaFile) {
478
+ meta = print;
479
+ metaMtimeMs = Number(info.mtimeMs);
480
+ } else
481
+ file.push(print);
482
+ }
483
+ return { file: file.join("|"), meta, metaMtimeMs };
484
+ }
485
+ function searchText(value) {
486
+ if (typeof value === "string" || typeof value === "number")
487
+ return String(value);
488
+ if (Array.isArray(value))
489
+ return value.map(searchText).join(" ");
490
+ if (value && typeof value === "object")
491
+ return Object.values(value).map(searchText).join(" ");
492
+ return "";
493
+ }
494
+ function checkID(id) {
495
+ if (!safeName.test(id))
496
+ throw new MethodError("VALIDATION", `Not a file id: ${id}`);
497
+ }
498
+ function checkName(name) {
499
+ if (!/^[^/\\\0.][^/\\\0]{0,254}$/.test(name) || name === metaFile) {
500
+ throw new MethodError("VALIDATION", `A file name has no slashes, doesn't start with a dot and isn't meta.json: ${name}`);
501
+ }
502
+ }
503
+
504
+ // src/collections.ts
505
+ class Collection {
506
+ database;
507
+ events;
508
+ plugin;
509
+ name;
510
+ schema;
511
+ table;
512
+ tag;
513
+ constructor(database, events, plugin, namespace, name, schema) {
514
+ this.database = database;
515
+ this.events = events;
516
+ this.plugin = plugin;
517
+ this.name = name;
518
+ this.schema = schema;
519
+ this.table = name.replace(/[^a-zA-Z0-9_]/g, "_");
520
+ this.tag = `${namespace}.${name}`;
521
+ }
522
+ async setup() {
523
+ await this.sql.unsafe(`create table if not exists "${this.table}" (
524
+ id text primary key,
525
+ data text not null,
526
+ created_at text not null,
527
+ updated_at text not null
528
+ )`);
529
+ }
530
+ get sql() {
531
+ return this.database.sql;
532
+ }
533
+ async create(input) {
534
+ assertWritable(this.tag);
535
+ const record2 = await this.database.transaction(async () => {
536
+ const data = this.schema.parse(input);
537
+ const now = new Date().toISOString();
538
+ const record3 = { ...data, id: crypto.randomUUID(), createdAt: now, updatedAt: now };
539
+ await this.sql.unsafe(`insert into "${this.table}" (id, data, created_at, updated_at) values ($1, $2, $3, $4)`, [
540
+ record3.id,
541
+ JSON.stringify(data),
542
+ now,
543
+ now
544
+ ]);
545
+ recordWrite(this.tag);
546
+ return record3;
547
+ });
548
+ await this.events.emit(this.eventName("created"), record2);
549
+ return record2;
550
+ }
551
+ async get(id) {
552
+ recordRead(this.tag);
553
+ const rows = await this.sql.unsafe(`select * from "${this.table}" where id = $1`, [id]);
554
+ return rows[0] ? this.toRecord(rows[0]) : null;
555
+ }
556
+ async update(id, patch) {
557
+ assertWritable(this.tag);
558
+ const { before, after } = await this.database.transaction(async () => {
559
+ const before2 = await this.get(id);
560
+ if (!before2)
561
+ throw new Error(`${this.name} ${id} not found`);
562
+ const { id: _, createdAt, updatedAt: __, ...fields } = before2;
563
+ const data = this.schema.parse({ ...fields, ...patch });
564
+ const now = new Date().toISOString();
565
+ await this.sql.unsafe(`update "${this.table}" set data = $1, updated_at = $2 where id = $3`, [JSON.stringify(data), now, id]);
566
+ recordWrite(this.tag);
567
+ return { before: before2, after: { ...data, id, createdAt, updatedAt: now } };
568
+ });
569
+ await this.events.emit(this.eventName("updated"), { before, after });
570
+ return after;
571
+ }
572
+ async delete(id) {
573
+ assertWritable(this.tag);
574
+ const before = await this.database.transaction(async () => {
575
+ const before2 = await this.get(id);
576
+ if (!before2)
577
+ return null;
578
+ await this.sql.unsafe(`delete from "${this.table}" where id = $1`, [id]);
579
+ recordWrite(this.tag);
580
+ return before2;
581
+ });
582
+ if (before)
583
+ await this.events.emit(this.eventName("deleted"), before);
584
+ }
585
+ async list(options = {}) {
586
+ recordRead(this.tag);
587
+ const { sql, params } = this.select(options);
588
+ const rows = await this.sql.unsafe(sql, params);
589
+ return rows.map((row) => this.toRecord(row));
590
+ }
591
+ async deleteMany(options = {}) {
592
+ assertWritable(this.tag);
593
+ const deleted = await this.database.transaction(async () => {
594
+ const { sql, params } = this.select(options);
595
+ const rows = (await this.sql.unsafe(sql, params)).map((row) => this.toRecord(row));
596
+ if (!rows.length)
597
+ return rows;
598
+ const ids = rows.map((row) => row.id);
599
+ await this.sql.unsafe(`delete from "${this.table}" where id in (${ids.map((_, i) => `$${i + 1}`).join(", ")})`, ids);
600
+ recordWrite(this.tag);
601
+ return rows;
602
+ });
603
+ for (const row of deleted)
604
+ await this.events.emit(this.eventName("deleted"), row);
605
+ return deleted.length;
606
+ }
607
+ select(options) {
608
+ const params = [];
609
+ const field = (name) => {
610
+ if (/["\\]/.test(name))
611
+ throw new Error(`bad field name: ${name}`);
612
+ params.push(`$."${name}"`);
613
+ return `json_extract(data, $${params.length})`;
614
+ };
615
+ const clauses = Object.entries(options.where ?? {}).map(([name, value]) => {
616
+ const path = field(name);
617
+ if (value === null)
618
+ return `${path} is null`;
619
+ params.push(typeof value === "boolean" ? Number(value) : value);
620
+ return `${path} = $${params.length}`;
621
+ });
622
+ const where = clauses.length ? `where ${clauses.join(" and ")}` : "";
623
+ const orderBy = options.orderBy ?? "createdAt";
624
+ const columns = { id: "id", createdAt: "created_at", updatedAt: "updated_at" };
625
+ const column = columns[orderBy] ?? field(orderBy);
626
+ const order = options.order === "asc" ? "asc" : "desc";
627
+ params.push(options.limit ?? 100, options.offset ?? 0);
628
+ return {
629
+ sql: `select * from "${this.table}" ${where} order by ${column} ${order}, id ${order} limit $${params.length - 1} offset $${params.length}`,
630
+ params
631
+ };
632
+ }
633
+ eventName(kind) {
634
+ return `${this.plugin}.${this.name}.${kind}`;
635
+ }
636
+ toRecord(row) {
637
+ return { ...JSON.parse(row.data), id: row.id, createdAt: row.created_at, updatedAt: row.updated_at };
638
+ }
639
+ }
640
+
641
+ // src/cron.ts
642
+ var ranges = { second: [0, 59], minute: [0, 59], hour: [0, 23], day: [1, 31], month: [1, 12], weekday: [0, 7] };
643
+ function parseSchedule(source) {
644
+ const fields = source.trim().split(/\s+/);
645
+ if (fields.length !== 5 && fields.length !== 6) {
646
+ throw new Error(`"${source}" has ${fields.length} fields; a cron has 5 (minute hour day month weekday), or 6 with seconds first`);
647
+ }
648
+ const [second, minute, hour, day, month, weekday] = fields.length === 6 ? fields : [null, ...fields];
649
+ const weekdays = field(weekday, "weekday");
650
+ if (weekdays.has(7)) {
651
+ weekdays.delete(7);
652
+ weekdays.add(0);
653
+ }
654
+ return {
655
+ seconds: second === null ? null : field(second, "second"),
656
+ minutes: field(minute, "minute"),
657
+ hours: field(hour, "hour"),
658
+ days: field(day, "day"),
659
+ months: field(month, "month"),
660
+ weekdays,
661
+ daysRestricted: day !== "*",
662
+ weekdaysRestricted: weekday !== "*"
663
+ };
664
+ }
665
+ function field(source, name) {
666
+ const [min, max] = ranges[name];
667
+ const out = new Set;
668
+ for (const part of source.split(",")) {
669
+ const [span, stepText] = part.split("/");
670
+ const step = stepText === undefined ? 1 : whole(stepText, name);
671
+ if (step < 1)
672
+ throw new Error(`the ${name} step in "${part}" must be at least 1`);
673
+ let [from, to] = [min, max];
674
+ if (span !== "*") {
675
+ const [a, b] = span.split("-");
676
+ from = whole(a, name);
677
+ to = b === undefined ? stepText === undefined ? from : max : whole(b, name);
678
+ }
679
+ if (from < min || to > max || from > to)
680
+ throw new Error(`the ${name} "${part}" is outside ${min}-${max}`);
681
+ for (let v = from;v <= to; v += step)
682
+ out.add(v);
683
+ }
684
+ return out;
685
+ }
686
+ function whole(text, name) {
687
+ if (!/^\d+$/.test(text))
688
+ throw new Error(`the ${name} "${text}" isn't a whole number`);
689
+ return Number(text);
690
+ }
691
+ function checkTimezone(zone) {
692
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
693
+ }
694
+ var formats = new Map;
695
+ function wall(ms, zone) {
696
+ let format2 = formats.get(zone);
697
+ if (!format2) {
698
+ format2 = new Intl.DateTimeFormat("en-US", {
699
+ timeZone: zone,
700
+ hourCycle: "h23",
701
+ weekday: "short",
702
+ year: "numeric",
703
+ month: "numeric",
704
+ day: "numeric",
705
+ hour: "numeric",
706
+ minute: "numeric",
707
+ second: "numeric"
708
+ });
709
+ formats.set(zone, format2);
710
+ }
711
+ const parts = {};
712
+ for (const p of format2.formatToParts(new Date(ms)))
713
+ parts[p.type] = p.value;
714
+ return {
715
+ year: Number(parts.year),
716
+ month: Number(parts.month),
717
+ day: Number(parts.day),
718
+ weekday: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(parts.weekday),
719
+ hour: Number(parts.hour),
720
+ minute: Number(parts.minute),
721
+ second: Number(parts.second)
722
+ };
723
+ }
724
+ var minute = 60000;
725
+ var horizon = 4 * 366 * 24 * 60 * minute;
726
+ function nextDue(s, after, zone) {
727
+ let at = Math.floor(after / minute) * minute;
728
+ const end = after + horizon;
729
+ if (s.seconds) {
730
+ const first = firstSecond(s, at, after, zone);
731
+ if (first !== null)
732
+ return first;
733
+ }
734
+ at += minute;
735
+ while (at < end) {
736
+ const w = wall(at, zone);
737
+ if (!s.months.has(w.month)) {
738
+ at += untilNextDay(w);
739
+ continue;
740
+ }
741
+ if (!dayMatches(s, w)) {
742
+ at += untilNextDay(w);
743
+ continue;
744
+ }
745
+ if (!s.hours.has(w.hour)) {
746
+ at += (60 - w.minute) * minute;
747
+ continue;
748
+ }
749
+ if (!s.minutes.has(w.minute) || repeated(at, w, zone)) {
750
+ at += minute;
751
+ continue;
752
+ }
753
+ if (!s.seconds)
754
+ return at;
755
+ const first = firstSecond(s, at, at - 1, zone);
756
+ if (first !== null)
757
+ return first;
758
+ at += minute;
759
+ }
760
+ return null;
761
+ }
762
+ function firstSecond(s, start, after, zone) {
763
+ const w = wall(start, zone);
764
+ if (!s.months.has(w.month) || !dayMatches(s, w) || !s.hours.has(w.hour) || !s.minutes.has(w.minute) || repeated(start, w, zone))
765
+ return null;
766
+ for (let second = 0;second < 60; second++) {
767
+ const at = start + second * 1000;
768
+ if (s.seconds.has(second) && at > after)
769
+ return at;
770
+ }
771
+ return null;
772
+ }
773
+ function dayMatches(s, w) {
774
+ const day = s.days.has(w.day), weekday = s.weekdays.has(w.weekday);
775
+ if (s.daysRestricted && s.weekdaysRestricted)
776
+ return day || weekday;
777
+ return day && weekday;
778
+ }
779
+ function untilNextDay(w) {
780
+ return ((23 - w.hour) * 60 + (60 - w.minute)) * minute;
781
+ }
782
+ function repeated(at, w, zone) {
783
+ for (const back of [60, 120]) {
784
+ const earlier = wall(at - back * minute, zone);
785
+ if (earlier.day === w.day && earlier.hour === w.hour && earlier.minute === w.minute)
786
+ return true;
787
+ }
788
+ return false;
789
+ }
790
+
791
+ // src/database.ts
792
+ var {SQL } = globalThis.Bun;
793
+
794
+ class Database {
795
+ sql;
796
+ writes = Promise.resolve();
797
+ constructor(sql) {
798
+ this.sql = sql;
799
+ }
800
+ static async open(path) {
801
+ const sql = new SQL(`sqlite://${path}`);
802
+ await sql.unsafe("pragma journal_mode = wal");
803
+ await sql.unsafe("pragma busy_timeout = 5000");
804
+ return new Database(sql);
805
+ }
806
+ transaction(work) {
807
+ const result = this.writes.then(() => this.run(work), () => this.run(work));
808
+ this.writes = result.catch(() => {});
809
+ return result;
810
+ }
811
+ close() {
812
+ this.sql.close();
813
+ }
814
+ async run(work) {
815
+ await this.sql.unsafe("begin immediate");
816
+ try {
817
+ const value = await work();
818
+ await this.sql.unsafe("commit");
819
+ return value;
820
+ } catch (error) {
821
+ await this.sql.unsafe("rollback").catch(() => {});
822
+ throw error;
823
+ }
824
+ }
825
+ }
826
+
827
+ // src/duration.ts
828
+ var unitMs = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
829
+ function parseDuration(duration) {
830
+ if (typeof duration === "number") {
831
+ if (!Number.isFinite(duration) || duration <= 0)
832
+ throw new Error(`a duration is a positive number of ms: ${duration}`);
833
+ return duration;
834
+ }
835
+ const match = /^(\d+(?:\.\d+)?)([smhd])$/.exec(duration);
836
+ if (!match)
837
+ throw new Error(`a duration is like "30s", "5m", "1h" or "7d": ${duration}`);
838
+ const ms = Number(match[1]) * unitMs[match[2]];
839
+ if (ms <= 0)
840
+ throw new Error(`a duration is longer than zero: ${duration}`);
841
+ return ms;
842
+ }
843
+
844
+ // src/events.ts
845
+ class EventBus {
846
+ listeners = new Map;
847
+ all = [];
848
+ on(name, listener) {
849
+ const list = this.listeners.get(name) ?? [];
850
+ list.push(listener);
851
+ this.listeners.set(name, list);
852
+ }
853
+ onAny(listener) {
854
+ this.all.push(listener);
855
+ }
856
+ async emit(name, payload) {
857
+ const event = { name, payload, at: new Date().toISOString() };
858
+ const listeners = [...this.listeners.get(name) ?? [], ...this.all];
859
+ const results = await Promise.allSettled(listeners.map(async (listener) => listener(event)));
860
+ for (const result of results) {
861
+ if (result.status === "rejected")
862
+ console.error(`[events] ${name}:`, result.reason);
863
+ }
864
+ }
865
+ }
866
+
867
+ // src/generated.ts
868
+ import { isAbsolute } from "path";
869
+ import { z } from "zod";
870
+ var limit = z.number().int().positive().max(500).optional();
871
+ var ttl = z.union([z.number().positive(), z.string().regex(/^\d+(\.\d+)?[smhd]$/)]).optional();
872
+ function kvMethods(name, kv, schema) {
873
+ const key = z.string().min(1);
874
+ const entry = z.object({ key: z.string(), value: schema, expiresAt: z.string().nullable(), createdAt: z.string(), updatedAt: z.string() });
875
+ return {
876
+ get: {
877
+ kind: "query",
878
+ description: `Get one ${name} entry by key, or null`,
879
+ input: z.object({ key }),
880
+ output: entry.nullable(),
881
+ run: ({ key: key2 }) => kv.entry(key2)
882
+ },
883
+ list: {
884
+ kind: "query",
885
+ description: `List ${name} entries in key order, optionally by prefix`,
886
+ input: z.object({ prefix: z.string().optional(), limit }),
887
+ output: z.array(entry),
888
+ run: (input) => kv.list(input)
889
+ },
890
+ set: {
891
+ kind: "mutation",
892
+ description: `Set a ${name} key; ttl ("5m", "1h", "7d" or ms) makes it expire`,
893
+ input: z.object({ key, value: schema, ttl }),
894
+ output: entry,
895
+ run: ({ key: key2, value, ttl: ttl2 }) => kv.set(key2, value, { ttl: ttl2 })
896
+ },
897
+ delete: {
898
+ kind: "mutation",
899
+ description: `Delete a ${name} key`,
900
+ input: z.object({ key }),
901
+ output: z.null(),
902
+ confirm: "Delete this key?",
903
+ run: async ({ key: key2 }) => (await kv.delete(key2), null)
904
+ }
905
+ };
906
+ }
907
+ function streamMethods(name, stream, schema) {
908
+ const key = z.string().min(1);
909
+ const line = z.object({ id: z.string(), key: z.string(), offset: z.number(), at: z.string(), data: schema });
910
+ return {
911
+ tail: {
912
+ kind: "stream",
913
+ description: `Follow a ${name} key live: its last lines (100 unless last says), then each new one, until it ends`,
914
+ input: z.object({ key, from: z.number().int().min(0).optional(), last: z.number().int().positive().max(500).optional() }),
915
+ run: ({ key: key2, from, last }, signal) => stream.follow(key2, { from, last, signal })
916
+ },
917
+ read: {
918
+ kind: "query",
919
+ description: `Read a ${name} key's lines from an offset`,
920
+ input: z.object({ key, from: z.number().int().min(0).optional(), limit }),
921
+ output: z.array(line),
922
+ run: ({ key: key2, ...options }) => stream.read(key2, options)
923
+ },
924
+ keys: {
925
+ kind: "query",
926
+ description: `List ${name} keys, most recently written first`,
927
+ input: z.object({ limit }),
928
+ output: z.array(z.object({ key: z.string(), lines: z.number(), bytes: z.number(), endedAt: z.string().nullable(), updatedAt: z.string() })),
929
+ run: (input) => stream.keys(input)
930
+ },
931
+ append: {
932
+ kind: "mutation",
933
+ description: `Append a line to a ${name} key`,
934
+ input: z.object({ key, data: schema }),
935
+ output: line,
936
+ run: ({ key: key2, data }) => stream.append(key2, data)
937
+ },
938
+ end: {
939
+ kind: "mutation",
940
+ description: `End a ${name} key: followers stop and appends fail`,
941
+ input: z.object({ key }),
942
+ output: z.null(),
943
+ run: async ({ key: key2 }) => (await stream.end(key2), null)
944
+ },
945
+ delete: {
946
+ kind: "mutation",
947
+ description: `Delete a ${name} key and its lines`,
948
+ input: z.object({ key }),
949
+ output: z.null(),
950
+ confirm: "Delete this stream and its lines?",
951
+ run: async ({ key: key2 }) => (await stream.delete(key2), null)
952
+ }
953
+ };
954
+ }
955
+ function bucketMethods(name, bucket, schema) {
956
+ const id = z.string().min(1);
957
+ const status = z.enum(["ok", "empty", "invalid", "malformed"]);
958
+ const file = z.object({
959
+ id: z.string(),
960
+ name: z.string(),
961
+ folder: z.string(),
962
+ path: z.string(),
963
+ size: z.number(),
964
+ type: z.string(),
965
+ meta: schema.partial(),
966
+ status,
967
+ problem: z.string().nullable(),
968
+ revision: z.number(),
969
+ createdAt: z.string(),
970
+ updatedAt: z.string()
971
+ });
972
+ const revision = z.number().int().optional();
973
+ return {
974
+ list: {
975
+ kind: "query",
976
+ description: `List ${name} files, newest first, optionally in one folder or with one status`,
977
+ input: z.object({ folder: z.string().optional(), status: status.optional(), limit }),
978
+ output: z.array(file),
979
+ run: (input) => bucket.list(input)
980
+ },
981
+ search: {
982
+ kind: "query",
983
+ description: `Search ${name} files by name, folder and meta`,
984
+ input: z.object({ query: z.string(), limit }),
985
+ output: z.array(file),
986
+ run: ({ query, limit: limit2 }) => bucket.search(query, { limit: limit2 })
987
+ },
988
+ get: {
989
+ kind: "query",
990
+ description: `Get one ${name} file by id, or null`,
991
+ input: z.object({ id }),
992
+ output: file.nullable(),
993
+ run: ({ id: id2 }) => bucket.get(id2)
994
+ },
995
+ put: {
996
+ kind: "mutation",
997
+ description: `Add a file to ${name}: its name, and text or an absolute path to copy`,
998
+ input: z.object({
999
+ name: z.string(),
1000
+ folder: z.string().optional(),
1001
+ text: z.string().optional(),
1002
+ from: z.string().refine(isAbsolute, "from is an absolute path").optional(),
1003
+ meta: schema.optional()
1004
+ }),
1005
+ output: file,
1006
+ run: async ({ name: name2, folder, text, from, meta }) => {
1007
+ if (text === undefined === (from === undefined))
1008
+ throw new MethodError("VALIDATION", "Send text or from, one of them.");
1009
+ return bucket.put({ name: name2, folder, meta, body: text ?? Bun.file(from) });
1010
+ }
1011
+ },
1012
+ update: {
1013
+ kind: "mutation",
1014
+ description: `Change fields of a ${name} file's meta; null removes one`,
1015
+ input: z.object({ id, meta: z.record(z.string(), z.unknown()), revision }),
1016
+ output: file,
1017
+ run: ({ id: id2, meta, revision: revision2 }) => bucket.update(id2, meta, { revision: revision2 })
1018
+ },
1019
+ move: {
1020
+ kind: "mutation",
1021
+ description: `Rename a ${name} file or move it to another folder`,
1022
+ input: z.object({ id, name: z.string().optional(), folder: z.string().optional(), revision }),
1023
+ output: file,
1024
+ run: ({ id: id2, name: name2, folder, revision: revision2 }) => bucket.move(id2, { name: name2, folder }, { revision: revision2 })
1025
+ },
1026
+ delete: {
1027
+ kind: "mutation",
1028
+ description: `Delete a ${name} file and its meta`,
1029
+ input: z.object({ id }),
1030
+ output: z.null(),
1031
+ confirm: "Delete this file?",
1032
+ run: async ({ id: id2 }) => (await bucket.delete(id2), null)
1033
+ }
1034
+ };
1035
+ }
1036
+
1037
+ // src/kv.ts
1038
+ class Kv extends Store {
1039
+ schema;
1040
+ table;
1041
+ constructor(options, name, schema) {
1042
+ super(options, "kv", name);
1043
+ this.schema = schema;
1044
+ this.table = tableName("kv", name);
1045
+ }
1046
+ async setup() {
1047
+ await this.sql.unsafe(`create table if not exists "${this.table}" (
1048
+ key text primary key,
1049
+ value text not null,
1050
+ expires_at integer,
1051
+ created_at text not null,
1052
+ updated_at text not null
1053
+ )`);
1054
+ }
1055
+ async get(key) {
1056
+ return (await this.entry(key))?.value ?? null;
1057
+ }
1058
+ async entry(key) {
1059
+ recordRead(this.tag);
1060
+ const row = await this.live(key);
1061
+ return row ? toEntry(row) : null;
1062
+ }
1063
+ async set(key, value, options = {}) {
1064
+ assertWritable(this.tag);
1065
+ const parsed = this.schema.parse(value);
1066
+ const expiresAt = options.ttl === undefined ? null : Date.now() + parseDuration(options.ttl);
1067
+ const { entry, created } = await this.options.database.transaction(async () => {
1068
+ const before = await this.live(key);
1069
+ const now = new Date().toISOString();
1070
+ const row = {
1071
+ key,
1072
+ value: JSON.stringify(parsed),
1073
+ expires_at: expiresAt,
1074
+ created_at: before?.created_at ?? now,
1075
+ updated_at: now
1076
+ };
1077
+ await this.sql.unsafe(`insert into "${this.table}" (key, value, expires_at, created_at, updated_at) values ($1, $2, $3, $4, $5)
1078
+ on conflict (key) do update set value = excluded.value, expires_at = excluded.expires_at,
1079
+ created_at = excluded.created_at, updated_at = excluded.updated_at`, [row.key, row.value, row.expires_at, row.created_at, row.updated_at]);
1080
+ recordWrite(this.tag);
1081
+ return { entry: toEntry(row), created: !before };
1082
+ });
1083
+ await this.emit(created ? "created" : "updated", entry);
1084
+ return entry;
1085
+ }
1086
+ async delete(key) {
1087
+ assertWritable(this.tag);
1088
+ const before = await this.options.database.transaction(async () => {
1089
+ const before2 = await this.live(key);
1090
+ await this.sql.unsafe(`delete from "${this.table}" where key = $1`, [key]);
1091
+ if (before2)
1092
+ recordWrite(this.tag);
1093
+ return before2;
1094
+ });
1095
+ if (before)
1096
+ await this.emit("deleted", toEntry(before));
1097
+ }
1098
+ async list(options = {}) {
1099
+ recordRead(this.tag);
1100
+ const prefix = options.prefix ?? "";
1101
+ const rows = await this.sql.unsafe(`select * from "${this.table}"
1102
+ where substr(key, 1, length($1)) = $1 and (expires_at is null or expires_at > $2)
1103
+ order by key limit $3`, [prefix, Date.now(), options.limit ?? 100]);
1104
+ return rows.map(toEntry);
1105
+ }
1106
+ async tick(now) {
1107
+ const expired = await this.options.database.transaction(async () => {
1108
+ const rows = await this.sql.unsafe(`select * from "${this.table}" where expires_at <= $1`, [now]);
1109
+ if (rows.length > 0)
1110
+ await this.sql.unsafe(`delete from "${this.table}" where expires_at <= $1`, [now]);
1111
+ return rows;
1112
+ });
1113
+ if (expired.length === 0)
1114
+ return;
1115
+ this.options.announce([this.tag]);
1116
+ for (const row of expired)
1117
+ await this.emit("deleted", toEntry(row));
1118
+ }
1119
+ async live(key) {
1120
+ const rows = await this.sql.unsafe(`select * from "${this.table}" where key = $1 and (expires_at is null or expires_at > $2)`, [key, Date.now()]);
1121
+ return rows[0] ?? null;
1122
+ }
1123
+ }
1124
+ function toEntry(row) {
1125
+ return {
1126
+ key: row.key,
1127
+ value: JSON.parse(row.value),
1128
+ expiresAt: row.expires_at === null ? null : new Date(row.expires_at).toISOString(),
1129
+ createdAt: row.created_at,
1130
+ updatedAt: row.updated_at
1131
+ };
1132
+ }
1133
+
1134
+ // src/streams.ts
1135
+ import { appendFile, mkdir as mkdir2, readdir as readdir2, readFile as readFile2, rm as rm2, stat as stat2, truncate } from "fs/promises";
1136
+ import { join as join2 } from "path";
1137
+ var defaultRetain = { age: "7d", bytes: 50000000 };
1138
+ var segmentBytes = 1e6;
1139
+ var retainEveryMs = 60000;
1140
+ var followPollMs = 1000;
1141
+
1142
+ class Stream extends Store {
1143
+ schema;
1144
+ table;
1145
+ folder;
1146
+ ageMs;
1147
+ maxBytes;
1148
+ lastRetain = 0;
1149
+ waiting = new Map;
1150
+ constructor(options, name, schema, retain = defaultRetain) {
1151
+ super(options, "streams", name);
1152
+ this.schema = schema;
1153
+ this.table = tableName("streams", name);
1154
+ this.folder = join2(options.dir, "streams", name);
1155
+ this.ageMs = retain === "forever" || retain.age === undefined ? null : parseDuration(retain.age);
1156
+ this.maxBytes = retain === "forever" ? null : retain.bytes ?? null;
1157
+ }
1158
+ get path() {
1159
+ return this.folder;
1160
+ }
1161
+ async setup() {
1162
+ await mkdir2(this.folder, { recursive: true });
1163
+ await this.sql.unsafe(`create table if not exists "${this.table}" (
1164
+ key text primary key,
1165
+ first_offset integer not null,
1166
+ next_offset integer not null,
1167
+ segment integer not null,
1168
+ segment_bytes integer not null,
1169
+ bytes integer not null,
1170
+ ended_at text,
1171
+ updated_at text not null
1172
+ )`);
1173
+ }
1174
+ async append(key, data) {
1175
+ assertWritable(this.tag);
1176
+ checkKey(key);
1177
+ const parsed = this.schema.parse(data);
1178
+ const entry = await this.options.database.transaction(async () => {
1179
+ const row = await this.row(key) ?? await this.start(key);
1180
+ if (row.ended_at)
1181
+ throw new MethodError("VALIDATION", `${this.name} ${key} has ended; append to a new key.`);
1182
+ await this.cutUncommitted(row);
1183
+ const at = new Date().toISOString();
1184
+ const line = JSON.stringify({ offset: row.next_offset, at, data: parsed }) + `
1185
+ `;
1186
+ const size = Buffer.byteLength(line);
1187
+ const segment = row.segment_bytes > 0 && row.segment_bytes + size > segmentBytes ? row.next_offset : row.segment;
1188
+ const written = segment === row.segment ? row.segment_bytes : 0;
1189
+ await appendFile(this.segmentPath(key, segment), line);
1190
+ await this.sql.unsafe(`update "${this.table}" set next_offset = $1, segment = $2, segment_bytes = $3, bytes = bytes + $4, updated_at = $5 where key = $6`, [row.next_offset + 1, segment, written + size, size, at, key]);
1191
+ recordWrite(this.tag);
1192
+ return { id: `${key}:${row.next_offset}`, key, offset: row.next_offset, at, data: parsed };
1193
+ });
1194
+ this.wake(key);
1195
+ return entry;
1196
+ }
1197
+ async read(key, options = {}) {
1198
+ recordRead(this.tag);
1199
+ const row = await this.row(key);
1200
+ return row ? this.lines(row, options.from ?? 0, options.limit ?? 100) : [];
1201
+ }
1202
+ async* follow(key, options) {
1203
+ recordRead(this.tag);
1204
+ let next = options.from ?? Math.max(0, ((await this.row(key))?.next_offset ?? 0) - (options.last ?? 100));
1205
+ while (!options.signal.aborted) {
1206
+ const appended = this.nextAppend(key, options.signal);
1207
+ const row = await this.row(key);
1208
+ const lines = row ? await this.lines(row, next, 500) : [];
1209
+ for (const line of lines)
1210
+ yield line;
1211
+ next = lines.at(-1) ? lines.at(-1).offset + 1 : next;
1212
+ if (lines.length === 500)
1213
+ continue;
1214
+ if (row?.ended_at)
1215
+ return;
1216
+ await appended;
1217
+ }
1218
+ }
1219
+ async end(key) {
1220
+ assertWritable(this.tag);
1221
+ await this.options.database.transaction(async () => {
1222
+ if (!await this.row(key))
1223
+ throw new MethodError("NOT_FOUND", `${this.name} has no key ${key}.`);
1224
+ const now = new Date().toISOString();
1225
+ await this.sql.unsafe(`update "${this.table}" set ended_at = $1, updated_at = $1 where key = $2`, [now, key]);
1226
+ recordWrite(this.tag);
1227
+ });
1228
+ this.wake(key);
1229
+ }
1230
+ async keys(options = {}) {
1231
+ recordRead(this.tag);
1232
+ const rows = await this.sql.unsafe(`select * from "${this.table}" order by updated_at desc limit $1`, [
1233
+ options.limit ?? 100
1234
+ ]);
1235
+ return rows.map(toKey);
1236
+ }
1237
+ async delete(key) {
1238
+ assertWritable(this.tag);
1239
+ await this.options.database.transaction(async () => {
1240
+ await rm2(join2(this.folder, key), { recursive: true, force: true });
1241
+ await this.sql.unsafe(`delete from "${this.table}" where key = $1`, [key]);
1242
+ recordWrite(this.tag);
1243
+ });
1244
+ this.wake(key);
1245
+ }
1246
+ async tick(now) {
1247
+ if (now - this.lastRetain < retainEveryMs || this.ageMs === null && this.maxBytes === null)
1248
+ return;
1249
+ this.lastRetain = now;
1250
+ const changed = await this.options.database.transaction(async () => {
1251
+ let changed2 = false;
1252
+ const rows = await this.sql.unsafe(`select * from "${this.table}"`);
1253
+ for (const row of rows)
1254
+ changed2 = await this.retain(row, now) || changed2;
1255
+ return changed2;
1256
+ });
1257
+ if (changed)
1258
+ this.options.announce([this.tag]);
1259
+ }
1260
+ close() {
1261
+ for (const wakers of this.waiting.values())
1262
+ for (const wake of wakers)
1263
+ wake();
1264
+ this.waiting.clear();
1265
+ }
1266
+ async retain(row, now) {
1267
+ if (this.ageMs !== null && Date.parse(row.updated_at) <= now - this.ageMs) {
1268
+ await rm2(join2(this.folder, row.key), { recursive: true, force: true });
1269
+ await this.sql.unsafe(`delete from "${this.table}" where key = $1`, [row.key]);
1270
+ return true;
1271
+ }
1272
+ const segments = (await this.segments(row.key)).filter((start) => start < row.segment);
1273
+ let { bytes, first_offset: first } = row;
1274
+ for (const [index, start] of segments.entries()) {
1275
+ const path = this.segmentPath(row.key, start);
1276
+ const info = await stat2(path);
1277
+ const old = this.ageMs !== null && info.mtimeMs <= now - this.ageMs;
1278
+ const over = this.maxBytes !== null && bytes > this.maxBytes;
1279
+ if (!old && !over)
1280
+ break;
1281
+ await rm2(path, { force: true });
1282
+ bytes -= info.size;
1283
+ first = segments[index + 1] ?? row.segment;
1284
+ }
1285
+ if (first === row.first_offset)
1286
+ return false;
1287
+ await this.sql.unsafe(`update "${this.table}" set first_offset = $1, bytes = $2 where key = $3`, [first, bytes, row.key]);
1288
+ return true;
1289
+ }
1290
+ async lines(row, from, limit2) {
1291
+ const start = Math.max(from, row.first_offset);
1292
+ const segments = await this.segments(row.key);
1293
+ const firstSegment = segments.findLastIndex((segment) => segment <= start);
1294
+ const lines = [];
1295
+ for (const segment of segments.slice(Math.max(0, firstSegment))) {
1296
+ const text = await readFile2(this.segmentPath(row.key, segment), "utf8").catch(() => "");
1297
+ for (const raw of text.split(`
1298
+ `)) {
1299
+ if (!raw)
1300
+ continue;
1301
+ const line = JSON.parse(raw);
1302
+ if (line.offset < start || line.offset >= row.next_offset)
1303
+ continue;
1304
+ lines.push({ id: `${row.key}:${line.offset}`, key: row.key, ...line });
1305
+ if (lines.length >= limit2)
1306
+ return lines;
1307
+ }
1308
+ }
1309
+ return lines;
1310
+ }
1311
+ async cutUncommitted(row) {
1312
+ const path = this.segmentPath(row.key, row.segment);
1313
+ const size = await stat2(path).then((info) => info.size, () => 0);
1314
+ if (size > row.segment_bytes)
1315
+ await truncate(path, row.segment_bytes);
1316
+ }
1317
+ async start(key) {
1318
+ await mkdir2(join2(this.folder, key), { recursive: true });
1319
+ const row = {
1320
+ key,
1321
+ first_offset: 0,
1322
+ next_offset: 0,
1323
+ segment: 0,
1324
+ segment_bytes: 0,
1325
+ bytes: 0,
1326
+ ended_at: null,
1327
+ updated_at: new Date().toISOString()
1328
+ };
1329
+ await this.sql.unsafe(`insert into "${this.table}" (key, first_offset, next_offset, segment, segment_bytes, bytes, updated_at)
1330
+ values ($1, 0, 0, 0, 0, 0, $2)`, [key, row.updated_at]);
1331
+ return row;
1332
+ }
1333
+ async row(key) {
1334
+ const rows = await this.sql.unsafe(`select * from "${this.table}" where key = $1`, [key]);
1335
+ return rows[0] ?? null;
1336
+ }
1337
+ async segments(key) {
1338
+ const names = await readdir2(join2(this.folder, key)).catch(() => []);
1339
+ return names.filter((name) => /^\d+\.jsonl$/.test(name)).map((name) => Number.parseInt(name)).sort((a, b) => a - b);
1340
+ }
1341
+ segmentPath(key, segment) {
1342
+ return join2(this.folder, key, `${String(segment).padStart(12, "0")}.jsonl`);
1343
+ }
1344
+ nextAppend(key, signal) {
1345
+ return new Promise((resolve) => {
1346
+ const wakers = this.waiting.get(key) ?? new Set;
1347
+ this.waiting.set(key, wakers);
1348
+ const done = () => {
1349
+ clearTimeout(timer);
1350
+ signal.removeEventListener("abort", done);
1351
+ wakers.delete(done);
1352
+ resolve();
1353
+ };
1354
+ const timer = setTimeout(done, followPollMs);
1355
+ signal.addEventListener("abort", done);
1356
+ wakers.add(done);
1357
+ });
1358
+ }
1359
+ wake(key) {
1360
+ for (const wake of [...this.waiting.get(key) ?? []])
1361
+ wake();
1362
+ }
1363
+ }
1364
+ function checkKey(key) {
1365
+ if (!safeName.test(key)) {
1366
+ throw new MethodError("VALIDATION", `A stream key is letters, digits, dots, dashes and underscores, up to 128: ${key}`);
1367
+ }
1368
+ }
1369
+ function toKey(row) {
1370
+ return {
1371
+ key: row.key,
1372
+ lines: row.next_offset - row.first_offset,
1373
+ bytes: row.bytes,
1374
+ endedAt: row.ended_at,
1375
+ updatedAt: row.updated_at
1376
+ };
1377
+ }
1378
+
1379
+ // src/runtime.ts
1380
+ var sampleMs = Number(process.env.REPOGO_PLUGINS_SAMPLE_MS ?? 5000);
1381
+ var sampleLimit = 200;
1382
+ var cronLateMs = 2 * 60000;
1383
+ var never = new AbortController().signal;
1384
+ var agentTimeoutMs = 10 * 60000;
1385
+ var agentWaitMs = 50000;
1386
+ var pluginName = /^[a-z][a-z0-9-]*$/;
1387
+ var namespaceName = /^[a-z][a-z0-9.-]*$/;
1388
+
1389
+ class Runtime {
1390
+ dataDir;
1391
+ root;
1392
+ host;
1393
+ events = new EventBus;
1394
+ methods = new Map;
1395
+ plugins = new Map;
1396
+ crons = [];
1397
+ databases = new Map;
1398
+ stores = new Map;
1399
+ invalidationListeners = new Set;
1400
+ noticeListeners = new Set;
1401
+ pluginsDir = null;
1402
+ constructor(dataDir, root, host) {
1403
+ this.dataDir = dataDir;
1404
+ this.root = root;
1405
+ this.host = host;
1406
+ }
1407
+ callRoot = new AsyncLocalStorage3;
1408
+ callSignal = new AsyncLocalStorage3;
1409
+ callCaller = new AsyncLocalStorage3;
1410
+ callConfirmed = new AsyncLocalStorage3;
1411
+ cronsTag = `crons.${crypto.randomUUID()}`;
1412
+ static async open(options) {
1413
+ await mkdir3(options.dataDir, { recursive: true });
1414
+ return new Runtime(options.dataDir, options.root ?? process.cwd(), options.host);
1415
+ }
1416
+ async loadDir(pluginsDir, only = null) {
1417
+ this.pluginsDir = pluginsDir;
1418
+ const entries = await readdir3(pluginsDir, { withFileTypes: true });
1419
+ for (const entry of entries) {
1420
+ if (!entry.isDirectory() || !pluginName.test(entry.name))
1421
+ continue;
1422
+ if (only && !only.includes(entry.name))
1423
+ continue;
1424
+ const module = await logContext.run({ plugin: entry.name }, () => import(join3(pluginsDir, entry.name, "app.ts")));
1425
+ await this.add(entry.name, module.default);
1426
+ }
1427
+ }
1428
+ async add(name, plugin) {
1429
+ if (!pluginName.test(name))
1430
+ throw new Error(`plugin names are lowercase letters, digits and hyphens: ${name}`);
1431
+ const collections = {};
1432
+ const buckets = {};
1433
+ const kv = {};
1434
+ const streams = {};
1435
+ const declared = Object.entries(plugin.collections ?? {});
1436
+ const stores = [...declared.map(([n]) => n), ...[plugin.kv, plugin.streams, plugin.buckets].flatMap((group) => Object.keys(group ?? {}))];
1437
+ const hidden = new Set(plugin.private ?? []);
1438
+ for (const store of hidden) {
1439
+ if (!stores.includes(store))
1440
+ throw new Error(`plugin ${name} lists ${store} as private, but keeps no collection, kv, stream or bucket by that name`);
1441
+ }
1442
+ const hasData = declared.length > 0 || [plugin.buckets, plugin.kv, plugin.streams].some((stores2) => Object.keys(stores2 ?? {}).length > 0);
1443
+ const namespace = plugin.namespace ?? "";
1444
+ if (hasData && !namespace)
1445
+ throw new Error(`plugin ${name} keeps data, so it needs a namespace: "<project>.${name}" for one project's, "${name}" to share it`);
1446
+ if (namespace && !namespaceName.test(namespace))
1447
+ throw new Error(`a plugin's namespace is lowercase letters, digits, dots and hyphens: ${namespace}`);
1448
+ const file = hasData ? await this.database(namespace) : null;
1449
+ for (const [collectionName, schema] of declared) {
1450
+ const collection = new Collection(file, this.events, name, namespace, collectionName, schema);
1451
+ await collection.setup();
1452
+ collections[collectionName] = collection;
1453
+ if (!hidden.has(collectionName))
1454
+ this.registerCrud(name, collectionName, collection, schema);
1455
+ }
1456
+ const options = {
1457
+ database: file,
1458
+ events: this.events,
1459
+ namespace,
1460
+ dir: join3(this.dataDir, namespace),
1461
+ announce: (tags) => this.notifyInvalidated(tags)
1462
+ };
1463
+ for (const [storeName, schema] of Object.entries(plugin.kv ?? {})) {
1464
+ const store = await this.store(name, "kv", storeName, schema, () => new Kv(options, storeName, schema));
1465
+ kv[storeName] = store;
1466
+ if (!hidden.has(storeName))
1467
+ this.registerGenerated(name, storeName, kvMethods(storeName, store, schema));
1468
+ }
1469
+ for (const [storeName, definition] of Object.entries(plugin.streams ?? {})) {
1470
+ const { item, retain } = "item" in definition ? definition : { item: definition, retain: defaultRetain };
1471
+ const store = await this.store(name, "streams", storeName, item, () => new Stream(options, storeName, item, retain), retain);
1472
+ streams[storeName] = store;
1473
+ if (!hidden.has(storeName))
1474
+ this.registerGenerated(name, storeName, streamMethods(storeName, store, item));
1475
+ }
1476
+ for (const [storeName, schema] of Object.entries(plugin.buckets ?? {})) {
1477
+ const store = await this.store(name, "buckets", storeName, schema, () => new Bucket(options, storeName, schema));
1478
+ buckets[storeName] = store;
1479
+ if (!hidden.has(storeName))
1480
+ this.registerGenerated(name, storeName, bucketMethods(storeName, store, schema));
1481
+ }
1482
+ const runtime = this;
1483
+ const ctx = {
1484
+ plugin: name,
1485
+ get caller() {
1486
+ return runtime.callCaller.getStore() ?? "code";
1487
+ },
1488
+ get root() {
1489
+ return runtime.callRoot.getStore() ?? runtime.root;
1490
+ },
1491
+ get signal() {
1492
+ return runtime.callSignal.getStore() ?? never;
1493
+ },
1494
+ collections,
1495
+ buckets,
1496
+ kv,
1497
+ streams,
1498
+ call: async (method, input) => (await this.call(method.includes(".") ? method : `${name}.${method}`, input)).result,
1499
+ emit: (event, payload) => this.events.emit(`${name}.${event}`, payload),
1500
+ host: async (method, params) => {
1501
+ if (!runtime.host)
1502
+ throw new MethodError("INTERNAL", "ctx.host runs only under the RepoGo host.");
1503
+ return runtime.host(name, method, params ?? {});
1504
+ },
1505
+ mcp: async (server, tool, input) => {
1506
+ if (!runtime.host)
1507
+ throw new MethodError("INTERNAL", "ctx.mcp runs only under the RepoGo host.");
1508
+ const answer = await runtime.host(name, "mcp.call", {
1509
+ project: ctx.root,
1510
+ server,
1511
+ tool,
1512
+ input: input ?? {},
1513
+ read: currentAccess()?.readOnly ?? false,
1514
+ confirmed: runtime.callConfirmed.getStore() ?? false
1515
+ });
1516
+ if (answer.error)
1517
+ throw mcpFailure(server, tool, answer.error);
1518
+ return answer.result ?? null;
1519
+ },
1520
+ plugins: () => {
1521
+ recordRead(this.cronsTag);
1522
+ return this.manifest().plugins;
1523
+ },
1524
+ notify: (notice) => {
1525
+ const sent = { ...notice, plugin: name, root: runtime.callRoot.getStore() ?? runtime.root };
1526
+ for (const listener of runtime.noticeListeners)
1527
+ listener(sent);
1528
+ },
1529
+ agent: (prompt, options2) => this.agent(name, ctx, prompt, options2 ?? {}),
1530
+ log: (...args) => console.log(...args)
1531
+ };
1532
+ for (const [methodName, method] of Object.entries(plugin.methods ?? {})) {
1533
+ this.register({
1534
+ name: `${name}.${methodName}`,
1535
+ plugin: name,
1536
+ kind: method.kind,
1537
+ description: method.description,
1538
+ confirm: method.confirm,
1539
+ input: method.input,
1540
+ output: method.output,
1541
+ run: async (input) => method.run(input, ctx)
1542
+ });
1543
+ }
1544
+ for (const [event, handler] of Object.entries(plugin.on ?? {})) {
1545
+ this.events.on(`${name}.${event}`, (e) => logContext.run(this.logContextFor(name), () => handler(e.payload, ctx)));
1546
+ }
1547
+ this.plugins.set(name, plugin);
1548
+ }
1549
+ async call(name, input = {}, options = {}) {
1550
+ if (options.root && options.root !== this.callRoot.getStore()) {
1551
+ const { root, ...rest } = options;
1552
+ return this.callRoot.run(root, () => this.call(name, input, rest));
1553
+ }
1554
+ if (options.caller && options.caller !== this.callCaller.getStore()) {
1555
+ const { caller, ...rest } = options;
1556
+ return this.callCaller.run(caller, () => this.call(name, input, rest));
1557
+ }
1558
+ const method = this.methods.get(name);
1559
+ if (!method)
1560
+ throw new MethodError("NOT_FOUND", `There is no method ${name}.`);
1561
+ const where = this.logContextFor(method.plugin, name, options.chat);
1562
+ if (!logContext.getStore())
1563
+ return runWith(where, () => this.call(name, input, options));
1564
+ if (where !== logContext.getStore())
1565
+ return logContext.run(where, () => this.call(name, input, options));
1566
+ if ((options.expect === "query" || currentAccess()?.readOnly) && method.kind === "mutation") {
1567
+ throw new MethodError("FORBIDDEN", `${name} changes data, so it only runs when the user acts.`);
1568
+ }
1569
+ const raw = input ?? {};
1570
+ const parsed = method.input.parse(raw);
1571
+ if (method.confirm && options.confirmed === false)
1572
+ throw new MethodError("CONFIRM", fillConfirm(method.confirm, parsed));
1573
+ if (method.kind === "stream")
1574
+ return { result: await this.sample(name, input), reads: [], invalidates: [] };
1575
+ const confirmed = this.callConfirmed.getStore() ?? false;
1576
+ const run = await tracked(method.kind === "query", (tags) => this.notifyInvalidated(tags), () => this.callConfirmed.run(confirmed || method.confirm !== undefined, async () => {
1577
+ const result = await method.run(parsed, raw);
1578
+ await this.events.emit(name, { input: parsed, result });
1579
+ return result;
1580
+ }));
1581
+ const invalidates = run.outermost ? [...run.access.writes] : [];
1582
+ if (invalidates.length > 0)
1583
+ this.notifyInvalidated(invalidates);
1584
+ if (!run.ok) {
1585
+ if (!(run.error instanceof MethodError && run.error.code === "CONFIRM"))
1586
+ record("error", [run.error]);
1587
+ throw run.error;
1588
+ }
1589
+ return { result: run.value ?? null, reads: [...run.access.reads], invalidates };
1590
+ }
1591
+ stream(name, input, options, onItem) {
1592
+ const method = this.methods.get(name);
1593
+ if (!method)
1594
+ throw new MethodError("NOT_FOUND", `There is no method ${name}.`);
1595
+ if (method.kind !== "stream")
1596
+ throw new MethodError("VALIDATION", `${name} is a ${method.kind}, not a stream.`);
1597
+ const raw = input ?? {};
1598
+ const parsed = method.input.parse(raw);
1599
+ const root = options.root ?? this.callRoot.getStore() ?? this.root;
1600
+ const caller = options.caller ?? this.callCaller.getStore() ?? "code";
1601
+ const where = this.logContextFor(method.plugin, name);
1602
+ return this.callRoot.run(root, () => this.callCaller.run(caller, () => this.callSignal.run(options.signal, () => runWith(where, async () => {
1603
+ const run = await tracked(true, (tags) => this.notifyInvalidated(tags), async () => {
1604
+ const items = await method.run(parsed, raw);
1605
+ if (!isAsyncIterable(items))
1606
+ throw new MethodError("INTERNAL", `${name} is a stream but returned no async iterable.`);
1607
+ for await (const item of items) {
1608
+ if (options.signal.aborted)
1609
+ break;
1610
+ onItem(item);
1611
+ }
1612
+ });
1613
+ if (!run.ok && !options.signal.aborted) {
1614
+ record("error", [run.error]);
1615
+ throw run.error;
1616
+ }
1617
+ }))));
1618
+ }
1619
+ async sample(name, input) {
1620
+ const items = [];
1621
+ const stop = new AbortController;
1622
+ const timer = setTimeout(() => stop.abort(), sampleMs);
1623
+ try {
1624
+ const stopped = new Promise((resolve) => stop.signal.addEventListener("abort", () => resolve()));
1625
+ const ended = this.stream(name, input, { signal: stop.signal }, (item) => {
1626
+ items.push(item);
1627
+ if (items.length >= sampleLimit)
1628
+ stop.abort();
1629
+ });
1630
+ await Promise.race([ended, stopped]);
1631
+ return { items, done: !stop.signal.aborted };
1632
+ } finally {
1633
+ clearTimeout(timer);
1634
+ stop.abort();
1635
+ }
1636
+ }
1637
+ onNotify(listener) {
1638
+ this.noticeListeners.add(listener);
1639
+ return () => this.noticeListeners.delete(listener);
1640
+ }
1641
+ onInvalidate(listener) {
1642
+ this.invalidationListeners.add(listener);
1643
+ return () => this.invalidationListeners.delete(listener);
1644
+ }
1645
+ setCrons(entries) {
1646
+ const kept = new Map(this.crons.map((job) => [job.signature, job]));
1647
+ const jobs = [];
1648
+ const statuses = [];
1649
+ for (const entry of entries) {
1650
+ const timezone = entry.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
1651
+ const signature = `${entry.plugin}.${entry.method} ${entry.schedule} ${timezone}`;
1652
+ try {
1653
+ const job = kept.get(signature) ?? this.cronJob(entry, timezone, signature);
1654
+ jobs.push(job);
1655
+ statuses.push({ ...cronState(job), plugin: job.plugin, error: null });
1656
+ } catch (error) {
1657
+ statuses.push({
1658
+ plugin: entry.plugin,
1659
+ method: `${entry.plugin}.${entry.method}`,
1660
+ schedule: entry.schedule,
1661
+ timezone,
1662
+ error: error.message,
1663
+ nextRunAt: null,
1664
+ lastRunAt: null,
1665
+ lastError: null,
1666
+ lastDurationMs: null,
1667
+ running: false,
1668
+ runs: 0
1669
+ });
1670
+ }
1671
+ }
1672
+ this.crons.splice(0, this.crons.length, ...jobs);
1673
+ this.notifyInvalidated([this.cronsTag]);
1674
+ return statuses;
1675
+ }
1676
+ cronJob(entry, timezone, signature) {
1677
+ if (!this.plugins.has(entry.plugin))
1678
+ throw new Error(`${entry.plugin} isn't loaded here`);
1679
+ const name = `${entry.plugin}.${entry.method}`;
1680
+ const method = this.methods.get(name);
1681
+ if (!method || method.plugin !== entry.plugin)
1682
+ throw new Error(`${entry.method} isn't one of ${entry.plugin}'s methods`);
1683
+ if (method.kind === "stream")
1684
+ throw new Error(`${name} is a stream; a cron runs a query or a mutation`);
1685
+ if (method.confirm)
1686
+ throw new Error(`${name} asks before it runs, so nothing may run it on a schedule`);
1687
+ if (!method.input.safeParse({}).success)
1688
+ throw new Error(`${name} needs input; a cron calls it with {}`);
1689
+ const parsed = parseSchedule(entry.schedule);
1690
+ try {
1691
+ checkTimezone(timezone);
1692
+ } catch {
1693
+ throw new Error(`"${timezone}" isn't a time zone`);
1694
+ }
1695
+ const nextRunAt = nextDue(parsed, Date.now(), timezone);
1696
+ if (nextRunAt === null)
1697
+ throw new Error(`"${entry.schedule}" is never due`);
1698
+ return {
1699
+ signature,
1700
+ plugin: entry.plugin,
1701
+ method: name,
1702
+ schedule: entry.schedule,
1703
+ timezone,
1704
+ parsed,
1705
+ nextRunAt,
1706
+ lastRunAt: null,
1707
+ lastDurationMs: null,
1708
+ lastError: null,
1709
+ running: false,
1710
+ runs: 0
1711
+ };
1712
+ }
1713
+ tickCrons(now = Date.now()) {
1714
+ const started = [];
1715
+ for (const job of this.crons) {
1716
+ if (job.nextRunAt === null || job.nextRunAt > now)
1717
+ continue;
1718
+ const due = job.nextRunAt;
1719
+ job.nextRunAt = nextDue(job.parsed, now, job.timezone);
1720
+ if (job.running || now - due > cronLateMs)
1721
+ continue;
1722
+ job.running = true;
1723
+ job.lastRunAt = now;
1724
+ const startedAt = Date.now();
1725
+ started.push(this.call(job.method, {}, { caller: "cron" }).then(() => {
1726
+ job.lastError = null;
1727
+ }, (error) => {
1728
+ job.lastError = toCallError(error).message;
1729
+ }).finally(() => {
1730
+ job.running = false;
1731
+ job.runs += 1;
1732
+ job.lastDurationMs = Date.now() - startedAt;
1733
+ this.notifyInvalidated([this.cronsTag]);
1734
+ }));
1735
+ }
1736
+ if (started.length)
1737
+ this.notifyInvalidated([this.cronsTag]);
1738
+ return started;
1739
+ }
1740
+ logContextFor(plugin, method, chat) {
1741
+ const outer = logContext.getStore();
1742
+ if (outer && outer.plugin === plugin && outer.method === method)
1743
+ return outer;
1744
+ return {
1745
+ plugin,
1746
+ method: method ?? outer?.method,
1747
+ caller: this.callCaller.getStore() ?? outer?.caller,
1748
+ callId: outer?.callId ?? crypto.randomUUID().slice(0, 8),
1749
+ chatId: chat ?? outer?.chatId
1750
+ };
1751
+ }
1752
+ async agent(plugin, ctx, prompt, options) {
1753
+ if (!this.host)
1754
+ throw new MethodError("INTERNAL", "ctx.agent runs only under the RepoGo host.");
1755
+ if (!options.agent)
1756
+ throw new MethodError("VALIDATION", 'ctx.agent needs the agent to run: { agent: "claude" } or "codex".');
1757
+ const started = await this.host(plugin, "chats.start", {
1758
+ cwd: options.project ?? ctx.root,
1759
+ agent: options.agent,
1760
+ title: options.title ?? "",
1761
+ turn: { prompt, config: {}, attachments: [] }
1762
+ });
1763
+ const deadline = Date.now() + parseDuration(options.timeout ?? agentTimeoutMs);
1764
+ let chatId = "";
1765
+ for (;; ) {
1766
+ const remaining = deadline - Date.now();
1767
+ if (remaining <= 0) {
1768
+ throw new MethodError("INTERNAL", `The agent didn't answer in time; its chat ${chatId || started.turn_id} is still running.`);
1769
+ }
1770
+ const waited = await this.host(plugin, "chats.wait", { turn_id: started.turn_id, timeout_ms: Math.min(remaining, agentWaitMs) });
1771
+ chatId = waited.chat_id ?? chatId;
1772
+ if (waited.state === "queued" || waited.state === "running")
1773
+ continue;
1774
+ if (waited.state !== "done")
1775
+ throw new MethodError("INTERNAL", `The agent's turn ${waited.state}${waited.error ? `: ${waited.error}` : ""} (chat ${chatId}).`);
1776
+ return { chatId, text: waited.reply ?? "" };
1777
+ }
1778
+ }
1779
+ manifest() {
1780
+ const plugins = [...this.plugins.entries()].map(([name, plugin]) => ({
1781
+ name,
1782
+ description: plugin.description ?? null,
1783
+ methods: [...this.methods.values()].filter((method) => method.plugin === name).map((method) => ({
1784
+ name: method.name,
1785
+ kind: method.kind,
1786
+ description: method.description ?? null,
1787
+ confirm: method.confirm ?? null,
1788
+ input: z2.toJSONSchema(method.input, { io: "input", unrepresentable: "any" }),
1789
+ output: method.output ? z2.toJSONSchema(method.output, { io: "output", unrepresentable: "any" }) : null
1790
+ })),
1791
+ crons: this.crons.filter((job) => job.plugin === name).map(cronState),
1792
+ storage: {
1793
+ buckets: this.storesOf(name, "buckets").map((store) => ({ name: store.name, path: store.path })),
1794
+ kv: this.storesOf(name, "kv").map((store) => ({ name: store.name })),
1795
+ streams: this.storesOf(name, "streams").map((store) => ({ name: store.name, path: store.path }))
1796
+ }
1797
+ }));
1798
+ return { plugins };
1799
+ }
1800
+ get runningCrons() {
1801
+ return this.crons.filter((job) => job.running).map((job) => job.method);
1802
+ }
1803
+ get hasStorage() {
1804
+ return this.stores.size > 0;
1805
+ }
1806
+ async tickStorage(now = Date.now()) {
1807
+ for (const { store } of this.stores.values()) {
1808
+ await store.tick(now).catch((error) => console.error(`[${store.kind}] ${store.name}:`, toCallError(error).message));
1809
+ }
1810
+ }
1811
+ close() {
1812
+ for (const { store } of this.stores.values())
1813
+ store.close();
1814
+ for (const database of this.databases.values())
1815
+ database.close();
1816
+ }
1817
+ async database(namespace) {
1818
+ let database = this.databases.get(namespace);
1819
+ if (database)
1820
+ return database;
1821
+ await mkdir3(join3(this.dataDir, namespace), { recursive: true });
1822
+ database = await Database.open(join3(this.dataDir, namespace, "data.db"));
1823
+ this.databases.set(namespace, database);
1824
+ return database;
1825
+ }
1826
+ async store(plugin, kind, name, schema, make, extra) {
1827
+ const signature = JSON.stringify([z2.toJSONSchema(schema, { unrepresentable: "any" }), extra ?? null]);
1828
+ const made = make();
1829
+ const found = this.stores.get(made.tag);
1830
+ if (found && found.schema !== signature) {
1831
+ throw new Error(`${plugin}: ${kind} ${name} is declared by another plugin in this namespace with a different schema`);
1832
+ }
1833
+ const store = found?.store ?? made;
1834
+ if (!found) {
1835
+ await store.setup();
1836
+ this.stores.set(store.tag, { store, schema: signature });
1837
+ }
1838
+ store.plugins.add(plugin);
1839
+ return store;
1840
+ }
1841
+ storesOf(plugin, kind) {
1842
+ return [...this.stores.values()].map(({ store }) => store).filter((store) => store.kind === kind && store.plugins.has(plugin));
1843
+ }
1844
+ notifyInvalidated(tags) {
1845
+ for (const listener of this.invalidationListeners)
1846
+ listener(tags);
1847
+ }
1848
+ register(method) {
1849
+ if (this.methods.has(method.name))
1850
+ throw new Error(`${method.name} is defined twice; rename the method or the data`);
1851
+ this.methods.set(method.name, method);
1852
+ }
1853
+ registerGenerated(plugin, name, methods) {
1854
+ for (const [method, { run, ...spec }] of Object.entries(methods)) {
1855
+ this.register({
1856
+ name: `${plugin}.${name}.${method}`,
1857
+ plugin,
1858
+ ...spec,
1859
+ run: async (input) => run(input, this.callSignal.getStore() ?? never)
1860
+ });
1861
+ }
1862
+ }
1863
+ registerCrud(plugin, name, collection, schema) {
1864
+ const prefix = `${plugin}.${name}`;
1865
+ const id = z2.object({ id: z2.string() });
1866
+ const record2 = schema.extend({ id: z2.string(), createdAt: z2.string(), updatedAt: z2.string() });
1867
+ const crud = (method, kind, input, output, description, run, confirm) => this.register({
1868
+ name: `${prefix}.${method}`,
1869
+ plugin,
1870
+ kind,
1871
+ description,
1872
+ confirm,
1873
+ input,
1874
+ output,
1875
+ run: async (value, raw) => run(value, raw)
1876
+ });
1877
+ crud("list", "query", z2.object({
1878
+ limit: z2.number().int().positive().max(500).optional(),
1879
+ offset: z2.number().int().min(0).optional(),
1880
+ orderBy: z2.string().optional(),
1881
+ order: z2.enum(["asc", "desc"]).optional()
1882
+ }), z2.array(record2), `List ${name} records, newest first unless orderBy says`, (input) => collection.list(input));
1883
+ crud("get", "query", id, record2.nullable(), `Get one ${name} record by id, or null`, (input) => collection.get(input.id));
1884
+ crud("create", "mutation", schema, record2, `Create a ${name} record`, (input) => collection.create(input));
1885
+ crud("update", "mutation", id.extend(schema.partial().shape), record2, `Update fields on a ${name} record`, ({ id: id2, ...fields }, raw) => collection.update(id2, Object.fromEntries(Object.entries(fields).filter(([key]) => Object.hasOwn(raw ?? {}, key)))));
1886
+ crud("delete", "mutation", id, z2.null(), `Delete a ${name} record by id`, async (input) => {
1887
+ await collection.delete(input.id);
1888
+ return null;
1889
+ }, "Delete this record?");
1890
+ }
1891
+ }
1892
+ function fillConfirm(question, input) {
1893
+ const fields = input ?? {};
1894
+ return question.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, (_, field2) => {
1895
+ const value = Object.hasOwn(fields, field2) ? fields[field2] : undefined;
1896
+ return value === undefined || value === null ? "" : String(value);
1897
+ });
1898
+ }
1899
+ function cronState(job) {
1900
+ return {
1901
+ method: job.method,
1902
+ schedule: job.schedule,
1903
+ timezone: job.timezone,
1904
+ nextRunAt: job.nextRunAt,
1905
+ lastRunAt: job.lastRunAt,
1906
+ lastError: job.lastError,
1907
+ lastDurationMs: job.lastDurationMs,
1908
+ running: job.running,
1909
+ runs: job.runs
1910
+ };
1911
+ }
1912
+ function mcpFailure(server, tool, error) {
1913
+ switch (error.code) {
1914
+ case "NOT_READ_ONLY":
1915
+ return new MethodError("FORBIDDEN", `${server}.${tool} changes data, so it runs from a mutation, not a query.`);
1916
+ case "CONFIRM":
1917
+ return new MethodError("FORBIDDEN", `${server}.${tool} may destroy data, so it runs only from a method with a confirm.`);
1918
+ }
1919
+ return new MethodError("INTERNAL", error.message);
1920
+ }
1921
+ function isAsyncIterable(value) {
1922
+ return typeof value?.[Symbol.asyncIterator] === "function";
1923
+ }
1924
+
1925
+ // src/testing.ts
1926
+ async function testRuntime(plugins, options = {}) {
1927
+ const dataDir = await mkdtemp(join4(tmpdir(), "repogo-plugin-test-"));
1928
+ const project = join4(dataDir, "project");
1929
+ await mkdir4(project);
1930
+ const host = options.mcp || options.host ? testHost(options.mcp, options.host) : undefined;
1931
+ const runtime = await Runtime.open({ dataDir: join4(dataDir, "data"), root: options.root ?? project, host });
1932
+ const notices = [];
1933
+ runtime.onNotify((notice) => notices.push(notice));
1934
+ const logs = [];
1935
+ captureConsole((entry) => logs.push(entry), { passThrough: true });
1936
+ try {
1937
+ for (const [name, plugin] of Object.entries(plugins))
1938
+ await runtime.add(name, plugin);
1939
+ } catch (error) {
1940
+ releaseConsole();
1941
+ runtime.close();
1942
+ await rm3(dataDir, { recursive: true, force: true });
1943
+ throw error;
1944
+ }
1945
+ return {
1946
+ call: async (method, input = {}, call = {}) => (await runtime.call(method, input, call)).result,
1947
+ setCrons: (crons) => runtime.setCrons(crons),
1948
+ tickCrons: async (now) => {
1949
+ await Promise.all(runtime.tickCrons(now));
1950
+ },
1951
+ manifest: () => runtime.manifest(),
1952
+ notices,
1953
+ logs,
1954
+ close: async () => {
1955
+ releaseConsole();
1956
+ runtime.close();
1957
+ await rm3(dataDir, { recursive: true, force: true });
1958
+ }
1959
+ };
1960
+ }
1961
+ function testHost(mcp, host) {
1962
+ return async (_plugin, method, params) => {
1963
+ if (method !== "mcp.call") {
1964
+ if (!host)
1965
+ throw new MethodError("FORBIDDEN", `This test runtime doesn't serve ${method}.`);
1966
+ return host(method, params);
1967
+ }
1968
+ if (!mcp)
1969
+ throw new MethodError("FORBIDDEN", "This test runtime has no MCP servers.");
1970
+ try {
1971
+ return { result: await mcp(params) };
1972
+ } catch (error) {
1973
+ return { error: { code: "TOOL_ERROR", message: error instanceof Error ? error.message : String(error) } };
1974
+ }
1975
+ };
1976
+ }
1977
+ export {
1978
+ testRuntime
1979
+ };