@agentlip/kernel 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.
package/src/index.ts ADDED
@@ -0,0 +1,316 @@
1
+ // Ring 1: Kernel (SQLite schema + migrations + invariants)
2
+ // Implemented in Phase 0.
3
+ //
4
+ // Exports:
5
+ // - openDb, runMigrations, isFtsAvailable (schema management)
6
+ // - insertEvent, getLatestEventId, replayEvents, etc. (events.ts)
7
+ // - listChannels, listTopicsByChannel, listMessages, etc. (queries.ts)
8
+
9
+ import { Database } from "bun:sqlite";
10
+ import { randomUUID } from "node:crypto";
11
+ import { copyFileSync, existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ // Export migrations directory
15
+ export const MIGRATIONS_DIR = join(import.meta.dir, "../migrations");
16
+
17
+ // Re-export events module
18
+ export {
19
+ insertEvent,
20
+ getLatestEventId,
21
+ replayEvents,
22
+ getEventById,
23
+ countEventsInRange,
24
+ type EventScopes,
25
+ type EventEntity,
26
+ type InsertEventOptions,
27
+ type EventRow,
28
+ type ParsedEvent,
29
+ type ReplayEventsOptions,
30
+ } from "./events";
31
+
32
+ // Re-export queries module
33
+ export {
34
+ listChannels,
35
+ getChannelById,
36
+ getChannelByName,
37
+ listTopicsByChannel,
38
+ getTopicById,
39
+ getTopicByTitle,
40
+ listMessages,
41
+ tailMessages,
42
+ getMessageById,
43
+ listTopicAttachments,
44
+ getAttachmentById,
45
+ findAttachmentByDedupeKey,
46
+ type Channel,
47
+ type Topic,
48
+ type Message,
49
+ type TopicAttachment,
50
+ type PaginationOptions,
51
+ type MessageQueryOptions,
52
+ type ListResult,
53
+ } from "./queries";
54
+
55
+ // Re-export message mutations module
56
+ export {
57
+ editMessage,
58
+ tombstoneDeleteMessage,
59
+ retopicMessage,
60
+ VersionConflictError,
61
+ MessageNotFoundError,
62
+ CrossChannelMoveError,
63
+ TopicNotFoundError,
64
+ type EditMessageOptions,
65
+ type EditMessageResult,
66
+ type TombstoneDeleteOptions,
67
+ type TombstoneDeleteResult,
68
+ type RetopicMode,
69
+ type RetopicMessageOptions,
70
+ type RetopicMessageResult,
71
+ } from "./messageMutations";
72
+
73
+ export const SCHEMA_VERSION = 1;
74
+
75
+ interface OpenDbOptions {
76
+ dbPath: string;
77
+ readonly?: boolean;
78
+ }
79
+
80
+ interface RunMigrationsOptions {
81
+ db: Database;
82
+ migrationsDir: string;
83
+ enableFts?: boolean;
84
+ }
85
+
86
+ interface MigrationResult {
87
+ appliedMigrations: string[];
88
+ ftsAvailable: boolean;
89
+ ftsError?: string;
90
+ }
91
+
92
+ /**
93
+ * Open SQLite database with required PRAGMAs.
94
+ *
95
+ * Sets:
96
+ * - WAL mode (concurrent reads + single writer)
97
+ * - foreign_keys = ON
98
+ * - busy_timeout = 5000ms
99
+ * - synchronous = NORMAL (balance safety/performance)
100
+ * - query_only = ON (for readonly mode)
101
+ *
102
+ * @param options - Database path and readonly flag
103
+ * @returns Configured Database instance
104
+ */
105
+ export function openDb({ dbPath, readonly = false }: OpenDbOptions): Database {
106
+ const db = new Database(dbPath, {
107
+ create: !readonly,
108
+ readonly
109
+ });
110
+
111
+ // Set PRAGMAs
112
+ if (!readonly) {
113
+ db.run("PRAGMA journal_mode = WAL");
114
+ }
115
+ db.run("PRAGMA foreign_keys = ON");
116
+ db.run("PRAGMA busy_timeout = 5000");
117
+ db.run("PRAGMA synchronous = NORMAL");
118
+
119
+ if (readonly) {
120
+ db.run("PRAGMA query_only = ON");
121
+ }
122
+
123
+ return db;
124
+ }
125
+
126
+ /**
127
+ * Ensure meta table has required keys initialized.
128
+ *
129
+ * Required keys:
130
+ * - db_id: UUIDv4 generated at init, never changes
131
+ * - schema_version: integer, current version (initially '0')
132
+ * - created_at: ISO8601 timestamp
133
+ *
134
+ * @param db - Database instance
135
+ */
136
+ export function ensureMetaInitialized(db: Database): void {
137
+ // Check if meta table exists
138
+ const metaExists = db
139
+ .query<{ count: number }, []>(
140
+ "SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='meta'"
141
+ )
142
+ .get();
143
+
144
+ if (!metaExists || metaExists.count === 0) {
145
+ return; // Meta table doesn't exist yet; will be created by migration
146
+ }
147
+
148
+ // Check for required keys
149
+ const checkKey = db.prepare<{ value: string }, [string]>(
150
+ "SELECT value FROM meta WHERE key = ?"
151
+ );
152
+
153
+ const dbId = checkKey.get("db_id");
154
+ if (!dbId) {
155
+ // Generate UUIDv4
156
+ const uuid = randomUUID();
157
+ db.run("INSERT INTO meta (key, value) VALUES (?, ?)", ["db_id", uuid]);
158
+ }
159
+
160
+ const schemaVersion = checkKey.get("schema_version");
161
+ if (!schemaVersion) {
162
+ db.run("INSERT INTO meta (key, value) VALUES (?, ?)", [
163
+ "schema_version",
164
+ "0",
165
+ ]);
166
+ }
167
+
168
+ const createdAt = checkKey.get("created_at");
169
+ if (!createdAt) {
170
+ const timestamp = new Date().toISOString();
171
+ db.run("INSERT INTO meta (key, value) VALUES (?, ?)", [
172
+ "created_at",
173
+ timestamp,
174
+ ]);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Create timestamped backup of database before migration.
180
+ *
181
+ * Backup filename: {dbPath}.backup-v{fromVersion}-{timestamp}
182
+ *
183
+ * @param dbPath - Path to database file
184
+ * @param fromVersion - Current schema version before migration
185
+ */
186
+ export function backupBeforeMigration(dbPath: string, fromVersion: number): void {
187
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
188
+ const backupPath = `${dbPath}.backup-v${fromVersion}-${timestamp}`;
189
+
190
+ copyFileSync(dbPath, backupPath);
191
+
192
+ // Also backup WAL if it exists
193
+ const walPath = `${dbPath}-wal`;
194
+ if (existsSync(walPath)) {
195
+ copyFileSync(walPath, `${backupPath}-wal`);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Run forward-only migrations tracked by meta.schema_version.
201
+ *
202
+ * Applies migrations in order:
203
+ * 1. 0001_schema_v1.sql (if schema_version is 0 or missing)
204
+ * 2. 0001_schema_v1_fts.sql (if enableFts=true, opportunistic, non-fatal)
205
+ *
206
+ * Before applying migrations, creates timestamped backup.
207
+ *
208
+ * @param options - Database, migrations directory, and FTS flag
209
+ * @returns Migration result with applied migrations and FTS status
210
+ */
211
+ export function runMigrations({
212
+ db,
213
+ migrationsDir,
214
+ enableFts = false,
215
+ }: RunMigrationsOptions): MigrationResult {
216
+ const result: MigrationResult = {
217
+ appliedMigrations: [],
218
+ ftsAvailable: false,
219
+ };
220
+
221
+ // Ensure meta table exists or will be created
222
+ ensureMetaInitialized(db);
223
+
224
+ // Get current schema version
225
+ const getCurrentVersion = (): number => {
226
+ try {
227
+ const row = db
228
+ .query<{ value: string }, []>("SELECT value FROM meta WHERE key = 'schema_version'")
229
+ .get();
230
+ return row ? parseInt(row.value, 10) : 0;
231
+ } catch {
232
+ return 0;
233
+ }
234
+ };
235
+
236
+ const currentVersion = getCurrentVersion();
237
+
238
+ // Apply schema_v1 if needed
239
+ if (currentVersion < 1) {
240
+ const migrationPath = join(migrationsDir, "0001_schema_v1.sql");
241
+
242
+ if (!existsSync(migrationPath)) {
243
+ throw new Error(`Migration file not found: ${migrationPath}`);
244
+ }
245
+
246
+ // Create backup before migration
247
+ const dbPath = db.filename;
248
+ if (dbPath && currentVersion > 0) {
249
+ backupBeforeMigration(dbPath, currentVersion);
250
+ }
251
+
252
+ // Apply migration
253
+ const sql = readFileSync(migrationPath, "utf-8");
254
+ db.exec(sql);
255
+
256
+ // Update schema version
257
+ db.run(
258
+ "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '1')"
259
+ );
260
+
261
+ result.appliedMigrations.push("0001_schema_v1.sql");
262
+
263
+ // Initialize other required meta keys after schema is created
264
+ ensureMetaInitialized(db);
265
+ }
266
+
267
+ // Opportunistically apply FTS schema if requested
268
+ if (enableFts) {
269
+ // Check if FTS is already applied
270
+ const ftsExists = isFtsAvailable(db);
271
+
272
+ if (!ftsExists) {
273
+ const ftsPath = join(migrationsDir, "0001_schema_v1_fts.sql");
274
+
275
+ if (existsSync(ftsPath)) {
276
+ try {
277
+ const sql = readFileSync(ftsPath, "utf-8");
278
+ db.exec(sql);
279
+ result.ftsAvailable = true;
280
+ result.appliedMigrations.push("0001_schema_v1_fts.sql");
281
+ } catch (err) {
282
+ // Non-fatal: FTS may not be available in this SQLite build
283
+ result.ftsAvailable = false;
284
+ result.ftsError =
285
+ err instanceof Error ? err.message : String(err);
286
+ }
287
+ } else {
288
+ result.ftsError = "FTS migration file not found";
289
+ }
290
+ } else {
291
+ // FTS already available
292
+ result.ftsAvailable = true;
293
+ }
294
+ }
295
+
296
+ return result;
297
+ }
298
+
299
+ /**
300
+ * Check if FTS is available in the database.
301
+ *
302
+ * @param db - Database instance
303
+ * @returns true if messages_fts table exists
304
+ */
305
+ export function isFtsAvailable(db: Database): boolean {
306
+ try {
307
+ const row = db
308
+ .query<{ count: number }, []>(
309
+ "SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='messages_fts'"
310
+ )
311
+ .get();
312
+ return row ? row.count > 0 : false;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }