@egildo/provenance 0.0.0-stage → 0.7.0
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/LICENSE +21 -0
- package/README.md +173 -2
- package/dist/css.d.ts +2 -0
- package/dist/css.js +77 -0
- package/dist/html.d.ts +2 -0
- package/dist/html.js +128 -0
- package/dist/index.d.ts +397 -0
- package/dist/index.js +3 -0
- package/dist/memory.d.ts +6 -0
- package/dist/memory.js +83 -0
- package/dist/node.d.ts +4 -0
- package/dist/node.js +110 -0
- package/dist/offsets.d.ts +6 -0
- package/dist/offsets.js +43 -0
- package/dist/resolvers.d.ts +4 -0
- package/dist/resolvers.js +42 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +1057 -0
- package/package.json +59 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
export { openSession } from "./session.ts";
|
|
2
|
+
export interface Session {
|
|
3
|
+
/** Starts a read pass (specs/read-passes/spec.md). Discards an open pass with the same label. */
|
|
4
|
+
read(label?: string): ReadPass;
|
|
5
|
+
sources(): readonly Source[];
|
|
6
|
+
source(id: string): Source | undefined;
|
|
7
|
+
edgesFrom(id: string): readonly Edge[];
|
|
8
|
+
edgesInto(id: string): readonly Edge[];
|
|
9
|
+
unresolved(): readonly Edge[];
|
|
10
|
+
cycles(): readonly (readonly string[])[];
|
|
11
|
+
addRoot(location: string): Promise<void>;
|
|
12
|
+
removeRoot(location: string): Promise<void>;
|
|
13
|
+
onChange(listener: (change: Change) => void): () => void;
|
|
14
|
+
/**
|
|
15
|
+
* Stages an edit of the source at `location` (specs/editing/spec.md, specs/insert-delete/spec.md): the
|
|
16
|
+
* operation's path in the format's own terms, and what to do there. A new edit at a path that already
|
|
17
|
+
* has one replaces it. Refusals resolve; a writer bug throws.
|
|
18
|
+
*/
|
|
19
|
+
stage(location: string, operation: EditOperation): Promise<StageResult>;
|
|
20
|
+
/** The staged edits, source by source, each against its source's base version. */
|
|
21
|
+
index(): readonly StagedEdit[];
|
|
22
|
+
/** Drops the staged edit at `path`, or every staged edit of the source. */
|
|
23
|
+
unstage(location: string, path?: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* The index as plain data (specs/saveable-index/spec.md): per staged source its location, base version and base
|
|
26
|
+
* bytes, and each edit's operation, path, value and status, in staging order. It survives a structured clone.
|
|
27
|
+
* Writing it anywhere is the embedder's.
|
|
28
|
+
*/
|
|
29
|
+
save(): SavedIndex;
|
|
30
|
+
/**
|
|
31
|
+
* Stages a saved index again (specs/saveable-index/spec.md): per source, its edits are staged against the saved base
|
|
32
|
+
* bytes through the writer, in saved order, and then rebased onto the version the session holds. A source that
|
|
33
|
+
* already has staged edits, or whose bytes do not hash to the saved base, is refused and the others restored.
|
|
34
|
+
*/
|
|
35
|
+
restore(saved: SavedIndex): Promise<RestoreReport>;
|
|
36
|
+
/** The source's base with every staged edit spliced in, or `undefined` when none is staged. */
|
|
37
|
+
preview(id: string): Promise<Preview | undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Writes every source with staged edits, or none: each is checked and rebased first, and one that cannot
|
|
40
|
+
* be written (or an edit dropped on rebase) aborts the commit; a write that fails puts back the files
|
|
41
|
+
* already written. Each file is written atomically by the host. The report says exactly what landed.
|
|
42
|
+
*/
|
|
43
|
+
commit(): Promise<CommitReport>;
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
export interface CommitReport {
|
|
47
|
+
/** `committed`: every source landed (an empty index is committed). `aborted`: nothing did, bar any `unrestored` source. */
|
|
48
|
+
readonly outcome: "committed" | "aborted";
|
|
49
|
+
/** One entry per source that had staged edits, in the order they were first staged. */
|
|
50
|
+
readonly sources: readonly SourceCommit[];
|
|
51
|
+
}
|
|
52
|
+
export interface SourceCommit {
|
|
53
|
+
readonly source: string;
|
|
54
|
+
readonly location: string;
|
|
55
|
+
/**
|
|
56
|
+
* `written`; `refused` (outside every writable root, the host has no write verb, or the disk version does not
|
|
57
|
+
* open); `failed` (the host's reason; the edits are kept); `conflicted` (at least one edit was dropped on
|
|
58
|
+
* rebase, and `edits` lists the dropped ones); `aborted` (this source was fine, but another one stopped the
|
|
59
|
+
* commit, and `reason` names it; nothing of this source is on disk and its edits stay staged); `unrestored`
|
|
60
|
+
* (this commit wrote it and could not put it back; `reason` says why, its edits are on disk and left the index).
|
|
61
|
+
*/
|
|
62
|
+
readonly outcome: "written" | "refused" | "failed" | "conflicted" | "aborted" | "unrestored";
|
|
63
|
+
readonly reason?: string;
|
|
64
|
+
/** The version this commit wrote (also for `unrestored`). */
|
|
65
|
+
readonly version?: string;
|
|
66
|
+
/** What became of each edit that was written or dropped; empty when refused or failed. */
|
|
67
|
+
readonly edits: readonly {
|
|
68
|
+
readonly path: string;
|
|
69
|
+
/** `overrode`: the value on disk had changed, and the edit replaced it. */
|
|
70
|
+
readonly outcome: "written" | "overrode" | "conflicted";
|
|
71
|
+
/** Why an edit was dropped; present exactly when `outcome` is `conflicted`. */
|
|
72
|
+
readonly reason?: ConflictReason;
|
|
73
|
+
/** The writer's words, as `EditNotice.detail` carries them (FIX-004). */
|
|
74
|
+
readonly detail?: string;
|
|
75
|
+
/** The bytes on disk that an overriding edit replaced. */
|
|
76
|
+
readonly disk?: Uint8Array;
|
|
77
|
+
}[];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The value an edit sets: a scalar, a sequence or a mapping (a `Map` of string keys), nested. The same union,
|
|
81
|
+
* under the same name, as Cascata's writers take (`EditData`), so a writer from either library fits the other
|
|
82
|
+
* by shape and not by method bivariance. A plain object is not one: its array-index keys reorder and
|
|
83
|
+
* `__proto__` is swallowed, so a writer refuses it as a programmer error.
|
|
84
|
+
*/
|
|
85
|
+
export type EditData = string | number | bigint | boolean | null | readonly EditData[] | ReadonlyMap<string, EditData>;
|
|
86
|
+
/**
|
|
87
|
+
* Why a staged edit was dropped as conflicted: `gone`, its path is no longer found in the source's
|
|
88
|
+
* new version (or the source left the graph); `overlap`, its new range overlaps an earlier-staged
|
|
89
|
+
* edit's; `unwritable`, its writer found the path but refused to write the value on the new bytes; `exists`
|
|
90
|
+
* (an insert), the key was added on disk meanwhile; `changed` (a delete), the entry was changed on disk meanwhile; `shifted`, a list
|
|
91
|
+
* along its path changed length, so an index in it may name another item.
|
|
92
|
+
*/
|
|
93
|
+
export type ConflictReason = "gone" | "overlap" | "unwritable" | "exists" | "changed" | "shifted";
|
|
94
|
+
/** What `stage` is asked to do, JSON Patch's operation object with Cascata's verb names. */
|
|
95
|
+
export type EditOperation = {
|
|
96
|
+
readonly op: "set" | "add";
|
|
97
|
+
readonly path: string;
|
|
98
|
+
readonly value: EditData;
|
|
99
|
+
} | {
|
|
100
|
+
readonly op: "remove";
|
|
101
|
+
readonly path: string;
|
|
102
|
+
};
|
|
103
|
+
export type StageResult = {
|
|
104
|
+
readonly ok: true;
|
|
105
|
+
} | {
|
|
106
|
+
readonly ok: false;
|
|
107
|
+
readonly reason: string;
|
|
108
|
+
};
|
|
109
|
+
/** One staged edit: a byte address in its source's base version, and the semantic address that finds it again. */
|
|
110
|
+
export interface StagedEdit {
|
|
111
|
+
readonly source: string;
|
|
112
|
+
readonly path: string;
|
|
113
|
+
/** What the edit does at `path`. */
|
|
114
|
+
readonly op: EditOperation["op"];
|
|
115
|
+
/** The value as staged (a `Map` stays a `Map`); present for `set` and `add`, absent for `remove`. */
|
|
116
|
+
readonly value?: EditData;
|
|
117
|
+
/** The base version the byte address is against. */
|
|
118
|
+
readonly base: string;
|
|
119
|
+
readonly start: number;
|
|
120
|
+
readonly end: number;
|
|
121
|
+
/** `"overrides"`: a rebase found the value changed on disk, and this edit will replace it. */
|
|
122
|
+
readonly status: "staged" | "overrides";
|
|
123
|
+
/** The bytes of the value on disk that an overriding edit will replace. */
|
|
124
|
+
readonly disk?: Uint8Array;
|
|
125
|
+
/** Present while the source's newest version does not open: the writer's words. The edit waits against its base (FIX-001). */
|
|
126
|
+
readonly waiting?: string;
|
|
127
|
+
}
|
|
128
|
+
/** What `session.save()` returns: the index as plain data, `Uint8Array` and `Map` the only objects in it. */
|
|
129
|
+
export interface SavedIndex {
|
|
130
|
+
/** Which shape this is; `restore` refuses a number it does not know. */
|
|
131
|
+
readonly format: 1;
|
|
132
|
+
readonly sources: readonly {
|
|
133
|
+
/** The canonical location the session staged under. */
|
|
134
|
+
readonly location: string;
|
|
135
|
+
/** The base version the edits were staged against, and the bytes that hash to it. */
|
|
136
|
+
readonly base: string;
|
|
137
|
+
readonly bytes: Uint8Array;
|
|
138
|
+
readonly edits: readonly SavedEdit[];
|
|
139
|
+
}[];
|
|
140
|
+
}
|
|
141
|
+
/** One saved edit: the semantic half only. Byte ranges are made again by the writer on restore. */
|
|
142
|
+
export interface SavedEdit {
|
|
143
|
+
readonly op: EditOperation["op"];
|
|
144
|
+
readonly path: string;
|
|
145
|
+
readonly value?: EditData;
|
|
146
|
+
readonly status: "staged" | "overrides";
|
|
147
|
+
readonly disk?: Uint8Array;
|
|
148
|
+
}
|
|
149
|
+
export interface RestoreReport {
|
|
150
|
+
/** `refused`: the whole save, for a `format` this version does not know (`reason` says so, and `sources` is empty). */
|
|
151
|
+
readonly outcome: "restored" | "refused";
|
|
152
|
+
readonly reason?: string;
|
|
153
|
+
/** One entry per saved source, in the save's order. */
|
|
154
|
+
readonly sources: readonly SourceRestore[];
|
|
155
|
+
}
|
|
156
|
+
export interface SourceRestore {
|
|
157
|
+
readonly location: string;
|
|
158
|
+
/**
|
|
159
|
+
* `refused`: the source is not in the session's graph, no writer claims it, it already has staged edits, its saved
|
|
160
|
+
* bytes do not hash to its base, or none of its edits could be staged; `reason` says which. Otherwise `restored`:
|
|
161
|
+
* what the rebase onto the session's version then did arrives as the usual `Change` notices.
|
|
162
|
+
*/
|
|
163
|
+
readonly outcome: "restored" | "refused";
|
|
164
|
+
readonly reason?: string;
|
|
165
|
+
/** Per saved edit, in saved order; empty when the source was refused before its edits were tried. */
|
|
166
|
+
readonly edits: readonly {
|
|
167
|
+
readonly path: string;
|
|
168
|
+
readonly outcome: "restored" | "refused";
|
|
169
|
+
/** The writer's or the stage's words, as `StageResult` carries them. */
|
|
170
|
+
readonly reason?: string;
|
|
171
|
+
}[];
|
|
172
|
+
}
|
|
173
|
+
/** A source's base plus its staged edits, with the content hash that names it as a version. */
|
|
174
|
+
export interface Preview {
|
|
175
|
+
readonly bytes: Uint8Array;
|
|
176
|
+
readonly version: string;
|
|
177
|
+
}
|
|
178
|
+
/** One render's reads through the session. */
|
|
179
|
+
export interface ReadPass {
|
|
180
|
+
/** Cascata's `Host` shape. No member uses `this`, so it may be spread into a composed host. */
|
|
181
|
+
readonly host: SessionHost;
|
|
182
|
+
/** Records a location read outside `host.read`, such as a code module. */
|
|
183
|
+
note(location: string): void;
|
|
184
|
+
/** Makes the pass's locations its label's roots, replacing the previous pass's, and settles once. */
|
|
185
|
+
end(): Promise<RenderManifest>;
|
|
186
|
+
}
|
|
187
|
+
/** Which version of each source a render used, and where it found nothing. */
|
|
188
|
+
export interface RenderManifest {
|
|
189
|
+
readonly read: readonly {
|
|
190
|
+
readonly source: string;
|
|
191
|
+
readonly version: string;
|
|
192
|
+
}[];
|
|
193
|
+
/** Locations. A note with nothing readable there is listed here. */
|
|
194
|
+
readonly missing: readonly string[];
|
|
195
|
+
}
|
|
196
|
+
export type Source = {
|
|
197
|
+
readonly id: string;
|
|
198
|
+
readonly location: string;
|
|
199
|
+
} & ({
|
|
200
|
+
readonly state: "analysed" | "leaf" | "undecodable";
|
|
201
|
+
readonly version: string;
|
|
202
|
+
} | {
|
|
203
|
+
readonly state: "refused";
|
|
204
|
+
readonly reason: string;
|
|
205
|
+
} | {
|
|
206
|
+
readonly state: "external";
|
|
207
|
+
});
|
|
208
|
+
/** A place in a source at a known version. Offsets are bytes; both absent means the whole source. */
|
|
209
|
+
export interface Address {
|
|
210
|
+
readonly source: string;
|
|
211
|
+
readonly version: string;
|
|
212
|
+
readonly start?: number;
|
|
213
|
+
readonly end?: number;
|
|
214
|
+
}
|
|
215
|
+
export interface Edge {
|
|
216
|
+
readonly from: Address;
|
|
217
|
+
readonly request: string;
|
|
218
|
+
readonly target?: string;
|
|
219
|
+
readonly kind: "requires" | "candidate";
|
|
220
|
+
readonly origin: "declared";
|
|
221
|
+
readonly probes: readonly string[];
|
|
222
|
+
}
|
|
223
|
+
export interface Change {
|
|
224
|
+
readonly added: readonly string[];
|
|
225
|
+
readonly removed: readonly string[];
|
|
226
|
+
readonly changed: readonly string[];
|
|
227
|
+
/** What a rebase did to staged edits because their source changed; absent when it did nothing. */
|
|
228
|
+
readonly edits?: readonly EditNotice[];
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* One staged edit that a rebase moved, found overriding a newer value, dropped as conflicted, or
|
|
232
|
+
* respelled: it kept its range, but its writer now spells the value differently on the new bytes. Or
|
|
233
|
+
* `waiting`: the source's new version does not open, so the edit stays staged against its base until a
|
|
234
|
+
* version that opens arrives (specs/insert-delete/fixes.md FIX-001).
|
|
235
|
+
*/
|
|
236
|
+
export interface EditNotice {
|
|
237
|
+
readonly source: string;
|
|
238
|
+
readonly path: string;
|
|
239
|
+
readonly outcome: "moved" | "overrides" | "conflicted" | "respelled" | "waiting";
|
|
240
|
+
/** Why the edit was dropped; present exactly when `outcome` is `conflicted`. */
|
|
241
|
+
readonly reason?: ConflictReason;
|
|
242
|
+
/** The edit's new byte range in the source's new version; absent when conflicted. */
|
|
243
|
+
readonly start?: number;
|
|
244
|
+
readonly end?: number;
|
|
245
|
+
/** The bytes on disk that an overriding edit will replace. */
|
|
246
|
+
readonly disk?: Uint8Array;
|
|
247
|
+
/**
|
|
248
|
+
* The writer's words, where a writer's refusal caused the outcome: why the new version does not open (`waiting`), or why
|
|
249
|
+
* it refused the edit (`conflicted` as `gone`, `exists` or `unwritable`; FIX-004). Absent where the session decided alone.
|
|
250
|
+
*/
|
|
251
|
+
readonly detail?: string;
|
|
252
|
+
}
|
|
253
|
+
export interface Handler {
|
|
254
|
+
claims(location: string): boolean;
|
|
255
|
+
/** Pure. Throwing is a bug and propagates. */
|
|
256
|
+
analyze(text: string): Findings;
|
|
257
|
+
}
|
|
258
|
+
export interface Findings {
|
|
259
|
+
readonly requests: readonly {
|
|
260
|
+
/** String index, inclusive. */
|
|
261
|
+
readonly start: number;
|
|
262
|
+
/** String index, exclusive. */
|
|
263
|
+
readonly end: number;
|
|
264
|
+
readonly request: string;
|
|
265
|
+
readonly kind: "requires" | "candidate";
|
|
266
|
+
}[];
|
|
267
|
+
readonly base?: string;
|
|
268
|
+
}
|
|
269
|
+
export type Resolution = {
|
|
270
|
+
readonly probes: readonly string[];
|
|
271
|
+
readonly target?: string;
|
|
272
|
+
} | {
|
|
273
|
+
readonly external: string;
|
|
274
|
+
} | null;
|
|
275
|
+
/**
|
|
276
|
+
* The editing half of a format plug-in (specs/editing/spec.md, specs/insert-delete/spec.md). Pure, like a
|
|
277
|
+
* handler: it reads and writes nothing. `open` parses the bytes once; the document it answers is then asked
|
|
278
|
+
* as often as needed, each answer against the bytes as opened. `path` is the format's own address of a value
|
|
279
|
+
* (a JSON Pointer, a selector and a property); offsets are bytes.
|
|
280
|
+
*/
|
|
281
|
+
export interface Writer {
|
|
282
|
+
claims(location: string): boolean;
|
|
283
|
+
open(bytes: Uint8Array): WriterOpenResult;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* A writer's refusal: its words, and optionally a code naming why (fixes FIX-003). The session reads three codes, Cascata's
|
|
287
|
+
* names for them: `edit-target-exists` (the key is there), `edit-target-absent` and `edit-parent-scalar` (the place is gone).
|
|
288
|
+
* Any other code, or none, is the writer's own business.
|
|
289
|
+
*/
|
|
290
|
+
export type WriterRefusal = {
|
|
291
|
+
readonly ok: false;
|
|
292
|
+
readonly reason: string;
|
|
293
|
+
readonly code?: string;
|
|
294
|
+
};
|
|
295
|
+
export type WriterOpenResult = {
|
|
296
|
+
readonly ok: true;
|
|
297
|
+
readonly document: WriterDocument;
|
|
298
|
+
} | WriterRefusal;
|
|
299
|
+
export type WriterLocateResult = {
|
|
300
|
+
readonly ok: true;
|
|
301
|
+
readonly start: number;
|
|
302
|
+
readonly end: number;
|
|
303
|
+
} | WriterRefusal;
|
|
304
|
+
export type AmendResult = {
|
|
305
|
+
readonly inside: false;
|
|
306
|
+
} | {
|
|
307
|
+
readonly inside: true;
|
|
308
|
+
readonly ok: true;
|
|
309
|
+
readonly value: EditData;
|
|
310
|
+
} | {
|
|
311
|
+
readonly inside: true;
|
|
312
|
+
readonly ok: false;
|
|
313
|
+
readonly reason: string;
|
|
314
|
+
};
|
|
315
|
+
export type WriterWriteResult = {
|
|
316
|
+
readonly ok: true;
|
|
317
|
+
readonly edit: {
|
|
318
|
+
readonly start: number;
|
|
319
|
+
readonly end: number;
|
|
320
|
+
readonly bytes: Uint8Array;
|
|
321
|
+
};
|
|
322
|
+
} | WriterRefusal;
|
|
323
|
+
/** A document a writer has parsed once. */
|
|
324
|
+
export interface WriterDocument {
|
|
325
|
+
/** Where the value at `path` is in the bytes as opened, or why it is not there. */
|
|
326
|
+
locate(path: string): WriterLocateResult;
|
|
327
|
+
/** The one splice that sets the value at `path`, in the source's own style, or why it cannot. */
|
|
328
|
+
write(path: string, value: EditData): WriterWriteResult;
|
|
329
|
+
/** The splice that creates the entry at `path`, which must not exist; answers what `write` answers. Absent: the writer cannot insert. */
|
|
330
|
+
add?(path: string, value: EditData): WriterWriteResult;
|
|
331
|
+
/** The splice that deletes the entry at `path`, with the comments attached to it; answers what `write` answers. Absent: the writer cannot delete. */
|
|
332
|
+
remove?(path: string): WriterWriteResult;
|
|
333
|
+
/**
|
|
334
|
+
* Whether `path` lies strictly below `within`, in the format's own path language. Provenance reads no path,
|
|
335
|
+
* so this is how it learns that an edit lands inside a staged edit. Absent: nothing is treated as inside.
|
|
336
|
+
*/
|
|
337
|
+
inside?(path: string, within: string): boolean;
|
|
338
|
+
/**
|
|
339
|
+
* `operation` applied inside the value staged at `staged.path`: the amended value, a refusal in the writer's
|
|
340
|
+
* words, or `{ inside: false }` when the operation's path is not below it. Pure; the staged value is not changed.
|
|
341
|
+
*/
|
|
342
|
+
amend?(staged: {
|
|
343
|
+
readonly path: string;
|
|
344
|
+
readonly value: EditData;
|
|
345
|
+
}, operation: EditOperation): AmendResult;
|
|
346
|
+
/**
|
|
347
|
+
* Whether a list along `path` has a different length in `newer`, a later version of this document opened by
|
|
348
|
+
* the same writer: an index step in it may now name another item. Absent: no guard (a CSS path has no indexes).
|
|
349
|
+
*/
|
|
350
|
+
shifted?(path: string, newer: WriterDocument): boolean;
|
|
351
|
+
}
|
|
352
|
+
export interface Resolver {
|
|
353
|
+
claims(request: string): boolean;
|
|
354
|
+
resolve(request: string, base: string, host: Host): Promise<Resolution>;
|
|
355
|
+
}
|
|
356
|
+
export type HostReadResult = {
|
|
357
|
+
readonly ok: true;
|
|
358
|
+
readonly bytes: Uint8Array;
|
|
359
|
+
} | {
|
|
360
|
+
readonly ok: false;
|
|
361
|
+
readonly reason: string;
|
|
362
|
+
};
|
|
363
|
+
/** Cascata's shape. */
|
|
364
|
+
export interface PathFacility {
|
|
365
|
+
readonly separator: string;
|
|
366
|
+
readonly homeDirectory: string | undefined;
|
|
367
|
+
dirname(path: string): string;
|
|
368
|
+
isAbsolute(path: string): boolean;
|
|
369
|
+
resolve(base: string, segment?: string): string;
|
|
370
|
+
}
|
|
371
|
+
export type HostWriteResult = {
|
|
372
|
+
readonly ok: true;
|
|
373
|
+
} | {
|
|
374
|
+
readonly ok: false;
|
|
375
|
+
readonly reason: string;
|
|
376
|
+
};
|
|
377
|
+
export interface Host {
|
|
378
|
+
read(location: string): Promise<HostReadResult>;
|
|
379
|
+
/** Optional: a host without it can stage and preview, and a commit is refused. Atomic per file. */
|
|
380
|
+
write?(location: string, bytes: Uint8Array): Promise<HostWriteResult>;
|
|
381
|
+
/** The canonical identity of a location, or `undefined` when nothing is there. */
|
|
382
|
+
canonicalize(location: string): Promise<string | undefined>;
|
|
383
|
+
readonly paths: PathFacility;
|
|
384
|
+
watch(onChange: (locations: readonly string[]) => void): {
|
|
385
|
+
/** Replaces the whole watched set. */
|
|
386
|
+
set(locations: readonly string[]): void;
|
|
387
|
+
close(): void;
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** A read pass's view of the host. Satisfies Cascata's `Host` by shape. */
|
|
391
|
+
export interface SessionHost {
|
|
392
|
+
read(location: string): Promise<HostReadResult>;
|
|
393
|
+
canonicalize(location: string): Promise<string | undefined>;
|
|
394
|
+
cacheRead(key: string): Promise<Uint8Array | undefined>;
|
|
395
|
+
cacheWrite(key: string, bytes: Uint8Array): Promise<void>;
|
|
396
|
+
readonly paths: PathFacility;
|
|
397
|
+
}
|
package/dist/index.js
ADDED
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Host } from "./index.ts";
|
|
2
|
+
export declare function createMemoryHost(files?: Record<string, string | Uint8Array>): Host & {
|
|
3
|
+
write(location: string, content: string | Uint8Array): void;
|
|
4
|
+
remove(location: string): void;
|
|
5
|
+
refuse(location: string, reason: string): void;
|
|
6
|
+
};
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// An in-memory host for any realm: POSIX paths, files held as bytes, events at the next microtask.
|
|
2
|
+
const paths = {
|
|
3
|
+
separator: "/",
|
|
4
|
+
homeDirectory: undefined,
|
|
5
|
+
dirname(path) {
|
|
6
|
+
const cut = path.replace(/\/+$/, "").lastIndexOf("/");
|
|
7
|
+
return cut > 0 ? path.slice(0, cut) : cut === 0 ? "/" : ".";
|
|
8
|
+
},
|
|
9
|
+
isAbsolute: path => path.startsWith("/"),
|
|
10
|
+
resolve(base, segment) {
|
|
11
|
+
const joined = segment?.startsWith("/") ? segment : `${base}/${segment ?? ""}`;
|
|
12
|
+
const parts = [];
|
|
13
|
+
for (const part of joined.split("/")) {
|
|
14
|
+
if (part === "..")
|
|
15
|
+
parts.pop();
|
|
16
|
+
else if (part !== "" && part !== ".")
|
|
17
|
+
parts.push(part);
|
|
18
|
+
}
|
|
19
|
+
return `/${parts.join("/")}`;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
export function createMemoryHost(files = {}) {
|
|
23
|
+
const store = new Map();
|
|
24
|
+
const refused = new Map();
|
|
25
|
+
const watchers = new Set();
|
|
26
|
+
let pending = new Set();
|
|
27
|
+
const at = (location) => paths.resolve("/", location);
|
|
28
|
+
const toBytes = (content) => typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
29
|
+
function notify(location) {
|
|
30
|
+
if (pending.size === 0)
|
|
31
|
+
queueMicrotask(deliver);
|
|
32
|
+
pending.add(location);
|
|
33
|
+
}
|
|
34
|
+
function deliver() {
|
|
35
|
+
const batch = [...pending];
|
|
36
|
+
pending = new Set();
|
|
37
|
+
for (const { watched, onChange } of watchers) {
|
|
38
|
+
const mine = batch.filter(location => watched.has(location));
|
|
39
|
+
if (mine.length > 0)
|
|
40
|
+
onChange(mine);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const [location, content] of Object.entries(files))
|
|
44
|
+
store.set(at(location), toBytes(content));
|
|
45
|
+
return {
|
|
46
|
+
paths,
|
|
47
|
+
async read(location) {
|
|
48
|
+
const key = at(location);
|
|
49
|
+
const reason = refused.get(key);
|
|
50
|
+
if (reason !== undefined)
|
|
51
|
+
return { ok: false, reason };
|
|
52
|
+
const bytes = store.get(key);
|
|
53
|
+
return bytes ? { ok: true, bytes } : { ok: false, reason: "not found" };
|
|
54
|
+
},
|
|
55
|
+
async canonicalize(location) {
|
|
56
|
+
const key = at(location);
|
|
57
|
+
return store.has(key) || refused.has(key) ? key : undefined;
|
|
58
|
+
},
|
|
59
|
+
watch(onChange) {
|
|
60
|
+
const watcher = { watched: new Set(), onChange };
|
|
61
|
+
watchers.add(watcher);
|
|
62
|
+
return {
|
|
63
|
+
set: locations => void (watcher.watched = new Set(locations.map(at))),
|
|
64
|
+
close: () => void watchers.delete(watcher),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
/** In place. The embedder's own writes (a test changing the disk) and a session's commit both come here. */
|
|
68
|
+
async write(location, content) {
|
|
69
|
+
store.set(at(location), toBytes(content));
|
|
70
|
+
notify(at(location));
|
|
71
|
+
return { ok: true };
|
|
72
|
+
},
|
|
73
|
+
remove(location) {
|
|
74
|
+
store.delete(at(location));
|
|
75
|
+
refused.delete(at(location));
|
|
76
|
+
notify(at(location));
|
|
77
|
+
},
|
|
78
|
+
refuse(location, reason) {
|
|
79
|
+
refused.set(at(location), reason);
|
|
80
|
+
notify(at(location));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/dist/node.d.ts
ADDED
package/dist/node.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The Node host: the only module that imports `node:` (design principle: Supply, not meaning).
|
|
2
|
+
import { promises as fs, watch as watchDirectory } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
let counter = 0;
|
|
6
|
+
export function createNodeHost({ debounce = 300 } = {}) {
|
|
7
|
+
return {
|
|
8
|
+
paths: {
|
|
9
|
+
separator: path.sep,
|
|
10
|
+
homeDirectory: os.homedir(),
|
|
11
|
+
dirname: path.dirname,
|
|
12
|
+
isAbsolute: path.isAbsolute,
|
|
13
|
+
resolve: (base, segment) => (segment === undefined ? path.resolve(base) : path.resolve(base, segment)),
|
|
14
|
+
},
|
|
15
|
+
async read(location) {
|
|
16
|
+
try {
|
|
17
|
+
return { ok: true, bytes: await fs.readFile(location) };
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
/** Atomic per file: a temporary file in the same directory, then a rename over the target. */
|
|
24
|
+
async write(location, bytes) {
|
|
25
|
+
const temporary = path.join(path.dirname(location), `.${path.basename(location)}.${process.pid}.${(counter += 1)}.tmp`);
|
|
26
|
+
try {
|
|
27
|
+
const mode = await fs.stat(location).then(stat => stat.mode, () => undefined);
|
|
28
|
+
await fs.writeFile(temporary, bytes);
|
|
29
|
+
if (mode !== undefined)
|
|
30
|
+
await fs.chmod(temporary, mode);
|
|
31
|
+
await fs.rename(temporary, location);
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
await fs.rm(temporary, { force: true });
|
|
36
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
async canonicalize(location) {
|
|
40
|
+
try {
|
|
41
|
+
return await fs.realpath(location);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
/**
|
|
48
|
+
* Watches the parent directory of each location, not the file: a file watch follows its inode
|
|
49
|
+
* and misses an editor's write-then-rename save. Events are debounced into one batch.
|
|
50
|
+
*/
|
|
51
|
+
watch(onChange) {
|
|
52
|
+
let watched = new Set();
|
|
53
|
+
const directories = new Map();
|
|
54
|
+
let pending = new Set();
|
|
55
|
+
let timer;
|
|
56
|
+
const flush = () => {
|
|
57
|
+
timer = undefined;
|
|
58
|
+
const batch = [...pending];
|
|
59
|
+
pending = new Set();
|
|
60
|
+
if (batch.length > 0)
|
|
61
|
+
onChange(batch);
|
|
62
|
+
};
|
|
63
|
+
const heard = (directory, filename) => {
|
|
64
|
+
for (const location of watched) {
|
|
65
|
+
if (path.dirname(location) !== directory)
|
|
66
|
+
continue;
|
|
67
|
+
// Some platforms do not say which file changed; then every watched file there may have.
|
|
68
|
+
if (filename === null || path.basename(location) === filename)
|
|
69
|
+
pending.add(location);
|
|
70
|
+
}
|
|
71
|
+
if (pending.size === 0)
|
|
72
|
+
return;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
timer = setTimeout(flush, debounce);
|
|
75
|
+
};
|
|
76
|
+
const stop = (directory) => {
|
|
77
|
+
directories.get(directory)?.close();
|
|
78
|
+
directories.delete(directory);
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
set(locations) {
|
|
82
|
+
watched = new Set(locations);
|
|
83
|
+
const needed = new Set(locations.map(location => path.dirname(location)));
|
|
84
|
+
for (const directory of directories.keys())
|
|
85
|
+
if (!needed.has(directory))
|
|
86
|
+
stop(directory);
|
|
87
|
+
for (const directory of needed) {
|
|
88
|
+
if (directories.has(directory))
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const watcher = watchDirectory(directory, (_event, filename) => heard(directory, filename));
|
|
92
|
+
watcher.on("error", () => stop(directory));
|
|
93
|
+
directories.set(directory, watcher);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// ponytail: a directory that does not exist yet is not watched, so a probe inside it
|
|
97
|
+
// heals only on the next event elsewhere. Watch the nearest existing ancestor if needed.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
close() {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
for (const directory of [...directories.keys()])
|
|
104
|
+
stop(directory);
|
|
105
|
+
watched = new Set();
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts string indices into `text` to UTF-8 byte offsets, in one pass. An index it cannot
|
|
3
|
+
* convert is a handler bug, and throws a `RangeError`: not an integer, negative, past the end of
|
|
4
|
+
* `text` (`text.length` itself is the end, and valid), or between the halves of a surrogate pair.
|
|
5
|
+
*/
|
|
6
|
+
export declare function byteOffsets(text: string, indices: readonly number[]): number[];
|
package/dist/offsets.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The one place string indices become byte offsets (design principle: One address space).
|
|
2
|
+
/**
|
|
3
|
+
* Converts string indices into `text` to UTF-8 byte offsets, in one pass. An index it cannot
|
|
4
|
+
* convert is a handler bug, and throws a `RangeError`: not an integer, negative, past the end of
|
|
5
|
+
* `text` (`text.length` itself is the end, and valid), or between the halves of a surrogate pair.
|
|
6
|
+
*/
|
|
7
|
+
export function byteOffsets(text, indices) {
|
|
8
|
+
for (const index of indices) {
|
|
9
|
+
if (!Number.isInteger(index))
|
|
10
|
+
throw new RangeError(`index ${index} is not an integer`);
|
|
11
|
+
if (index < 0)
|
|
12
|
+
throw new RangeError(`index ${index} is negative`);
|
|
13
|
+
if (index > text.length)
|
|
14
|
+
throw new RangeError(`index ${index} is past the end of the text (length ${text.length})`);
|
|
15
|
+
}
|
|
16
|
+
const order = indices.map((_, k) => k).sort((a, b) => indices[a] - indices[b]);
|
|
17
|
+
const result = new Array(indices.length);
|
|
18
|
+
let i = 0;
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
for (const k of order) {
|
|
21
|
+
const target = indices[k];
|
|
22
|
+
while (i < target) {
|
|
23
|
+
const code = text.charCodeAt(i);
|
|
24
|
+
if (code < 0x80)
|
|
25
|
+
bytes += 1;
|
|
26
|
+
else if (code < 0x800)
|
|
27
|
+
bytes += 2;
|
|
28
|
+
else if (code >= 0xd800 && code < 0xdc00 && isLow(text.charCodeAt(i + 1))) {
|
|
29
|
+
bytes += 4;
|
|
30
|
+
i += 1;
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
bytes += 3;
|
|
34
|
+
i += 1;
|
|
35
|
+
}
|
|
36
|
+
if (i > target)
|
|
37
|
+
throw new RangeError(`index ${target} is inside a surrogate pair`);
|
|
38
|
+
result[k] = bytes;
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
/** A low surrogate; `NaN` (past the end) is not. */
|
|
43
|
+
const isLow = (code) => code >= 0xdc00 && code < 0xe000;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Host, Resolution, Resolver } from "./index.ts";
|
|
2
|
+
export declare const hasScheme: (request: string) => boolean;
|
|
3
|
+
/** Tries the embedder's resolvers first, then the shipped ones. Unclaimed means unresolved, no probes. */
|
|
4
|
+
export declare function resolve(request: string, base: string, host: Host, resolvers?: readonly Resolver[]): Promise<Resolution>;
|