pi-hashline-edit-pro 0.17.1 → 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.1",
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": {
@@ -43,7 +43,8 @@
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",
package/src/constants.ts CHANGED
@@ -5,6 +5,7 @@ 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_BUSY_TIMEOUT = 1000;
8
9
  export const HASH_STORE_VERSION = 2;
9
10
  export const CONTENT_LINES_NOT_STRING_MSG =
10
11
  `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
package/src/hash-store.ts CHANGED
@@ -1,23 +1,23 @@
1
- import initSqlJs, { type Database as DatabaseType, type SqlValue } from "sql.js";
2
1
  import { readFileSync, writeFileSync, existsSync } from "fs";
3
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
4
3
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
4
  import { errCode } from "./utils";
6
5
  import { initHasher, contentChecksum } from "./hashline/hasher";
7
- import { HASH_STORE_VERSION } from "./constants";
6
+ import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
7
+ import initSqlJs from "sql.js";
8
8
 
9
- type SqlParams = SqlValue[];
9
+ type SqlParams = (string | number)[];
10
10
 
11
11
  interface Prepared {
12
- get: (...params: SqlParams) => Record<string, SqlValue> | undefined;
13
- allPaths: (...params: SqlParams) => Record<string, SqlValue>[];
12
+ get: (...params: SqlParams) => Record<string, unknown> | undefined;
13
+ allPaths: (...params: SqlParams) => Record<string, unknown>[];
14
14
  deleteOne: (...params: SqlParams) => void;
15
15
  upsert: (...params: SqlParams) => void;
16
16
  }
17
17
 
18
18
  export interface HashStore {
19
- readonly db: DatabaseType;
20
19
  readonly stmts: Prepared;
20
+ readonly engine: "better-sqlite3" | "sql.js";
21
21
  }
22
22
 
23
23
  interface LegacySnapshot {
@@ -36,60 +36,88 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
36
36
  return true;
37
37
  }
38
38
 
39
- let cachedStore: { path: string; store: HashStore; filePath: string } | null = null;
39
+ // ── Backend handle abstraction ──────────────────────────────────────
40
40
 
41
- let sqlJsInit: Promise<void> | null = null;
42
- let DatabaseCtor!: new (data?: ArrayLike<number> | null) => DatabaseType;
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
+ }
43
47
 
44
- function makeGetter(db: DatabaseType, sql: string) {
45
- return (...params: SqlParams) => {
46
- const stmt = db.prepare(sql);
47
- if (params.length > 0) stmt.bind(params);
48
- let result: Record<string, SqlValue> | undefined;
49
- if (stmt.step()) {
50
- result = stmt.getAsObject() as Record<string, SqlValue>;
51
- }
52
- stmt.free();
53
- return result;
54
- };
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
+ }
55
64
  }
56
65
 
57
- function makeAll(db: DatabaseType, sql: string) {
58
- return (...params: SqlParams) => {
59
- const stmt = db.prepare(sql);
60
- if (params.length > 0) stmt.bind(params);
61
- const results: Record<string, SqlValue>[] = [];
62
- while (stmt.step()) {
63
- results.push(stmt.getAsObject() as Record<string, SqlValue>);
64
- }
65
- stmt.free();
66
- return results;
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); },
67
94
  };
68
- }
69
95
 
70
- function makeRun(db: DatabaseType, sql: string) {
71
- return (...params: SqlParams) => {
72
- if (params.length > 0) {
73
- db.run(sql, params);
74
- } else {
75
- db.run(sql);
76
- }
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
+ },
77
104
  };
78
105
  }
79
106
 
80
- function saveDb(storePath: string, db: DatabaseType): void {
81
- const data = db.export();
82
- writeFileSync(storePath, Buffer.from(data));
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;
83
116
  }
84
117
 
85
- function openDatabase(storePath: string): HashStore {
86
- let db: DatabaseType;
87
- if (existsSync(storePath)) {
88
- const fileBuffer = readFileSync(storePath);
89
- db = new DatabaseCtor(new Uint8Array(fileBuffer));
90
- } else {
91
- db = new DatabaseCtor();
92
- }
118
+ function openSqlJsDb(storePath: string): BackendHandle {
119
+ const data = existsSync(storePath) ? new Uint8Array(readFileSync(storePath)) : undefined;
120
+ const db = new SqlJsDatabase(data);
93
121
 
94
122
  db.run(
95
123
  "CREATE TABLE IF NOT EXISTS snapshots (" +
@@ -100,33 +128,124 @@ function openDatabase(storePath: string): HashStore {
100
128
  "updated_at INTEGER NOT NULL" +
101
129
  ")"
102
130
  );
103
- saveDb(storePath, db);
104
131
 
105
- const get = makeGetter(db, "SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
106
- const allPaths = makeAll(db, "SELECT path FROM snapshots");
107
- const deleteOne = makeRun(db, "DELETE FROM snapshots WHERE path = ?");
108
- const upsert = makeRun(
109
- db,
110
- "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
111
- "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at"
112
- );
132
+ function save() {
133
+ writeFileSync(storePath, Buffer.from(db.export()));
134
+ }
113
135
 
114
- const stmts: Prepared = { get, allPaths, deleteOne, upsert };
115
- return { db, stmts };
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
+ };
116
183
  }
117
184
 
118
- function withTransaction(db: DatabaseType, fn: () => void): void {
119
- db.run("BEGIN IMMEDIATE");
120
- try {
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;
196
+ }
197
+
198
+ // ── Public API ──────────────────────────────────────────────────────
199
+
200
+ export async function loadHashStore(): Promise<HashStore> {
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();
211
+
212
+ const existed = existsSync(storePath);
213
+
214
+ let handle: BackendHandle;
215
+ if (BetterDatabase) {
216
+ handle = openBetterDb(storePath);
217
+ } else {
218
+ await ensureSqlJs();
219
+ handle = openSqlJsDb(storePath);
220
+ }
221
+
222
+ if (!existed) {
223
+ await migrateLegacy(handle);
224
+ }
225
+
226
+ cachedHandle = { path: storePath, handle };
227
+ return handle.store;
228
+ }
229
+
230
+ export function shutdownHashStore(): void {
231
+ if (cachedHandle) {
232
+ cachedHandle.handle.close();
233
+ cachedHandle = null;
234
+ }
235
+ }
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 {
121
244
  fn();
122
- db.run("COMMIT");
123
- } catch (e) {
124
- db.run("ROLLBACK");
125
- throw e;
126
245
  }
127
246
  }
128
247
 
129
- async function migrateLegacy(store: HashStore, storePath: string): Promise<void> {
248
+ async function migrateLegacy(handle: BackendHandle): Promise<void> {
130
249
  const legacyPath = legacyHashStorePath();
131
250
  let content: string;
132
251
  try {
@@ -161,16 +280,13 @@ async function migrateLegacy(store: HashStore, storePath: string): Promise<void>
161
280
  }
162
281
 
163
282
  if (rows.length > 0) {
164
- withTransaction(store.db, () => {
165
- const stmt = store.db.prepare(
283
+ handle.transact(() => {
284
+ const stmt = handle.prepare(
166
285
  "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
167
286
  );
168
- for (const row of rows) {
169
- stmt.run(row);
170
- }
287
+ for (const row of rows) stmt.run(...row);
171
288
  stmt.free();
172
289
  });
173
- saveDb(storePath, store.db);
174
290
  }
175
291
 
176
292
  try {
@@ -180,40 +296,7 @@ async function migrateLegacy(store: HashStore, storePath: string): Promise<void>
180
296
  }
181
297
  }
182
298
 
183
- export async function loadHashStore(): Promise<HashStore> {
184
- const storePath = hashStorePath();
185
- if (cachedStore && cachedStore.path === storePath) {
186
- return cachedStore.store;
187
- }
188
-
189
- shutdownHashStore();
190
-
191
- await initHasher();
192
- await mkdir(hashStoreDir(), { recursive: true });
193
-
194
- if (!sqlJsInit) {
195
- sqlJsInit = initSqlJs().then((SQL) => {
196
- DatabaseCtor = SQL.Database;
197
- });
198
- }
199
- await sqlJsInit;
200
-
201
- const existed = existsSync(storePath);
202
- const store = openDatabase(storePath);
203
- if (!existed) {
204
- await migrateLegacy(store, storePath);
205
- }
206
-
207
- cachedStore = { path: storePath, store, filePath: storePath };
208
- return store;
209
- }
210
-
211
- export function shutdownHashStore(): void {
212
- if (cachedStore) {
213
- cachedStore.store.db.close();
214
- cachedStore = null;
215
- }
216
- }
299
+ // ── Snapshot operations ─────────────────────────────────────────────
217
300
 
218
301
  export function getSnapshot(
219
302
  store: HashStore,
@@ -234,17 +317,15 @@ export function upsertSnapshot(
234
317
  hashes: string[],
235
318
  ): void {
236
319
  const hashesJson = JSON.stringify(hashes);
237
- withTransaction(store.db, () => {
320
+ withStore(store, () => {
238
321
  store.stmts.upsert(path, checksum, lineCount, hashesJson, Date.now());
239
322
  });
240
- if (cachedStore) saveDb(cachedStore.filePath, store.db);
241
323
  }
242
324
 
243
325
  export function deleteSnapshot(store: HashStore, path: string): void {
244
- withTransaction(store.db, () => {
326
+ withStore(store, () => {
245
327
  store.stmts.deleteOne(path);
246
328
  });
247
- if (cachedStore) saveDb(cachedStore.filePath, store.db);
248
329
  }
249
330
 
250
331
  export async function pruneMissing(store: HashStore): Promise<void> {
@@ -258,12 +339,9 @@ export async function pruneMissing(store: HashStore): Promise<void> {
258
339
  }
259
340
  }
260
341
  if (missing.length === 0) return;
261
- withTransaction(store.db, () => {
262
- for (const path of missing) {
263
- store.stmts.deleteOne(path);
264
- }
342
+ withStore(store, () => {
343
+ for (const path of missing) store.stmts.deleteOne(path);
265
344
  });
266
- if (cachedStore) saveDb(cachedStore.filePath, store.db);
267
345
  }
268
346
 
269
347
  export { HASH_STORE_VERSION };