codetrap 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.
@@ -0,0 +1,154 @@
1
+ import type { Database, SQLQueryBindings } from "bun:sqlite";
2
+ import type { Trap } from "../domain/trap";
3
+ import { DEFAULT_TRAP_STATUS, type TrapStatus } from "../lib/constants";
4
+ import {
5
+ decodeEmbedding,
6
+ encodeEmbedding,
7
+ type EmbeddingConfig,
8
+ type FreshEmbedding,
9
+ type StoredEmbedding,
10
+ } from "../lib/embedder";
11
+ import { embeddingIsFresh, passageHashForTrap } from "../lib/trap-search-document";
12
+ import { countTraps, listTraps } from "./queries";
13
+
14
+ type TrapEmbeddingRow = {
15
+ trap_id: number;
16
+ provider: string;
17
+ model: string;
18
+ dimensions: number;
19
+ passage_version: number;
20
+ passage_hash: string;
21
+ embedding: Uint8Array;
22
+ updated_at: string;
23
+ };
24
+
25
+ export function getEmbedding(db: Database, trapId: number): StoredEmbedding | null {
26
+ const row = db
27
+ .query("SELECT * FROM trap_embeddings WHERE trap_id = ?")
28
+ .get(trapId) as TrapEmbeddingRow | null;
29
+ return row ? rowToStoredEmbedding(row) : null;
30
+ }
31
+
32
+ export function upsertEmbedding(db: Database, record: StoredEmbedding): void {
33
+ db.prepare(`
34
+ INSERT INTO trap_embeddings (
35
+ trap_id, provider, model, dimensions, passage_version, passage_hash, embedding, updated_at
36
+ )
37
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
38
+ ON CONFLICT(trap_id) DO UPDATE SET
39
+ provider = excluded.provider,
40
+ model = excluded.model,
41
+ dimensions = excluded.dimensions,
42
+ passage_version = excluded.passage_version,
43
+ passage_hash = excluded.passage_hash,
44
+ embedding = excluded.embedding,
45
+ updated_at = datetime('now')
46
+ `).run(
47
+ record.trap_id,
48
+ record.provider,
49
+ record.model,
50
+ record.dimensions,
51
+ record.passage_version,
52
+ record.passage_hash,
53
+ encodeEmbedding(record.embedding)
54
+ );
55
+ }
56
+
57
+ export function deleteEmbedding(db: Database, trapId: number): void {
58
+ db.prepare("DELETE FROM trap_embeddings WHERE trap_id = ?").run(trapId);
59
+ }
60
+
61
+ export function getAllFreshEmbeddings(
62
+ db: Database,
63
+ config: EmbeddingConfig,
64
+ opts: { category?: string; scope?: string; status?: TrapStatus | "all" } = {}
65
+ ): FreshEmbedding[] {
66
+ const conditions = [
67
+ "e.provider = ?",
68
+ "e.model = ?",
69
+ "e.dimensions = ?",
70
+ "e.passage_version = ?",
71
+ ];
72
+ const params: SQLQueryBindings[] = [
73
+ config.provider,
74
+ config.model,
75
+ config.dimensions,
76
+ config.passageVersion,
77
+ ];
78
+
79
+ if (opts.category) {
80
+ conditions.push("t.category = ?");
81
+ params.push(opts.category);
82
+ }
83
+ if (opts.scope) {
84
+ conditions.push("t.scope = ?");
85
+ params.push(opts.scope);
86
+ }
87
+ if (opts.status !== "all") {
88
+ conditions.push("t.status = ?");
89
+ params.push(opts.status ?? DEFAULT_TRAP_STATUS);
90
+ }
91
+
92
+ const rows = db
93
+ .query(
94
+ `
95
+ SELECT
96
+ t.*,
97
+ e.embedding AS embedding,
98
+ e.passage_hash AS embedding_passage_hash
99
+ FROM trap_embeddings e
100
+ JOIN traps t ON t.id = e.trap_id
101
+ WHERE ${conditions.join(" AND ")}
102
+ `
103
+ )
104
+ .all(...params) as (Trap & { embedding: Uint8Array; embedding_passage_hash: string })[];
105
+
106
+ return rows.flatMap((row) => {
107
+ if (row.embedding_passage_hash !== passageHashForTrap(row)) return [];
108
+ const { embedding, embedding_passage_hash: _passageHash, ...trap } = row;
109
+ return [{ trap: trap as Trap, embedding: decodeEmbedding(embedding) }];
110
+ });
111
+ }
112
+
113
+ export function getTrapsNeedingEmbeddings(
114
+ db: Database,
115
+ config: EmbeddingConfig,
116
+ opts: { scope?: string; category?: string; status?: TrapStatus | "all"; force?: boolean; limit?: number } = {}
117
+ ): Trap[] {
118
+ const traps = listTraps(db, {
119
+ scope: opts.scope,
120
+ category: opts.category,
121
+ status: opts.status,
122
+ limit: 100000,
123
+ });
124
+ const needed: Trap[] = [];
125
+
126
+ for (const trap of traps) {
127
+ const embedding = getEmbedding(db, trap.id);
128
+ if (opts.force || !embeddingIsFresh(trap, embedding, config)) {
129
+ needed.push(trap);
130
+ }
131
+ }
132
+
133
+ return needed.slice(0, opts.limit ?? needed.length);
134
+ }
135
+
136
+ export function countEmbeddableTraps(
137
+ db: Database,
138
+ opts: { scope?: string; category?: string; status?: TrapStatus | "all" } = {}
139
+ ): number {
140
+ return countTraps(db, opts);
141
+ }
142
+
143
+ function rowToStoredEmbedding(row: TrapEmbeddingRow): StoredEmbedding {
144
+ return {
145
+ trap_id: row.trap_id,
146
+ provider: row.provider,
147
+ model: row.model,
148
+ dimensions: row.dimensions,
149
+ passage_version: row.passage_version,
150
+ passage_hash: row.passage_hash,
151
+ embedding: decodeEmbedding(row.embedding),
152
+ updated_at: row.updated_at,
153
+ };
154
+ }
@@ -0,0 +1,296 @@
1
+ import type { Database, SQLQueryBindings } from "bun:sqlite";
2
+ import { DEFAULT_SEVERITY, DEFAULT_TRAP_STATUS, type TrapStatus } from "../lib/constants";
3
+ import {
4
+ TRAP_UPDATE_FIELDS,
5
+ type Trap,
6
+ type TrapEvidence,
7
+ type TrapExportRecord,
8
+ type TrapEvidenceInput,
9
+ type TrapInput,
10
+ type TrapSearchResult,
11
+ type TrapUpdate,
12
+ } from "../domain/trap";
13
+ import { prepareFTSQuery } from "../lib/fts-query";
14
+ import { normalizeQuery } from "../lib/search-normalizer";
15
+ import { normalizeEvidenceForExport } from "../lib/trap-archive";
16
+ import { encodeEvidenceRelatedFiles, encodeTrapTags } from "../lib/trap-json-fields";
17
+ import { buildTrapSearchText, passageFieldsChanged, searchTextFieldsChanged } from "../lib/trap-search-document";
18
+
19
+ export type TrapStatusFilter = TrapStatus | "all";
20
+
21
+ export function insertTrap(db: Database, input: TrapInput): number {
22
+ const tags = encodeTrapTags(input.tags);
23
+ const searchText = buildTrapSearchText({ ...input, tags });
24
+ const stmt = db.prepare(`
25
+ INSERT INTO traps (
26
+ title, category, tags, scope, context, mistake, fix, search_text,
27
+ before_code, after_code, severity, state_key, status, supersedes_id,
28
+ valid_from, valid_until, project_path
29
+ )
30
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?)
31
+ `);
32
+ const result = stmt.run(
33
+ input.title,
34
+ input.category,
35
+ tags,
36
+ input.scope,
37
+ input.context,
38
+ input.mistake,
39
+ input.fix,
40
+ searchText,
41
+ input.before_code ?? null,
42
+ input.after_code ?? null,
43
+ input.severity ?? DEFAULT_SEVERITY,
44
+ null,
45
+ DEFAULT_TRAP_STATUS,
46
+ null,
47
+ null,
48
+ input.project_path ?? null
49
+ );
50
+ return Number(result.lastInsertRowid);
51
+ }
52
+
53
+ export function searchTraps(
54
+ db: Database,
55
+ query: string,
56
+ opts: { category?: string; scope?: string; limit?: number; status?: TrapStatusFilter } = {}
57
+ ): TrapSearchResult[] {
58
+ const prepared = prepareFTSQuery(normalizeQuery(query));
59
+ if (!prepared) return [];
60
+
61
+ const conditions = ["traps_fts MATCH ?"];
62
+ const params: SQLQueryBindings[] = [prepared];
63
+
64
+ addTrapFilters(conditions, params, opts, "t");
65
+
66
+ params.push(opts.limit ?? 20);
67
+
68
+ const rows = db
69
+ .query(
70
+ `
71
+ SELECT t.*, rank
72
+ FROM traps_fts f
73
+ JOIN traps t ON t.id = f.rowid
74
+ WHERE ${conditions.join(" AND ")}
75
+ ORDER BY rank
76
+ LIMIT ?
77
+ `
78
+ )
79
+ .all(...params) as (Trap & { rank: number })[];
80
+ return rows.map((r) => ({ trap: r, rank: r.rank }));
81
+ }
82
+
83
+ export function getTrap(db: Database, id: number): Trap | null {
84
+ return db.query("SELECT * FROM traps WHERE id = ?").get(id) as Trap | null;
85
+ }
86
+
87
+ export function listTraps(
88
+ db: Database,
89
+ opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatusFilter } = {}
90
+ ): Trap[] {
91
+ const conditions: string[] = [];
92
+ const params: SQLQueryBindings[] = [];
93
+
94
+ addTrapFilters(conditions, params, opts);
95
+
96
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
97
+ const limit = opts.limit ?? 50;
98
+ const offset = opts.offset ?? 0;
99
+
100
+ return db
101
+ .query(`SELECT * FROM traps ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)
102
+ .all(...params, limit, offset) as Trap[];
103
+ }
104
+
105
+ export function updateTrap(db: Database, id: number, input: TrapUpdate): boolean {
106
+ const updates: string[] = [];
107
+ const params: SQLQueryBindings[] = [];
108
+ const current = searchTextFieldsChanged(input) || passageFieldsChanged(input) ? getTrap(db, id) : null;
109
+
110
+ for (const key of TRAP_UPDATE_FIELDS) {
111
+ if (key === "tags") continue;
112
+ const value = input[key];
113
+ if (value !== undefined) {
114
+ updates.push(`${key} = ?`);
115
+ params.push(value ?? null);
116
+ }
117
+ }
118
+ if (input.tags !== undefined) {
119
+ updates.push("tags = ?");
120
+ params.push(encodeTrapTags(input.tags));
121
+ }
122
+
123
+ if (current && searchTextFieldsChanged(input)) {
124
+ const merged = {
125
+ ...current,
126
+ ...input,
127
+ tags: input.tags !== undefined ? encodeTrapTags(input.tags) : current.tags,
128
+ };
129
+ updates.push("search_text = ?");
130
+ params.push(buildTrapSearchText(merged));
131
+ }
132
+
133
+ if (updates.length === 0) return false;
134
+
135
+ updates.push("updated_at = datetime('now')");
136
+ params.push(id);
137
+
138
+ const result = db.prepare(`UPDATE traps SET ${updates.join(", ")} WHERE id = ?`).run(...params);
139
+ return result.changes > 0;
140
+ }
141
+
142
+ export function deleteTrap(db: Database, id: number): boolean {
143
+ const result = db.prepare("DELETE FROM traps WHERE id = ?").run(id);
144
+ return result.changes > 0;
145
+ }
146
+
147
+ export function addTrapEvidence(db: Database, trapId: number, input: TrapEvidenceInput): number {
148
+ const result = db
149
+ .prepare(
150
+ `
151
+ INSERT INTO trap_evidence (
152
+ trap_id, source_type, source_ref, observed_at, related_files, note
153
+ )
154
+ VALUES (?, ?, ?, COALESCE(?, datetime('now')), ?, ?)
155
+ `
156
+ )
157
+ .run(
158
+ trapId,
159
+ input.source_type,
160
+ input.source_ref ?? null,
161
+ input.observed_at ?? null,
162
+ encodeEvidenceRelatedFiles(input.related_files),
163
+ input.note ?? null
164
+ );
165
+ return Number(result.lastInsertRowid);
166
+ }
167
+
168
+ export function listTrapEvidence(db: Database, trapId: number): TrapEvidence[] {
169
+ return db
170
+ .query("SELECT * FROM trap_evidence WHERE trap_id = ? ORDER BY observed_at DESC, id DESC")
171
+ .all(trapId) as TrapEvidence[];
172
+ }
173
+
174
+ export function archiveTrap(db: Database, id: number): boolean {
175
+ const result = db
176
+ .prepare(
177
+ `
178
+ UPDATE traps
179
+ SET status = 'archived',
180
+ valid_until = COALESCE(valid_until, datetime('now')),
181
+ updated_at = datetime('now')
182
+ WHERE id = ?
183
+ `
184
+ )
185
+ .run(id);
186
+ return result.changes > 0;
187
+ }
188
+
189
+ export function supersedeTrap(
190
+ db: Database,
191
+ id: number,
192
+ supersededById: number,
193
+ stateKey?: string
194
+ ): boolean {
195
+ if (id === supersededById) return false;
196
+
197
+ const oldTrap = getTrap(db, id);
198
+ const newTrap = getTrap(db, supersededById);
199
+ if (!oldTrap || !newTrap) return false;
200
+
201
+ const key = stateKey ?? oldTrap.state_key ?? newTrap.state_key ?? `trap:${id}`;
202
+ const tx = db.transaction(() => {
203
+ db.prepare(
204
+ `
205
+ UPDATE traps
206
+ SET status = 'superseded',
207
+ state_key = ?,
208
+ valid_until = COALESCE(valid_until, datetime('now')),
209
+ updated_at = datetime('now')
210
+ WHERE id = ?
211
+ `
212
+ ).run(key, id);
213
+ db.prepare(
214
+ `
215
+ UPDATE traps
216
+ SET status = 'active',
217
+ state_key = ?,
218
+ supersedes_id = ?,
219
+ valid_from = COALESCE(valid_from, datetime('now')),
220
+ valid_until = NULL,
221
+ updated_at = datetime('now')
222
+ WHERE id = ?
223
+ `
224
+ ).run(key, id, supersededById);
225
+ });
226
+ tx();
227
+ return true;
228
+ }
229
+
230
+ export function incrementHitCount(db: Database, id: number): void {
231
+ db.prepare("UPDATE traps SET hit_count = hit_count + 1, updated_at = datetime('now') WHERE id = ?").run(id);
232
+ }
233
+
234
+ export function getTopTraps(db: Database, scope: string, limit = 20): Trap[] {
235
+ return db
236
+ .query("SELECT * FROM traps WHERE scope = ? AND status = 'active' ORDER BY hit_count DESC LIMIT ?")
237
+ .all(scope, limit) as Trap[];
238
+ }
239
+
240
+ export function getStats(db: Database): {
241
+ total: number;
242
+ byCategory: Record<string, number>;
243
+ bySeverity: Record<string, number>;
244
+ } {
245
+ const total = (db.query("SELECT COUNT(*) as c FROM traps").get() as { c: number }).c;
246
+ const byCategory = db
247
+ .query("SELECT category, COUNT(*) as c FROM traps GROUP BY category")
248
+ .all() as { category: string; c: number }[];
249
+ const bySeverity = db
250
+ .query("SELECT severity, COUNT(*) as c FROM traps GROUP BY severity")
251
+ .all() as { severity: string; c: number }[];
252
+
253
+ return {
254
+ total,
255
+ byCategory: Object.fromEntries(byCategory.map((r) => [r.category, r.c])),
256
+ bySeverity: Object.fromEntries(bySeverity.map((r) => [r.severity, r.c])),
257
+ };
258
+ }
259
+
260
+ export function exportTraps(db: Database): TrapExportRecord[] {
261
+ const traps = db.query("SELECT * FROM traps").all() as Trap[];
262
+ return traps.map((trap) => ({
263
+ ...trap,
264
+ evidence: listTrapEvidence(db, trap.id).map(normalizeEvidenceForExport),
265
+ }));
266
+ }
267
+
268
+ export function countTraps(db: Database, opts: { scope?: string; category?: string; status?: TrapStatusFilter } = {}): number {
269
+ const conditions: string[] = [];
270
+ const params: SQLQueryBindings[] = [];
271
+ addTrapFilters(conditions, params, opts);
272
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
273
+ const row = db.query(`SELECT COUNT(*) as c FROM traps ${where}`).get(...params) as { c: number };
274
+ return row.c;
275
+ }
276
+
277
+ function addTrapFilters(
278
+ conditions: string[],
279
+ params: SQLQueryBindings[],
280
+ opts: { category?: string; scope?: string; status?: TrapStatusFilter },
281
+ alias?: string
282
+ ): void {
283
+ const prefix = alias ? `${alias}.` : "";
284
+ if (opts.category) {
285
+ conditions.push(`${prefix}category = ?`);
286
+ params.push(opts.category);
287
+ }
288
+ if (opts.scope) {
289
+ conditions.push(`${prefix}scope = ?`);
290
+ params.push(opts.scope);
291
+ }
292
+ if (opts.status !== "all") {
293
+ conditions.push(`${prefix}status = ?`);
294
+ params.push(opts.status ?? DEFAULT_TRAP_STATUS);
295
+ }
296
+ }
@@ -0,0 +1,141 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type {
3
+ Trap,
4
+ TrapDetails,
5
+ TrapEvidenceInput,
6
+ TrapExportRecord,
7
+ TrapInput,
8
+ TrapSearchResult,
9
+ TrapUpdate,
10
+ } from "../domain/trap";
11
+ import * as embeddingQueries from "./embedding-queries";
12
+ import { SearchService, type SearchOptions } from "../lib/search-service";
13
+ import {
14
+ type EmbeddingConfig,
15
+ type EmbeddingProvider,
16
+ type StoredEmbedding,
17
+ } from "../lib/embedder";
18
+ import { runEmbeddingJob } from "../lib/embedding-job";
19
+ import { passageFieldsChanged } from "../lib/trap-search-document";
20
+ import * as queries from "./queries";
21
+ import type { TrapStatus } from "../lib/constants";
22
+
23
+ export type TrapStats = ReturnType<typeof queries.getStats>;
24
+
25
+ export class TrapRepository {
26
+ private readonly searchService: SearchService;
27
+
28
+ constructor(
29
+ private readonly db: Database,
30
+ private readonly embedder?: EmbeddingProvider
31
+ ) {
32
+ this.searchService = new SearchService(db, embedder);
33
+ }
34
+
35
+ add(input: TrapInput): number {
36
+ return queries.insertTrap(this.db, input);
37
+ }
38
+
39
+ search(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
40
+ return this.searchService.search(query, opts);
41
+ }
42
+
43
+ get(id: number): Trap | null {
44
+ return queries.getTrap(this.db, id);
45
+ }
46
+
47
+ getDetails(id: number, scope: "project" | "global"): TrapDetails | null {
48
+ const trap = queries.getTrap(this.db, id);
49
+ if (!trap) return null;
50
+ return {
51
+ trap,
52
+ evidence: queries.listTrapEvidence(this.db, id),
53
+ scope,
54
+ };
55
+ }
56
+
57
+ list(opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatus | "all" } = {}): Trap[] {
58
+ return queries.listTraps(this.db, opts);
59
+ }
60
+
61
+ update(id: number, input: TrapUpdate): boolean {
62
+ const success = queries.updateTrap(this.db, id, input);
63
+ if (success && passageFieldsChanged(input)) {
64
+ embeddingQueries.deleteEmbedding(this.db, id);
65
+ }
66
+ return success;
67
+ }
68
+
69
+ delete(id: number): boolean {
70
+ return queries.deleteTrap(this.db, id);
71
+ }
72
+
73
+ addEvidence(trapId: number, input: TrapEvidenceInput): number | null {
74
+ if (!queries.getTrap(this.db, trapId)) return null;
75
+ return queries.addTrapEvidence(this.db, trapId, input);
76
+ }
77
+
78
+ archive(id: number): boolean {
79
+ return queries.archiveTrap(this.db, id);
80
+ }
81
+
82
+ supersede(id: number, supersededById: number, stateKey?: string): boolean {
83
+ return queries.supersedeTrap(this.db, id, supersededById, stateKey);
84
+ }
85
+
86
+ hit(id: number): void {
87
+ queries.incrementHitCount(this.db, id);
88
+ }
89
+
90
+ top(scope: string, limit = 20): Trap[] {
91
+ return queries.getTopTraps(this.db, scope, limit);
92
+ }
93
+
94
+ stats(): TrapStats {
95
+ return queries.getStats(this.db);
96
+ }
97
+
98
+ exportAll(): TrapExportRecord[] {
99
+ return queries.exportTraps(this.db);
100
+ }
101
+
102
+ getEmbedding(trapId: number): StoredEmbedding | null {
103
+ return embeddingQueries.getEmbedding(this.db, trapId);
104
+ }
105
+
106
+ upsertEmbedding(record: StoredEmbedding): void {
107
+ embeddingQueries.upsertEmbedding(this.db, record);
108
+ }
109
+
110
+ deleteEmbedding(trapId: number): void {
111
+ embeddingQueries.deleteEmbedding(this.db, trapId);
112
+ }
113
+
114
+ getTrapsNeedingEmbeddings(
115
+ config: EmbeddingConfig,
116
+ opts: { scope?: string; category?: string; status?: TrapStatus | "all"; force?: boolean; limit?: number } = {}
117
+ ): Trap[] {
118
+ return embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, opts);
119
+ }
120
+
121
+ async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
122
+ generated: number;
123
+ skipped: number;
124
+ batches: number;
125
+ }> {
126
+ if (!this.embedder) {
127
+ throw new Error("Embedding provider is unavailable. Set JINA_API_KEY to generate embeddings.");
128
+ }
129
+
130
+ return runEmbeddingJob(
131
+ {
132
+ countEmbeddable: (countOpts) => embeddingQueries.countEmbeddableTraps(this.db, countOpts),
133
+ trapsNeedingEmbeddings: (config, jobOpts) =>
134
+ embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, jobOpts),
135
+ saveEmbedding: (record) => embeddingQueries.upsertEmbedding(this.db, record),
136
+ },
137
+ this.embedder,
138
+ opts
139
+ );
140
+ }
141
+ }