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 +3 -6
- package/src/constants.ts +0 -2
- package/src/hash-store.ts +227 -149
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.17.
|
|
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 "
|
|
2
|
-
import
|
|
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
|
|
7
|
+
import { HASH_STORE_VERSION } from "./constants";
|
|
8
|
+
|
|
9
|
+
type SqlParams = SqlValue[];
|
|
9
10
|
|
|
10
11
|
interface Prepared {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
19
|
+
readonly db: DatabaseType;
|
|
20
|
+
readonly stmts: Prepared;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface LegacySnapshot {
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
content: string;
|
|
25
|
+
hashes: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
184
|
+
const storePath = hashStorePath();
|
|
185
|
+
if (cachedStore && cachedStore.path === storePath) {
|
|
186
|
+
return cachedStore.store;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
shutdownHashStore();
|
|
123
190
|
|
|
124
|
-
|
|
191
|
+
await initHasher();
|
|
192
|
+
await mkdir(hashStoreDir(), { recursive: true });
|
|
125
193
|
|
|
126
|
-
|
|
127
|
-
|
|
194
|
+
if (!sqlJsInit) {
|
|
195
|
+
sqlJsInit = initSqlJs().then((SQL) => {
|
|
196
|
+
DatabaseCtor = SQL.Database;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
await sqlJsInit;
|
|
128
200
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
212
|
+
if (cachedStore) {
|
|
213
|
+
cachedStore.store.db.close();
|
|
214
|
+
cachedStore = null;
|
|
215
|
+
}
|
|
143
216
|
}
|
|
144
217
|
|
|
145
218
|
export function getSnapshot(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
219
|
+
store: HashStore,
|
|
220
|
+
path: string,
|
|
221
|
+
content: string,
|
|
149
222
|
): string[] | undefined {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
230
|
+
store: HashStore,
|
|
231
|
+
path: string,
|
|
232
|
+
checksum: string,
|
|
233
|
+
lineCount: number,
|
|
234
|
+
hashes: string[],
|
|
162
235
|
): void {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
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 };
|