@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/migrations/0001_schema_v1.sql +156 -0
- package/migrations/0001_schema_v1_fts.sql +32 -0
- package/package.json +27 -0
- package/src/events.ts +365 -0
- package/src/index.ts +316 -0
- package/src/messageMutations.ts +523 -0
- package/src/queries.ts +418 -0
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
|
+
}
|