memory-journal-mcp 4.4.2 → 4.5.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/.github/workflows/lint-and-test.yml +1 -1
- package/.github/workflows/security-update.yml +1 -1
- package/CHANGELOG.md +81 -1
- package/DOCKER_README.md +57 -7
- package/Dockerfile +17 -17
- package/README.md +65 -6
- package/SECURITY.md +27 -35
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/constants/ServerInstructions.d.ts +5 -1
- package/dist/constants/ServerInstructions.d.ts.map +1 -1
- package/dist/constants/ServerInstructions.js +137 -83
- package/dist/constants/ServerInstructions.js.map +1 -1
- package/dist/database/SqliteAdapter.d.ts +2 -1
- package/dist/database/SqliteAdapter.d.ts.map +1 -1
- package/dist/database/SqliteAdapter.js +15 -8
- package/dist/database/SqliteAdapter.js.map +1 -1
- package/dist/handlers/resources/index.d.ts +3 -1
- package/dist/handlers/resources/index.d.ts.map +1 -1
- package/dist/handlers/resources/index.js +5 -2
- package/dist/handlers/resources/index.js.map +1 -1
- package/dist/handlers/tools/index.d.ts.map +1 -1
- package/dist/handlers/tools/index.js +63 -16
- package/dist/handlers/tools/index.js.map +1 -1
- package/dist/server/McpServer.d.ts +2 -0
- package/dist/server/McpServer.d.ts.map +1 -1
- package/dist/server/McpServer.js +43 -2
- package/dist/server/McpServer.js.map +1 -1
- package/dist/server/Scheduler.d.ts +91 -0
- package/dist/server/Scheduler.d.ts.map +1 -0
- package/dist/server/Scheduler.js +201 -0
- package/dist/server/Scheduler.js.map +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +6 -3
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/security-utils.d.ts +0 -21
- package/dist/utils/security-utils.d.ts.map +1 -1
- package/dist/utils/security-utils.js +0 -47
- package/dist/utils/security-utils.js.map +1 -1
- package/hooks/README.md +107 -0
- package/hooks/cursor/hooks.json +10 -0
- package/hooks/cursor/memory-journal.mdc +22 -0
- package/hooks/cursor/session-end.sh +19 -0
- package/hooks/kilo-code/session-end-mode.json +11 -0
- package/hooks/kiro/session-end.md +13 -0
- package/package.json +8 -8
- package/releases/v4.5.0.md +116 -0
- package/scripts/generate-server-instructions.ts +176 -0
- package/scripts/server-instructions-function-body.ts +77 -0
- package/server.json +3 -3
- package/src/cli.ts +26 -0
- package/src/constants/ServerInstructions.ts +137 -83
- package/src/constants/server-instructions.md +262 -0
- package/src/database/SqliteAdapter.ts +22 -8
- package/src/handlers/resources/index.ts +8 -2
- package/src/handlers/tools/index.ts +70 -20
- package/src/server/McpServer.ts +60 -2
- package/src/server/Scheduler.ts +278 -0
- package/src/utils/logger.ts +6 -3
- package/src/utils/security-utils.ts +0 -52
- package/tests/constants/server-instructions.test.ts +26 -0
- package/tests/database/sqlite-adapter.test.ts +84 -0
- package/tests/filtering/tool-filter.test.ts +46 -0
- package/tests/handlers/github-resource-handlers.test.ts +453 -0
- package/tests/handlers/github-tool-handlers.test.ts +899 -0
- package/tests/handlers/prompt-handlers.test.ts +40 -0
- package/tests/handlers/resource-handlers.test.ts +32 -0
- package/tests/handlers/tool-handlers.test.ts +13 -2
- package/tests/security/sql-injection.test.ts +3 -54
- package/tests/server/mcp-server.test.ts +491 -5
- package/tests/server/scheduler.test.ts +400 -0
- package/tests/vector/vector-search-manager.test.ts +60 -0
- package/.vscode/settings.json +0 -84
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Journal MCP Server - Scheduler
|
|
3
|
+
*
|
|
4
|
+
* Lightweight in-process scheduler for periodic maintenance jobs.
|
|
5
|
+
* Only meaningful for HTTP/SSE transport (long-lived server processes).
|
|
6
|
+
* Uses setInterval for simplicity — no external dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SqliteAdapter } from '../database/SqliteAdapter.js'
|
|
10
|
+
import type { VectorSearchManager } from '../vector/VectorSearchManager.js'
|
|
11
|
+
import { logger } from '../utils/logger.js'
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** Scheduler configuration options */
|
|
18
|
+
export interface SchedulerOptions {
|
|
19
|
+
/** Automated backup interval in minutes (0 = disabled) */
|
|
20
|
+
backupIntervalMinutes: number
|
|
21
|
+
/** Max backups to retain during automated cleanup */
|
|
22
|
+
keepBackups: number
|
|
23
|
+
/** Database optimize interval in minutes (0 = disabled) */
|
|
24
|
+
vacuumIntervalMinutes: number
|
|
25
|
+
/** Vector index rebuild interval in minutes (0 = disabled) */
|
|
26
|
+
rebuildIndexIntervalMinutes: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Status of a single scheduled job */
|
|
30
|
+
export interface JobStatus {
|
|
31
|
+
name: string
|
|
32
|
+
enabled: boolean
|
|
33
|
+
intervalMinutes: number
|
|
34
|
+
lastRun: string | null
|
|
35
|
+
lastResult: 'success' | 'error' | null
|
|
36
|
+
lastError: string | null
|
|
37
|
+
nextRun: string | null
|
|
38
|
+
runCount: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Overall scheduler status */
|
|
42
|
+
export interface SchedulerStatus {
|
|
43
|
+
active: boolean
|
|
44
|
+
jobs: JobStatus[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Internal timer tracking for a job */
|
|
48
|
+
interface JobTimer {
|
|
49
|
+
name: string
|
|
50
|
+
intervalMinutes: number
|
|
51
|
+
timer: ReturnType<typeof setInterval>
|
|
52
|
+
lastRun: Date | null
|
|
53
|
+
lastResult: 'success' | 'error' | null
|
|
54
|
+
lastError: string | null
|
|
55
|
+
runCount: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Scheduler
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scheduler — runs periodic maintenance jobs for long-lived server processes.
|
|
64
|
+
*
|
|
65
|
+
* Jobs:
|
|
66
|
+
* - **backup**: Exports database to timestamped file, then prunes old backups.
|
|
67
|
+
* - **vacuum**: Runs `PRAGMA optimize` and flushes database to disk.
|
|
68
|
+
* - **rebuild-index**: Rebuilds vector search index from all entries.
|
|
69
|
+
*/
|
|
70
|
+
export class Scheduler {
|
|
71
|
+
private readonly options: SchedulerOptions
|
|
72
|
+
private readonly db: SqliteAdapter
|
|
73
|
+
private readonly vectorManager: VectorSearchManager | null
|
|
74
|
+
private readonly timers: JobTimer[] = []
|
|
75
|
+
private started = false
|
|
76
|
+
|
|
77
|
+
constructor(options: SchedulerOptions, db: SqliteAdapter, vectorManager?: VectorSearchManager) {
|
|
78
|
+
this.options = options
|
|
79
|
+
this.db = db
|
|
80
|
+
this.vectorManager = vectorManager ?? null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Start all enabled scheduled jobs.
|
|
85
|
+
* Each job runs on its own interval and failures are isolated.
|
|
86
|
+
*/
|
|
87
|
+
start(): void {
|
|
88
|
+
if (this.started) {
|
|
89
|
+
logger.warning('Scheduler already started, ignoring duplicate start()', {
|
|
90
|
+
module: 'Scheduler',
|
|
91
|
+
})
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
this.started = true
|
|
95
|
+
|
|
96
|
+
const { backupIntervalMinutes, vacuumIntervalMinutes, rebuildIndexIntervalMinutes } =
|
|
97
|
+
this.options
|
|
98
|
+
|
|
99
|
+
if (backupIntervalMinutes > 0) {
|
|
100
|
+
this.scheduleJob('backup', backupIntervalMinutes, () => this.runBackup())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (vacuumIntervalMinutes > 0) {
|
|
104
|
+
this.scheduleJob('vacuum', vacuumIntervalMinutes, () => this.runVacuumOptimize())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (rebuildIndexIntervalMinutes > 0) {
|
|
108
|
+
if (this.vectorManager) {
|
|
109
|
+
this.scheduleJob('rebuild-index', rebuildIndexIntervalMinutes, () =>
|
|
110
|
+
this.runRebuildIndex()
|
|
111
|
+
)
|
|
112
|
+
} else {
|
|
113
|
+
logger.warning(
|
|
114
|
+
'rebuild-index-interval specified but vector manager not available, skipping',
|
|
115
|
+
{ module: 'Scheduler' }
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.timers.length > 0) {
|
|
121
|
+
const summary = this.timers.map((t) => `${t.name} (${String(t.intervalMinutes)}min)`)
|
|
122
|
+
logger.info(`Scheduler started: ${summary.join(', ')}`, { module: 'Scheduler' })
|
|
123
|
+
} else {
|
|
124
|
+
logger.info('Scheduler started with no jobs enabled', { module: 'Scheduler' })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Stop all scheduled jobs and clear timers.
|
|
130
|
+
* Safe to call multiple times.
|
|
131
|
+
*/
|
|
132
|
+
stop(): void {
|
|
133
|
+
for (const job of this.timers) {
|
|
134
|
+
clearInterval(job.timer)
|
|
135
|
+
}
|
|
136
|
+
if (this.timers.length > 0) {
|
|
137
|
+
logger.info(`Scheduler stopped, cleared ${String(this.timers.length)} job(s)`, {
|
|
138
|
+
module: 'Scheduler',
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
this.timers.length = 0
|
|
142
|
+
this.started = false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get the current status of all scheduled jobs.
|
|
147
|
+
*/
|
|
148
|
+
getStatus(): SchedulerStatus {
|
|
149
|
+
return {
|
|
150
|
+
active: this.started,
|
|
151
|
+
jobs: this.timers.map((t) => ({
|
|
152
|
+
name: t.name,
|
|
153
|
+
enabled: true,
|
|
154
|
+
intervalMinutes: t.intervalMinutes,
|
|
155
|
+
lastRun: t.lastRun?.toISOString() ?? null,
|
|
156
|
+
lastResult: t.lastResult,
|
|
157
|
+
lastError: t.lastError,
|
|
158
|
+
nextRun: t.lastRun
|
|
159
|
+
? new Date(t.lastRun.getTime() + t.intervalMinutes * 60_000).toISOString()
|
|
160
|
+
: new Date(Date.now() + t.intervalMinutes * 60_000).toISOString(),
|
|
161
|
+
runCount: t.runCount,
|
|
162
|
+
})),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ========================================================================
|
|
167
|
+
// Private — Job scheduling
|
|
168
|
+
// ========================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Schedule a recurring job.
|
|
172
|
+
*/
|
|
173
|
+
private scheduleJob(name: string, intervalMinutes: number, fn: () => Promise<void>): void {
|
|
174
|
+
const intervalMs = intervalMinutes * 60_000
|
|
175
|
+
|
|
176
|
+
const jobTimer: JobTimer = {
|
|
177
|
+
name,
|
|
178
|
+
intervalMinutes,
|
|
179
|
+
timer: setInterval(() => {
|
|
180
|
+
void this.executeJob(jobTimer, fn)
|
|
181
|
+
}, intervalMs),
|
|
182
|
+
lastRun: null,
|
|
183
|
+
lastResult: null,
|
|
184
|
+
lastError: null,
|
|
185
|
+
runCount: 0,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.timers.push(jobTimer)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Execute a job with error isolation and status tracking.
|
|
193
|
+
*/
|
|
194
|
+
private async executeJob(job: JobTimer, fn: () => Promise<void>): Promise<void> {
|
|
195
|
+
const startTime = Date.now()
|
|
196
|
+
try {
|
|
197
|
+
await fn()
|
|
198
|
+
job.lastRun = new Date(startTime)
|
|
199
|
+
job.lastResult = 'success'
|
|
200
|
+
job.lastError = null
|
|
201
|
+
job.runCount++
|
|
202
|
+
} catch (error) {
|
|
203
|
+
job.lastRun = new Date(startTime)
|
|
204
|
+
job.lastResult = 'error'
|
|
205
|
+
job.lastError = error instanceof Error ? error.message : String(error)
|
|
206
|
+
job.runCount++
|
|
207
|
+
logger.error(`Scheduled job '${job.name}' failed`, {
|
|
208
|
+
module: 'Scheduler',
|
|
209
|
+
operation: job.name,
|
|
210
|
+
error: job.lastError,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ========================================================================
|
|
216
|
+
// Private — Job implementations
|
|
217
|
+
// ========================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Backup job: export database to file, then cleanup old backups.
|
|
221
|
+
*/
|
|
222
|
+
private async runBackup(): Promise<void> {
|
|
223
|
+
const result = this.db.exportToFile()
|
|
224
|
+
logger.info('Scheduled backup created', {
|
|
225
|
+
module: 'Scheduler',
|
|
226
|
+
operation: 'backup',
|
|
227
|
+
context: { filename: result.filename, sizeBytes: result.sizeBytes },
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const cleanup = this.db.deleteOldBackups(this.options.keepBackups)
|
|
231
|
+
if (cleanup.deleted.length > 0) {
|
|
232
|
+
logger.info(
|
|
233
|
+
`Backup cleanup: deleted ${String(cleanup.deleted.length)}, kept ${String(cleanup.kept)}`,
|
|
234
|
+
{
|
|
235
|
+
module: 'Scheduler',
|
|
236
|
+
operation: 'backup-cleanup',
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await Promise.resolve()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Vacuum/optimize job: run PRAGMA optimize and flush to disk.
|
|
246
|
+
*
|
|
247
|
+
* Note: sql.js uses an in-memory database. PRAGMA optimize updates
|
|
248
|
+
* internal statistics, and flushSave() ensures the disk file is current.
|
|
249
|
+
* A full VACUUM on sql.js only compacts the in-memory representation.
|
|
250
|
+
*/
|
|
251
|
+
private async runVacuumOptimize(): Promise<void> {
|
|
252
|
+
const rawDb = this.db.getRawDb()
|
|
253
|
+
rawDb.run('PRAGMA optimize')
|
|
254
|
+
this.db.flushSave()
|
|
255
|
+
logger.info('Scheduled database optimize completed', {
|
|
256
|
+
module: 'Scheduler',
|
|
257
|
+
operation: 'vacuum',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
await Promise.resolve()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Rebuild index job: full vector index rebuild from database entries.
|
|
265
|
+
*/
|
|
266
|
+
private async runRebuildIndex(): Promise<void> {
|
|
267
|
+
if (!this.vectorManager) {
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const count = await this.vectorManager.rebuildIndex(this.db)
|
|
272
|
+
logger.info(`Scheduled vector index rebuild: ${String(count)} entries indexed`, {
|
|
273
|
+
module: 'Scheduler',
|
|
274
|
+
operation: 'rebuild-index',
|
|
275
|
+
context: { entriesIndexed: count },
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -97,11 +97,14 @@ class Logger {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
setLevel(level: LogLevel): void {
|
|
100
|
-
|
|
100
|
+
if (level in LOG_LEVELS) {
|
|
101
|
+
this.minLevel = LOG_LEVELS[level]
|
|
102
|
+
}
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
// Get log level from environment
|
|
105
|
-
const
|
|
106
|
+
// Get log level from environment (validated against known levels)
|
|
107
|
+
const rawLevel = process.env['LOG_LEVEL'] ?? 'info'
|
|
108
|
+
const envLevel: LogLevel = rawLevel in LOG_LEVELS ? (rawLevel as LogLevel) : 'info'
|
|
106
109
|
|
|
107
110
|
export const logger = new Logger(envLevel)
|
|
@@ -32,16 +32,6 @@ export class InvalidDateFormatError extends SecurityError {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Thrown when SQL injection patterns are detected in input
|
|
37
|
-
*/
|
|
38
|
-
export class SqlInjectionError extends SecurityError {
|
|
39
|
-
constructor(pattern: string) {
|
|
40
|
-
super(`Potential SQL injection detected: '${pattern}'`, 'SQL_INJECTION')
|
|
41
|
-
this.name = 'SqlInjectionError'
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
35
|
/**
|
|
46
36
|
* Thrown when path traversal is detected in input
|
|
47
37
|
*/
|
|
@@ -111,48 +101,6 @@ export function sanitizeSearchQuery(query: string): string {
|
|
|
111
101
|
return query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
|
|
112
102
|
}
|
|
113
103
|
|
|
114
|
-
// ============================================================================
|
|
115
|
-
// SQL Injection Detection
|
|
116
|
-
// ============================================================================
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Patterns that indicate SQL injection attempts.
|
|
120
|
-
* Used for validation in edge cases where parameterized queries aren't possible.
|
|
121
|
-
*/
|
|
122
|
-
const SQL_INJECTION_PATTERNS = [
|
|
123
|
-
/;\s*(DROP|DELETE|INSERT|UPDATE|CREATE|ALTER|TRUNCATE)/i,
|
|
124
|
-
/--\s*/,
|
|
125
|
-
/\/\*[\s\S]*?\*\//,
|
|
126
|
-
/UNION\s+(ALL\s+)?SELECT/i,
|
|
127
|
-
/'\s*OR\s+['"]?1['"]?\s*=\s*['"]?1/i,
|
|
128
|
-
/ATTACH\s+DATABASE/i,
|
|
129
|
-
/DETACH\s+DATABASE/i,
|
|
130
|
-
/load_extension\s*\(/i,
|
|
131
|
-
] as const
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Checks if a string contains potential SQL injection patterns.
|
|
135
|
-
* This is a secondary defense layer; parameterized queries are the primary defense.
|
|
136
|
-
*
|
|
137
|
-
* @param input - The string to check
|
|
138
|
-
* @returns true if injection patterns are detected, false otherwise
|
|
139
|
-
*/
|
|
140
|
-
export function containsSqlInjection(input: string): boolean {
|
|
141
|
-
return SQL_INJECTION_PATTERNS.some((pattern) => pattern.test(input))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Validates that input does not contain SQL injection patterns.
|
|
146
|
-
*
|
|
147
|
-
* @param input - The string to validate
|
|
148
|
-
* @throws SqlInjectionError if injection patterns are detected
|
|
149
|
-
*/
|
|
150
|
-
export function assertNoSqlInjection(input: string): void {
|
|
151
|
-
if (containsSqlInjection(input)) {
|
|
152
|
-
throw new SqlInjectionError(input.substring(0, 50))
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
104
|
// ============================================================================
|
|
157
105
|
// Path Validation
|
|
158
106
|
// ============================================================================
|
|
@@ -99,6 +99,32 @@ describe('generateInstructions', () => {
|
|
|
99
99
|
)
|
|
100
100
|
expect(result).not.toContain('## GitHub Integration')
|
|
101
101
|
})
|
|
102
|
+
|
|
103
|
+
it('should include Session End section', () => {
|
|
104
|
+
const result = generateInstructions(
|
|
105
|
+
TEST_TOOLS,
|
|
106
|
+
TEST_RESOURCES,
|
|
107
|
+
TEST_PROMPTS,
|
|
108
|
+
undefined,
|
|
109
|
+
'essential'
|
|
110
|
+
)
|
|
111
|
+
expect(result).toContain('Session End')
|
|
112
|
+
expect(result).toContain('session-summary')
|
|
113
|
+
expect(result).toContain('retrospective')
|
|
114
|
+
expect(result).toContain('opt-out')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should include Rule & Skill Suggestions section', () => {
|
|
118
|
+
const result = generateInstructions(
|
|
119
|
+
TEST_TOOLS,
|
|
120
|
+
TEST_RESOURCES,
|
|
121
|
+
TEST_PROMPTS,
|
|
122
|
+
undefined,
|
|
123
|
+
'essential'
|
|
124
|
+
)
|
|
125
|
+
expect(result).toContain('Rule & Skill Suggestions')
|
|
126
|
+
expect(result).toContain('always ask the user first')
|
|
127
|
+
})
|
|
102
128
|
})
|
|
103
129
|
|
|
104
130
|
describe('standard level', () => {
|
|
@@ -471,6 +471,65 @@ describe('SqliteAdapter', () => {
|
|
|
471
471
|
fs.unlinkSync(backup.path)
|
|
472
472
|
}
|
|
473
473
|
})
|
|
474
|
+
|
|
475
|
+
it('should delete old backups keeping only keepCount', () => {
|
|
476
|
+
const fs = require('node:fs')
|
|
477
|
+
|
|
478
|
+
// Clean up any pre-existing backups from other tests
|
|
479
|
+
const preExisting = db.listBackups()
|
|
480
|
+
for (const backup of preExisting) {
|
|
481
|
+
if (fs.existsSync(backup.path)) fs.unlinkSync(backup.path)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Create 3 backups
|
|
485
|
+
const b1 = db.exportToFile('cleanup-1')
|
|
486
|
+
const b2 = db.exportToFile('cleanup-2')
|
|
487
|
+
const b3 = db.exportToFile('cleanup-3')
|
|
488
|
+
|
|
489
|
+
// Keep only 1 newest
|
|
490
|
+
db.deleteOldBackups(1)
|
|
491
|
+
|
|
492
|
+
const remaining = db.listBackups()
|
|
493
|
+
// Should have exactly 1 backup remaining (newest)
|
|
494
|
+
expect(remaining.length).toBe(1)
|
|
495
|
+
|
|
496
|
+
// Cleanup any remaining
|
|
497
|
+
for (const path of [b1.path, b2.path, b3.path]) {
|
|
498
|
+
if (fs.existsSync(path)) fs.unlinkSync(path)
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('should restore from a backup file', async () => {
|
|
503
|
+
const fs = require('node:fs')
|
|
504
|
+
// Create an entry and backup
|
|
505
|
+
db.createEntry({ content: 'Before restore test' })
|
|
506
|
+
const countBefore = db.getActiveEntryCount()
|
|
507
|
+
const backup = db.exportToFile('restore-test')
|
|
508
|
+
|
|
509
|
+
// Create more entries after backup
|
|
510
|
+
db.createEntry({ content: 'After backup 1' })
|
|
511
|
+
db.createEntry({ content: 'After backup 2' })
|
|
512
|
+
const countAfterAdding = db.getActiveEntryCount()
|
|
513
|
+
expect(countAfterAdding).toBeGreaterThan(countBefore)
|
|
514
|
+
|
|
515
|
+
// Restore should revert to backup state
|
|
516
|
+
const result = await db.restoreFromFile(backup.filename)
|
|
517
|
+
expect(result.previousEntryCount).toBe(countAfterAdding)
|
|
518
|
+
expect(result.newEntryCount).toBe(countBefore)
|
|
519
|
+
|
|
520
|
+
// Cleanup
|
|
521
|
+
const backups = db.listBackups()
|
|
522
|
+
for (const b of backups) {
|
|
523
|
+
const path = require('node:path').join('backups', b.filename)
|
|
524
|
+
if (fs.existsSync(path)) fs.unlinkSync(path)
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('should get raw database handle', () => {
|
|
529
|
+
const rawDb = db.getRawDb()
|
|
530
|
+
expect(rawDb).toBeDefined()
|
|
531
|
+
expect(typeof rawDb.exec).toBe('function')
|
|
532
|
+
})
|
|
474
533
|
})
|
|
475
534
|
|
|
476
535
|
// ========================================================================
|
|
@@ -552,4 +611,29 @@ describe('SqliteAdapter', () => {
|
|
|
552
611
|
expect(results.length).toBeGreaterThan(0)
|
|
553
612
|
})
|
|
554
613
|
})
|
|
614
|
+
|
|
615
|
+
// ========================================================================
|
|
616
|
+
// Backup edge cases
|
|
617
|
+
// ========================================================================
|
|
618
|
+
|
|
619
|
+
describe('backup edge cases', () => {
|
|
620
|
+
it('should return empty array when backups directory does not exist', () => {
|
|
621
|
+
// Use a fresh adapter with no backups dir created
|
|
622
|
+
const tempDb = new SqliteAdapter('./test-no-backups.db')
|
|
623
|
+
tempDb.initialize()
|
|
624
|
+
const backups = tempDb.listBackups()
|
|
625
|
+
expect(backups).toEqual([])
|
|
626
|
+
tempDb.close()
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('should throw when deleteOldBackups keepCount is less than 1', () => {
|
|
630
|
+
expect(() => db.deleteOldBackups(0)).toThrow('keepCount must be at least 1')
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('should throw when restoring from non-existent backup file', async () => {
|
|
634
|
+
await expect(db.restoreFromFile('nonexistent-backup.db')).rejects.toThrow(
|
|
635
|
+
'Backup file not found'
|
|
636
|
+
)
|
|
637
|
+
})
|
|
638
|
+
})
|
|
555
639
|
})
|
|
@@ -264,3 +264,49 @@ describe('getFilterSummary', () => {
|
|
|
264
264
|
expect(summary.length).toBeGreaterThan(0)
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// parseToolFilter edge cases
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
describe('parseToolFilter edge cases', () => {
|
|
273
|
+
it('should handle blacklist-first mode (-admin = all except admin)', () => {
|
|
274
|
+
const config = parseToolFilter('-admin')
|
|
275
|
+
// Should have all tools EXCEPT admin tools
|
|
276
|
+
expect(config.enabledTools.has('create_entry')).toBe(true)
|
|
277
|
+
expect(config.enabledTools.has('search_entries')).toBe(true)
|
|
278
|
+
expect(config.enabledTools.has('backup_journal')).toBe(true)
|
|
279
|
+
// Admin tools should be excluded
|
|
280
|
+
expect(config.enabledTools.has('delete_entry')).toBe(false)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('should handle single tool name whitelist', () => {
|
|
284
|
+
const config = parseToolFilter('create_entry')
|
|
285
|
+
expect(config.enabledTools.has('create_entry')).toBe(true)
|
|
286
|
+
expect(config.enabledTools.size).toBe(1)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should handle meta-group in non-first position', () => {
|
|
290
|
+
const config = parseToolFilter('backup,starter')
|
|
291
|
+
// starter = core + search, plus backup group
|
|
292
|
+
expect(config.enabledTools.has('create_entry')).toBe(true)
|
|
293
|
+
expect(config.enabledTools.has('search_entries')).toBe(true)
|
|
294
|
+
expect(config.enabledTools.has('backup_journal')).toBe(true)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should handle combined meta-group with tool exclusion', () => {
|
|
298
|
+
const config = parseToolFilter('starter,-create_entry_minimal')
|
|
299
|
+
expect(config.enabledTools.has('create_entry')).toBe(true)
|
|
300
|
+
expect(config.enabledTools.has('create_entry_minimal')).toBe(false)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('should handle multiple exclusions', () => {
|
|
304
|
+
const config = parseToolFilter('full,-admin,-backup,-github')
|
|
305
|
+
expect(config.enabledTools.has('create_entry')).toBe(true)
|
|
306
|
+
expect(config.enabledTools.has('search_entries')).toBe(true)
|
|
307
|
+
// Excluded groups
|
|
308
|
+
expect(config.enabledTools.has('delete_entry')).toBe(false)
|
|
309
|
+
expect(config.enabledTools.has('backup_journal')).toBe(false)
|
|
310
|
+
expect(config.enabledTools.has('get_github_issues')).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
})
|