@mark1russell7/client-sqlite 0.0.0-stage → 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/module.ts ADDED
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Module (no side effects): the procedures of this package as data, for `createRegistry()`.
3
+ *
4
+ * Procedure Registration for SQLite
5
+ *
6
+ * Registers database and logging procedures with the client system.
7
+ * This file is referenced by package.json's client.procedures field.
8
+ */
9
+
10
+ import { createProcedure, zodAdapter, defineModule, type ProcedureModule } from "@mark1russell7/client";
11
+ import { z } from "zod";
12
+ import {
13
+ withConnection,
14
+ query,
15
+ execute,
16
+ lastInsertRowId,
17
+ runMigrations,
18
+ type Migration,
19
+ } from "@mark1russell7/docker-sqlite";
20
+ import {
21
+ DEFAULT_DB_PATH,
22
+ LOG_LEVELS,
23
+ type DbQueryInput,
24
+ type DbQueryOutput,
25
+ type DbExecuteInput,
26
+ type DbExecuteOutput,
27
+ type LogsStoreInput,
28
+ type LogsStoreOutput,
29
+ type LogsQueryInput,
30
+ type LogsQueryOutput,
31
+ type LogEntry,
32
+ type LogLevel,
33
+ } from "./types.js";
34
+
35
+ // =============================================================================
36
+ // Schemas
37
+ // =============================================================================
38
+
39
+ // Real schemas (roadmap 2.1): the input is validated before the handler, and the MCP tool list
40
+ // and the help of mark describe each field. zodAdapter<T> checks each schema against its type.
41
+
42
+ const dbPathField = z
43
+ .string()
44
+ .optional()
45
+ .describe(`The SQLite database file (default: ${DEFAULT_DB_PATH}). A query does not make a missing file.`);
46
+ const paramsField = z
47
+ .array(z.unknown())
48
+ .optional()
49
+ .describe("The values of the ? placeholders of the SQL, in order");
50
+ const logLevel = z.enum(LOG_LEVELS as [LogLevel, ...LogLevel[]], { error: (issue) => `Invalid log level: ${JSON.stringify(issue.input)}` });
51
+ /** A non-negative integer: it goes into the SQL text, so the schema is strict. */
52
+ const nonNegativeInteger = (field: string) =>
53
+ z
54
+ .number({ error: (issue) => `Invalid ${field}: expected a non-negative integer, got ${JSON.stringify(issue.input)}` })
55
+ .int({ error: `Invalid ${field}: expected a non-negative integer` })
56
+ .min(0, { error: `Invalid ${field}: expected a non-negative integer` });
57
+
58
+ const dbQueryInputSchema = zodAdapter<DbQueryInput>(
59
+ z.object({
60
+ sql: z.string().describe("The SQL query (SELECT)"),
61
+ params: paramsField,
62
+ dbPath: dbPathField,
63
+ })
64
+ );
65
+ const dbQueryOutputSchema = zodAdapter<DbQueryOutput>(
66
+ z.looseObject({
67
+ columns: z.array(z.string()).describe("The column names"),
68
+ rows: z.array(z.record(z.string(), z.unknown())).describe("The rows, one object for each row"),
69
+ })
70
+ );
71
+ const dbExecuteInputSchema = zodAdapter<DbExecuteInput>(
72
+ z.object({
73
+ sql: z.string().describe("The SQL statement or script (INSERT, UPDATE, DELETE, CREATE TABLE, ...)"),
74
+ params: paramsField,
75
+ dbPath: dbPathField,
76
+ })
77
+ );
78
+ const dbExecuteOutputSchema = zodAdapter<DbExecuteOutput>(
79
+ z.looseObject({ changes: z.number().describe("The number of rows that the statements changed") })
80
+ );
81
+ const logsStoreInputSchema = zodAdapter<LogsStoreInput>(
82
+ z.object({
83
+ level: logLevel.describe("The level: trace, debug, info, warn or error"),
84
+ message: z.string().describe("The log message"),
85
+ sessionId: z.string().optional().describe("The session that the entry belongs to (to group entries)"),
86
+ command: z.string().optional().describe("The command that made the entry"),
87
+ context: z.string().optional().describe("More context, for example a package name"),
88
+ data: z.record(z.string(), z.unknown()).optional().describe("Structured data of the entry"),
89
+ errorStack: z.string().optional().describe("The stack trace of an error"),
90
+ dbPath: dbPathField,
91
+ })
92
+ );
93
+ const logsStoreOutputSchema = zodAdapter<LogsStoreOutput>(
94
+ z.looseObject({ id: z.number().describe("The id of the new log row") })
95
+ );
96
+ const logsQueryInputSchema = zodAdapter<LogsQueryInput>(
97
+ z.object({
98
+ sessionId: z.string().optional().describe("Only the entries of this session"),
99
+ command: z.string().optional().describe("Only the entries of this command"),
100
+ level: z.union([logLevel, z.array(logLevel)]).optional().describe("Only the entries of this level, or of these levels"),
101
+ limit: nonNegativeInteger("limit").optional().describe("The most entries to give"),
102
+ offset: nonNegativeInteger("offset").optional().describe("The number of entries to skip (for pages)"),
103
+ orderBy: z.enum(["asc", "desc"]).optional().describe("The order by time: desc (the default, newest first) or asc"),
104
+ dbPath: dbPathField,
105
+ })
106
+ );
107
+ const logsQueryOutputSchema = zodAdapter<LogsQueryOutput>(
108
+ z.looseObject({
109
+ logs: z
110
+ .array(
111
+ z.looseObject({
112
+ id: z.number().describe("The id of the log row"),
113
+ timestamp: z.string().describe("The time of the entry (SQLite datetime text, UTC)"),
114
+ level: logLevel.describe("The level of the entry"),
115
+ message: z.string().describe("The log message"),
116
+ data: z.record(z.string(), z.unknown()).nullable().describe("The structured data of the entry, or null"),
117
+ sessionId: z.string().nullable().describe("The session of the entry, or null"),
118
+ command: z.string().nullable().describe("The command that made the entry, or null"),
119
+ context: z.string().nullable().describe("More context, or null"),
120
+ errorStack: z.string().nullable().describe("The stack trace of an error, or null"),
121
+ })
122
+ )
123
+ .describe("The entries that match the filters, in the order of orderBy"),
124
+ total: z.number().optional().describe("The number of entries that match the filters, without limit and offset"),
125
+ })
126
+ );
127
+
128
+ // =============================================================================
129
+ // Log Table Migrations
130
+ // =============================================================================
131
+
132
+ const LOG_MIGRATIONS: Migration[] = [
133
+ {
134
+ version: 1,
135
+ description: "Create logs table",
136
+ up: `
137
+ CREATE TABLE IF NOT EXISTS logs (
138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
140
+ level TEXT NOT NULL CHECK (level IN ('trace', 'debug', 'info', 'warn', 'error')),
141
+ message TEXT NOT NULL,
142
+ data TEXT,
143
+ session_id TEXT,
144
+ command TEXT,
145
+ context TEXT,
146
+ error_stack TEXT,
147
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
148
+ );
149
+ CREATE INDEX IF NOT EXISTS idx_logs_session ON logs(session_id);
150
+ CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
151
+ CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);
152
+ CREATE INDEX IF NOT EXISTS idx_logs_command ON logs(command);
153
+ `,
154
+ down: "DROP TABLE IF EXISTS logs;",
155
+ },
156
+ ];
157
+
158
+ /**
159
+ * Ensure the logs table exists
160
+ */
161
+ async function ensureLogsTable(dbPath: string): Promise<void> {
162
+ await withConnection({ dbPath }, (db) => {
163
+ runMigrations(db, LOG_MIGRATIONS);
164
+ });
165
+ }
166
+
167
+ // =============================================================================
168
+ // db.query Procedure
169
+ // =============================================================================
170
+
171
+ const dbQueryProcedure = createProcedure()
172
+ .path(["db", "query"])
173
+ .input(dbQueryInputSchema)
174
+ .output(dbQueryOutputSchema)
175
+ .meta({ description: "Execute a SQL SELECT query" })
176
+ .handler(async (input: DbQueryInput): Promise<DbQueryOutput> => {
177
+ const dbPath = input.dbPath ?? DEFAULT_DB_PATH;
178
+ const params = input.params ?? [];
179
+
180
+ return withConnection({ dbPath }, (db) => {
181
+ const result = query(db, input.sql, params);
182
+ return {
183
+ columns: result.columns,
184
+ rows: result.rows,
185
+ };
186
+ });
187
+ })
188
+ .build();
189
+
190
+ // =============================================================================
191
+ // db.execute Procedure
192
+ // =============================================================================
193
+
194
+ const dbExecuteProcedure = createProcedure()
195
+ .path(["db", "execute"])
196
+ .input(dbExecuteInputSchema)
197
+ .output(dbExecuteOutputSchema)
198
+ .meta({ description: "Execute a SQL statement (INSERT, UPDATE, DELETE)" })
199
+ .handler(async (input: DbExecuteInput): Promise<DbExecuteOutput> => {
200
+ const dbPath = input.dbPath ?? DEFAULT_DB_PATH;
201
+ const params = input.params ?? [];
202
+
203
+ return withConnection({ dbPath }, (db) => {
204
+ const result = execute(db, input.sql, params);
205
+ return { changes: result.changes };
206
+ });
207
+ })
208
+ .build();
209
+
210
+ // =============================================================================
211
+ // logs.store Procedure
212
+ // =============================================================================
213
+
214
+ const logsStoreProcedure = createProcedure()
215
+ .path(["logs", "store"])
216
+ .input(logsStoreInputSchema)
217
+ .output(logsStoreOutputSchema)
218
+ .meta({ description: "Store a log entry in the database" })
219
+ .handler(async (input: LogsStoreInput): Promise<LogsStoreOutput> => {
220
+ const dbPath = input.dbPath ?? DEFAULT_DB_PATH;
221
+
222
+ // Validate log level
223
+ if (!LOG_LEVELS.includes(input.level)) {
224
+ throw new Error(`Invalid log level: ${input.level}`);
225
+ }
226
+
227
+ // Ensure table exists
228
+ await ensureLogsTable(dbPath);
229
+
230
+ return withConnection({ dbPath }, (db) => {
231
+ const dataJson = input.data ? JSON.stringify(input.data) : null;
232
+
233
+ execute(
234
+ db,
235
+ `INSERT INTO logs (level, message, data, session_id, command, context, error_stack)
236
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
237
+ [
238
+ input.level,
239
+ input.message,
240
+ dataJson,
241
+ input.sessionId ?? null,
242
+ input.command ?? null,
243
+ input.context ?? null,
244
+ input.errorStack ?? null,
245
+ ]
246
+ );
247
+
248
+ const id = lastInsertRowId(db);
249
+ return { id };
250
+ });
251
+ })
252
+ .build();
253
+
254
+ // =============================================================================
255
+ // logs.query Procedure
256
+ // =============================================================================
257
+
258
+ /**
259
+ * Validate a value as a non-negative integer before it is interpolated into
260
+ * SQL. The input schema is a pass-through, so `limit`/`offset` can arrive as
261
+ * arbitrary values; interpolating them directly is a SQL injection / syntax
262
+ * hazard. Throws on anything that is not a finite, non-negative integer.
263
+ */
264
+ function toNonNegativeInt(value: unknown, field: string): number {
265
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
266
+ throw new Error(
267
+ `Invalid ${field}: expected a non-negative integer, got ${JSON.stringify(value)}`
268
+ );
269
+ }
270
+ return value;
271
+ }
272
+
273
+ const logsQueryProcedure = createProcedure()
274
+ .path(["logs", "query"])
275
+ .input(logsQueryInputSchema)
276
+ .output(logsQueryOutputSchema)
277
+ .meta({ description: "Query log entries from the database" })
278
+ .handler(async (input: LogsQueryInput): Promise<LogsQueryOutput> => {
279
+ const dbPath = input.dbPath ?? DEFAULT_DB_PATH;
280
+
281
+ // Ensure table exists
282
+ await ensureLogsTable(dbPath);
283
+
284
+ return withConnection({ dbPath }, (db) => {
285
+ const conditions: string[] = [];
286
+ const params: unknown[] = [];
287
+
288
+ if (input.sessionId !== undefined) {
289
+ conditions.push("session_id = ?");
290
+ params.push(input.sessionId);
291
+ }
292
+
293
+ if (input.command !== undefined) {
294
+ conditions.push("command = ?");
295
+ params.push(input.command);
296
+ }
297
+
298
+ if (input.level !== undefined) {
299
+ const levels = Array.isArray(input.level) ? input.level : [input.level];
300
+ const placeholders = levels.map(() => "?").join(", ");
301
+ conditions.push(`level IN (${placeholders})`);
302
+ params.push(...levels);
303
+ }
304
+
305
+ const whereClause =
306
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
307
+ const orderDir = input.orderBy === "asc" ? "ASC" : "DESC";
308
+ let limitClause =
309
+ input.limit !== undefined
310
+ ? `LIMIT ${toNonNegativeInt(input.limit, "limit")}`
311
+ : "";
312
+ let offsetClause = "";
313
+ if (input.offset !== undefined) {
314
+ offsetClause = `OFFSET ${toNonNegativeInt(input.offset, "offset")}`;
315
+ // SQLite's grammar only allows OFFSET as part of a LIMIT clause; an
316
+ // OFFSET with no LIMIT is a syntax error. Use "LIMIT -1" (unbounded)
317
+ // so an offset-only query remains valid.
318
+ if (input.limit === undefined) {
319
+ limitClause = "LIMIT -1";
320
+ }
321
+ }
322
+
323
+ const sql = `
324
+ SELECT id, timestamp, level, message, data, session_id, command, context, error_stack
325
+ FROM logs
326
+ ${whereClause}
327
+ ORDER BY timestamp ${orderDir}
328
+ ${limitClause}
329
+ ${offsetClause}
330
+ `;
331
+
332
+ const result = query<{
333
+ id: number;
334
+ timestamp: string;
335
+ level: string;
336
+ message: string;
337
+ data: string | null;
338
+ session_id: string | null;
339
+ command: string | null;
340
+ context: string | null;
341
+ error_stack: string | null;
342
+ }>(db, sql, params);
343
+
344
+ const logs: LogEntry[] = result.rows.map((row) => ({
345
+ id: row.id,
346
+ timestamp: row.timestamp,
347
+ level: row.level as LogLevel,
348
+ message: row.message,
349
+ data: row.data ? (JSON.parse(row.data) as Record<string, unknown>) : null,
350
+ sessionId: row.session_id,
351
+ command: row.command,
352
+ context: row.context,
353
+ errorStack: row.error_stack,
354
+ }));
355
+
356
+ // Total number of rows matching the filters, ignoring LIMIT/OFFSET, so
357
+ // callers can paginate. `params` holds only the WHERE-clause bindings.
358
+ const countResult = query<{ total: number }>(
359
+ db,
360
+ `SELECT COUNT(*) AS total FROM logs ${whereClause}`,
361
+ params
362
+ );
363
+ const total = countResult.rows[0]?.total ?? 0;
364
+
365
+ return { logs, total };
366
+ });
367
+ })
368
+ .build();
369
+
370
+ // =============================================================================
371
+ // Registration
372
+ // =============================================================================
373
+
374
+ /**
375
+ * The module of this package, for `createRegistry()`: its procedures as data. Importing this
376
+ * file registers nothing. (`register.ts` registers the same procedures in the global registry.)
377
+ */
378
+ export const sqliteModule: ProcedureModule = defineModule("@mark1russell7/client-sqlite", [
379
+ dbQueryProcedure,
380
+ dbExecuteProcedure,
381
+ logsStoreProcedure,
382
+ logsQueryProcedure,
383
+ ]);
384
+
385
+ export default sqliteModule;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Importing this file registers the procedures of @mark1russell7/client-sqlite in the global registry
3
+ * (DECISIONS-2026-10, "Every package root registers its procedures"). The procedures as data
4
+ * are in `module.ts`, which registers nothing: a host with its own registry uses that one.
5
+ */
6
+
7
+ import { registerProcedures } from "@mark1russell7/client";
8
+ import { sqliteModule } from "./module.js";
9
+
10
+ export * from "./module.js";
11
+
12
+ export function registerSqliteProcedures(): void {
13
+ registerProcedures(sqliteModule.procedures);
14
+ }
15
+
16
+ // Auto-register when this module is loaded
17
+ registerSqliteProcedures();
package/src/types.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Type definitions for client-sqlite procedures
3
+ */
4
+
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ // =============================================================================
9
+ // Configuration
10
+ // =============================================================================
11
+
12
+ /**
13
+ * Default database path for CLI logs
14
+ */
15
+ export const DEFAULT_DB_PATH: string = join(homedir(), "logs", "cli", "cli.db");
16
+
17
+ // =============================================================================
18
+ // Log Levels
19
+ // =============================================================================
20
+
21
+ export type LogLevel = "trace" | "debug" | "info" | "warn" | "error";
22
+
23
+ export const LOG_LEVELS: readonly LogLevel[] = [
24
+ "trace",
25
+ "debug",
26
+ "info",
27
+ "warn",
28
+ "error",
29
+ ] as const;
30
+
31
+ // =============================================================================
32
+ // db.query types
33
+ // =============================================================================
34
+
35
+ export interface DbQueryInput {
36
+ /** SQL query to execute */
37
+ sql: string;
38
+ /** Query parameters */
39
+ params?: unknown[] | undefined;
40
+ /** Database path (defaults to CLI log database) */
41
+ dbPath?: string | undefined;
42
+ }
43
+
44
+ export interface DbQueryOutput<T = Record<string, unknown>> {
45
+ /** Column names */
46
+ columns: string[];
47
+ /** Query results */
48
+ rows: T[];
49
+ }
50
+
51
+ // =============================================================================
52
+ // db.execute types
53
+ // =============================================================================
54
+
55
+ export interface DbExecuteInput {
56
+ /** SQL statement to execute (INSERT, UPDATE, DELETE, etc.) */
57
+ sql: string;
58
+ /** Statement parameters */
59
+ params?: unknown[] | undefined;
60
+ /** Database path (defaults to CLI log database) */
61
+ dbPath?: string | undefined;
62
+ }
63
+
64
+ export interface DbExecuteOutput {
65
+ /** Number of rows affected */
66
+ changes: number;
67
+ }
68
+
69
+ // =============================================================================
70
+ // logs.store types
71
+ // =============================================================================
72
+
73
+ export interface LogsStoreInput {
74
+ /** Log level */
75
+ level: LogLevel;
76
+ /** Log message */
77
+ message: string;
78
+ /** Session ID for grouping logs */
79
+ sessionId?: string | undefined;
80
+ /** Command that generated this log */
81
+ command?: string | undefined;
82
+ /** Additional context (e.g., package name) */
83
+ context?: string | undefined;
84
+ /** Additional structured data */
85
+ data?: Record<string, unknown> | undefined;
86
+ /** Error stack trace if applicable */
87
+ errorStack?: string | undefined;
88
+ /** Database path (defaults to CLI log database) */
89
+ dbPath?: string | undefined;
90
+ }
91
+
92
+ export interface LogsStoreOutput {
93
+ /** ID of the inserted log row */
94
+ id: number;
95
+ }
96
+
97
+ // =============================================================================
98
+ // logs.query types
99
+ // =============================================================================
100
+
101
+ export interface LogsQueryInput {
102
+ /** Filter by session ID */
103
+ sessionId?: string | undefined;
104
+ /** Filter by command */
105
+ command?: string | undefined;
106
+ /** Filter by log level(s) */
107
+ level?: LogLevel | LogLevel[] | undefined;
108
+ /** Maximum number of logs to return */
109
+ limit?: number | undefined;
110
+ /** Offset for pagination */
111
+ offset?: number | undefined;
112
+ /** Order by (default: timestamp DESC) */
113
+ orderBy?: "asc" | "desc" | undefined;
114
+ /** Database path (defaults to CLI log database) */
115
+ dbPath?: string | undefined;
116
+ }
117
+
118
+ export interface LogEntry {
119
+ id: number;
120
+ timestamp: string;
121
+ level: LogLevel;
122
+ message: string;
123
+ data: Record<string, unknown> | null;
124
+ sessionId: string | null;
125
+ command: string | null;
126
+ context: string | null;
127
+ errorStack: string | null;
128
+ }
129
+
130
+ export interface LogsQueryOutput {
131
+ /** Log entries matching the query */
132
+ logs: LogEntry[];
133
+ /** Total count (if limit was applied) */
134
+ total?: number | undefined;
135
+ }