@nothumanwork/nn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/bin/nn.js +106 -0
  4. package/package.json +74 -0
  5. package/src/config/env.ts +31 -0
  6. package/src/config/paths.ts +50 -0
  7. package/src/config/runtime.ts +37 -0
  8. package/src/config/sync.ts +48 -0
  9. package/src/db/client.ts +333 -0
  10. package/src/db/libsql-native.ts +66 -0
  11. package/src/db/lock.ts +72 -0
  12. package/src/db/migrate.ts +246 -0
  13. package/src/db/replica-migrate.ts +162 -0
  14. package/src/db/schema.sql +99 -0
  15. package/src/export/claude.ts +92 -0
  16. package/src/export/codex.ts +86 -0
  17. package/src/export/cursor.ts +68 -0
  18. package/src/export/generic.ts +19 -0
  19. package/src/export/registry.ts +118 -0
  20. package/src/export/types.ts +44 -0
  21. package/src/hooks/ingest.ts +107 -0
  22. package/src/hooks/resolvers/antigravity.ts +44 -0
  23. package/src/hooks/resolvers/claude.ts +27 -0
  24. package/src/hooks/resolvers/codex.ts +65 -0
  25. package/src/hooks/resolvers/common.ts +21 -0
  26. package/src/hooks/resolvers/cursor.ts +31 -0
  27. package/src/hooks/resolvers/grok.ts +59 -0
  28. package/src/hooks/resolvers/index.ts +35 -0
  29. package/src/hooks/resolvers/pi.ts +72 -0
  30. package/src/hooks/types.ts +20 -0
  31. package/src/index.ts +247 -0
  32. package/src/ingest/jsonl.ts +38 -0
  33. package/src/ingest/pipeline.ts +101 -0
  34. package/src/install/index.ts +227 -0
  35. package/src/install/types.ts +85 -0
  36. package/src/ir/event-id.ts +26 -0
  37. package/src/ir/types.ts +84 -0
  38. package/src/providers/antigravity/index.ts +175 -0
  39. package/src/providers/claude/index.ts +228 -0
  40. package/src/providers/codex/index.ts +264 -0
  41. package/src/providers/copilot/index.ts +24 -0
  42. package/src/providers/cursor/index.ts +340 -0
  43. package/src/providers/grok/index.ts +146 -0
  44. package/src/providers/pi/index.ts +197 -0
  45. package/src/providers/registry.ts +31 -0
  46. package/src/providers/types.ts +53 -0
  47. package/src/sync/coordinator.ts +186 -0
  48. package/src/sync/turso.ts +64 -0
  49. package/src/types/assets.d.ts +4 -0
@@ -0,0 +1,333 @@
1
+ import type { Client } from "@libsql/client";
2
+
3
+ import { projectDir } from "../config/paths.ts";
4
+ import {
5
+ contentHash,
6
+ eventIdFor,
7
+ projectIdFor,
8
+ sessionIdFor,
9
+ } from "../ir/event-id.ts";
10
+ import type {
11
+ NormalizedEvent,
12
+ ProviderId,
13
+ SourceImportBatch,
14
+ SourceSessionRef,
15
+ } from "../ir/types.ts";
16
+ import { getDb, withDb } from "./migrate.ts";
17
+
18
+ export interface StoredSession {
19
+ id: string;
20
+ source: ProviderId;
21
+ sourceSessionId: string;
22
+ workspaceRoot: string | null;
23
+ sourcePath: string | null;
24
+ title: string | null;
25
+ }
26
+
27
+ export async function upsertProject(
28
+ client: Client,
29
+ canonicalPath: string,
30
+ ): Promise<string> {
31
+ const id = projectIdFor(canonicalPath);
32
+ const now = Date.now();
33
+ await client.execute({
34
+ sql: `INSERT INTO projects(id, canonical_path, display_name, created_at, updated_at)
35
+ VALUES (?, ?, ?, ?, ?)
36
+ ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at`,
37
+ args: [id, canonicalPath, canonicalPath, now, now],
38
+ });
39
+ return id;
40
+ }
41
+
42
+ export async function upsertSession(
43
+ client: Client,
44
+ projectId: string,
45
+ session: SourceSessionRef,
46
+ ): Promise<string> {
47
+ const id = sessionIdFor(projectId, session.source, session.sessionId);
48
+ const now = Date.now();
49
+ await client.execute({
50
+ sql: `INSERT INTO sessions(
51
+ id, project_id, source, source_session_id, workspace_root, source_path,
52
+ title, model, started_at, updated_at, created_at, modified_at
53
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
54
+ ON CONFLICT(project_id, source, source_session_id) DO UPDATE SET
55
+ workspace_root = COALESCE(excluded.workspace_root, sessions.workspace_root),
56
+ source_path = COALESCE(excluded.source_path, sessions.source_path),
57
+ title = COALESCE(excluded.title, sessions.title),
58
+ model = COALESCE(excluded.model, sessions.model),
59
+ updated_at = COALESCE(excluded.updated_at, sessions.updated_at),
60
+ modified_at = excluded.modified_at`,
61
+ args: [
62
+ id,
63
+ projectId,
64
+ session.source,
65
+ session.sessionId,
66
+ session.workspaceRoot ?? null,
67
+ session.path,
68
+ session.title ?? null,
69
+ session.model ?? null,
70
+ new Date().toISOString(),
71
+ new Date().toISOString(),
72
+ now,
73
+ now,
74
+ ],
75
+ });
76
+ return id;
77
+ }
78
+
79
+ export async function getNextSeq(
80
+ client: Client,
81
+ sessionId: string,
82
+ ): Promise<number> {
83
+ const result = await client.execute({
84
+ sql: "SELECT COALESCE(MAX(seq), 0) AS max_seq FROM events WHERE session_id = ?",
85
+ args: [sessionId],
86
+ });
87
+ const maxSeq = Number(result.rows[0]?.max_seq ?? 0);
88
+ return maxSeq + 1;
89
+ }
90
+
91
+ export async function insertEvents(
92
+ client: Client,
93
+ sessionId: string,
94
+ events: NormalizedEvent[],
95
+ ): Promise<number> {
96
+ if (events.length === 0) {
97
+ return 0;
98
+ }
99
+
100
+ let seq = await getNextSeq(client, sessionId);
101
+ let inserted = 0;
102
+ const now = Date.now();
103
+
104
+ for (const event of events) {
105
+ const id = eventIdFor(event);
106
+ const result = await client.execute({
107
+ sql: `INSERT OR IGNORE INTO events(
108
+ id, session_id, seq, boundary, role, timestamp, content_text,
109
+ tool_name, tool_call_id, tool_input_json, tool_output_json,
110
+ raw_json, parser_version, raw_local_ref, line_key, content_hash, created_at
111
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
112
+ args: [
113
+ id,
114
+ sessionId,
115
+ seq,
116
+ event.boundary,
117
+ event.role,
118
+ event.timestamp,
119
+ event.contentText,
120
+ event.toolName ?? null,
121
+ event.toolCallId ?? null,
122
+ event.toolInputJson ?? null,
123
+ event.toolOutputJson ?? null,
124
+ event.rawJson,
125
+ event.parserVersion,
126
+ event.rawLocalRef,
127
+ event.lineKey,
128
+ contentHash(event.contentText),
129
+ now,
130
+ ],
131
+ });
132
+ if (result.rowsAffected > 0) {
133
+ inserted += 1;
134
+ seq += 1;
135
+ }
136
+ }
137
+
138
+ return inserted;
139
+ }
140
+
141
+ export async function setSourceCursor(
142
+ client: Client,
143
+ source: ProviderId,
144
+ cursorKey: string,
145
+ lastByteOffset: number,
146
+ parserVersion: string,
147
+ cursorValue: string,
148
+ lastLineUuid?: string | null,
149
+ ): Promise<void> {
150
+ await client.execute({
151
+ sql: `INSERT INTO source_cursors(source, cursor_key, last_byte_offset, last_line_uuid, parser_version, cursor_value, updated_at)
152
+ VALUES (?, ?, ?, ?, ?, ?, ?)
153
+ ON CONFLICT(source, cursor_key) DO UPDATE SET
154
+ last_byte_offset = excluded.last_byte_offset,
155
+ last_line_uuid = excluded.last_line_uuid,
156
+ parser_version = excluded.parser_version,
157
+ cursor_value = excluded.cursor_value,
158
+ updated_at = excluded.updated_at`,
159
+ args: [
160
+ source,
161
+ cursorKey,
162
+ lastByteOffset,
163
+ lastLineUuid ?? null,
164
+ parserVersion,
165
+ cursorValue,
166
+ Date.now(),
167
+ ],
168
+ });
169
+ }
170
+
171
+ export async function getSourceCursor(
172
+ client: Client,
173
+ source: ProviderId,
174
+ cursorKey: string,
175
+ ): Promise<number> {
176
+ const result = await client.execute({
177
+ sql: "SELECT last_byte_offset FROM source_cursors WHERE source = ? AND cursor_key = ?",
178
+ args: [source, cursorKey],
179
+ });
180
+ return Number(result.rows[0]?.last_byte_offset ?? 0);
181
+ }
182
+
183
+ export async function ingestBatch(
184
+ batch: SourceImportBatch,
185
+ ): Promise<{ inserted: number; sessionId: string }> {
186
+ const client = await getDb();
187
+ const projectId = await upsertProject(client, projectDir());
188
+ const sessionId = await upsertSession(client, projectId, batch.session);
189
+ const inserted = await insertEvents(client, sessionId, batch.events);
190
+ await setSourceCursor(
191
+ client,
192
+ batch.source,
193
+ batch.cursorKey,
194
+ batch.consumedBytes,
195
+ batch.parserVersion,
196
+ `line:${batch.emittedLines};bytes:${batch.consumedBytes}`,
197
+ );
198
+ return { inserted, sessionId };
199
+ }
200
+
201
+ export async function listSessions(
202
+ provider?: ProviderId,
203
+ ): Promise<StoredSession[]> {
204
+ return withDb(async (client) => {
205
+ const sql =
206
+ provider ?
207
+ "SELECT id, source, source_session_id, workspace_root, source_path, title FROM sessions WHERE source = ? ORDER BY modified_at DESC"
208
+ : "SELECT id, source, source_session_id, workspace_root, source_path, title FROM sessions ORDER BY modified_at DESC";
209
+ const args = provider ? [provider] : [];
210
+ const result = await client.execute({ sql, args });
211
+ return result.rows.map((row) => ({
212
+ id: String(row.id),
213
+ source: String(row.source) as ProviderId,
214
+ sourceSessionId: String(row.source_session_id),
215
+ workspaceRoot: row.workspace_root ? String(row.workspace_root) : null,
216
+ sourcePath: row.source_path ? String(row.source_path) : null,
217
+ title: row.title ? String(row.title) : null,
218
+ }));
219
+ });
220
+ }
221
+
222
+ export async function getSessionEvents(sessionId: string): Promise<
223
+ Array<{
224
+ seq: number;
225
+ boundary: string;
226
+ role: string;
227
+ timestamp: string;
228
+ contentText: string;
229
+ toolName: string | null;
230
+ toolCallId: string | null;
231
+ toolInputJson: string | null;
232
+ toolOutputJson: string | null;
233
+ rawJson: string;
234
+ source: ProviderId;
235
+ sourceSessionId: string;
236
+ }>
237
+ > {
238
+ return withDb(async (client) => {
239
+ const result = await client.execute({
240
+ sql: `SELECT e.seq, e.boundary, e.role, e.timestamp, e.content_text, e.tool_name,
241
+ e.tool_call_id, e.tool_input_json, e.tool_output_json, e.raw_json,
242
+ s.source, s.source_session_id
243
+ FROM events e
244
+ JOIN sessions s ON s.id = e.session_id
245
+ WHERE e.session_id = ?
246
+ ORDER BY e.seq ASC`,
247
+ args: [sessionId],
248
+ });
249
+ return result.rows.map((row) => ({
250
+ seq: Number(row.seq),
251
+ boundary: String(row.boundary),
252
+ role: String(row.role),
253
+ timestamp: String(row.timestamp),
254
+ contentText: String(row.content_text),
255
+ toolName: row.tool_name ? String(row.tool_name) : null,
256
+ toolCallId: row.tool_call_id ? String(row.tool_call_id) : null,
257
+ toolInputJson: row.tool_input_json ? String(row.tool_input_json) : null,
258
+ toolOutputJson:
259
+ row.tool_output_json ? String(row.tool_output_json) : null,
260
+ rawJson: String(row.raw_json),
261
+ source: String(row.source) as ProviderId,
262
+ sourceSessionId: String(row.source_session_id),
263
+ }));
264
+ });
265
+ }
266
+
267
+ export async function searchEvents(
268
+ query: string,
269
+ limit = 20,
270
+ ): Promise<
271
+ Array<{
272
+ sessionId: string;
273
+ seq: number;
274
+ contentText: string;
275
+ boundary: string;
276
+ }>
277
+ > {
278
+ return withDb(async (client) => {
279
+ const result = await client.execute({
280
+ sql: `SELECT e.session_id, e.seq, e.content_text, e.boundary
281
+ FROM events_fts fts
282
+ JOIN events e ON e.rowid = fts.rowid
283
+ WHERE events_fts MATCH ?
284
+ ORDER BY rank
285
+ LIMIT ?`,
286
+ args: [query, limit],
287
+ });
288
+ return result.rows.map((row) => ({
289
+ sessionId: String(row.session_id),
290
+ seq: Number(row.seq),
291
+ contentText: String(row.content_text),
292
+ boundary: String(row.boundary),
293
+ }));
294
+ });
295
+ }
296
+
297
+ export async function recordExport(
298
+ sessionId: string,
299
+ targetProvider: string,
300
+ outputPath: string,
301
+ lossy: boolean,
302
+ warnings: string[],
303
+ ): Promise<void> {
304
+ await withDb(async (client) => {
305
+ const id = `exp:v1:${contentHash(`${sessionId}|${targetProvider}|${outputPath}|${Date.now()}`)}`;
306
+ await client.execute({
307
+ sql: `INSERT INTO exports(id, session_id, target_provider, output_path, exported_at, lossy, warnings_json)
308
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
309
+ args: [
310
+ id,
311
+ sessionId,
312
+ targetProvider,
313
+ outputPath,
314
+ Date.now(),
315
+ lossy ? 1 : 0,
316
+ JSON.stringify(warnings),
317
+ ],
318
+ });
319
+ });
320
+ }
321
+
322
+ export async function findSessionBySourceId(
323
+ source: ProviderId,
324
+ sourceSessionId: string,
325
+ ): Promise<string | null> {
326
+ return withDb(async (client) => {
327
+ const result = await client.execute({
328
+ sql: "SELECT id FROM sessions WHERE source = ? AND source_session_id = ? LIMIT 1",
329
+ args: [source, sourceSessionId],
330
+ });
331
+ return result.rows[0]?.id ? String(result.rows[0].id) : null;
332
+ });
333
+ }
@@ -0,0 +1,66 @@
1
+ import { embeddedFiles } from "bun";
2
+ import { createRequire } from "node:module";
3
+ import Module from "node:module";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ import { isCompiledExecutable } from "../config/runtime.ts";
9
+
10
+ function libsqlPackageName(): string {
11
+ if (process.platform === "darwin" && process.arch === "arm64") {
12
+ return "@libsql/darwin-arm64";
13
+ }
14
+ if (process.platform === "darwin" && process.arch === "x64") {
15
+ return "@libsql/darwin-x64";
16
+ }
17
+ if (process.platform === "linux" && process.arch === "arm64") {
18
+ return "@libsql/linux-arm64-gnu";
19
+ }
20
+ if (process.platform === "linux" && process.arch === "x64") {
21
+ return "@libsql/linux-x64-gnu";
22
+ }
23
+ if (process.platform === "win32" && process.arch === "x64") {
24
+ return "@libsql/win32-x64-msvc";
25
+ }
26
+ throw new Error(
27
+ `unsupported platform for libsql: ${process.platform}-${process.arch}`,
28
+ );
29
+ }
30
+
31
+ function libsqlPackageSuffix(packageName: string): string {
32
+ return packageName.replace("@libsql/", "");
33
+ }
34
+
35
+ async function installCompiledLibsqlNative(): Promise<void> {
36
+ const packageName = libsqlPackageName();
37
+ const suffix = libsqlPackageSuffix(packageName);
38
+ const blob =
39
+ embeddedFiles.find(
40
+ (file) => file.name.includes("libsql") && file.name.includes(suffix),
41
+ ) ?? embeddedFiles.find((file) => file.name.endsWith(".node"));
42
+ if (!blob) {
43
+ throw new Error(
44
+ `compiled nn is missing embedded libsql native addon for ${packageName}`,
45
+ );
46
+ }
47
+
48
+ const dir = join(tmpdir(), "nn-libsql", suffix);
49
+ mkdirSync(dir, { recursive: true });
50
+ const nativePath = join(dir, "index.node");
51
+ writeFileSync(nativePath, await blob.arrayBuffer());
52
+
53
+ const require = createRequire(import.meta.url);
54
+ const native = require(nativePath);
55
+ const originalRequire = Module.prototype.require;
56
+ Module.prototype.require = function (id: string) {
57
+ if (id === packageName) {
58
+ return native;
59
+ }
60
+ return originalRequire.call(this, id);
61
+ };
62
+ }
63
+
64
+ if (isCompiledExecutable()) {
65
+ await installCompiledLibsqlNative();
66
+ }
package/src/db/lock.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { dirname } from "node:path";
12
+
13
+ import { lockPath } from "../config/paths.ts";
14
+ import schemaSql from "./schema.sql" with { type: "text" };
15
+
16
+ const LOCK_MAX_WAIT_MS = 5_000;
17
+ const LOCK_RETRY_MS = 25;
18
+ const LOCK_STALE_MS = 60_000;
19
+
20
+ export function sleep(ms: number): Promise<void> {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ export async function withIngestLock<T>(fn: () => Promise<T> | T): Promise<T> {
25
+ const path = lockPath();
26
+ mkdirSync(dirname(path), { recursive: true });
27
+ const deadline = Date.now() + LOCK_MAX_WAIT_MS;
28
+
29
+ while (Date.now() < deadline) {
30
+ try {
31
+ if (existsSync(path)) {
32
+ try {
33
+ const stale = Date.now() - statSync(path).mtimeMs > LOCK_STALE_MS;
34
+ if (stale) {
35
+ unlinkSync(path);
36
+ }
37
+ } catch {
38
+ // ignore
39
+ }
40
+ }
41
+
42
+ const fd = openSync(path, "wx");
43
+ writeFileSync(fd, `${process.pid}\n${Date.now()}\n`);
44
+ closeSync(fd);
45
+
46
+ try {
47
+ return await fn();
48
+ } finally {
49
+ try {
50
+ unlinkSync(path);
51
+ } catch {
52
+ // ignore
53
+ }
54
+ }
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
57
+ throw error;
58
+ }
59
+ await sleep(LOCK_RETRY_MS);
60
+ }
61
+ }
62
+
63
+ throw new Error("[nn] timed out waiting for ingest lock");
64
+ }
65
+
66
+ export function checksum(content: string): string {
67
+ return createHash("sha256").update(content, "utf8").digest("hex");
68
+ }
69
+
70
+ export function readSchemaSql(): string {
71
+ return schemaSql;
72
+ }
@@ -0,0 +1,246 @@
1
+ import { createClient, type Client } from "@libsql/client";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ import { syncConfig, tursoConfigured } from "../config/sync.ts";
6
+ import { dbPath } from "../config/paths.ts";
7
+ import { checksum, readSchemaSql, withIngestLock } from "./lock.ts";
8
+ import {
9
+ findPendingBackup,
10
+ importedBackupDir,
11
+ importPendingLocalBackup,
12
+ isReplicaConflictError,
13
+ prepareForEmbeddedReplica,
14
+ recoverEmbeddedReplica,
15
+ } from "./replica-migrate.ts";
16
+
17
+ const MIGRATION_VERSION = 1;
18
+ const MIGRATION_NAME = "initial_schema";
19
+
20
+ let cachedClient: Client | null = null;
21
+ let activeDbClient: Client | null = null;
22
+
23
+ export function createDbClient(path = dbPath()): Client {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ const url = path.startsWith("file:") ? path : `file:${path}`;
26
+
27
+ if (tursoConfigured()) {
28
+ prepareForEmbeddedReplica(path);
29
+ const config = syncConfig();
30
+ return createClient({
31
+ url,
32
+ syncUrl: process.env.TURSO_DATABASE_URL!,
33
+ authToken: process.env.TURSO_AUTH_TOKEN!,
34
+ syncInterval:
35
+ config.syncIntervalMs > 0 ? config.syncIntervalMs : undefined,
36
+ });
37
+ }
38
+
39
+ return createClient({ url });
40
+ }
41
+
42
+ export async function withDb<T>(
43
+ fn: (client: Client) => Promise<T>,
44
+ ): Promise<T> {
45
+ return withIngestLock(async () => {
46
+ if (tursoConfigured()) {
47
+ const client = await openDbClient();
48
+ activeDbClient = client;
49
+ try {
50
+ return await fn(client);
51
+ } finally {
52
+ client.close();
53
+ activeDbClient = null;
54
+ resetDbCache();
55
+ }
56
+ }
57
+
58
+ const client = await getDb();
59
+ return fn(client);
60
+ });
61
+ }
62
+
63
+ export async function getDb(): Promise<Client> {
64
+ if (activeDbClient) {
65
+ return activeDbClient;
66
+ }
67
+
68
+ if (!cachedClient) {
69
+ cachedClient = createDbClient();
70
+ await initializeLocalClient(cachedClient, dbPath());
71
+ }
72
+ return cachedClient;
73
+ }
74
+
75
+ async function openDbClient(
76
+ path = dbPath(),
77
+ allowRecovery = true,
78
+ ): Promise<Client> {
79
+ try {
80
+ const client = createDbClient(path);
81
+ try {
82
+ await prepareEmbeddedReplicaClient(client, path);
83
+ return client;
84
+ } catch (error) {
85
+ client.close();
86
+ throw error;
87
+ }
88
+ } catch (error) {
89
+ if (
90
+ !allowRecovery ||
91
+ !tursoConfigured() ||
92
+ !isReplicaConflictError(error)
93
+ ) {
94
+ throw error;
95
+ }
96
+
97
+ recoverEmbeddedReplica(path);
98
+ return openDbClient(path, false);
99
+ }
100
+ }
101
+
102
+ async function prepareEmbeddedReplicaClient(
103
+ client: Client,
104
+ path: string,
105
+ syncFirst = true,
106
+ ): Promise<void> {
107
+ if (syncFirst) {
108
+ await client.sync();
109
+ }
110
+ await initializeLocalClient(client, path, { embeddedReplica: true });
111
+ }
112
+
113
+ async function initializeLocalClient(
114
+ client: Client,
115
+ path: string,
116
+ options: { embeddedReplica?: boolean } = {},
117
+ ): Promise<void> {
118
+ await migrate(client);
119
+
120
+ if (!options.embeddedReplica) {
121
+ await client.execute("PRAGMA journal_mode = WAL");
122
+ }
123
+
124
+ await importPendingLocalBackup(client, path);
125
+ }
126
+
127
+ export async function migrate(client: Client): Promise<void> {
128
+ const schema = readSchemaSql();
129
+ const schemaChecksum = checksum(schema);
130
+
131
+ await client.execute(
132
+ "CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, checksum TEXT NOT NULL, applied_at INTEGER NOT NULL)",
133
+ );
134
+
135
+ const existing = await client.execute({
136
+ sql: "SELECT version, checksum FROM schema_migrations WHERE version = ?",
137
+ args: [MIGRATION_VERSION],
138
+ });
139
+
140
+ if (existing.rows.length > 0) {
141
+ const row = existing.rows[0] as { checksum?: string };
142
+ if (row.checksum && row.checksum !== schemaChecksum) {
143
+ throw new Error(
144
+ `schema migration ${MIGRATION_VERSION} checksum mismatch`,
145
+ );
146
+ }
147
+ return;
148
+ }
149
+
150
+ const statements = splitSqlStatements(schema);
151
+
152
+ for (const statement of statements) {
153
+ await client.execute(statement);
154
+ }
155
+
156
+ await client.execute({
157
+ sql: "INSERT INTO schema_migrations(version, name, checksum, applied_at) VALUES (?, ?, ?, ?)",
158
+ args: [MIGRATION_VERSION, MIGRATION_NAME, schemaChecksum, Date.now()],
159
+ });
160
+ }
161
+
162
+ export async function doctorDb(): Promise<{
163
+ ok: boolean;
164
+ path: string;
165
+ tables: string[];
166
+ replica: {
167
+ mode: "local-only" | "embedded-replica" | "missing";
168
+ pendingBackup: string | null;
169
+ importedBackup: string | null;
170
+ };
171
+ }> {
172
+ const path = dbPath();
173
+ return withDb(async (client) => {
174
+ const tables = await client.execute(
175
+ "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
176
+ );
177
+ const replicaInfoExists = existsSync(`${path}-info`);
178
+ return {
179
+ ok: existsSync(path),
180
+ path,
181
+ tables: tables.rows.map((row) => String(row.name)),
182
+ replica: {
183
+ mode:
184
+ !existsSync(path) ? "missing"
185
+ : replicaInfoExists ? "embedded-replica"
186
+ : "local-only",
187
+ pendingBackup: findPendingBackup(path),
188
+ importedBackup:
189
+ existsSync(importedBackupDir(path)) ? importedBackupDir(path) : null,
190
+ },
191
+ };
192
+ });
193
+ }
194
+
195
+ export function resetDbCache(): void {
196
+ cachedClient = null;
197
+ }
198
+
199
+ function splitSqlStatements(sql: string): string[] {
200
+ const cleaned = sql
201
+ .split("\n")
202
+ .filter((line) => !line.trim().startsWith("--"))
203
+ .join("\n");
204
+
205
+ const statements: string[] = [];
206
+ let current = "";
207
+ let depth = 0;
208
+
209
+ for (let index = 0; index < cleaned.length; index += 1) {
210
+ const rest = cleaned.slice(index);
211
+ const beginMatch = rest.match(/^BEGIN\b/i);
212
+ if (beginMatch) {
213
+ depth += 1;
214
+ current += beginMatch[0];
215
+ index += beginMatch[0].length - 1;
216
+ continue;
217
+ }
218
+
219
+ const endMatch = rest.match(/^END\b/i);
220
+ if (endMatch) {
221
+ depth = Math.max(0, depth - 1);
222
+ current += endMatch[0];
223
+ index += endMatch[0].length - 1;
224
+ continue;
225
+ }
226
+
227
+ const char = cleaned[index];
228
+ if (char === ";" && depth === 0) {
229
+ const statement = current.trim();
230
+ if (statement.length > 0) {
231
+ statements.push(statement);
232
+ }
233
+ current = "";
234
+ continue;
235
+ }
236
+
237
+ current += char;
238
+ }
239
+
240
+ const tail = current.trim();
241
+ if (tail.length > 0) {
242
+ statements.push(tail);
243
+ }
244
+
245
+ return statements;
246
+ }