pi-hashline-edit-pro 0.17.0 → 0.17.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  ]
32
32
  },
33
33
  "dependencies": {
34
- "better-sqlite3": "^13.0.1",
35
34
  "diff": "^8.0.2",
36
35
  "file-type": "^21.3.0",
36
+ "sql.js": "1.11",
37
37
  "xxhash-wasm": "^1.1.0"
38
38
  },
39
39
  "peerDependencies": {
@@ -43,16 +43,14 @@
43
43
  "scripts": {
44
44
  "test": "vitest run",
45
45
  "test:watch": "vitest",
46
- "typecheck": "tsc --noEmit"
46
+ "typecheck": "tsc --noEmit",
47
+ "postinstall": "node scripts/ensure-better-sqlite3.js"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@earendil-works/pi-coding-agent": "^0.74.0",
50
- "@types/better-sqlite3": "^7.6.13",
51
51
  "@types/node": "^22.0.0",
52
+ "@types/sql.js": "^1.4.11",
52
53
  "typescript": "^5.8.0",
53
54
  "vitest": "^4.1.8"
54
- },
55
- "allowScripts": {
56
- "better-sqlite3@13.0.1": true
57
55
  }
58
56
  }
package/src/constants.ts CHANGED
@@ -5,9 +5,8 @@ export const MAX_BYTES = 100 * 1024 * 1024;
5
5
  export const MAX_HASH_LINES = 1_000_000;
6
6
  export const MAX_HASH_RETRIES = 262_144;
7
7
 
8
- export const HASH_STORE_VERSION = 2;
9
8
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
10
-
9
+ export const HASH_STORE_VERSION = 2;
11
10
  export const CONTENT_LINES_NOT_STRING_MSG =
12
11
  `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
13
12
  + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
package/src/hash-store.ts CHANGED
@@ -1,191 +1,347 @@
1
- import Database from "better-sqlite3";
2
- import type { Database as DatabaseType, Statement } from "better-sqlite3";
3
- import { existsSync } from "fs";
1
+ import { readFileSync, writeFileSync, existsSync } from "fs";
4
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
5
3
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
6
4
  import { errCode } from "./utils";
7
5
  import { initHasher, contentChecksum } from "./hashline/hasher";
8
6
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
7
+ import initSqlJs from "sql.js";
8
+
9
+ type SqlParams = (string | number)[];
9
10
 
10
11
  interface Prepared {
11
- get: Statement<[string, string, number], { hashes: string }>;
12
- allPaths: Statement<[], { path: string }>;
13
- deleteOne: Statement<[string]>;
14
- upsert: Statement<[string, string, number, string, number]>;
12
+ get: (...params: SqlParams) => Record<string, unknown> | undefined;
13
+ allPaths: (...params: SqlParams) => Record<string, unknown>[];
14
+ deleteOne: (...params: SqlParams) => void;
15
+ upsert: (...params: SqlParams) => void;
15
16
  }
16
17
 
17
18
  export interface HashStore {
18
- readonly db: DatabaseType;
19
- readonly stmts: Prepared;
19
+ readonly stmts: Prepared;
20
+ readonly engine: "better-sqlite3" | "sql.js";
20
21
  }
21
22
 
22
23
  interface LegacySnapshot {
23
- content: string;
24
- hashes: string[];
24
+ content: string;
25
+ hashes: string[];
25
26
  }
26
27
 
27
28
  function isValidSnapshot(value: unknown): value is LegacySnapshot {
28
- if (typeof value !== "object" || value === null) return false;
29
- const v = value as Record<string, unknown>;
30
- if (typeof v.content !== "string") return false;
31
- if (!Array.isArray(v.hashes)) return false;
32
- for (const h of v.hashes) {
33
- if (typeof h !== "string") return false;
34
- }
35
- return true;
36
- }
37
-
38
- let cachedStore: { path: string; store: HashStore } | null = null;
39
-
40
- function openDatabase(storePath: string): HashStore {
41
- const db = new Database(storePath);
42
- db.pragma("journal_mode = WAL");
43
- db.pragma("synchronous = NORMAL");
44
- db.pragma(`busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
45
- db.exec(`
46
- CREATE TABLE IF NOT EXISTS snapshots (
47
- path TEXT PRIMARY KEY,
48
- checksum TEXT NOT NULL,
49
- line_count INTEGER NOT NULL,
50
- hashes TEXT NOT NULL,
51
- updated_at INTEGER NOT NULL
52
- );
53
- `);
54
- const stmts: Prepared = {
55
- get: db.prepare<[string, string, number], { hashes: string }>(
56
- "SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?",
57
- ),
58
- allPaths: db.prepare<[], { path: string }>("SELECT path FROM snapshots"),
59
- deleteOne: db.prepare<[string]>("DELETE FROM snapshots WHERE path = ?"),
60
- upsert: db.prepare<[string, string, number, string, number]>(
61
- "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
62
- "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at",
63
- ),
64
- };
65
- return { db, stmts };
66
- }
67
-
68
- async function migrateLegacy(store: HashStore): Promise<void> {
69
- const legacyPath = legacyHashStorePath();
70
- let content: string;
71
- try {
72
- content = await readFile(legacyPath, "utf-8");
73
- } catch (error: unknown) {
74
- if (errCode(error) === "ENOENT") return;
75
- console.error("Failed to read legacy hash store for migration:", error);
76
- return;
77
- }
78
-
79
- let parsed: { snapshots?: Record<string, unknown> };
80
- try {
81
- parsed = JSON.parse(content) as typeof parsed;
82
- } catch (error) {
83
- console.error("Failed to parse legacy hash store, skipping migration:", error);
84
- return;
85
- }
86
-
87
- const raw = parsed.snapshots;
88
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
89
-
90
- const insert = store.db.prepare(
91
- "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)",
92
- );
93
- const rows: [string, string, number, string, number][] = [];
94
- for (const [key, value] of Object.entries(raw)) {
95
- if (!isValidSnapshot(value)) continue;
96
- rows.push([
97
- key,
98
- contentChecksum(value.content),
99
- value.content.split("\n").length,
100
- JSON.stringify(value.hashes),
101
- Date.now(),
102
- ]);
103
- }
104
-
105
- if (rows.length > 0) {
106
- store.db.transaction(() => {
107
- for (const row of rows) insert.run(...row);
108
- }).immediate();
109
- }
110
-
111
- try {
112
- await rename(legacyPath, `${legacyPath}.bak`);
113
- } catch (error) {
114
- console.error("Failed to rename legacy hash store after migration:", error);
115
- }
29
+ if (typeof value !== "object" || value === null) return false;
30
+ const v = value as Record<string, unknown>;
31
+ if (typeof v.content !== "string") return false;
32
+ if (!Array.isArray(v.hashes)) return false;
33
+ for (const h of v.hashes) {
34
+ if (typeof h !== "string") return false;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ // ── Backend handle abstraction ──────────────────────────────────────
40
+
41
+ interface BackendHandle {
42
+ store: HashStore;
43
+ close: () => void;
44
+ transact: (fn: () => void) => void;
45
+ prepare: (sql: string) => { run: (...params: unknown[]) => void; free: () => void };
46
+ }
47
+
48
+ let cachedHandle: { path: string; handle: BackendHandle } | null = null;
49
+
50
+ // ── better-sqlite3 backend ──────────────────────────────────────────
51
+
52
+ let BetterDatabase: any = undefined;
53
+
54
+ async function tryLoadBetter(): Promise<boolean> {
55
+ if (BetterDatabase !== undefined) return BetterDatabase !== null;
56
+ try {
57
+ const mod = await import("better-sqlite3");
58
+ BetterDatabase = mod.default || mod;
59
+ return true;
60
+ } catch {
61
+ BetterDatabase = null;
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function openBetterDb(storePath: string): BackendHandle {
67
+ const db = new BetterDatabase(storePath);
68
+ db.pragma("journal_mode = WAL");
69
+ db.pragma("synchronous = NORMAL");
70
+ db.pragma(`busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
71
+ db.exec(
72
+ "CREATE TABLE IF NOT EXISTS snapshots (" +
73
+ "path TEXT PRIMARY KEY, " +
74
+ "checksum TEXT NOT NULL, " +
75
+ "line_count INTEGER NOT NULL, " +
76
+ "hashes TEXT NOT NULL, " +
77
+ "updated_at INTEGER NOT NULL" +
78
+ ")"
79
+ );
80
+
81
+ const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
82
+ const allStmt = db.prepare("SELECT path FROM snapshots");
83
+ const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
84
+ const upsertStmt = db.prepare(
85
+ "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
86
+ "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at"
87
+ );
88
+
89
+ const stmts: Prepared = {
90
+ get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
91
+ allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
92
+ deleteOne: (...params) => { delStmt.run(...params); },
93
+ upsert: (...params) => { upsertStmt.run(...params); },
94
+ };
95
+
96
+ return {
97
+ store: { stmts, engine: "better-sqlite3" },
98
+ close: () => { db.close(); },
99
+ transact: (fn) => { db.transaction(fn).immediate(); },
100
+ prepare: (sql) => {
101
+ const stmt = db.prepare(sql);
102
+ return { run: (...params) => stmt.run(...params), free: () => {} };
103
+ },
104
+ };
105
+ }
106
+
107
+ // ── sql.js backend ──────────────────────────────────────────────────
108
+
109
+ let SqlJsDatabase: any = null;
110
+ let sqlJsPromise: Promise<void> | null = null;
111
+
112
+ async function ensureSqlJs(): Promise<void> {
113
+ if (sqlJsPromise) return sqlJsPromise;
114
+ sqlJsPromise = initSqlJs().then((SQL) => { SqlJsDatabase = SQL.Database; });
115
+ return sqlJsPromise;
116
+ }
117
+
118
+ function openSqlJsDb(storePath: string): BackendHandle {
119
+ const data = existsSync(storePath) ? new Uint8Array(readFileSync(storePath)) : undefined;
120
+ const db = new SqlJsDatabase(data);
121
+
122
+ db.run(
123
+ "CREATE TABLE IF NOT EXISTS snapshots (" +
124
+ "path TEXT PRIMARY KEY, " +
125
+ "checksum TEXT NOT NULL, " +
126
+ "line_count INTEGER NOT NULL, " +
127
+ "hashes TEXT NOT NULL, " +
128
+ "updated_at INTEGER NOT NULL" +
129
+ ")"
130
+ );
131
+
132
+ function save() {
133
+ writeFileSync(storePath, Buffer.from(db.export()));
134
+ }
135
+
136
+ save();
137
+
138
+ const stmts: Prepared = {
139
+ get: (...params) => {
140
+ const stmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
141
+ stmt.bind(params);
142
+ let result: Record<string, unknown> | undefined;
143
+ if (stmt.step()) result = stmt.getAsObject() as Record<string, unknown>;
144
+ stmt.free();
145
+ return result;
146
+ },
147
+ allPaths: (...params) => {
148
+ const stmt = db.prepare("SELECT path FROM snapshots");
149
+ if (params.length > 0) stmt.bind(params);
150
+ const results: Record<string, unknown>[] = [];
151
+ while (stmt.step()) results.push(stmt.getAsObject() as Record<string, unknown>);
152
+ stmt.free();
153
+ return results;
154
+ },
155
+ deleteOne: (...params) => {
156
+ if (params.length > 0) db.run("DELETE FROM snapshots WHERE path = ?", params);
157
+ else db.run("DELETE FROM snapshots WHERE path = ?");
158
+ },
159
+ upsert: (...params) => {
160
+ db.run(
161
+ "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
162
+ "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at",
163
+ params
164
+ );
165
+ },
166
+ };
167
+
168
+ return {
169
+ store: { stmts, engine: "sql.js" },
170
+ close: () => { db.close(); },
171
+ transact: (fn) => {
172
+ db.run("BEGIN IMMEDIATE");
173
+ try { fn(); db.run("COMMIT"); save(); } catch (e) { db.run("ROLLBACK"); throw e; }
174
+ },
175
+ prepare: (sql) => {
176
+ const stmt = db.prepare(sql);
177
+ return {
178
+ run: (...params) => { stmt.bind(params); stmt.step(); stmt.reset(); },
179
+ free: () => { stmt.free(); },
180
+ };
181
+ },
182
+ };
183
+ }
184
+
185
+ // ── Init ────────────────────────────────────────────────────────────
186
+
187
+ let backendPromise: Promise<void> | null = null;
188
+
189
+ async function initBackend(): Promise<void> {
190
+ if (backendPromise) return backendPromise;
191
+ backendPromise = (async () => {
192
+ const hasBetter = await tryLoadBetter();
193
+ if (!hasBetter) await ensureSqlJs();
194
+ })();
195
+ return backendPromise;
116
196
  }
117
197
 
198
+ // ── Public API ──────────────────────────────────────────────────────
199
+
118
200
  export async function loadHashStore(): Promise<HashStore> {
119
- const storePath = hashStorePath();
120
- if (cachedStore && cachedStore.path === storePath) {
121
- return cachedStore.store;
122
- }
201
+ const storePath = hashStorePath();
202
+ if (cachedHandle && cachedHandle.path === storePath) {
203
+ return cachedHandle.handle.store;
204
+ }
205
+
206
+ shutdownHashStore();
207
+
208
+ await initHasher();
209
+ await mkdir(hashStoreDir(), { recursive: true });
210
+ await initBackend();
123
211
 
124
- shutdownHashStore();
212
+ const existed = existsSync(storePath);
125
213
 
126
- await initHasher();
127
- await mkdir(hashStoreDir(), { recursive: true });
214
+ let handle: BackendHandle;
215
+ if (BetterDatabase) {
216
+ handle = openBetterDb(storePath);
217
+ } else {
218
+ await ensureSqlJs();
219
+ handle = openSqlJsDb(storePath);
220
+ }
128
221
 
129
- const existed = existsSync(storePath);
130
- const store = openDatabase(storePath);
131
- if (!existed) {
132
- await migrateLegacy(store);
133
- }
134
- cachedStore = { path: storePath, store };
135
- return store;
222
+ if (!existed) {
223
+ await migrateLegacy(handle);
224
+ }
225
+
226
+ cachedHandle = { path: storePath, handle };
227
+ return handle.store;
136
228
  }
137
229
 
138
230
  export function shutdownHashStore(): void {
139
- if (cachedStore) {
140
- cachedStore.store.db.close();
141
- cachedStore = null;
142
- }
231
+ if (cachedHandle) {
232
+ cachedHandle.handle.close();
233
+ cachedHandle = null;
234
+ }
143
235
  }
144
236
 
237
+ // ── Internal helpers ────────────────────────────────────────────────
238
+
239
+ function withStore(store: HashStore, fn: () => void): void {
240
+ const h = cachedHandle?.handle;
241
+ if (h && h.store === store) {
242
+ h.transact(fn);
243
+ } else {
244
+ fn();
245
+ }
246
+ }
247
+
248
+ async function migrateLegacy(handle: BackendHandle): Promise<void> {
249
+ const legacyPath = legacyHashStorePath();
250
+ let content: string;
251
+ try {
252
+ content = await readFile(legacyPath, "utf-8");
253
+ } catch (error: unknown) {
254
+ if (errCode(error) === "ENOENT") return;
255
+ console.error("Failed to read legacy hash store for migration:", error);
256
+ return;
257
+ }
258
+
259
+ let parsed: { snapshots?: Record<string, unknown> };
260
+ try {
261
+ parsed = JSON.parse(content) as typeof parsed;
262
+ } catch (error) {
263
+ console.error("Failed to parse legacy hash store, skipping migration:", error);
264
+ return;
265
+ }
266
+
267
+ const raw = parsed.snapshots;
268
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
269
+
270
+ const rows: [string, string, number, string, number][] = [];
271
+ for (const [key, value] of Object.entries(raw)) {
272
+ if (!isValidSnapshot(value)) continue;
273
+ rows.push([
274
+ key,
275
+ contentChecksum(value.content),
276
+ value.content.split("\n").length,
277
+ JSON.stringify(value.hashes),
278
+ Date.now(),
279
+ ]);
280
+ }
281
+
282
+ if (rows.length > 0) {
283
+ handle.transact(() => {
284
+ const stmt = handle.prepare(
285
+ "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
286
+ );
287
+ for (const row of rows) stmt.run(...row);
288
+ stmt.free();
289
+ });
290
+ }
291
+
292
+ try {
293
+ await rename(legacyPath, `${legacyPath}.bak`);
294
+ } catch (error) {
295
+ console.error("Failed to rename legacy hash store after migration:", error);
296
+ }
297
+ }
298
+
299
+ // ── Snapshot operations ─────────────────────────────────────────────
300
+
145
301
  export function getSnapshot(
146
- store: HashStore,
147
- path: string,
148
- content: string,
302
+ store: HashStore,
303
+ path: string,
304
+ content: string,
149
305
  ): string[] | undefined {
150
- const checksum = contentChecksum(content);
151
- const lineCount = content.split("\n").length;
152
- const row = store.stmts.get.get(path, checksum, lineCount);
153
- return row ? (JSON.parse(row.hashes) as string[]) : undefined;
306
+ const checksum = contentChecksum(content);
307
+ const lineCount = content.split("\n").length;
308
+ const row = store.stmts.get(path, checksum, lineCount);
309
+ return row ? (JSON.parse(row.hashes as string) as string[]) : undefined;
154
310
  }
155
311
 
156
312
  export function upsertSnapshot(
157
- store: HashStore,
158
- path: string,
159
- checksum: string,
160
- lineCount: number,
161
- hashes: string[],
313
+ store: HashStore,
314
+ path: string,
315
+ checksum: string,
316
+ lineCount: number,
317
+ hashes: string[],
162
318
  ): void {
163
- const hashesJson = JSON.stringify(hashes);
164
- store.db.transaction(() => {
165
- store.stmts.upsert.run(path, checksum, lineCount, hashesJson, Date.now());
166
- }).immediate();
319
+ const hashesJson = JSON.stringify(hashes);
320
+ withStore(store, () => {
321
+ store.stmts.upsert(path, checksum, lineCount, hashesJson, Date.now());
322
+ });
167
323
  }
168
324
 
169
325
  export function deleteSnapshot(store: HashStore, path: string): void {
170
- store.db.transaction(() => {
171
- store.stmts.deleteOne.run(path);
172
- }).immediate();
326
+ withStore(store, () => {
327
+ store.stmts.deleteOne(path);
328
+ });
173
329
  }
174
330
 
175
331
  export async function pruneMissing(store: HashStore): Promise<void> {
176
- const rows = store.stmts.allPaths.all();
177
- const missing: string[] = [];
178
- for (const row of rows) {
179
- try {
180
- await stat(row.path);
181
- } catch {
182
- missing.push(row.path);
183
- }
184
- }
185
- if (missing.length === 0) return;
186
- store.db.transaction(() => {
187
- for (const path of missing) store.stmts.deleteOne.run(path);
188
- }).immediate();
189
- }
190
-
191
- export { HASH_STORE_VERSION };
332
+ const rows = store.stmts.allPaths() as { path: string }[];
333
+ const missing: string[] = [];
334
+ for (const row of rows) {
335
+ try {
336
+ await stat(row.path);
337
+ } catch {
338
+ missing.push(row.path);
339
+ }
340
+ }
341
+ if (missing.length === 0) return;
342
+ withStore(store, () => {
343
+ for (const path of missing) store.stmts.deleteOne(path);
344
+ });
345
+ }
346
+
347
+ export { HASH_STORE_VERSION };