feat: make search() FTS-safe; complete Tier 2 helper-shape contract tests
search() falls back to safe per-token quoting on malformed FTS input instead of crashing (documented in api-reference.md). Adds raw() to the doc-synced shape contract. Full suite 107/107.
This commit is contained in:
@@ -76,6 +76,11 @@ Use `context(uuid)` or `trace(uuid)` for causal/parent-chain expansion. Lower
|
|||||||
FTS rank sorts earlier; prefer returned order unless deliberately inspecting
|
FTS rank sorts earlier; prefer returned order unless deliberately inspecting
|
||||||
FTS ranking.
|
FTS ranking.
|
||||||
|
|
||||||
|
Valid FTS5 syntax in `text` is honored. Input that FTS5 would reject as
|
||||||
|
malformed (for example a hyphenated term like `foo-bar`) does not error: it
|
||||||
|
falls back to safe per-token quoting — the same tokenization `memories()` uses —
|
||||||
|
so ordinary text never crashes the query.
|
||||||
|
|
||||||
#### `context(uuid)`
|
#### `context(uuid)`
|
||||||
|
|
||||||
Full indexed context around one message.
|
Full indexed context around one message.
|
||||||
@@ -368,7 +373,11 @@ well as `Edit`/`Write`.
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
Array<{ toolCall, session, timestamp }>
|
Array<{
|
||||||
|
toolCall: { id, message_uuid, name, input_json },
|
||||||
|
session: { id, title, project },
|
||||||
|
timestamp
|
||||||
|
}>
|
||||||
```
|
```
|
||||||
|
|
||||||
Use raw SQL with `ORDER BY m.timestamp DESC` when you need newest-first file
|
Use raw SQL with `ORDER BY m.timestamp DESC` when you need newest-first file
|
||||||
|
|||||||
+20
-10
@@ -73,22 +73,32 @@ function createQueryApi(db) {
|
|||||||
const search = (text, opts = {}) => {
|
const search = (text, opts = {}) => {
|
||||||
const { limit = 20, sessionId, project, after, before, cwd, source, includeMeta = false } = opts;
|
const { limit = 20, sessionId, project, after, before, cwd, source, includeMeta = false } = opts;
|
||||||
let where = 'WHERE mf.text MATCH ?';
|
let where = 'WHERE mf.text MATCH ?';
|
||||||
const p = [text];
|
const filterParams = [];
|
||||||
if (sessionId) { where += ' AND mf.session_id=?'; p.push(sessionId); }
|
if (sessionId) { where += ' AND mf.session_id=?'; filterParams.push(sessionId); }
|
||||||
if (project) { where += ' AND s.project LIKE ?'; p.push(project); }
|
if (project) { where += ' AND s.project LIKE ?'; filterParams.push(project); }
|
||||||
if (after) { where += ' AND m.timestamp>?'; p.push(after); }
|
if (after) { where += ' AND m.timestamp>?'; filterParams.push(after); }
|
||||||
if (before) { where += ' AND m.timestamp<?'; p.push(before); }
|
if (before) { where += ' AND m.timestamp<?'; filterParams.push(before); }
|
||||||
if (cwd) { where += ' AND m.cwd LIKE ?'; p.push(cwd); }
|
if (cwd) { where += ' AND m.cwd LIKE ?'; filterParams.push(cwd); }
|
||||||
if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; p.push(source); }
|
if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; filterParams.push(source); }
|
||||||
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
|
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
|
||||||
p.push(limit);
|
const stmt = db.prepare(`
|
||||||
const rows = db.prepare(`
|
|
||||||
SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,m.source as m_source,
|
SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,m.source as m_source,
|
||||||
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
|
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
|
||||||
s.source as s_source,
|
s.source as s_source,
|
||||||
rank
|
rank
|
||||||
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
|
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
|
||||||
${where} ORDER BY rank LIMIT ?`).all(...p);
|
${where} ORDER BY rank LIMIT ?`);
|
||||||
|
const runMatch = (matchText) => stmt.all(matchText, ...filterParams, limit);
|
||||||
|
// Honor raw FTS5 syntax when the query is valid, but never crash on ordinary
|
||||||
|
// input (hyphens, punctuation) that FTS5 would parse as operators: fall back
|
||||||
|
// to safe per-token quoting, the same tokenization memories() uses.
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = runMatch(text);
|
||||||
|
} catch {
|
||||||
|
const safe = buildSafeFtsQuery(text);
|
||||||
|
rows = safe ? runMatch(safe) : [];
|
||||||
|
}
|
||||||
return rows.map(r => {
|
return rows.map(r => {
|
||||||
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
|
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
|
||||||
const ctx = db.prepare(
|
const ctx = db.prepare(
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Tier 2 contract golden tests (see docs/adr/0002-two-tier-runtime-contract.md).
|
||||||
|
//
|
||||||
|
// These lock the *composite return shapes* that agents parse and that
|
||||||
|
// references/api-reference.md documents explicitly, so the upcoming TypeScript /
|
||||||
|
// runtime-core refactor cannot silently reshape a helper's output. Two guards
|
||||||
|
// work together:
|
||||||
|
//
|
||||||
|
// 1. Live-shape assertions: build a representative in-memory DB, call each
|
||||||
|
// helper, and assert the returned object's key set matches the contract.
|
||||||
|
// 2. Doc-sync guard: assert every contracted key name also appears in
|
||||||
|
// references/api-reference.md. If an implementation shape changes, guard (1)
|
||||||
|
// fails; if the test contract changes without updating the doc, guard (2)
|
||||||
|
// fails. Either way, changing a helper forces a doc change.
|
||||||
|
//
|
||||||
|
// Out of scope here (covered elsewhere):
|
||||||
|
// - Bare `Array<session_row>` helpers (sessions/recent/workflows/trace/thread)
|
||||||
|
// return `SELECT *` rows; their columns are pinned by db-schema.test.mjs.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { createQueryApi, createAttuneApi } from '../scripts/query.mjs';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const SCHEMA = readFileSync(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
|
||||||
|
const API_REFERENCE = readFileSync(new URL('../references/api-reference.md', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
// Every key asserted below is recorded here so the doc-sync guard can confirm
|
||||||
|
// references/api-reference.md still documents it.
|
||||||
|
const assertedKeys = new Set();
|
||||||
|
|
||||||
|
function exactKeys(obj, expected, label) {
|
||||||
|
expected.forEach(k => assertedKeys.add(k));
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(obj).sort(),
|
||||||
|
[...expected].sort(),
|
||||||
|
`${label}: key set drifted from api-reference.md`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKeys(obj, expected, label) {
|
||||||
|
const keys = new Set(Object.keys(obj));
|
||||||
|
for (const k of expected) {
|
||||||
|
assertedKeys.add(k);
|
||||||
|
assert.ok(keys.has(k), `${label}: missing documented key "${k}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
db.exec(SCHEMA);
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO sessions (id, title, project, project_path, started_at, ended_at, git_branch, message_count, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||||
|
'sid-1', 'Contract session', 'quiet-zero', process.cwd(),
|
||||||
|
'2026-06-10T10:00:00Z', '2026-06-10T11:00:00Z', 'main', 5, 'claude',
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertMsg = db.prepare(`INSERT INTO messages
|
||||||
|
(uuid, session_id, type, parent_uuid, timestamp, role, text, content_type, is_meta, model, agent_id, cwd, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
insertMsg.run('m-root', 'sid-1', 'user', null, '2026-06-10T10:00:00Z', 'user', 'contract needle root', 'text', 0, null, null, process.cwd(), 'claude');
|
||||||
|
insertMsg.run('m-child', 'sid-1', 'assistant', 'm-root', '2026-06-10T10:00:10Z', 'assistant', 'contract needle child', 'text', 0, 'claude-opus', 'sub-A', process.cwd(), 'claude');
|
||||||
|
insertMsg.run('m-fail', 'sid-1', 'assistant', 'm-child', '2026-06-10T10:00:20Z', 'assistant', 'ran a command', 'tool_use', 0, 'claude-opus', null, process.cwd(), 'claude');
|
||||||
|
insertMsg.run('m-after', 'sid-1', 'assistant', 'm-fail', '2026-06-10T10:00:30Z', 'assistant', 'recovered after failure', 'text', 0, 'claude-opus', null, process.cwd(), 'claude');
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json, file_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`).run('tc-read', 'm-child', 'sid-1', 'Read', '{"file_path":"/x/file.ts"}', '/x/file.ts');
|
||||||
|
db.prepare(`INSERT INTO tool_calls (id, message_uuid, session_id, name, input_json, file_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`).run('tc-bash', 'm-fail', 'sid-1', 'Bash', '{"command":"boom"}', null);
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO tool_results (tool_use_id, message_uuid, session_id, content, file_path, is_error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`).run('tc-bash', 'm-fail', 'sid-1', 'command failed', null, 1);
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO subagents (agent_id, session_id, parent_tool_use_id, agent_type, description, duration_ms, total_tokens)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('sub-A', 'sid-1', 'tc-read', 'general-purpose', 'a subagent', 100, 200);
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO workflows (run_id, session_id, task_id, script, result_json, timestamp, agent_count, status, workflow_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run('wf-1', 'sid-1', 'task-1', 'export const meta={}', '{"ok":true}', '2026-06-10T10:05:00Z', 1, 'done', 'demo');
|
||||||
|
db.prepare(`INSERT INTO workflow_agents (agent_id, run_id, session_id, agent_type, description, phase, label, model, state, duration_ms, tokens, tool_calls)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run('wa-1', 'wf-1', 'sid-1', 'worker', 'agent', 'Find', 'find:x', 'claude-opus', 'done', 50, 10, 2);
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO summaries (id, session_id, timestamp, source, content)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`).run('su-1', 'sid-1', '2026-06-10T10:06:00Z', 'away_summary', 'a summary');
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO memories (id, session_id, project, path, summary, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`).run('mem-1', 'sid-1', 'quiet-zero', '.obelisk/memories/x.md', 'Decision: contract fixture memory.', '2026-06-10T10:07:00Z');
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('search() hit shape matches api-reference.md', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const hit = createQueryApi(db).search('needle', { limit: 1 })[0];
|
||||||
|
|
||||||
|
exactKeys(hit, ['message', 'session', 'rank', 'context'], 'search() hit');
|
||||||
|
exactKeys(hit.message, ['uuid', 'text', 'content_type', 'is_meta', 'role', 'timestamp', 'model', 'cwd', 'source'], 'search() hit.message');
|
||||||
|
exactKeys(hit.session, ['id', 'title', 'project', 'started_at', 'source'], 'search() hit.session');
|
||||||
|
assert.ok(Array.isArray(hit.context), 'search() hit.context is an array');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('context() shape matches api-reference.md', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const ctx = createQueryApi(db).context('m-child');
|
||||||
|
|
||||||
|
exactKeys(ctx, ['message', 'parentChain', 'session', 'subagent', 'workflow'], 'context()');
|
||||||
|
assert.ok(Array.isArray(ctx.parentChain), 'context() parentChain is an array');
|
||||||
|
assert.equal(ctx.parentChain[0].uuid, 'm-root');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fileHistory() row shape matches api-reference.md', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const row = createQueryApi(db).fileHistory('/x/file.ts')[0];
|
||||||
|
|
||||||
|
exactKeys(row, ['toolCall', 'session', 'timestamp'], 'fileHistory() row');
|
||||||
|
exactKeys(row.toolCall, ['id', 'message_uuid', 'name', 'input_json'], 'fileHistory() row.toolCall');
|
||||||
|
exactKeys(row.session, ['id', 'title', 'project'], 'fileHistory() row.session');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failures() row shape matches api-reference.md', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const row = createQueryApi(db).failures()[0];
|
||||||
|
|
||||||
|
exactKeys(row, ['toolCall', 'result', 'session', 'nextMessages'], 'failures() row');
|
||||||
|
assert.ok(Array.isArray(row.nextMessages), 'failures() row.nextMessages is an array');
|
||||||
|
assert.equal(row.nextMessages[0].uuid, 'm-after');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subagents() row carries messageCount', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const row = createQueryApi(db).subagents()[0];
|
||||||
|
|
||||||
|
hasKeys(row, ['messageCount'], 'subagents() row');
|
||||||
|
assert.equal(row.messageCount, 1);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workflowTree() shape carries result and agents with messageCount', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const tree = createQueryApi(db).workflowTree('wf-1');
|
||||||
|
|
||||||
|
hasKeys(tree, ['result', 'agents'], 'workflowTree()');
|
||||||
|
assert.deepEqual(tree.result, { ok: true });
|
||||||
|
assert.ok(Array.isArray(tree.agents));
|
||||||
|
hasKeys(tree.agents[0], ['messageCount'], 'workflowTree() agent');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summaries() row carries session_title and project', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const row = createQueryApi(db).summaries()[0];
|
||||||
|
|
||||||
|
hasKeys(row, ['session_title', 'project'], 'summaries() row');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overview() shape matches api-reference.md', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const view = createQueryApi(db).overview({ limit: 5 });
|
||||||
|
|
||||||
|
exactKeys(view, ['current', 'current_project', 'projects', 'totals'], 'overview()');
|
||||||
|
exactKeys(view.current, ['cwd', 'project'], 'overview() current');
|
||||||
|
exactKeys(view.totals, ['projects', 'sessions', 'memories', 'sources'], 'overview() totals');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forget() result shape matches api-reference.md', () => {
|
||||||
|
const db = fixture();
|
||||||
|
const api = createAttuneApi(db);
|
||||||
|
|
||||||
|
const forgotten = api.forget({ id: 'mem-1', reason: 'contract test' });
|
||||||
|
exactKeys(forgotten, ['id', 'deleted_at', 'deleted_reason'], 'forget()');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raw() shape matches api-reference.md', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'obelisk-raw-'));
|
||||||
|
const jsonlPath = join(dir, 'session.jsonl');
|
||||||
|
const line = JSON.stringify({ uuid: 'm-raw', type: 'user', message: { role: 'user', content: 'raw line body' } });
|
||||||
|
writeFileSync(jsonlPath, line + '\n');
|
||||||
|
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
db.exec(SCHEMA);
|
||||||
|
db.prepare(`INSERT INTO sessions (id, title, project, project_path, jsonl_path, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`).run('sid-raw', 'Raw session', 'quiet-zero', dir, jsonlPath, 'claude');
|
||||||
|
db.prepare(`INSERT INTO messages (uuid, session_id, type, role, text, content_type, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('m-raw', 'sid-raw', 'user', 'user', 'raw line body', 'text', 'claude');
|
||||||
|
|
||||||
|
const result = createQueryApi(db).raw('m-raw');
|
||||||
|
exactKeys(result, ['text', 'totalLength', 'offset', 'limit', 'hasMore'], 'raw()');
|
||||||
|
assert.equal(result.text, line);
|
||||||
|
assert.equal(result.totalLength, line.length);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doc-sync guard: every asserted contract key appears in api-reference.md', () => {
|
||||||
|
// Runs after the shape tests have populated assertedKeys. Guarantees the test
|
||||||
|
// contract and the authoritative doc cannot drift apart silently.
|
||||||
|
assert.ok(assertedKeys.size > 0, 'expected contract keys to have been asserted');
|
||||||
|
const missing = [...assertedKeys].filter(k => !API_REFERENCE.includes(k));
|
||||||
|
assert.deepEqual(missing, [], `keys asserted in tests but absent from api-reference.md: ${missing.join(', ')}`);
|
||||||
|
});
|
||||||
@@ -65,6 +65,19 @@ function searchDb() {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('search falls back to safe tokenization for FTS-special input instead of throwing', () => {
|
||||||
|
const db = searchDb();
|
||||||
|
const api = createQueryApi(db);
|
||||||
|
|
||||||
|
// 'needle-reply' is FTS5 operator syntax (a hyphen). Raw MATCH would throw;
|
||||||
|
// search() must fall back to safe per-token quoting ("needle" "reply") and
|
||||||
|
// still find the message that contains both tokens.
|
||||||
|
const rows = api.search('needle-reply', { limit: 5 });
|
||||||
|
|
||||||
|
assert.deepEqual(rows.map(r => r.message.uuid), ['msg-text']);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('search exposes content_type on hits and temporal context', () => {
|
test('search exposes content_type on hits and temporal context', () => {
|
||||||
const db = searchDb();
|
const db = searchDb();
|
||||||
const api = createQueryApi(db);
|
const api = createQueryApi(db);
|
||||||
|
|||||||
@@ -108,16 +108,14 @@ test('unknown verb prints usage to stderr and exits non-zero', () => {
|
|||||||
assert.match(result.stderr, /--build/);
|
assert.match(result.stderr, /--build/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('--search shares the { error, stack } envelope instead of crashing to stderr', () => {
|
test('--search tolerates FTS-special input via safe tokenization', () => {
|
||||||
// A hyphenated term is FTS5 syntax and makes search() throw. The uniform
|
// A hyphenated term is FTS5 operator syntax. search() falls back to safe
|
||||||
// error envelope (see docs/adr/0002) requires this surface as { error, stack }
|
// per-token quoting instead of crashing, so the CLI returns an array, not an
|
||||||
// on stdout with exit 1 — the same shape as --query/--attune — not a raw crash.
|
// error. (The uniform { error, stack } envelope is exercised via --query/--attune.)
|
||||||
const home = tempHome();
|
const home = tempHome();
|
||||||
const result = runRuntime(['--search', 'foo-bar'], { home });
|
const result = runRuntime(['--search', 'foo-bar'], { home });
|
||||||
|
|
||||||
assert.equal(result.status, 1);
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
const payload = JSON.parse(result.stdout);
|
assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array');
|
||||||
assert.equal(typeof payload.error, 'string');
|
|
||||||
assert.equal(typeof payload.stack, 'string');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user