@wenathlan/getry 2.0.164 → 2.0.166

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/db.ts ADDED
@@ -0,0 +1,810 @@
1
+ /**
2
+ * db.ts — the database contract of the gateway (root layer).
3
+ *
4
+ * schema.prisma describes the kinds the gateway lanes consume
5
+ * (ChatMessage, ApiKey, SessionContext, TownKey, OnboardingState) with the
6
+ * family sync contract: identity pointers are text, counts are 32-bit ints,
7
+ * time is datetime or iso text. the self-hosted deploy answers those rows
8
+ * over HTTPS through the Prisma 7 client with the libsql adapter; this
9
+ * module carries the typed rows, the small typed in-memory seeds and the
10
+ * HTTPS accessors the static interface calls — the visitor machine stores
11
+ * nothing and falls back to the seeds when the endpoint is absent.
12
+ *
13
+ * The sqlite mirror (better-sqlite3, the local runtime of the gateway)
14
+ * lives at the foot of this file: prepared statements only, never
15
+ * concatenated SQL, DATABASE_URL env, idempotent schema on first use. The
16
+ * driver is loaded through a computed dynamic import that vite never
17
+ * bundles — the Sol pages reach this module, so the node layer stays behind
18
+ * a process guard and the browser never fetches the addon.
19
+ */
20
+
21
+ /// <reference types="better-sqlite3" />
22
+
23
+ import { GATEWAYROUTES, GATEWAYVERSIONS, type GatewayRoute, type GatewayVersion } from "./gateway";
24
+ import { LABEL_MANIFEST, LABEL_REVISION } from "./labels";
25
+
26
+ /** fetch budget for the HTTPS answers of the self-hosted DB. */
27
+ const fetchbudget = 2500;
28
+
29
+ /** one logged gateway request/response (the ChatMessage kind). */
30
+ export type ChatMessageRow = {
31
+ id: string;
32
+ chatId: string;
33
+ chatSubId?: string | null;
34
+ provider: string;
35
+ route: string;
36
+ model: string;
37
+ role: "user" | "assistant" | "system";
38
+ content: string;
39
+ reasoningContent?: string | null;
40
+ ip?: string | null;
41
+ keyId?: string | null;
42
+ finishReason?: string | null;
43
+ promptTokens?: number | null;
44
+ completionTokens?: number | null;
45
+ totalTokens?: number | null;
46
+ durationMs?: number | null;
47
+ createdAt: string;
48
+ };
49
+
50
+ /** one registered provider key (the ApiKey kind). */
51
+ export type ApiKeyRow = {
52
+ id: string;
53
+ provider: string;
54
+ key: string;
55
+ label?: string | null;
56
+ active: boolean;
57
+ status: "active" | "revoked" | "expired";
58
+ useCount: number;
59
+ rotationCount: number;
60
+ errorCount: number;
61
+ rateLimitHit: boolean;
62
+ createdAt: string;
63
+ expiresAt?: string | null;
64
+ };
65
+
66
+ /** one ephemeral IP-bound Babel key (the TownKey kind, 60min expiry). */
67
+ export type TownKeyRow = {
68
+ id: string;
69
+ key: string;
70
+ ip?: string | null;
71
+ ua?: string | null;
72
+ active: boolean;
73
+ createdAt: string;
74
+ expiresAt: string;
75
+ };
76
+
77
+ /** masks a provider key the way the /keys routes answer it. */
78
+ export function maskkey(key: string): string {
79
+ if (key.length <= 12) return `${key.slice(0, 4)}****`;
80
+ return `${key.slice(0, 8)}****${key.slice(-4)}`;
81
+ }
82
+
83
+ /**
84
+ * SECURITY: the seed rows carry masked demo placeholders — no real provider
85
+ * credential is stored in the repository. each value is read from the
86
+ * environment when present (GETRY_SEED_KEY_01 … GETRY_SEED_KEY_05) and
87
+ * falls back to a non-credential-shaped demo id, so the committed sources
88
+ * never embed a provider token shape (no nvapi-/sk- literals).
89
+ *
90
+ * @param slot zero-padded seed slot of the key row.
91
+ * @returns the environment-provided placeholder or the demo fallback.
92
+ */
93
+ function seedkey(slot: string): string {
94
+ const name = `GETRY_SEED_KEY_${slot}`;
95
+ const fromenv = typeof process !== "undefined" && process.env ? process.env[name] : undefined;
96
+ return fromenv ?? `demo-gateway-key-${slot}`;
97
+ }
98
+
99
+ /** the in-memory key seed the static build serves (masked on render). */
100
+ export const KEYSEED: ApiKeyRow[] = [
101
+ {
102
+ id: "key-01",
103
+ provider: "nvidia",
104
+ key: seedkey("01"),
105
+ label: "nim rotation 01",
106
+ active: true,
107
+ status: "active",
108
+ useCount: 412,
109
+ rotationCount: 18,
110
+ errorCount: 0,
111
+ rateLimitHit: false,
112
+ createdAt: "2026-09-12T10:00:00.000Z",
113
+ expiresAt: null,
114
+ },
115
+ {
116
+ id: "key-02",
117
+ provider: "nvidia",
118
+ key: seedkey("02"),
119
+ label: "nim rotation 02",
120
+ active: true,
121
+ status: "active",
122
+ useCount: 388,
123
+ rotationCount: 18,
124
+ errorCount: 1,
125
+ rateLimitHit: false,
126
+ createdAt: "2026-09-12T10:05:00.000Z",
127
+ expiresAt: null,
128
+ },
129
+ {
130
+ id: "key-03",
131
+ provider: "nvidia",
132
+ key: seedkey("03"),
133
+ label: "nim rotation 03",
134
+ active: true,
135
+ status: "active",
136
+ useCount: 401,
137
+ rotationCount: 18,
138
+ errorCount: 0,
139
+ rateLimitHit: true,
140
+ createdAt: "2026-09-12T10:10:00.000Z",
141
+ expiresAt: null,
142
+ },
143
+ {
144
+ id: "key-04",
145
+ provider: "zai",
146
+ key: seedkey("04"),
147
+ label: "sdk internal",
148
+ active: true,
149
+ status: "active",
150
+ useCount: 0,
151
+ rotationCount: 0,
152
+ errorCount: 0,
153
+ rateLimitHit: false,
154
+ createdAt: "2026-09-12T10:15:00.000Z",
155
+ expiresAt: null,
156
+ },
157
+ {
158
+ id: "key-05",
159
+ provider: "openrouter",
160
+ key: seedkey("05"),
161
+ label: "free tier",
162
+ active: true,
163
+ status: "active",
164
+ useCount: 97,
165
+ rotationCount: 2,
166
+ errorCount: 0,
167
+ rateLimitHit: false,
168
+ createdAt: "2026-09-20T08:00:00.000Z",
169
+ expiresAt: null,
170
+ },
171
+ ];
172
+
173
+ /** the in-memory chat log seed the static build serves. */
174
+ export const MESSAGESEED: ChatMessageRow[] = [
175
+ {
176
+ id: "msg-01",
177
+ chatId: "chat-9f21",
178
+ provider: "zai",
179
+ route: "/v1/chat/completions",
180
+ model: "devthink",
181
+ role: "user",
182
+ content: "ping the gateway",
183
+ ip: null,
184
+ keyId: null,
185
+ finishReason: "stop",
186
+ promptTokens: 12,
187
+ completionTokens: 26,
188
+ totalTokens: 38,
189
+ durationMs: 742,
190
+ createdAt: "2026-09-30T14:02:11.000Z",
191
+ },
192
+ {
193
+ id: "msg-02",
194
+ chatId: "chat-9f21",
195
+ provider: "zai",
196
+ route: "/v1/chat/completions",
197
+ model: "devthink",
198
+ role: "assistant",
199
+ content: "gateway alive, v1 answered with session id and usage",
200
+ reasoningContent: "decided to answer directly, no tool call needed",
201
+ ip: null,
202
+ keyId: null,
203
+ finishReason: "stop",
204
+ promptTokens: 12,
205
+ completionTokens: 26,
206
+ totalTokens: 38,
207
+ durationMs: 742,
208
+ createdAt: "2026-09-30T14:02:11.742Z",
209
+ },
210
+ {
211
+ id: "msg-03",
212
+ chatId: "chat-4c07",
213
+ provider: "nvidia",
214
+ route: "/v3/chat/completions",
215
+ model: "devthink",
216
+ role: "user",
217
+ content: "summarize the rotation state",
218
+ ip: null,
219
+ keyId: "key-02",
220
+ finishReason: "stop",
221
+ promptTokens: 18,
222
+ completionTokens: 41,
223
+ totalTokens: 59,
224
+ durationMs: 1187,
225
+ createdAt: "2026-09-30T14:07:40.000Z",
226
+ },
227
+ {
228
+ id: "msg-04",
229
+ chatId: "chat-4c07",
230
+ provider: "nvidia",
231
+ route: "/v3/chat/completions",
232
+ model: "devthink",
233
+ role: "assistant",
234
+ content: "22 keys in round robin, the meta model rotates every 6 messages",
235
+ reasoningContent: "read the rotation index before answering",
236
+ ip: null,
237
+ keyId: "key-02",
238
+ finishReason: "stop",
239
+ promptTokens: 18,
240
+ completionTokens: 41,
241
+ totalTokens: 59,
242
+ durationMs: 1187,
243
+ createdAt: "2026-09-30T14:07:41.187Z",
244
+ },
245
+ ];
246
+
247
+ /** the in-memory answer cache for the document lifetime (no storage). */
248
+ let keyscache: ApiKeyRow[] | null = null;
249
+ let messagescache: ChatMessageRow[] | null = null;
250
+
251
+ /**
252
+ * lists the registered provider keys: asks the self-hosted DB over HTTPS
253
+ * first and falls back to the in-memory seed when the endpoint is absent
254
+ * (static build). never touches the visitor machine.
255
+ *
256
+ * @returns the key rows.
257
+ */
258
+ export async function listkeys(): Promise<ApiKeyRow[]> {
259
+ if (keyscache) return keyscache;
260
+ try {
261
+ const controller = new AbortController();
262
+ const timer = setTimeout(() => controller.abort(), fetchbudget);
263
+ const response = await fetch("/api/v1/keys", { signal: controller.signal });
264
+ clearTimeout(timer);
265
+ if (!response.ok) throw new Error(`keys answered ${response.status}`);
266
+ const rows = (await response.json()) as ApiKeyRow[];
267
+ keyscache = rows;
268
+ return rows;
269
+ } catch {
270
+ return KEYSEED;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * lists the recent gateway chat log entries (bound to no chat id).
276
+ *
277
+ * @returns the message rows, newest last.
278
+ */
279
+ export async function listmessages(): Promise<ChatMessageRow[]> {
280
+ if (messagescache) return messagescache;
281
+ try {
282
+ const controller = new AbortController();
283
+ const timer = setTimeout(() => controller.abort(), fetchbudget);
284
+ const response = await fetch("/api/v1/messages", { signal: controller.signal });
285
+ clearTimeout(timer);
286
+ if (!response.ok) throw new Error(`messages answered ${response.status}`);
287
+ const rows = (await response.json()) as ChatMessageRow[];
288
+ messagescache = rows;
289
+ return rows;
290
+ } catch {
291
+ return MESSAGESEED;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * summarizes the rotation state of one provider key pool.
297
+ *
298
+ * @param keys the key rows to summarize.
299
+ * @returns the active, rotating and erroring key counts.
300
+ */
301
+ export function rotationsummary(keys: ApiKeyRow[]): { active: number; rotating: number; erroring: number } {
302
+ return {
303
+ active: keys.filter((key) => key.active && key.status === "active").length,
304
+ rotating: keys.filter((key) => key.rotationCount > 0).length,
305
+ erroring: keys.filter((key) => key.errorCount > 0).length,
306
+ };
307
+ }
308
+
309
+ /** one step of the key rotation: the row the caller authenticates with
310
+ * plus the cursor the next rotation resumes from (persist it per session —
311
+ * the same cursor discipline the session rotation index rides). */
312
+ export type keyrotationpick = { row: ApiKeyRow; cursor: number };
313
+
314
+ /**
315
+ * picks the next key of a provider pool by health-aware round-robin: the
316
+ * eligible pool (active, status active, not past a parseable expiry) is
317
+ * walked under a wrapping cursor, and the healthy seats (never rate
318
+ * limited, never erroring) lead the walk — an erroring key only rides
319
+ * when nothing healthy remains, a revoked, expired or inactive key never
320
+ * rides at all. the row is the internal api the gateway authenticates
321
+ * with: the render paths answer maskkey shapes, never the material.
322
+ *
323
+ * @law keys never leak — selection answers the row, rendering masks
324
+ * @param keys the key rows of the pool.
325
+ * @param cursor the persisted cursor of the previous pick (0 the first
326
+ * time, the `cursor` of the previous answer afterwards).
327
+ * @param now the reference clock in epoch ms (defaults to the real clock).
328
+ * @returns the picked row and the next cursor, or null when the pool
329
+ * holds no eligible key.
330
+ */
331
+ export function nextkeyrow(keys: readonly ApiKeyRow[], cursor = 0, now = Date.now()): keyrotationpick | null {
332
+ const expired = (row: ApiKeyRow): boolean => {
333
+ if (!row.expiresAt) return false;
334
+ const at = Date.parse(row.expiresAt);
335
+ return Number.isFinite(at) && at <= now;
336
+ };
337
+ const eligible = keys.filter((row) => row.active && row.status === "active" && !expired(row));
338
+ if (eligible.length === 0) return null;
339
+ const healthy = eligible.filter((row) => !row.rateLimitHit && row.errorCount === 0);
340
+ const pool = healthy.length > 0 ? healthy : eligible;
341
+ const start = ((Math.trunc(cursor) % pool.length) + pool.length) % pool.length;
342
+ return { row: pool[start], cursor: (start + 1) % pool.length };
343
+ }
344
+
345
+ /* ============================== sqlite mirror ==============================
346
+ the local runtime of the gateway: better-sqlite3 over DATABASE_URL (the
347
+ family convention), prepared statements only — every statement is bound,
348
+ no sql is ever assembled by string concatenation. The schema mirrors
349
+ schema.prisma (the onboarding_states table) and is created idempotently on
350
+ first use; the gateway layer serves the same rows over /api/v1/*. The
351
+ driver loads only in a node process: the browser graph reaches this
352
+ module, so the import rides a computed specifier with @vite-ignore behind
353
+ a process guard — vite never bundles the addon.
354
+ ========================================================================== */
355
+
356
+ /** true when this module runs inside a node process (the gateway runtime). */
357
+ function isnode(): boolean {
358
+ return typeof process !== "undefined" && process.versions?.node != null;
359
+ }
360
+
361
+ /** the database url of the local runtime (DATABASE_URL env, file: default). */
362
+ const DATABASE_URL = (typeof process !== "undefined" && process.env?.DATABASE_URL) || "file:./local.db";
363
+
364
+ /** the onboarding_states schema of the sqlite mirror (idempotent, mirrors the prisma model). */
365
+ const ONBOARDING_SCHEMA = `
366
+ CREATE TABLE IF NOT EXISTS onboarding_states (
367
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
368
+ visitor TEXT NOT NULL UNIQUE,
369
+ introseen INTEGER NOT NULL DEFAULT 0,
370
+ onboardingseen INTEGER NOT NULL DEFAULT 0,
371
+ updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
372
+ );
373
+ `;
374
+
375
+ /** the labels schema of the sqlite mirror (idempotent — the interface copy store). */
376
+ const LABELS_SCHEMA = `
377
+ CREATE TABLE IF NOT EXISTS labels (
378
+ id TEXT PRIMARY KEY,
379
+ text TEXT NOT NULL,
380
+ revision INTEGER NOT NULL DEFAULT 0,
381
+ updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
382
+ translated INTEGER NOT NULL DEFAULT 0
383
+ );
384
+ `;
385
+
386
+ /** the gateway_versions schema of the sqlite mirror (idempotent — the
387
+ * route table store): one row per provider gateway, the route kinds
388
+ * riding as a json array. the instance rows live here; the TS catalog of
389
+ * gateway.ts holds the family seed and the shapes alone. */
390
+ const GATEWAYVERSIONS_SCHEMA = `
391
+ CREATE TABLE IF NOT EXISTS gateway_versions (
392
+ v TEXT PRIMARY KEY,
393
+ provider TEXT NOT NULL,
394
+ pattern TEXT NOT NULL,
395
+ badge TEXT NOT NULL DEFAULT 'live',
396
+ models TEXT NOT NULL,
397
+ note TEXT NOT NULL,
398
+ routes TEXT NOT NULL DEFAULT '[]'
399
+ );
400
+ `;
401
+
402
+ /** the prepared statements of the mirror, created once per process. */
403
+ type OnboardingStatements = {
404
+ select: import("better-sqlite3").Statement;
405
+ upsert: import("better-sqlite3").Statement;
406
+ };
407
+
408
+ /** the prepared label statements of the mirror, created once per process. */
409
+ type LabelStatements = {
410
+ select: import("better-sqlite3").Statement;
411
+ selectAll: import("better-sqlite3").Statement;
412
+ upsert: import("better-sqlite3").Statement;
413
+ };
414
+
415
+ /** the prepared gateway version statements of the mirror. */
416
+ type VersionStatements = {
417
+ selectAll: import("better-sqlite3").Statement;
418
+ upsert: import("better-sqlite3").Statement;
419
+ };
420
+
421
+ /** the cached driver bundle (null until the first node call). */
422
+ let mirror: {
423
+ db: import("better-sqlite3").Database;
424
+ statements: OnboardingStatements;
425
+ labels: LabelStatements;
426
+ versions: VersionStatements;
427
+ } | null = null;
428
+
429
+ /**
430
+ * Boots the sqlite mirror exactly once per process: opens DATABASE_URL
431
+ * (creating parent folders), rides WAL and the busy timeout, creates the
432
+ * onboarding_states, labels and gateway_versions tables when absent
433
+ * (idempotent seed-on-first-run) and prepares every statement — never
434
+ * concatenated SQL.
435
+ *
436
+ * @returns the open database with its prepared statements.
437
+ */
438
+ async function openMirror(): Promise<{
439
+ db: import("better-sqlite3").Database;
440
+ statements: OnboardingStatements;
441
+ labels: LabelStatements;
442
+ versions: VersionStatements;
443
+ }> {
444
+ if (mirror) return mirror;
445
+ const nodefs = await import(/* @vite-ignore */ "node:fs");
446
+ const nodepath = await import(/* @vite-ignore */ "node:path");
447
+ const driver = "better-sqlite3";
448
+ const Database = (await import(/* @vite-ignore */ driver)).default;
449
+ const file = DATABASE_URL.startsWith("file:") ? DATABASE_URL.slice("file:".length) : DATABASE_URL;
450
+ nodefs.mkdirSync(nodepath.dirname(nodepath.resolve(file)), { recursive: true });
451
+ const db = new Database(file);
452
+ db.pragma("busy_timeout = 5000");
453
+ db.pragma("journal_mode = WAL");
454
+ db.exec(ONBOARDING_SCHEMA);
455
+ db.exec(LABELS_SCHEMA);
456
+ db.exec(GATEWAYVERSIONS_SCHEMA);
457
+ mirror = {
458
+ db,
459
+ statements: {
460
+ select: db.prepare(
461
+ "SELECT id, visitor, introseen, onboardingseen, updatedAt FROM onboarding_states WHERE visitor = ?",
462
+ ),
463
+ upsert: db.prepare(
464
+ `INSERT INTO onboarding_states (visitor, introseen, onboardingseen, updatedAt)
465
+ VALUES (?, ?, ?, datetime('now'))
466
+ ON CONFLICT(visitor) DO UPDATE SET
467
+ introseen = MAX(introseen, excluded.introseen),
468
+ onboardingseen = MAX(onboardingseen, excluded.onboardingseen),
469
+ updatedAt = datetime('now')`,
470
+ ),
471
+ },
472
+ labels: {
473
+ select: db.prepare("SELECT id, text, revision, updatedAt, translated FROM labels WHERE id = ?"),
474
+ selectAll: db.prepare("SELECT id, text, revision, updatedAt, translated FROM labels ORDER BY id ASC"),
475
+ upsert: db.prepare(
476
+ `INSERT INTO labels (id, text, revision, updatedAt, translated) VALUES (?, ?, ?, datetime('now'), ?)
477
+ ON CONFLICT(id) DO UPDATE SET
478
+ text = excluded.text,
479
+ revision = excluded.revision,
480
+ updatedAt = excluded.updatedAt,
481
+ translated = excluded.translated`,
482
+ ),
483
+ },
484
+ versions: {
485
+ selectAll: db.prepare(
486
+ "SELECT v, provider, pattern, badge, models, note, routes FROM gateway_versions ORDER BY v ASC",
487
+ ),
488
+ upsert: db.prepare(
489
+ `INSERT INTO gateway_versions (v, provider, pattern, badge, models, note, routes)
490
+ VALUES (?, ?, ?, ?, ?, ?, ?)
491
+ ON CONFLICT(v) DO UPDATE SET
492
+ provider = excluded.provider,
493
+ pattern = excluded.pattern,
494
+ badge = excluded.badge,
495
+ models = excluded.models,
496
+ note = excluded.note,
497
+ routes = excluded.routes`,
498
+ ),
499
+ },
500
+ };
501
+ return mirror;
502
+ }
503
+
504
+ /** the stage flags the entry flow consults, as booleans. */
505
+ export type OnboardingFlags = { introseen: boolean; onboardingseen: boolean };
506
+
507
+ /**
508
+ * Reads the onboarding stage flags of one visitor (bound parameters only;
509
+ * undefined before the first write). The browser accessor serves the same
510
+ * flags over GET /api/v1/onboarding (onboardingstate.ts).
511
+ *
512
+ * @param visitor the visitor key of the row.
513
+ * @returns the flags, or undefined when the visitor never wrote.
514
+ */
515
+ export async function getonboardingstate(visitor: string): Promise<OnboardingFlags | undefined> {
516
+ if (!isnode()) return undefined;
517
+ const { statements } = await openMirror();
518
+ const row = statements.select.get(visitor) as { introseen: number; onboardingseen: number } | undefined;
519
+ if (!row) return undefined;
520
+ return { introseen: row.introseen === 1, onboardingseen: row.onboardingseen === 1 };
521
+ }
522
+
523
+ /**
524
+ * Marks one onboarding stage seen for a visitor — idempotent seen-wins
525
+ * upsert (a stage seen anywhere stays seen), prepared and bound, never
526
+ * concatenated SQL.
527
+ *
528
+ * @param visitor the visitor key of the row.
529
+ * @param stage the stage the visitor just saw (or deliberately skipped).
530
+ * @returns the flags after the write.
531
+ */
532
+ export async function setonboardingstage(visitor: string, stage: "intro" | "onboarding"): Promise<OnboardingFlags> {
533
+ if (!isnode()) return { introseen: false, onboardingseen: false };
534
+ const { statements } = await openMirror();
535
+ statements.upsert.run(visitor, stage === "intro" ? 1 : 0, stage === "onboarding" ? 1 : 0);
536
+ return (await getonboardingstate(visitor)) ?? { introseen: false, onboardingseen: false };
537
+ }
538
+
539
+ /* --------------------------------------------------------------------- */
540
+ /* labels — the interface copy store */
541
+ /* --------------------------------------------------------------------- */
542
+
543
+ /** one labels row: the interface copy store. the id is the page.widget.key
544
+ * path, the text rides the english source of truth (labels.ts), and the
545
+ * translated flag protects a runtime translation from the seed overwrite.
546
+ * the shape mirrors the browser seed rows in labels.ts one to one. */
547
+ export type LabelRow = {
548
+ id: string;
549
+ text: string;
550
+ revision: number;
551
+ updatedAt: string;
552
+ translated?: boolean;
553
+ };
554
+
555
+ /** Projects one raw labels row into the stored label projection (translated
556
+ * rides the 0/1 cell of the mirror). */
557
+ function labelRowOf(row: {
558
+ id: string;
559
+ text: string;
560
+ revision: number;
561
+ updatedAt: string;
562
+ translated: number;
563
+ }): LabelRow {
564
+ return {
565
+ id: row.id,
566
+ text: row.text,
567
+ revision: row.revision,
568
+ updatedAt: row.updatedAt,
569
+ ...(row.translated === 1 ? { translated: true } : {}),
570
+ };
571
+ }
572
+
573
+ /**
574
+ * Writes one label row (idempotent upsert; the caller owns the revision),
575
+ * prepared and bound, never concatenated SQL.
576
+ *
577
+ * @param id the page.widget.key path of the label.
578
+ * @param text the surface text.
579
+ * @param revision the manifest revision the text carries.
580
+ * @param translated true when the row carries a runtime translation.
581
+ * @returns the stored label projection (undefined outside a node process).
582
+ */
583
+ export async function saveLabel(id: string, text: string, revision: number, translated = false): Promise<LabelRow | undefined> {
584
+ if (!isnode()) return undefined;
585
+ const { labels } = await openMirror();
586
+ labels.upsert.run(id, text, revision, translated ? 1 : 0);
587
+ const row = labels.select.get(id) as
588
+ | { id: string; text: string; revision: number; updatedAt: string; translated: number }
589
+ | undefined;
590
+ if (!row) throw new Error(`label "${id}" write did not land`);
591
+ return labelRowOf(row);
592
+ }
593
+
594
+ /**
595
+ * Reads one label row by its page.widget.key path.
596
+ *
597
+ * @param id the label id.
598
+ * @returns the row, or undefined when absent (or outside a node process).
599
+ */
600
+ export async function readLabel(id: string): Promise<LabelRow | undefined> {
601
+ if (!isnode()) return undefined;
602
+ const { labels } = await openMirror();
603
+ const row = labels.select.get(id) as
604
+ | { id: string; text: string; revision: number; updatedAt: string; translated: number }
605
+ | undefined;
606
+ return row ? labelRowOf(row) : undefined;
607
+ }
608
+
609
+ /**
610
+ * Reads every label row of the store ordered by id.
611
+ *
612
+ * @returns the label rows (empty outside a node process).
613
+ */
614
+ export async function readAllLabels(): Promise<LabelRow[]> {
615
+ if (!isnode()) return [];
616
+ const { labels } = await openMirror();
617
+ return (
618
+ labels.selectAll.all() as { id: string; text: string; revision: number; updatedAt: string; translated: number }[]
619
+ ).map(labelRowOf);
620
+ }
621
+
622
+ /**
623
+ * Ingests the english manifest of labels.ts into the labels table — the
624
+ * gateway-side twin of the browser seedLabels: idempotent, the changed rows
625
+ * refresh, and the translated rows stay protected from the overwrite.
626
+ *
627
+ * @returns the seeded and protected row counts (zeros outside a node process).
628
+ */
629
+ export async function seedLabelRows(): Promise<{ seeded: number; protected: number }> {
630
+ if (!isnode()) return { seeded: 0, protected: 0 };
631
+ const { labels } = await openMirror();
632
+ const rows = new Map(
633
+ (
634
+ labels.selectAll.all() as {
635
+ id: string;
636
+ text: string;
637
+ revision: number;
638
+ updatedAt: string;
639
+ translated: number;
640
+ }[]
641
+ ).map((row) => [row.id, labelRowOf(row)]),
642
+ );
643
+ let seeded = 0;
644
+ let saved = 0;
645
+ for (const [id, text] of Object.entries(LABEL_MANIFEST)) {
646
+ const row = rows.get(id);
647
+ if (row === undefined || (row.translated !== true && row.text !== text)) {
648
+ labels.upsert.run(id, text, LABEL_REVISION, row?.translated === true ? 1 : 0);
649
+ seeded += 1;
650
+ }
651
+ saved += 1;
652
+ }
653
+ return { seeded, protected: saved - seeded };
654
+ }
655
+
656
+ /* the copy law rides the mirror boot: the label seed ingests the english
657
+ * manifest of labels.ts when the gateway runtime opens the store (the
658
+ * interface strings land in the labels table, the translated rows protected
659
+ * from the overwrite). best effort: a sealed store never blocks the boot,
660
+ * the manifest always answers the wingets; the browser never runs this —
661
+ * the visitor machine mirrors the rows through seedLabels in labels.ts. */
662
+ if (isnode()) void seedLabelRows().catch(() => {});
663
+
664
+ /* --------------------------------------------------------------------- */
665
+ /* gateway versions — the route table rows */
666
+ /* --------------------------------------------------------------------- */
667
+
668
+ /** one raw gateway_versions row of the mirror. */
669
+ type VersionSqlRow = {
670
+ v: string;
671
+ provider: string;
672
+ pattern: string;
673
+ badge: string;
674
+ models: string;
675
+ note: string;
676
+ routes: string;
677
+ };
678
+
679
+ /** Projects one raw mirror row into the gateway version projection: the
680
+ * badge keeps its live/paused vocabulary, the route kinds parse back from
681
+ * the json cell (a cell that fails to parse answers the canonical route
682
+ * kinds — the row never loses its routes to a bad write). */
683
+ function versionRowOf(row: VersionSqlRow): GatewayVersion {
684
+ let routes: string[] = [];
685
+ try {
686
+ const parsed: unknown = JSON.parse(row.routes);
687
+ if (Array.isArray(parsed)) routes = parsed.filter((entry): entry is string => typeof entry === "string");
688
+ } catch {
689
+ routes = [];
690
+ }
691
+ return {
692
+ v: row.v,
693
+ provider: row.provider,
694
+ pattern: row.pattern,
695
+ badge: row.badge === "paused" ? "paused" : "live",
696
+ models: row.models,
697
+ note: row.note,
698
+ routes: (routes.length > 0 ? routes : [...GATEWAYROUTES]) as GatewayRoute[],
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Ingests the family seed of gateway.ts into the gateway_versions table —
704
+ * the data-driven route table law: the TS catalog holds the seed and the
705
+ * shapes, the instance rows live in the database. idempotent and
706
+ * registration-safe: a seed row lands only when absent, so a row the
707
+ * person registered or edited through savegatewayversion never gets
708
+ * overwritten by a later seed.
709
+ *
710
+ * @returns the count of seed rows that landed (0 outside a node process).
711
+ */
712
+ export async function seedGatewayVersionRows(): Promise<number> {
713
+ if (!isnode()) return 0;
714
+ const { versions } = await openMirror();
715
+ const known = new Set(
716
+ (versions.selectAll.all() as VersionSqlRow[]).map((row) => row.v),
717
+ );
718
+ let seeded = 0;
719
+ for (const version of GATEWAYVERSIONS) {
720
+ if (known.has(version.v)) continue;
721
+ versions.upsert.run(
722
+ version.v,
723
+ version.provider,
724
+ version.pattern,
725
+ version.badge,
726
+ version.models,
727
+ version.note,
728
+ JSON.stringify(version.routes),
729
+ );
730
+ seeded += 1;
731
+ }
732
+ return seeded;
733
+ }
734
+
735
+ /**
736
+ * Writes one gateway version row of the route table (idempotent upsert —
737
+ * the internal api the registration lanes call; a written row never falls
738
+ * back to the seed).
739
+ *
740
+ * @param version the row to write.
741
+ * @returns the stored projection (undefined outside a node process).
742
+ */
743
+ export async function savegatewayversion(version: GatewayVersion): Promise<GatewayVersion | undefined> {
744
+ if (!isnode()) return undefined;
745
+ const { versions } = await openMirror();
746
+ versions.upsert.run(
747
+ version.v,
748
+ version.provider,
749
+ version.pattern,
750
+ version.badge,
751
+ version.models,
752
+ version.note,
753
+ JSON.stringify(version.routes),
754
+ );
755
+ const row = (versions.selectAll.all() as VersionSqlRow[]).find((entry) => entry.v === version.v);
756
+ if (!row) throw new Error(`gateway version "${version.v}" write did not land`);
757
+ return versionRowOf(row);
758
+ }
759
+
760
+ /**
761
+ * Reads every gateway version row of the mirror ordered by version tag —
762
+ * the node lane of the route table (the browser lane rides
763
+ * listversionrows over HTTPS with the seed fallback).
764
+ *
765
+ * @returns the stored rows (the family seed when the table is empty,
766
+ * empty outside a node process).
767
+ */
768
+ export async function readgatewayversionrows(): Promise<GatewayVersion[]> {
769
+ if (!isnode()) return [];
770
+ const { versions } = await openMirror();
771
+ const rows = versions.selectAll.all() as VersionSqlRow[];
772
+ if (rows.length === 0) {
773
+ await seedGatewayVersionRows();
774
+ return GATEWAYVERSIONS;
775
+ }
776
+ return rows.map(versionRowOf);
777
+ }
778
+
779
+ /** the in-memory answer cache of the route table (one document lifetime). */
780
+ let versionscache: GatewayVersion[] | null = null;
781
+
782
+ /**
783
+ * lists the gateway version rows of the route table: the node runtime
784
+ * answers the sqlite mirror (seeded on first use), the browser asks the
785
+ * self-hosted DB over HTTPS and falls back to the in-memory family seed
786
+ * when the endpoint is absent (static build) — the same lane contract
787
+ * listkeys, listsessions and listmessages ride.
788
+ *
789
+ * @returns the gateway version rows.
790
+ */
791
+ export async function listversionrows(): Promise<GatewayVersion[]> {
792
+ if (versionscache) return versionscache;
793
+ if (isnode()) {
794
+ const rows = await readgatewayversionrows();
795
+ versionscache = rows;
796
+ return rows;
797
+ }
798
+ try {
799
+ const controller = new AbortController();
800
+ const timer = setTimeout(() => controller.abort(), fetchbudget);
801
+ const response = await fetch("/api/v1/gateway/versions", { signal: controller.signal });
802
+ clearTimeout(timer);
803
+ if (!response.ok) throw new Error(`gateway versions answered ${response.status}`);
804
+ const rows = (await response.json()) as GatewayVersion[];
805
+ versionscache = rows;
806
+ return rows;
807
+ } catch {
808
+ return GATEWAYVERSIONS;
809
+ }
810
+ }