pi-posthorse 0.10.3 → 0.10.4
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 +7 -2
- package/index.ts +13 -3
- package/package.json +6 -6
- package/src/contracts.ts +3 -0
- package/src/note-discovery.ts +291 -0
- package/src/note-evidence.ts +179 -0
- package/src/note-guards.ts +203 -0
- package/src/note-inventory.ts +111 -0
- package/src/note-provenance.ts +299 -0
- package/src/note-scopes.ts +293 -0
- package/src/note-snapshots.ts +185 -0
- package/src/note-store.ts +278 -0
- package/src/note-tool-effects.ts +190 -0
- package/src/notes.ts +117 -133
- package/src/recovery-note-touches.ts +59 -106
- package/src/recovery-notes.ts +50 -69
- package/src/recovery.ts +5 -0
- package/src/retention.ts +3 -0
- package/src/rollover.ts +22 -5
- package/src/session-files.ts +7 -2
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { Type, type Static } from "typebox";
|
|
4
|
+
import { Check } from "typebox/value";
|
|
5
|
+
import { isRecord, type EntryLike } from "./contracts.ts";
|
|
6
|
+
|
|
7
|
+
export const NOTES_BINDING = "posthorse-notes-scope";
|
|
8
|
+
export const NOTES_TOUCH = "posthorse-note-touch";
|
|
9
|
+
const ownerSchema = Type.Object({ id: Type.String(), timestamp: Type.String() });
|
|
10
|
+
const scopeSchema = Type.Object({ root: Type.String(), scope: Type.String() });
|
|
11
|
+
const addressSchema = Type.Object({ ...scopeSchema.properties, path: Type.String() });
|
|
12
|
+
const identitySchema = Type.Object({
|
|
13
|
+
size: Type.Number(),
|
|
14
|
+
mtimeMs: Type.Number(),
|
|
15
|
+
ctimeMs: Type.Number(),
|
|
16
|
+
ino: Type.Number(),
|
|
17
|
+
dev: Type.Number(),
|
|
18
|
+
});
|
|
19
|
+
const seedSchema = Type.Object({
|
|
20
|
+
path: Type.String(),
|
|
21
|
+
source: addressSchema,
|
|
22
|
+
lineage: addressSchema,
|
|
23
|
+
digest: Type.String(),
|
|
24
|
+
identity: identitySchema,
|
|
25
|
+
anchors: Type.Array(Type.String()),
|
|
26
|
+
});
|
|
27
|
+
const sharedBinding = {
|
|
28
|
+
version: Type.Literal(1),
|
|
29
|
+
owner: ownerSchema,
|
|
30
|
+
root: Type.String(),
|
|
31
|
+
};
|
|
32
|
+
const bindingSchema = Type.Union([
|
|
33
|
+
Type.Object({ ...sharedBinding, kind: Type.Literal("project"), scope: Type.Literal("") }),
|
|
34
|
+
Type.Object({
|
|
35
|
+
...sharedBinding,
|
|
36
|
+
kind: Type.Literal("fork"),
|
|
37
|
+
scope: Type.String(),
|
|
38
|
+
source: Type.Union([scopeSchema, Type.Null()]),
|
|
39
|
+
seeds: Type.Array(seedSchema),
|
|
40
|
+
absent: Type.Array(Type.String()),
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
const touchSchema = Type.Object({
|
|
44
|
+
version: Type.Literal(1),
|
|
45
|
+
owner: ownerSchema,
|
|
46
|
+
scope: scopeSchema,
|
|
47
|
+
rootToolCallId: Type.String(),
|
|
48
|
+
toolCallId: Type.String(),
|
|
49
|
+
files: Type.Array(
|
|
50
|
+
Type.Object({
|
|
51
|
+
...addressSchema.properties,
|
|
52
|
+
seed: Type.Optional(seedSchema),
|
|
53
|
+
kind: Type.Union([
|
|
54
|
+
Type.Literal("read"),
|
|
55
|
+
Type.Literal("write"),
|
|
56
|
+
Type.Literal("clear"),
|
|
57
|
+
Type.Literal("delete"),
|
|
58
|
+
Type.Literal("adopt"),
|
|
59
|
+
]),
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
});
|
|
63
|
+
type Immutable<T> = T extends readonly (infer Item)[]
|
|
64
|
+
? readonly Immutable<Item>[]
|
|
65
|
+
: T extends object
|
|
66
|
+
? { readonly [Key in keyof T]: Immutable<T[Key]> }
|
|
67
|
+
: T;
|
|
68
|
+
export type NoteOwner = Immutable<Static<typeof ownerSchema>>;
|
|
69
|
+
export type NoteScope = Immutable<Static<typeof scopeSchema>>;
|
|
70
|
+
export type NoteAddress = Immutable<Static<typeof addressSchema>>;
|
|
71
|
+
export type NoteIdentity = Immutable<Static<typeof identitySchema>>;
|
|
72
|
+
export type NoteSeed = Immutable<Static<typeof seedSchema>>;
|
|
73
|
+
export type NoteBinding = Immutable<Static<typeof bindingSchema>>;
|
|
74
|
+
export type NoteTouchRecord = Immutable<Static<typeof touchSchema>>;
|
|
75
|
+
export type ForkBinding = Extract<NoteBinding, { readonly kind: "fork" }>;
|
|
76
|
+
|
|
77
|
+
export function sameOwner(left: NoteOwner, right: NoteOwner): boolean {
|
|
78
|
+
return left.id === right.id && left.timestamp === right.timestamp;
|
|
79
|
+
}
|
|
80
|
+
export function forkScope(owner: NoteOwner): string {
|
|
81
|
+
return `.forks/${new Date(owner.timestamp).toISOString().replace(/[:.]/gu, "-")}_${encodeURIComponent(owner.id)}/notes`;
|
|
82
|
+
}
|
|
83
|
+
export function scopeDirectory(scope: NoteScope): string {
|
|
84
|
+
return join(scope.root, scope.scope);
|
|
85
|
+
}
|
|
86
|
+
export function sameScope(left: NoteScope, right: NoteScope): boolean {
|
|
87
|
+
return left.root === right.root && left.scope === right.scope;
|
|
88
|
+
}
|
|
89
|
+
export function inside(directory: string, path: string): boolean {
|
|
90
|
+
const part = relative(directory, path);
|
|
91
|
+
return part === "" || (part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part));
|
|
92
|
+
}
|
|
93
|
+
export function noteRelative(path: string): string {
|
|
94
|
+
if (path === "" || isAbsolute(path) || path.includes("\0")) {
|
|
95
|
+
throw new Error(`Invalid note path ${JSON.stringify(path)}: use a relative path.`);
|
|
96
|
+
}
|
|
97
|
+
const normalized = relative(resolve("/"), resolve("/", path));
|
|
98
|
+
if (
|
|
99
|
+
path.split(/[/\\]/u).includes("..") ||
|
|
100
|
+
normalized === "" ||
|
|
101
|
+
normalized.split(sep)[0] === ".forks"
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid note path ${JSON.stringify(path)}: must stay outside managed .forks paths and inside its scope.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
/** Resolve existing ancestors too, so aliases to files not created yet have one owner. */
|
|
110
|
+
export function canonicalPath(path: string): string {
|
|
111
|
+
const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
|
|
112
|
+
try {
|
|
113
|
+
return realpathSync(absolute);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
const parent = dirname(absolute);
|
|
119
|
+
return parent === absolute
|
|
120
|
+
? absolute
|
|
121
|
+
: join(canonicalPath(parent), relative(parent, absolute));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function notePath(scope: NoteScope, path: string): string {
|
|
125
|
+
const directory = scopeDirectory(scope);
|
|
126
|
+
const target = join(directory, noteRelative(path));
|
|
127
|
+
const canonical = canonicalPath(target);
|
|
128
|
+
const escapesPrivate = scope.scope !== "" && !inside(canonicalPath(directory), canonical);
|
|
129
|
+
const entersManaged =
|
|
130
|
+
scope.scope === "" && inside(canonicalPath(join(scope.root, ".forks")), canonical);
|
|
131
|
+
if (escapesPrivate || entersManaged) {
|
|
132
|
+
throw new Error(`Note path leaves its scope through a filesystem alias: ${target}`);
|
|
133
|
+
}
|
|
134
|
+
return target;
|
|
135
|
+
}
|
|
136
|
+
function validScope(scope: NoteScope): boolean {
|
|
137
|
+
return (
|
|
138
|
+
isAbsolute(scope.root) &&
|
|
139
|
+
(scope.scope === "" || /^\.forks\/[^/\\]+\/notes$/u.test(scope.scope))
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
function validSeed(seed: NoteSeed): boolean {
|
|
143
|
+
try {
|
|
144
|
+
return (
|
|
145
|
+
noteRelative(seed.path) === seed.path &&
|
|
146
|
+
validScope(seed.source) &&
|
|
147
|
+
validScope(seed.lineage) &&
|
|
148
|
+
noteRelative(seed.source.path) === seed.source.path &&
|
|
149
|
+
noteRelative(seed.lineage.path) === seed.lineage.path
|
|
150
|
+
);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function validNoteName(path: string): boolean {
|
|
156
|
+
try {
|
|
157
|
+
return noteRelative(path) === path;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function validFork(binding: ForkBinding): boolean {
|
|
163
|
+
return (
|
|
164
|
+
binding.scope === forkScope(binding.owner) &&
|
|
165
|
+
(binding.source === null || validScope(binding.source)) &&
|
|
166
|
+
binding.seeds.every(validSeed) &&
|
|
167
|
+
binding.absent.every(validNoteName)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
export function parseBinding(value: unknown): NoteBinding | undefined {
|
|
171
|
+
if (
|
|
172
|
+
!Check(bindingSchema, value) ||
|
|
173
|
+
!validScope(value) ||
|
|
174
|
+
value.owner.id === "" ||
|
|
175
|
+
!Number.isFinite(Date.parse(value.owner.timestamp))
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (value.kind === "fork" && !validFork(value)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
export function bindingsOf(entries: readonly EntryLike[]): NoteBinding[] {
|
|
185
|
+
return entries.flatMap((entry) => {
|
|
186
|
+
const binding =
|
|
187
|
+
entry.type === "custom" && entry.customType === NOTES_BINDING
|
|
188
|
+
? parseBinding(entry.data)
|
|
189
|
+
: undefined;
|
|
190
|
+
return binding === undefined ? [] : [binding];
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export function touchRecords(entries: readonly EntryLike[]): NoteTouchRecord[] {
|
|
194
|
+
return entries.flatMap((entry) => {
|
|
195
|
+
if (
|
|
196
|
+
entry.type !== "custom" ||
|
|
197
|
+
entry.customType !== NOTES_TOUCH ||
|
|
198
|
+
!Check(touchSchema, entry.data) ||
|
|
199
|
+
!validScope(entry.data.scope)
|
|
200
|
+
) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return entry.data.files.every(
|
|
205
|
+
(file) =>
|
|
206
|
+
validScope(file) &&
|
|
207
|
+
noteRelative(file.path) === file.path &&
|
|
208
|
+
(file.seed === undefined || validSeed(file.seed)),
|
|
209
|
+
)
|
|
210
|
+
? [entry.data]
|
|
211
|
+
: [];
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
export function seedsOf(binding: NoteBinding, entries: readonly EntryLike[]): readonly NoteSeed[] {
|
|
218
|
+
const seeds = new Map(
|
|
219
|
+
binding.kind === "fork" ? binding.seeds.map((seed) => [seed.path, seed]) : [],
|
|
220
|
+
);
|
|
221
|
+
for (const record of touchRecords(entries)) {
|
|
222
|
+
if (!sameOwner(record.owner, binding.owner)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const file of record.files) {
|
|
226
|
+
if (file.seed !== undefined && sameScope(file, binding)) {
|
|
227
|
+
seeds.set(file.path, file.seed);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return [...seeds.values()];
|
|
232
|
+
}
|
|
233
|
+
export function ownedPaths(binding: NoteBinding, entries: readonly EntryLike[]): Set<string> {
|
|
234
|
+
const paths = new Set(
|
|
235
|
+
binding.kind === "fork"
|
|
236
|
+
? [...binding.absent, ...binding.seeds.map((seed) => seed.path)]
|
|
237
|
+
: [],
|
|
238
|
+
);
|
|
239
|
+
for (const record of touchRecords(entries)) {
|
|
240
|
+
if (!sameOwner(record.owner, binding.owner)) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
for (const file of record.files) {
|
|
244
|
+
if (file.kind !== "read" && sameScope(binding, file)) {
|
|
245
|
+
paths.add(file.path);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return paths;
|
|
250
|
+
}
|
|
251
|
+
/** Pure longest-prefix matching is also used for historical paths on unavailable filesystems. */
|
|
252
|
+
export function matchNoteDirectory(
|
|
253
|
+
path: string,
|
|
254
|
+
directories: readonly string[],
|
|
255
|
+
): { readonly directory: string; readonly path: string } | undefined {
|
|
256
|
+
const slash = path.replaceAll("\\", "/");
|
|
257
|
+
for (const directory of directories.toSorted((a, b) => b.length - a.length)) {
|
|
258
|
+
const normalized = directory.replaceAll("\\", "/").replace(/\/+$/u, "");
|
|
259
|
+
const part = slash.startsWith(`${normalized}/`) ? slash.slice(normalized.length + 1) : "";
|
|
260
|
+
if (part !== "" && part !== ".forks" && !part.startsWith(".forks/")) {
|
|
261
|
+
return { directory, path: part };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
export function addressOf(path: string, scopes: readonly NoteScope[]): NoteAddress | undefined {
|
|
267
|
+
const candidates = scopes.map((scope) => ({
|
|
268
|
+
scope,
|
|
269
|
+
directory: canonicalPath(scopeDirectory(scope)),
|
|
270
|
+
}));
|
|
271
|
+
for (const target of [canonicalPath(path), resolve(path)]) {
|
|
272
|
+
const match = matchNoteDirectory(
|
|
273
|
+
target,
|
|
274
|
+
candidates.map((candidate) => candidate.directory),
|
|
275
|
+
);
|
|
276
|
+
const candidate =
|
|
277
|
+
match === undefined
|
|
278
|
+
? undefined
|
|
279
|
+
: candidates.find((item) => item.directory === match.directory);
|
|
280
|
+
if (match !== undefined && candidate !== undefined) {
|
|
281
|
+
return { ...candidate.scope, path: match.path };
|
|
282
|
+
}
|
|
283
|
+
if (
|
|
284
|
+
scopes.some(
|
|
285
|
+
(scope) =>
|
|
286
|
+
scope.scope === "" && inside(canonicalPath(join(scope.root, ".forks")), target),
|
|
287
|
+
)
|
|
288
|
+
) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
copyFile,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
mkdtemp,
|
|
8
|
+
open,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
stat,
|
|
13
|
+
utimes,
|
|
14
|
+
writeFile,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { isRecord } from "./contracts.ts";
|
|
19
|
+
import { publishFile } from "./files.ts";
|
|
20
|
+
import {
|
|
21
|
+
notePath,
|
|
22
|
+
parseBinding,
|
|
23
|
+
sameOwner,
|
|
24
|
+
scopeDirectory,
|
|
25
|
+
type ForkBinding,
|
|
26
|
+
type NoteAddress,
|
|
27
|
+
type NoteBinding,
|
|
28
|
+
type NoteIdentity,
|
|
29
|
+
type NoteSeed,
|
|
30
|
+
} from "./note-scopes.ts";
|
|
31
|
+
|
|
32
|
+
export function fileIdentity(info: NoteIdentity): NoteIdentity {
|
|
33
|
+
return {
|
|
34
|
+
size: info.size,
|
|
35
|
+
mtimeMs: info.mtimeMs,
|
|
36
|
+
ctimeMs: info.ctimeMs,
|
|
37
|
+
ino: info.ino,
|
|
38
|
+
dev: info.dev,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function sameIdentity(left: NoteIdentity, right: NoteIdentity): boolean {
|
|
42
|
+
return (
|
|
43
|
+
left.size === right.size &&
|
|
44
|
+
left.mtimeMs === right.mtimeMs &&
|
|
45
|
+
left.ctimeMs === right.ctimeMs &&
|
|
46
|
+
left.ino === right.ino &&
|
|
47
|
+
left.dev === right.dev
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
export async function fileDigest(path: string, signal?: AbortSignal): Promise<string> {
|
|
51
|
+
const file = await open(path, "r");
|
|
52
|
+
try {
|
|
53
|
+
const hash = createHash("sha256");
|
|
54
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
55
|
+
signal?.throwIfAborted();
|
|
56
|
+
const bytes: unknown = chunk;
|
|
57
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
58
|
+
throw new TypeError("Expected binary file data.");
|
|
59
|
+
}
|
|
60
|
+
hash.update(bytes);
|
|
61
|
+
}
|
|
62
|
+
return hash.digest("hex");
|
|
63
|
+
} finally {
|
|
64
|
+
await file.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function readManifest(directory: string): Promise<ForkBinding | undefined> {
|
|
68
|
+
try {
|
|
69
|
+
const raw: unknown = JSON.parse(
|
|
70
|
+
await readFile(join(dirname(directory), "manifest.json"), "utf8"),
|
|
71
|
+
);
|
|
72
|
+
const binding = parseBinding(raw);
|
|
73
|
+
return binding?.kind === "fork" && scopeDirectory(binding) === directory
|
|
74
|
+
? binding
|
|
75
|
+
: undefined;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (
|
|
78
|
+
error instanceof SyntaxError ||
|
|
79
|
+
(isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
|
|
80
|
+
) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export type SnapshotNote = {
|
|
87
|
+
readonly path: string;
|
|
88
|
+
readonly source: NoteAddress;
|
|
89
|
+
readonly lineage: NoteAddress;
|
|
90
|
+
readonly anchors: readonly string[];
|
|
91
|
+
};
|
|
92
|
+
async function copyNote(note: SnapshotNote, destination: string): Promise<NoteSeed> {
|
|
93
|
+
const source = notePath(note.source, note.source.path);
|
|
94
|
+
const before = await stat(source);
|
|
95
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
96
|
+
await copyFile(source, destination, constants.COPYFILE_EXCL);
|
|
97
|
+
const after = await stat(source);
|
|
98
|
+
if (!sameIdentity(before, after)) {
|
|
99
|
+
throw new Error(`Source note changed during snapshot: ${source}. Retry initialization.`);
|
|
100
|
+
}
|
|
101
|
+
await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
|
|
102
|
+
const digest = await fileDigest(destination);
|
|
103
|
+
return { ...note, digest, identity: fileIdentity(await stat(destination)) };
|
|
104
|
+
}
|
|
105
|
+
/** Publication is the ownership boundary. An existing unrelated directory is never overwritten. */
|
|
106
|
+
export async function snapshot(
|
|
107
|
+
binding: ForkBinding,
|
|
108
|
+
notes: readonly SnapshotNote[],
|
|
109
|
+
): Promise<ForkBinding> {
|
|
110
|
+
const directory = scopeDirectory(binding);
|
|
111
|
+
const existing = await readManifest(directory);
|
|
112
|
+
if (existing !== undefined && sameOwner(existing.owner, binding.owner)) {
|
|
113
|
+
return existing;
|
|
114
|
+
}
|
|
115
|
+
const container = dirname(directory);
|
|
116
|
+
if (
|
|
117
|
+
await lstat(container).catch((error: unknown) => {
|
|
118
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
})
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(`Incomplete notes snapshot: preserving unexpected data at ${container}.`);
|
|
125
|
+
}
|
|
126
|
+
if (notes.length === 0) {
|
|
127
|
+
return binding;
|
|
128
|
+
}
|
|
129
|
+
await mkdir(dirname(container), { recursive: true });
|
|
130
|
+
const stage = await mkdtemp(join(dirname(container), ".staging-"));
|
|
131
|
+
try {
|
|
132
|
+
const seeds: NoteSeed[] = [];
|
|
133
|
+
for (const note of notes) {
|
|
134
|
+
// Keep each file's identity and digest paired with the bytes actually copied.
|
|
135
|
+
seeds.push(await copyNote(note, notePath({ root: stage, scope: "notes" }, note.path)));
|
|
136
|
+
}
|
|
137
|
+
const complete: ForkBinding = { ...binding, seeds };
|
|
138
|
+
await writeFile(join(stage, "manifest.json"), JSON.stringify(complete), { flag: "wx" });
|
|
139
|
+
await rename(stage, container);
|
|
140
|
+
return complete;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Incomplete notes snapshot for ${directory}: ${error instanceof Error ? error.message : String(error)}`,
|
|
144
|
+
{ cause: error },
|
|
145
|
+
);
|
|
146
|
+
} finally {
|
|
147
|
+
await rm(stage, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Called inside the destination's native mutation queue, never queues that path recursively. */
|
|
151
|
+
export async function adoptNote(binding: NoteBinding, note: SnapshotNote): Promise<NoteSeed> {
|
|
152
|
+
return copyNote(note, notePath(binding, note.path));
|
|
153
|
+
}
|
|
154
|
+
/** Empty initialized scopes publish their manifest when the first note is created. */
|
|
155
|
+
export async function ensureManifest(binding: NoteBinding): Promise<void> {
|
|
156
|
+
if (binding.kind !== "fork") {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const path = join(dirname(scopeDirectory(binding)), "manifest.json");
|
|
160
|
+
await mkdir(dirname(path), { recursive: true });
|
|
161
|
+
try {
|
|
162
|
+
await writeFile(path, JSON.stringify(binding), { flag: "wx" });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (!isRecord(error) || error.code !== "EEXIST") {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
const existing = await readManifest(scopeDirectory(binding));
|
|
168
|
+
if (existing === undefined || !sameOwner(existing.owner, binding.owner)) {
|
|
169
|
+
throw new Error(`Preserving unexpected notes manifest at ${path}.`, { cause: error });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function saveAdoption(binding: NoteBinding, seed: NoteSeed): Promise<void> {
|
|
175
|
+
if (binding.kind !== "fork") {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const path = join(dirname(scopeDirectory(binding)), "manifest.json");
|
|
179
|
+
await withFileMutationQueue(path, async () => {
|
|
180
|
+
await ensureManifest(binding);
|
|
181
|
+
const previous = (await readManifest(scopeDirectory(binding))) ?? binding;
|
|
182
|
+
const seeds = [...previous.seeds.filter((item) => item.path !== seed.path), seed];
|
|
183
|
+
await publishFile(path, JSON.stringify({ ...previous, seeds }));
|
|
184
|
+
});
|
|
185
|
+
}
|