@openparachute/vault 0.7.9-rc.4 → 0.7.9-rc.6
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/README.md +4 -0
- package/core/src/compact.test.ts +248 -0
- package/core/src/delta.test.ts +49 -0
- package/core/src/delta.ts +21 -0
- package/core/src/doctor.ts +41 -1
- package/core/src/history-capture.test.ts +244 -0
- package/core/src/history-import.test.ts +82 -0
- package/core/src/history-import.ts +157 -0
- package/core/src/history.test.ts +229 -0
- package/core/src/history.ts +681 -0
- package/core/src/mcp-manifest.ts +17 -0
- package/core/src/mcp.ts +28 -3
- package/core/src/notes.ts +6 -1
- package/core/src/portable-md.ts +1 -1
- package/core/src/schema-v29-note-versions.test.ts +63 -0
- package/core/src/schema-v30-note-deltas.test.ts +51 -0
- package/core/src/schema-v31-history-import.test.ts +25 -0
- package/core/src/schema.ts +234 -1
- package/core/src/store.ts +103 -14
- package/core/src/types.ts +35 -2
- package/core/src/vendor/fossil-delta.ts +472 -0
- package/package.json +1 -1
- package/src/cli.ts +10 -1
- package/src/config.ts +42 -0
- package/src/history-import-mcp.test.ts +94 -0
- package/src/history-import-routes.test.ts +91 -0
- package/src/history-import.test.ts +318 -0
- package/src/history-import.ts +482 -0
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +18 -2
- package/src/mirror-config.ts +38 -0
- package/src/mirror-manager.ts +6 -0
- package/src/mirror-retirement.test.ts +79 -0
- package/src/mirror-routes.ts +12 -2
- package/src/routes.ts +226 -86
- package/src/routing.ts +8 -3
- package/src/server.ts +33 -14
- package/src/vault-compact-routes.test.ts +257 -0
- package/src/vault-history-routes.test.ts +562 -0
- package/src/vault-store.ts +13 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/*! (c) Dmitry Chestnykh, D. Richard Hipp | BSD License | https://github.com/dchest/fossil-delta-js/ */
|
|
3
|
+
// Vendored from fossil-delta@2.0.0 (npm sha512-rcvnd0xjV7KNkEbXhTCsjHhONvdami+WWhTYGf4ORkPk9ixkQDmVeII96F6wBD/6qa9zBjH15bOzSARKsQ/rKg==).
|
|
4
|
+
// Upstream fossil-delta.ts sha256 29d795837d08ff9305e145d20611e721e52aa29969c10c39c8f9d3d51be67b6c.
|
|
5
|
+
// Local path: core/src/vendor/fossil-delta.ts. License: BSD 2-Clause.
|
|
6
|
+
// CHANGES FROM UPSTREAM:
|
|
7
|
+
// 1. createStringDelta / applyStringDelta / getStringDeltaTargetSize are
|
|
8
|
+
// DELETED. They decode raw delta bytes with TextDecoder (upstream
|
|
9
|
+
// :455-461, :466-477), which replaces any non-UTF-8 byte run with U+FFFD.
|
|
10
|
+
// MEASURED on this box: with a MID-BODY EDIT on an emoji body,
|
|
11
|
+
// createStringDelta round-trips throw `unknown delta operator` 189 times
|
|
12
|
+
// in 200 randomised cases. A pure APPEND on the same alphabet round-trips
|
|
13
|
+
// fine, which is why the trigger is the edit shape, not the alphabet.
|
|
14
|
+
// 2. This provenance header.
|
|
15
|
+
// 3. @ts-nocheck added as line 1: upstream does not typecheck under
|
|
16
|
+
// noUncheckedIndexedAccess / noFallthroughCasesInSwitch.
|
|
17
|
+
// Why vendored rather than a dependency: core/package.json declares no
|
|
18
|
+
// dependencies at all, core never imports a third-party library by house rule
|
|
19
|
+
// (see BunSqliteStore.embeddingProvider's doc comment, core/src/store.ts:
|
|
20
|
+
// "core never imports a model library itself (dependency-purity rule)"), and
|
|
21
|
+
// PR 1 §1 probe E set the precedent by refusing @noble/hashes. The Fossil
|
|
22
|
+
// delta format is a frozen, documented wire format, not a moving target.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// Fossil SCM delta compression algorithm
|
|
26
|
+
|
|
27
|
+
// We accept plain arrays of bytes or Uint8Array.
|
|
28
|
+
export type ByteArray = number[] | Uint8Array;
|
|
29
|
+
|
|
30
|
+
// Hash window width in bytes. Must be a power of two.
|
|
31
|
+
const NHASH = 16;
|
|
32
|
+
|
|
33
|
+
class RollingHash {
|
|
34
|
+
private a = 0; // hash (16-bit unsigned)
|
|
35
|
+
private b = 0; // values (16-bit unsigned)
|
|
36
|
+
private i = 0; // start of the hash window (16-bit unsigned)
|
|
37
|
+
private z = new Array(NHASH); // the values that have been hashed.
|
|
38
|
+
|
|
39
|
+
// Initialize the rolling hash using the first NHASH bytes of
|
|
40
|
+
// z at the given position.
|
|
41
|
+
init(z: ByteArray, pos: number) {
|
|
42
|
+
let a = 0,
|
|
43
|
+
b = 0;
|
|
44
|
+
for (let i = 0; i < NHASH; i++) {
|
|
45
|
+
const x = z[pos + i];
|
|
46
|
+
a = (a + x) & 0xffff;
|
|
47
|
+
b = (b + (NHASH - i) * x) & 0xffff;
|
|
48
|
+
this.z[i] = x;
|
|
49
|
+
}
|
|
50
|
+
this.a = a & 0xffff;
|
|
51
|
+
this.b = b & 0xffff;
|
|
52
|
+
this.i = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Advance the rolling hash by a single byte "c".
|
|
56
|
+
next(c: number) {
|
|
57
|
+
const old = this.z[this.i];
|
|
58
|
+
this.z[this.i] = c;
|
|
59
|
+
this.i = (this.i + 1) & (NHASH - 1);
|
|
60
|
+
this.a = (this.a - old + c) & 0xffff;
|
|
61
|
+
this.b = (this.b - NHASH * old + this.a) & 0xffff;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Return a 32-bit hash value.
|
|
65
|
+
value() {
|
|
66
|
+
return ((this.a & 0xffff) | ((this.b & 0xffff) << 16)) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const zDigits =
|
|
71
|
+
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
|
|
72
|
+
.split("")
|
|
73
|
+
.map(function (x) {
|
|
74
|
+
return x.charCodeAt(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const zValue = [
|
|
78
|
+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
79
|
+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
|
80
|
+
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1,
|
|
81
|
+
-1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
|
|
82
|
+
24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36, -1, 37,
|
|
83
|
+
38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
|
|
84
|
+
57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Reader reads bytes, chars, ints from array.
|
|
88
|
+
class Reader {
|
|
89
|
+
public a: ByteArray;
|
|
90
|
+
public pos: number;
|
|
91
|
+
|
|
92
|
+
constructor(array: ByteArray) {
|
|
93
|
+
this.a = array; // source array
|
|
94
|
+
this.pos = 0; // current position in array
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
haveBytes() {
|
|
98
|
+
return this.pos < this.a.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getByte() {
|
|
102
|
+
const b = this.a[this.pos];
|
|
103
|
+
this.pos++;
|
|
104
|
+
if (this.pos > this.a.length) throw new RangeError("out of bounds");
|
|
105
|
+
return b;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getChar() {
|
|
109
|
+
return String.fromCharCode(this.getByte());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Read base64-encoded unsigned integer.
|
|
113
|
+
getInt() {
|
|
114
|
+
let v = 0;
|
|
115
|
+
let c: number;
|
|
116
|
+
while (this.haveBytes() && (c = zValue[0x7f & this.getByte()]) >= 0) {
|
|
117
|
+
v = (v << 6) + c;
|
|
118
|
+
}
|
|
119
|
+
this.pos--;
|
|
120
|
+
return v >>> 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Write writes an array.
|
|
125
|
+
class Writer {
|
|
126
|
+
private a: number[] = [];
|
|
127
|
+
|
|
128
|
+
toByteArray<T extends ByteArray>(sourceType: T): T {
|
|
129
|
+
if (Array.isArray(sourceType)) {
|
|
130
|
+
return this.a as T;
|
|
131
|
+
}
|
|
132
|
+
return new Uint8Array(this.a) as T;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
putByte(b: number) {
|
|
136
|
+
this.a.push(b & 0xff);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Write an ASCII character (s is a one-char string).
|
|
140
|
+
putChar(s: string) {
|
|
141
|
+
this.putByte(s.charCodeAt(0));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Write a base64 unsigned integer.
|
|
145
|
+
putInt(v: number) {
|
|
146
|
+
const zBuf: number[] = [];
|
|
147
|
+
if (v === 0) {
|
|
148
|
+
this.putChar("0");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let i = 0;
|
|
152
|
+
for (; v > 0; i++, v >>>= 6) {
|
|
153
|
+
zBuf.push(zDigits[v & 0x3f]);
|
|
154
|
+
}
|
|
155
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
156
|
+
this.putByte(zBuf[j]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Copy from array at start to end.
|
|
161
|
+
putArray(a: ByteArray, start: number, end: number) {
|
|
162
|
+
// TODO: optimize
|
|
163
|
+
for (let i = start; i < end; i++) this.a.push(a[i]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Return the number digits in the base64 representation of a positive integer.
|
|
168
|
+
function digitCount(v: number) {
|
|
169
|
+
let i: number, x: number;
|
|
170
|
+
for (i = 1, x = 64; v >= x; i++, x <<= 6) {
|
|
171
|
+
/* nothing */
|
|
172
|
+
}
|
|
173
|
+
return i;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Return a 32-bit checksum of the array.
|
|
177
|
+
function checksum(arr: ByteArray): number {
|
|
178
|
+
let sum0 = 0,
|
|
179
|
+
sum1 = 0,
|
|
180
|
+
sum2 = 0,
|
|
181
|
+
sum3 = 0,
|
|
182
|
+
z = 0,
|
|
183
|
+
N = arr.length;
|
|
184
|
+
//TODO measure if this unrolling is helpful.
|
|
185
|
+
while (N >= 16) {
|
|
186
|
+
sum0 = (sum0 + arr[z + 0]) | 0;
|
|
187
|
+
sum1 = (sum1 + arr[z + 1]) | 0;
|
|
188
|
+
sum2 = (sum2 + arr[z + 2]) | 0;
|
|
189
|
+
sum3 = (sum3 + arr[z + 3]) | 0;
|
|
190
|
+
|
|
191
|
+
sum0 = (sum0 + arr[z + 4]) | 0;
|
|
192
|
+
sum1 = (sum1 + arr[z + 5]) | 0;
|
|
193
|
+
sum2 = (sum2 + arr[z + 6]) | 0;
|
|
194
|
+
sum3 = (sum3 + arr[z + 7]) | 0;
|
|
195
|
+
|
|
196
|
+
sum0 = (sum0 + arr[z + 8]) | 0;
|
|
197
|
+
sum1 = (sum1 + arr[z + 9]) | 0;
|
|
198
|
+
sum2 = (sum2 + arr[z + 10]) | 0;
|
|
199
|
+
sum3 = (sum3 + arr[z + 11]) | 0;
|
|
200
|
+
|
|
201
|
+
sum0 = (sum0 + arr[z + 12]) | 0;
|
|
202
|
+
sum1 = (sum1 + arr[z + 13]) | 0;
|
|
203
|
+
sum2 = (sum2 + arr[z + 14]) | 0;
|
|
204
|
+
sum3 = (sum3 + arr[z + 15]) | 0;
|
|
205
|
+
|
|
206
|
+
z += 16;
|
|
207
|
+
N -= 16;
|
|
208
|
+
}
|
|
209
|
+
while (N >= 4) {
|
|
210
|
+
sum0 = (sum0 + arr[z + 0]) | 0;
|
|
211
|
+
sum1 = (sum1 + arr[z + 1]) | 0;
|
|
212
|
+
sum2 = (sum2 + arr[z + 2]) | 0;
|
|
213
|
+
sum3 = (sum3 + arr[z + 3]) | 0;
|
|
214
|
+
z += 4;
|
|
215
|
+
N -= 4;
|
|
216
|
+
}
|
|
217
|
+
sum3 = (((((sum3 + (sum2 << 8)) | 0) + (sum1 << 16)) | 0) + (sum0 << 24)) | 0;
|
|
218
|
+
switch (N) {
|
|
219
|
+
case 3:
|
|
220
|
+
sum3 = (sum3 + (arr[z + 2] << 8)) | 0; /* falls through */
|
|
221
|
+
case 2:
|
|
222
|
+
sum3 = (sum3 + (arr[z + 1] << 16)) | 0; /* falls through */
|
|
223
|
+
case 1:
|
|
224
|
+
sum3 = (sum3 + (arr[z + 0] << 24)) | 0; /* falls through */
|
|
225
|
+
}
|
|
226
|
+
return sum3 >>> 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Create a new delta array of bytes from source byte array to target byte array.
|
|
231
|
+
*/
|
|
232
|
+
export function createDelta<T extends ByteArray>(source: T, target: T): T {
|
|
233
|
+
const zDelta = new Writer();
|
|
234
|
+
const lenOut = target.length;
|
|
235
|
+
const lenSrc = source.length;
|
|
236
|
+
let lastRead = -1;
|
|
237
|
+
|
|
238
|
+
zDelta.putInt(lenOut);
|
|
239
|
+
zDelta.putChar("\n");
|
|
240
|
+
|
|
241
|
+
// If the source is very small, it means that we have no
|
|
242
|
+
// chance of ever doing a copy command. Just output a single
|
|
243
|
+
// literal segment for the entire target and exit.
|
|
244
|
+
if (lenSrc <= NHASH) {
|
|
245
|
+
zDelta.putInt(lenOut);
|
|
246
|
+
zDelta.putChar(":");
|
|
247
|
+
zDelta.putArray(target, 0, lenOut);
|
|
248
|
+
zDelta.putInt(checksum(target));
|
|
249
|
+
zDelta.putChar(";");
|
|
250
|
+
return zDelta.toByteArray(source);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Compute the hash table used to locate matching sections in the source.
|
|
254
|
+
const nHash = Math.ceil(lenSrc / NHASH);
|
|
255
|
+
const collide = new Array(nHash);
|
|
256
|
+
const landmark = new Array(nHash);
|
|
257
|
+
for (let i = 0; i < collide.length; i++) {
|
|
258
|
+
collide[i] = -1;
|
|
259
|
+
}
|
|
260
|
+
for (let i = 0; i < landmark.length; i++) {
|
|
261
|
+
landmark[i] = -1;
|
|
262
|
+
}
|
|
263
|
+
let hv: number;
|
|
264
|
+
const h = new RollingHash();
|
|
265
|
+
for (let i = 0; i < lenSrc - NHASH; i += NHASH) {
|
|
266
|
+
h.init(source, i);
|
|
267
|
+
hv = h.value() % nHash;
|
|
268
|
+
collide[i / NHASH] = landmark[hv];
|
|
269
|
+
landmark[hv] = i / NHASH;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let base = 0;
|
|
273
|
+
let iSrc: number,
|
|
274
|
+
iBlock: number,
|
|
275
|
+
bestCnt: number,
|
|
276
|
+
bestOfst: number,
|
|
277
|
+
bestLitsz: number;
|
|
278
|
+
while (base + NHASH < lenOut) {
|
|
279
|
+
bestOfst = 0;
|
|
280
|
+
bestLitsz = 0;
|
|
281
|
+
h.init(target, base);
|
|
282
|
+
let i = 0; // Trying to match a landmark against zOut[base+i]
|
|
283
|
+
bestCnt = 0;
|
|
284
|
+
while (1) {
|
|
285
|
+
let limit = 250;
|
|
286
|
+
hv = h.value() % nHash;
|
|
287
|
+
iBlock = landmark[hv];
|
|
288
|
+
while (iBlock >= 0 && limit-- > 0) {
|
|
289
|
+
//
|
|
290
|
+
// The hash window has identified a potential match against
|
|
291
|
+
// landmark block iBlock. But we need to investigate further.
|
|
292
|
+
//
|
|
293
|
+
// Look for a region in zOut that matches zSrc. Anchor the search
|
|
294
|
+
// at zSrc[iSrc] and zOut[base+i]. Do not include anything prior to
|
|
295
|
+
// zOut[base] or after zOut[outLen] nor anything after zSrc[srcLen].
|
|
296
|
+
//
|
|
297
|
+
// Set cnt equal to the length of the match and set ofst so that
|
|
298
|
+
// zSrc[ofst] is the first element of the match. litsz is the number
|
|
299
|
+
// of characters between zOut[base] and the beginning of the match.
|
|
300
|
+
// sz will be the overhead (in bytes) needed to encode the copy
|
|
301
|
+
// command. Only generate copy command if the overhead of the
|
|
302
|
+
// copy command is less than the amount of literal text to be copied.
|
|
303
|
+
//
|
|
304
|
+
let cnt: number, ofst: number, litsz: number;
|
|
305
|
+
let j: number, k: number, x: number, y: number;
|
|
306
|
+
let sz: number;
|
|
307
|
+
|
|
308
|
+
// Beginning at iSrc, match forwards as far as we can.
|
|
309
|
+
// j counts the number of characters that match.
|
|
310
|
+
iSrc = iBlock * NHASH;
|
|
311
|
+
for (
|
|
312
|
+
j = 0, x = iSrc, y = base + i;
|
|
313
|
+
x < lenSrc && y < lenOut;
|
|
314
|
+
j++, x++, y++
|
|
315
|
+
) {
|
|
316
|
+
if (source[x] !== target[y]) break;
|
|
317
|
+
}
|
|
318
|
+
j--;
|
|
319
|
+
|
|
320
|
+
// Beginning at iSrc-1, match backwards as far as we can.
|
|
321
|
+
// k counts the number of characters that match.
|
|
322
|
+
for (k = 1; k < iSrc && k <= i; k++) {
|
|
323
|
+
if (source[iSrc - k] !== target[base + i - k]) break;
|
|
324
|
+
}
|
|
325
|
+
k--;
|
|
326
|
+
|
|
327
|
+
// Compute the offset and size of the matching region.
|
|
328
|
+
ofst = iSrc - k;
|
|
329
|
+
cnt = j + k + 1;
|
|
330
|
+
litsz = i - k; // Number of bytes of literal text before the copy
|
|
331
|
+
// sz will hold the number of bytes needed to encode the "insert"
|
|
332
|
+
// command and the copy command, not counting the "insert" text.
|
|
333
|
+
sz = digitCount(i - k) + digitCount(cnt) + digitCount(ofst) + 3;
|
|
334
|
+
if (cnt >= sz && cnt > bestCnt) {
|
|
335
|
+
// Remember this match only if it is the best so far and it
|
|
336
|
+
// does not increase the file size.
|
|
337
|
+
bestCnt = cnt;
|
|
338
|
+
bestOfst = iSrc - k;
|
|
339
|
+
bestLitsz = litsz;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Check the next matching block
|
|
343
|
+
iBlock = collide[iBlock];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// We have a copy command that does not cause the delta to be larger
|
|
347
|
+
// than a literal insert. So add the copy command to the delta.
|
|
348
|
+
if (bestCnt > 0) {
|
|
349
|
+
if (bestLitsz > 0) {
|
|
350
|
+
// Add an insert command before the copy.
|
|
351
|
+
zDelta.putInt(bestLitsz);
|
|
352
|
+
zDelta.putChar(":");
|
|
353
|
+
zDelta.putArray(target, base, base + bestLitsz);
|
|
354
|
+
base += bestLitsz;
|
|
355
|
+
}
|
|
356
|
+
base += bestCnt;
|
|
357
|
+
zDelta.putInt(bestCnt);
|
|
358
|
+
zDelta.putChar("@");
|
|
359
|
+
zDelta.putInt(bestOfst);
|
|
360
|
+
zDelta.putChar(",");
|
|
361
|
+
if (bestOfst + bestCnt - 1 > lastRead) {
|
|
362
|
+
lastRead = bestOfst + bestCnt - 1;
|
|
363
|
+
}
|
|
364
|
+
bestCnt = 0;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// If we reach this point, it means no match is found so far
|
|
369
|
+
if (base + i + NHASH >= lenOut) {
|
|
370
|
+
// We have reached the end and have not found any
|
|
371
|
+
// matches. Do an "insert" for everything that does not match
|
|
372
|
+
zDelta.putInt(lenOut - base);
|
|
373
|
+
zDelta.putChar(":");
|
|
374
|
+
zDelta.putArray(target, base, base + lenOut - base);
|
|
375
|
+
base = lenOut;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Advance the hash by one character. Keep looking for a match.
|
|
380
|
+
h.next(target[base + i + NHASH]);
|
|
381
|
+
i++;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Output a final "insert" record to get all the text at the end of
|
|
385
|
+
// the file that does not match anything in the source.
|
|
386
|
+
if (base < lenOut) {
|
|
387
|
+
zDelta.putInt(lenOut - base);
|
|
388
|
+
zDelta.putChar(":");
|
|
389
|
+
zDelta.putArray(target, base, base + lenOut - base);
|
|
390
|
+
}
|
|
391
|
+
// Output the final checksum record.
|
|
392
|
+
zDelta.putInt(checksum(target));
|
|
393
|
+
zDelta.putChar(";");
|
|
394
|
+
return zDelta.toByteArray(source);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Return the size (in bytes) of the target from applying a delta.
|
|
399
|
+
*/
|
|
400
|
+
export function getDeltaTargetSize(delta: ByteArray): number {
|
|
401
|
+
const zDelta = new Reader(delta);
|
|
402
|
+
const size = zDelta.getInt();
|
|
403
|
+
if (zDelta.getChar() !== "\n") {
|
|
404
|
+
throw new Error("size integer not terminated by '\\n'");
|
|
405
|
+
}
|
|
406
|
+
return size;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export type Options = {
|
|
410
|
+
verifyChecksum?: boolean;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Apply a delta byte array to a source byte array, returning the target byte array.
|
|
415
|
+
*/
|
|
416
|
+
export function applyDelta<T extends ByteArray>(
|
|
417
|
+
source: T,
|
|
418
|
+
delta: T,
|
|
419
|
+
opts?: Options
|
|
420
|
+
): T {
|
|
421
|
+
let limit: number,
|
|
422
|
+
total = 0;
|
|
423
|
+
const zDelta = new Reader(delta);
|
|
424
|
+
const lenSrc = source.length;
|
|
425
|
+
const lenDelta = delta.length;
|
|
426
|
+
|
|
427
|
+
limit = zDelta.getInt();
|
|
428
|
+
if (zDelta.getChar() !== "\n")
|
|
429
|
+
throw new Error("size integer not terminated by '\\n'");
|
|
430
|
+
const zOut = new Writer();
|
|
431
|
+
while (zDelta.haveBytes()) {
|
|
432
|
+
let cnt = zDelta.getInt();
|
|
433
|
+
let ofst: number;
|
|
434
|
+
|
|
435
|
+
switch (zDelta.getChar()) {
|
|
436
|
+
case "@":
|
|
437
|
+
ofst = zDelta.getInt();
|
|
438
|
+
if (zDelta.haveBytes() && zDelta.getChar() !== ",")
|
|
439
|
+
throw new Error("copy command not terminated by ','");
|
|
440
|
+
total += cnt;
|
|
441
|
+
if (total > limit) throw new Error("copy exceeds output file size");
|
|
442
|
+
if (ofst + cnt > lenSrc)
|
|
443
|
+
throw new Error("copy extends past end of input");
|
|
444
|
+
zOut.putArray(source, ofst, ofst + cnt);
|
|
445
|
+
break;
|
|
446
|
+
|
|
447
|
+
case ":":
|
|
448
|
+
total += cnt;
|
|
449
|
+
if (total > limit)
|
|
450
|
+
throw new Error(
|
|
451
|
+
"insert command gives an output larger than predicted"
|
|
452
|
+
);
|
|
453
|
+
if (cnt > lenDelta)
|
|
454
|
+
throw new Error("insert count exceeds size of delta");
|
|
455
|
+
zOut.putArray(zDelta.a, zDelta.pos, zDelta.pos + cnt);
|
|
456
|
+
zDelta.pos += cnt;
|
|
457
|
+
break;
|
|
458
|
+
|
|
459
|
+
case ";":
|
|
460
|
+
const out = zOut.toByteArray(source);
|
|
461
|
+
if ((!opts || opts.verifyChecksum !== false) && cnt !== checksum(out))
|
|
462
|
+
throw new Error("bad checksum");
|
|
463
|
+
if (total !== limit)
|
|
464
|
+
throw new Error("generated size does not match predicted size");
|
|
465
|
+
return out;
|
|
466
|
+
|
|
467
|
+
default:
|
|
468
|
+
throw new Error("unknown delta operator");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
throw new Error("unterminated delta");
|
|
472
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -296,6 +296,11 @@ switch (command) {
|
|
|
296
296
|
case "backup":
|
|
297
297
|
await cmdBackup(cmdArgs);
|
|
298
298
|
break;
|
|
299
|
+
case "history-import": {
|
|
300
|
+
const { runHistoryImport } = await import("./history-import.ts");
|
|
301
|
+
console.log(JSON.stringify(await runHistoryImport(cmdArgs), null, 2));
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
299
304
|
case "import":
|
|
300
305
|
await cmdImport(cmdArgs);
|
|
301
306
|
break;
|
|
@@ -4815,7 +4820,7 @@ async function createVault(
|
|
|
4815
4820
|
// git-less box leaves the config written but inactive + logs an actionable
|
|
4816
4821
|
// hint — it must NEVER fail the vault create. Create-time ONLY: existing
|
|
4817
4822
|
// vaults are never retroactively migrated.
|
|
4818
|
-
if (shouldEnableCreateTimeMirror(opts)) {
|
|
4823
|
+
if (shouldEnableCreateTimeMirror(opts) && (await import("./mirror-config.ts")).readHistoryMirrorPhase(name) === "active") {
|
|
4819
4824
|
const mirrorConfig = historyPresetMirrorConfig();
|
|
4820
4825
|
writeMirrorConfigForVault(name, mirrorConfig);
|
|
4821
4826
|
const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
|
|
@@ -5213,6 +5218,10 @@ Import/Export:
|
|
|
5213
5218
|
--git-push to push after commit)
|
|
5214
5219
|
|
|
5215
5220
|
History:
|
|
5221
|
+
parachute-vault history-import plan|prepare|apply|retire|cancel --vault <name>
|
|
5222
|
+
Import a local git archive into note history;
|
|
5223
|
+
plan/prepare: --selections <file> for audited duplicates;
|
|
5224
|
+
see UPGRADING.md for offline cutover and recovery.
|
|
5216
5225
|
parachute-vault history [--vault <name>] Show the vault's git write history (the
|
|
5217
5226
|
mirror is git-backed — one file per note,
|
|
5218
5227
|
so git log IS a tamper-evident history)
|
package/src/config.ts
CHANGED
|
@@ -233,6 +233,22 @@ export interface VaultConfig {
|
|
|
233
233
|
auto_transcribe?: {
|
|
234
234
|
enabled?: boolean;
|
|
235
235
|
};
|
|
236
|
+
/** Note history policy; read once when the vault store opens (vault#524). */
|
|
237
|
+
history?: {
|
|
238
|
+
enabled?: boolean;
|
|
239
|
+
min_versions?: number;
|
|
240
|
+
max_versions?: number;
|
|
241
|
+
max_age_days?: number;
|
|
242
|
+
deleted_retention_days?: number | null;
|
|
243
|
+
/** Defaults: enabled=true, ratio=3, min=10, run=24, bytes=8 MiB, budget=250ms, notes=50. */
|
|
244
|
+
compact_enabled?: boolean;
|
|
245
|
+
compact_ratio?: number;
|
|
246
|
+
compact_min_versions?: number;
|
|
247
|
+
compact_run_length?: number;
|
|
248
|
+
max_bytes_per_note?: number | null;
|
|
249
|
+
compact_budget_ms?: number;
|
|
250
|
+
compact_max_notes?: number;
|
|
251
|
+
};
|
|
236
252
|
}
|
|
237
253
|
|
|
238
254
|
// ---------------------------------------------------------------------------
|
|
@@ -536,6 +552,13 @@ function serializeVaultConfig(config: VaultConfig): string {
|
|
|
536
552
|
lines.push(`audio_retention: ${config.audio_retention}`);
|
|
537
553
|
}
|
|
538
554
|
|
|
555
|
+
// Per-vault history policy: serialize only explicitly configured fields.
|
|
556
|
+
if (config.history) {
|
|
557
|
+
lines.push("history:");
|
|
558
|
+
for (const key of ["enabled", "min_versions", "max_versions", "max_age_days", "deleted_retention_days", "compact_enabled", "compact_ratio", "compact_min_versions", "compact_run_length", "max_bytes_per_note", "compact_budget_ms", "compact_max_notes"] as const) {
|
|
559
|
+
if (config.history[key] !== undefined) lines.push(` ${key}: ${config.history[key]}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
539
562
|
// Per-vault auto-transcribe override. Serialized as a nested block so future
|
|
540
563
|
// fields can grow under it (mirrors the GlobalConfig shape). Only emitted
|
|
541
564
|
// when `enabled` is explicitly set — an unset vault falls back to global.
|
|
@@ -696,6 +719,25 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
|
|
|
696
719
|
}
|
|
697
720
|
}
|
|
698
721
|
|
|
722
|
+
const historyStart = yaml.match(/^history:\s*$/m);
|
|
723
|
+
if (historyStart) {
|
|
724
|
+
const after = yaml.slice((historyStart.index ?? 0) + historyStart[0].length);
|
|
725
|
+
config.history = {};
|
|
726
|
+
for (const line of after.split("\n")) {
|
|
727
|
+
if (line.match(/^\S/) && line.trim().length > 0) break;
|
|
728
|
+
const enabled = line.match(/^\s+(enabled|compact_enabled):\s*(true|false)\s*$/);
|
|
729
|
+
if (enabled) config.history[enabled[1] as "enabled" | "compact_enabled"] = enabled[2] === "true";
|
|
730
|
+
const number = line.match(/^\s+(min_versions|max_versions|max_age_days|compact_min_versions|compact_run_length|compact_budget_ms|compact_max_notes):\s*(\d+)\s*$/);
|
|
731
|
+
if (number) config.history[number[1] as "min_versions" | "max_versions" | "max_age_days" | "compact_min_versions" | "compact_run_length" | "compact_budget_ms" | "compact_max_notes"] = Number(number[2]);
|
|
732
|
+
const ratio = line.match(/^\s+compact_ratio:\s*(\d+(?:\.\d+)?)\s*$/);
|
|
733
|
+
if (ratio) config.history.compact_ratio = Number(ratio[1]);
|
|
734
|
+
const bytes = line.match(/^\s+max_bytes_per_note:\s*(null|\d+)\s*$/);
|
|
735
|
+
if (bytes) config.history.max_bytes_per_note = bytes[1] === "null" ? null : Number(bytes[1]);
|
|
736
|
+
const deleted = line.match(/^\s+deleted_retention_days:\s*(null|\d+)\s*$/);
|
|
737
|
+
if (deleted) config.history.deleted_retention_days = deleted[1] === "null" ? null : Number(deleted[1]);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
699
741
|
return config;
|
|
700
742
|
}
|
|
701
743
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { handleMcp } from "./mcp-http.ts";
|
|
3
|
+
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
4
|
+
|
|
5
|
+
async function errorEnvelope(fields: Record<string, unknown>) {
|
|
6
|
+
const tool: McpToolDef = {
|
|
7
|
+
name: "query-notes", description: "serializer boundary fixture", requiredVerb: "read",
|
|
8
|
+
inputSchema: { type: "object" },
|
|
9
|
+
execute() { throw Object.assign(new Error("Unrecoverable history"), fields); },
|
|
10
|
+
};
|
|
11
|
+
const request = new Request("http://localhost/vault/import-test/mcp", {
|
|
12
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
13
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool.name, arguments: {} } }),
|
|
14
|
+
});
|
|
15
|
+
const response = await handleMcp(request, () => [tool], "import-test", "import-test", {
|
|
16
|
+
permission: "full", scopes: ["vault:import-test:read"], legacyDerived: false,
|
|
17
|
+
scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api",
|
|
18
|
+
}, "");
|
|
19
|
+
return await response.json() as any;
|
|
20
|
+
}
|
|
21
|
+
test("HTTP MCP forwards valid imported history references only on the dedicated error branch", async () => {
|
|
22
|
+
const fields = { code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", origin: "git-import", import_ix: 0, version_ix: -1 };
|
|
23
|
+
const rpc = await errorEnvelope(fields);
|
|
24
|
+
expect(rpc.result).toBeUndefined();
|
|
25
|
+
expect(rpc.error.code).toBe(-32602);
|
|
26
|
+
expect(rpc.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
|
|
27
|
+
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "0", null]) {
|
|
28
|
+
const bad = await errorEnvelope({ ...fields, import_ix: invalid });
|
|
29
|
+
expect(bad.result).toBeUndefined();
|
|
30
|
+
expect(bad.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
31
|
+
}
|
|
32
|
+
const generic = await errorEnvelope({ ...fields, code: "OTHER" });
|
|
33
|
+
expect(generic.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
34
|
+
const native = await errorEnvelope({ code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", version_ix: 0, note_id: "note" });
|
|
35
|
+
expect(native.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { writeVaultConfig } from "./config.ts";
|
|
42
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
43
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
44
|
+
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
45
|
+
import { resolveHistoryPolicy } from "../core/src/history.ts";
|
|
46
|
+
|
|
47
|
+
test("HTTP MCP imported get/list/error keeps discriminator and rejects mixed selectors", async () => {
|
|
48
|
+
const saved = process.env.PARACHUTE_HOME;
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "history-import-mcp-"));
|
|
50
|
+
process.env.PARACHUTE_HOME = dir;
|
|
51
|
+
try {
|
|
52
|
+
writeVaultConfig({ name: "import-test", api_keys: [], created_at: new Date().toISOString() });
|
|
53
|
+
const store = getVaultStore("import-test");
|
|
54
|
+
const note = await store.createNote("live");
|
|
55
|
+
const run = { run_id: "test", source_fingerprint: "source", tip: "tip", options_digest: "options" };
|
|
56
|
+
beginImportRun(store.db, run);
|
|
57
|
+
applyImportedNote(store.db, {
|
|
58
|
+
run, noteId: note.id, policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
|
|
59
|
+
targetDigest: importTargetDigest(store.db, note.id),
|
|
60
|
+
observations: [{ content: "archive", path: null, metadata: {}, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }],
|
|
61
|
+
});
|
|
62
|
+
const auth = { permission: "full" as const, scopes: ["vault:import-test:read"], legacyDerived: false, scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api" };
|
|
63
|
+
async function call(selector: Record<string, unknown>) {
|
|
64
|
+
const req = new Request("http://localhost/vault/import-test/mcp", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "query-notes", arguments: { versions: { note_id: note.id, ...selector } } } }) });
|
|
65
|
+
return await (await handleMcp(req, () => generateScopedMcpTools("import-test", auth), "test", "import-test", auth, "")).json() as any;
|
|
66
|
+
}
|
|
67
|
+
const got = await call({ origin: "git-import", import_ix: 0 });
|
|
68
|
+
const row = JSON.parse(got.result.content[0].text);
|
|
69
|
+
expect(row.content).toBe("archive");
|
|
70
|
+
expect(row.origin).toBe("git-import");
|
|
71
|
+
expect(row.import_ix).toBe(0);
|
|
72
|
+
expect(row.version_ix).toBeUndefined();
|
|
73
|
+
const listed = JSON.parse((await call({})).result.content[0].text);
|
|
74
|
+
expect(listed.versions[0].origin).toBe("git-import");
|
|
75
|
+
for (const selector of [{ origin: "git-import" }, { import_ix: 0 }, { version_ix: 0, origin: "git-import", import_ix: 0 }, { version_ix: -1 }]) {
|
|
76
|
+
const invalid = await call(selector);
|
|
77
|
+
expect(invalid.error).toBeDefined();
|
|
78
|
+
expect(invalid.result).toBeUndefined();
|
|
79
|
+
}
|
|
80
|
+
store.db.exec("UPDATE note_blobs SET encoding='unknown'");
|
|
81
|
+
const broken = await call({ origin: "git-import", import_ix: 0 });
|
|
82
|
+
expect(broken.result).toBeUndefined();
|
|
83
|
+
expect(broken.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
|
|
84
|
+
expect(broken.error.message).not.toContain("@-1");
|
|
85
|
+
const scoped = generateScopedMcpTools("import-test", { ...auth, scoped_tags: ["journal"] }).find(t => t.name === "query-notes")!;
|
|
86
|
+
const denied = await scoped.execute({ versions: { note_id: note.id, origin: "git-import", import_ix: 0 } });
|
|
87
|
+
expect(denied).toEqual({ error: "Note not found", error_type: "not_found", id: note.id });
|
|
88
|
+
|
|
89
|
+
} finally {
|
|
90
|
+
clearVaultStoreCache();
|
|
91
|
+
if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved;
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|