pi-hashline-edit-pro 0.17.0 → 0.17.1

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.1",
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": {
@@ -47,12 +47,9 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@earendil-works/pi-coding-agent": "^0.74.0",
50
- "@types/better-sqlite3": "^7.6.13",
51
50
  "@types/node": "^22.0.0",
51
+ "@types/sql.js": "^1.4.11",
52
52
  "typescript": "^5.8.0",
53
53
  "vitest": "^4.1.8"
54
- },
55
- "allowScripts": {
56
- "better-sqlite3@13.0.1": true
57
54
  }
58
55
  }
package/src/constants.ts CHANGED
@@ -6,8 +6,6 @@ export const MAX_HASH_LINES = 1_000_000;
6
6
  export const MAX_HASH_RETRIES = 262_144;
7
7
 
8
8
  export const HASH_STORE_VERSION = 2;
9
- export const HASH_STORE_BUSY_TIMEOUT = 1000;
10
-
11
9
  export const CONTENT_LINES_NOT_STRING_MSG =
12
10
  `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
13
11
  + ` 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,269 @@
1
- import Database from "better-sqlite3";
2
- import type { Database as DatabaseType, Statement } from "better-sqlite3";
3
- import { existsSync } from "fs";
1
+ import initSqlJs, { type Database as DatabaseType, type SqlValue } from "sql.js";
2
+ import { readFileSync, writeFileSync, existsSync } from "fs";
4
3
  import { readFile, rename, mkdir, stat } from "fs/promises";
5
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
6
5
  import { errCode } from "./utils";
7
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
8
- import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
7
+ import { HASH_STORE_VERSION } from "./constants";
8
+
9
+ type SqlParams = SqlValue[];
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, SqlValue> | undefined;
13
+ allPaths: (...params: SqlParams) => Record<string, SqlValue>[];
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 db: DatabaseType;
20
+ readonly stmts: Prepared;
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;
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;
36
37
  }
37
38
 
38
- let cachedStore: { path: string; store: HashStore } | null = null;
39
+ let cachedStore: { path: string; store: HashStore; filePath: string } | null = null;
40
+
41
+ let sqlJsInit: Promise<void> | null = null;
42
+ let DatabaseCtor!: new (data?: ArrayLike<number> | null) => DatabaseType;
43
+
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
+ };
55
+ }
56
+
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;
67
+ };
68
+ }
69
+
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
+ }
77
+ };
78
+ }
79
+
80
+ function saveDb(storePath: string, db: DatabaseType): void {
81
+ const data = db.export();
82
+ writeFileSync(storePath, Buffer.from(data));
83
+ }
39
84
 
40
85
  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
- }
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
+ }
93
+
94
+ db.run(
95
+ "CREATE TABLE IF NOT EXISTS snapshots (" +
96
+ "path TEXT PRIMARY KEY, " +
97
+ "checksum TEXT NOT NULL, " +
98
+ "line_count INTEGER NOT NULL, " +
99
+ "hashes TEXT NOT NULL, " +
100
+ "updated_at INTEGER NOT NULL" +
101
+ ")"
102
+ );
103
+ saveDb(storePath, db);
104
+
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
+ );
113
+
114
+ const stmts: Prepared = { get, allPaths, deleteOne, upsert };
115
+ return { db, stmts };
116
+ }
117
+
118
+ function withTransaction(db: DatabaseType, fn: () => void): void {
119
+ db.run("BEGIN IMMEDIATE");
120
+ try {
121
+ fn();
122
+ db.run("COMMIT");
123
+ } catch (e) {
124
+ db.run("ROLLBACK");
125
+ throw e;
126
+ }
127
+ }
128
+
129
+ async function migrateLegacy(store: HashStore, storePath: string): Promise<void> {
130
+ const legacyPath = legacyHashStorePath();
131
+ let content: string;
132
+ try {
133
+ content = await readFile(legacyPath, "utf-8");
134
+ } catch (error: unknown) {
135
+ if (errCode(error) === "ENOENT") return;
136
+ console.error("Failed to read legacy hash store for migration:", error);
137
+ return;
138
+ }
139
+
140
+ let parsed: { snapshots?: Record<string, unknown> };
141
+ try {
142
+ parsed = JSON.parse(content) as typeof parsed;
143
+ } catch (error) {
144
+ console.error("Failed to parse legacy hash store, skipping migration:", error);
145
+ return;
146
+ }
147
+
148
+ const raw = parsed.snapshots;
149
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
150
+
151
+ const rows: [string, string, number, string, number][] = [];
152
+ for (const [key, value] of Object.entries(raw)) {
153
+ if (!isValidSnapshot(value)) continue;
154
+ rows.push([
155
+ key,
156
+ contentChecksum(value.content),
157
+ value.content.split("\n").length,
158
+ JSON.stringify(value.hashes),
159
+ Date.now(),
160
+ ]);
161
+ }
162
+
163
+ if (rows.length > 0) {
164
+ withTransaction(store.db, () => {
165
+ const stmt = store.db.prepare(
166
+ "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)"
167
+ );
168
+ for (const row of rows) {
169
+ stmt.run(row);
170
+ }
171
+ stmt.free();
172
+ });
173
+ saveDb(storePath, store.db);
174
+ }
175
+
176
+ try {
177
+ await rename(legacyPath, `${legacyPath}.bak`);
178
+ } catch (error) {
179
+ console.error("Failed to rename legacy hash store after migration:", error);
180
+ }
116
181
  }
117
182
 
118
183
  export async function loadHashStore(): Promise<HashStore> {
119
- const storePath = hashStorePath();
120
- if (cachedStore && cachedStore.path === storePath) {
121
- return cachedStore.store;
122
- }
184
+ const storePath = hashStorePath();
185
+ if (cachedStore && cachedStore.path === storePath) {
186
+ return cachedStore.store;
187
+ }
188
+
189
+ shutdownHashStore();
123
190
 
124
- shutdownHashStore();
191
+ await initHasher();
192
+ await mkdir(hashStoreDir(), { recursive: true });
125
193
 
126
- await initHasher();
127
- await mkdir(hashStoreDir(), { recursive: true });
194
+ if (!sqlJsInit) {
195
+ sqlJsInit = initSqlJs().then((SQL) => {
196
+ DatabaseCtor = SQL.Database;
197
+ });
198
+ }
199
+ await sqlJsInit;
128
200
 
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;
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;
136
209
  }
137
210
 
138
211
  export function shutdownHashStore(): void {
139
- if (cachedStore) {
140
- cachedStore.store.db.close();
141
- cachedStore = null;
142
- }
212
+ if (cachedStore) {
213
+ cachedStore.store.db.close();
214
+ cachedStore = null;
215
+ }
143
216
  }
144
217
 
145
218
  export function getSnapshot(
146
- store: HashStore,
147
- path: string,
148
- content: string,
219
+ store: HashStore,
220
+ path: string,
221
+ content: string,
149
222
  ): 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;
223
+ const checksum = contentChecksum(content);
224
+ const lineCount = content.split("\n").length;
225
+ const row = store.stmts.get(path, checksum, lineCount);
226
+ return row ? (JSON.parse(row.hashes as string) as string[]) : undefined;
154
227
  }
155
228
 
156
229
  export function upsertSnapshot(
157
- store: HashStore,
158
- path: string,
159
- checksum: string,
160
- lineCount: number,
161
- hashes: string[],
230
+ store: HashStore,
231
+ path: string,
232
+ checksum: string,
233
+ lineCount: number,
234
+ hashes: string[],
162
235
  ): void {
163
- const hashesJson = JSON.stringify(hashes);
164
- store.db.transaction(() => {
165
- store.stmts.upsert.run(path, checksum, lineCount, hashesJson, Date.now());
166
- }).immediate();
236
+ const hashesJson = JSON.stringify(hashes);
237
+ withTransaction(store.db, () => {
238
+ store.stmts.upsert(path, checksum, lineCount, hashesJson, Date.now());
239
+ });
240
+ if (cachedStore) saveDb(cachedStore.filePath, store.db);
167
241
  }
168
242
 
169
243
  export function deleteSnapshot(store: HashStore, path: string): void {
170
- store.db.transaction(() => {
171
- store.stmts.deleteOne.run(path);
172
- }).immediate();
244
+ withTransaction(store.db, () => {
245
+ store.stmts.deleteOne(path);
246
+ });
247
+ if (cachedStore) saveDb(cachedStore.filePath, store.db);
173
248
  }
174
249
 
175
250
  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 };
251
+ const rows = store.stmts.allPaths() as { path: string }[];
252
+ const missing: string[] = [];
253
+ for (const row of rows) {
254
+ try {
255
+ await stat(row.path);
256
+ } catch {
257
+ missing.push(row.path);
258
+ }
259
+ }
260
+ if (missing.length === 0) return;
261
+ withTransaction(store.db, () => {
262
+ for (const path of missing) {
263
+ store.stmts.deleteOne(path);
264
+ }
265
+ });
266
+ if (cachedStore) saveDb(cachedStore.filePath, store.db);
267
+ }
268
+
269
+ export { HASH_STORE_VERSION };