@executablemd/runtime 0.7.0 → 0.8.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/esm/apis.js +119 -24
- package/esm/config.js +46 -17
- package/esm/duration.js +44 -0
- package/esm/files.js +558 -0
- package/esm/host-files.js +496 -0
- package/esm/mod.js +13 -7
- package/esm/service.js +126 -0
- package/esm/test/mod.js +2 -1
- package/esm/test/stubs.js +38 -6
- package/package.json +10 -4
- package/types/apis.d.ts +91 -18
- package/types/config.d.ts +30 -12
- package/types/duration.d.ts +18 -0
- package/types/files.d.ts +299 -0
- package/types/host-files.d.ts +80 -0
- package/types/mod.d.ts +17 -7
- package/types/service.d.ts +77 -0
- package/types/test/mod.d.ts +2 -1
- package/types/test/stubs.d.ts +9 -0
- package/esm/find-free-port.js +0 -33
- package/types/find-free-port.d.ts +0 -9
package/esm/files.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `API.Files` — the document filesystem boundary.
|
|
3
|
+
*
|
|
4
|
+
* A document names files with a path relative to the contextual working
|
|
5
|
+
* directory, and every one of those operations arrives here. What is on the
|
|
6
|
+
* other side is a provider's choice: `xmd run` installs a host adapter that
|
|
7
|
+
* resolves those paths in the caller's own filesystem, and `xmd workflow`
|
|
8
|
+
* installs one that resolves them in a logical filesystem owned by a database
|
|
9
|
+
* transaction. Neither is named in the components that call this Api, which is
|
|
10
|
+
* what lets one document mean the same thing under both.
|
|
11
|
+
*
|
|
12
|
+
* The operations are **semantic**, not primitive. `writeTextFile` is a whole
|
|
13
|
+
* replacement — admission, resolution, target classification, parent creation,
|
|
14
|
+
* and commit — rather than a sequence a caller assembles, because assembling it
|
|
15
|
+
* from outside is what would let a path admitted by one provider be used by
|
|
16
|
+
* another. `API.Fs` remains the low-level host surface a host adapter is built
|
|
17
|
+
* on; it is not this boundary.
|
|
18
|
+
*
|
|
19
|
+
* `checkFilePath` is the one exception, and it is deliberately weak: pure path
|
|
20
|
+
* arithmetic, no filesystem access, and nothing usable comes back — no path, no
|
|
21
|
+
* handle, no authority token. `<File>`'s write form calls it to decide whether
|
|
22
|
+
* its children may expand at all, and the later `writeTextFile` repeats the
|
|
23
|
+
* same admission from the same authored path. A check that was skipped,
|
|
24
|
+
* replaced, or answered by another provider therefore authorizes nothing.
|
|
25
|
+
*
|
|
26
|
+
* ## Two kinds of failure
|
|
27
|
+
*
|
|
28
|
+
* An ordinary filesystem condition — missing, a directory, permission denied,
|
|
29
|
+
* no space — comes back as `Err(FilesError)` carrying frozen structural data.
|
|
30
|
+
* The consumer reads that data and selects a sentence from a fixed vocabulary;
|
|
31
|
+
* no message, errno code, resolved path, temporary path, or symlink target
|
|
32
|
+
* crosses this boundary. Cancellation is neither of these: it is not caught and
|
|
33
|
+
* never becomes a Result.
|
|
34
|
+
*
|
|
35
|
+
* A provider that is absent, that refuses an operation, or that breaks its own
|
|
36
|
+
* contract is not a filesystem condition. Those **throw**, with fixed
|
|
37
|
+
* diagnostics and no cause, and they end the execution rather than becoming
|
|
38
|
+
* something a document renders. A run whose Files provider is missing must not
|
|
39
|
+
* quietly reach the host instead.
|
|
40
|
+
*
|
|
41
|
+
* ## Why the data is structural
|
|
42
|
+
*
|
|
43
|
+
* Both the failures and the write outcome carry a plain frozen object under a
|
|
44
|
+
* stable `type` tag, and every consumer recognizes them by parsing that tag
|
|
45
|
+
* rather than with `instanceof`. Two copies of this package can be loaded at
|
|
46
|
+
* once — a repository component resolving its own runtime beside the engine's —
|
|
47
|
+
* and `instanceof` answers false across them, which would turn a provider
|
|
48
|
+
* failure into an unrecognized throw exactly when it matters most.
|
|
49
|
+
*/
|
|
50
|
+
import { createApi } from "@effectionx/context-api";
|
|
51
|
+
/** The stable discriminant on ordinary filesystem failure data. */
|
|
52
|
+
export const FILES_ERROR = "executablemd.runtime.files-error/v1";
|
|
53
|
+
/** The stable discriminant on infrastructure failure data. */
|
|
54
|
+
export const FILES_FATAL = "executablemd.runtime.files-fatal/v1";
|
|
55
|
+
/** The stable discriminant on a successful write's outcome data. */
|
|
56
|
+
export const FILES_WRITE_SUCCESS = "executablemd.runtime.files-write-success/v1";
|
|
57
|
+
const REASONS = [
|
|
58
|
+
"empty-path",
|
|
59
|
+
"absolute-path",
|
|
60
|
+
"lexical-escape",
|
|
61
|
+
"resolved-escape",
|
|
62
|
+
"missing",
|
|
63
|
+
"directory",
|
|
64
|
+
"special-file",
|
|
65
|
+
"not-directory",
|
|
66
|
+
"permission-denied",
|
|
67
|
+
"read-only",
|
|
68
|
+
"too-many-symlinks",
|
|
69
|
+
"path-too-long",
|
|
70
|
+
"no-space",
|
|
71
|
+
"quota-exhausted",
|
|
72
|
+
"cross-device",
|
|
73
|
+
"busy",
|
|
74
|
+
"too-many-open-files",
|
|
75
|
+
"directory-not-empty",
|
|
76
|
+
"invalid-pattern",
|
|
77
|
+
"operation-failed",
|
|
78
|
+
];
|
|
79
|
+
const OPERATIONS = [
|
|
80
|
+
"check-file-path",
|
|
81
|
+
"read",
|
|
82
|
+
"glob",
|
|
83
|
+
"temporary-directory",
|
|
84
|
+
];
|
|
85
|
+
const PHASES = [
|
|
86
|
+
"lexical",
|
|
87
|
+
"resolution",
|
|
88
|
+
"target",
|
|
89
|
+
"access",
|
|
90
|
+
"pattern",
|
|
91
|
+
"traversal",
|
|
92
|
+
"acquire",
|
|
93
|
+
];
|
|
94
|
+
/**
|
|
95
|
+
* The message every ordinary failure carries.
|
|
96
|
+
*
|
|
97
|
+
* Constant on purpose. A message is the part of an Error that gets printed by
|
|
98
|
+
* accident, and there is nothing safe to put in this one: the authored path
|
|
99
|
+
* belongs to the consumer that wrote it, and everything else belongs to the
|
|
100
|
+
* platform.
|
|
101
|
+
*/
|
|
102
|
+
export const FILES_ERROR_MESSAGE = "Files operation failed";
|
|
103
|
+
/** An ordinary filesystem failure. What it means is in `data`, never in the message. */
|
|
104
|
+
export class FilesError extends Error {
|
|
105
|
+
data;
|
|
106
|
+
constructor(data) {
|
|
107
|
+
super(FILES_ERROR_MESSAGE);
|
|
108
|
+
this.name = "FilesError";
|
|
109
|
+
this.data = data;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const INVARIANT_CATEGORIES = [
|
|
113
|
+
"authority",
|
|
114
|
+
"savepoint",
|
|
115
|
+
"protocol",
|
|
116
|
+
"teardown",
|
|
117
|
+
];
|
|
118
|
+
export const FILES_PROVIDER_UNAVAILABLE_MESSAGE = "Files provider is not installed";
|
|
119
|
+
export const FILES_OPERATION_DENIED_MESSAGE = "Files provider does not support temporary-directory";
|
|
120
|
+
export const FILES_INVARIANT_MESSAGE = "Files provider invariant failed";
|
|
121
|
+
/** No Files provider is installed, and there is no host to fall back to. */
|
|
122
|
+
export class FilesProviderUnavailableError extends Error {
|
|
123
|
+
data;
|
|
124
|
+
constructor() {
|
|
125
|
+
super(FILES_PROVIDER_UNAVAILABLE_MESSAGE);
|
|
126
|
+
this.name = "FilesProviderUnavailableError";
|
|
127
|
+
this.data = Object.freeze({ type: FILES_FATAL, kind: "provider-unavailable" });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** The installed provider does not implement this operation at all. */
|
|
131
|
+
export class FilesOperationDeniedError extends Error {
|
|
132
|
+
data;
|
|
133
|
+
constructor(operation) {
|
|
134
|
+
super(FILES_OPERATION_DENIED_MESSAGE);
|
|
135
|
+
this.name = "FilesOperationDeniedError";
|
|
136
|
+
const denied = deniableOperation(operation);
|
|
137
|
+
if (denied === undefined) {
|
|
138
|
+
throw new FilesInvariantError("protocol");
|
|
139
|
+
}
|
|
140
|
+
this.data = Object.freeze({ type: FILES_FATAL, kind: "operation-denied", operation: denied });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** A provider broke its own contract. */
|
|
144
|
+
export class FilesInvariantError extends Error {
|
|
145
|
+
data;
|
|
146
|
+
constructor(category) {
|
|
147
|
+
super(FILES_INVARIANT_MESSAGE);
|
|
148
|
+
this.name = "FilesInvariantError";
|
|
149
|
+
const parsed = invariantCategory(category);
|
|
150
|
+
if (parsed === undefined) {
|
|
151
|
+
throw new Error(FILES_INVARIANT_MESSAGE);
|
|
152
|
+
}
|
|
153
|
+
this.data = Object.freeze({ type: FILES_FATAL, kind: "invariant", category: parsed });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Everything below reads values it did not create.
|
|
158
|
+
*
|
|
159
|
+
* A thrown value is whatever a provider threw, and a provider is as free to
|
|
160
|
+
* hand back a Proxy with a throwing `get` trap, an accessor that fails, or an
|
|
161
|
+
* object whose key enumeration explodes as it is to hand back a plain record.
|
|
162
|
+
* These parsers run from `fatalCause`, which every generic catch in the engine
|
|
163
|
+
* consults — so one of them throwing would replace the failure being classified
|
|
164
|
+
* with a failure *about classifying it*, at the exact moment the engine is
|
|
165
|
+
* deciding whether the execution may continue.
|
|
166
|
+
*
|
|
167
|
+
* So reading is total: every access that can fail goes through a helper that
|
|
168
|
+
* answers `undefined` instead, and each exported parser is additionally wrapped
|
|
169
|
+
* so that a shape nobody anticipated is simply not recognized.
|
|
170
|
+
*/
|
|
171
|
+
function attempt(read) {
|
|
172
|
+
try {
|
|
173
|
+
return read();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function isRecord(value) {
|
|
180
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
181
|
+
}
|
|
182
|
+
/** Whether this is an Error, without trusting its prototype chain. */
|
|
183
|
+
function isError(value) {
|
|
184
|
+
return attempt(() => value instanceof Error) === true;
|
|
185
|
+
}
|
|
186
|
+
/** One property, read through a trap that may refuse or fail. */
|
|
187
|
+
function property(target, name) {
|
|
188
|
+
return attempt(() => Reflect.get(target, name));
|
|
189
|
+
}
|
|
190
|
+
/** How many own enumerable keys, when the object will say. */
|
|
191
|
+
function keyCount(value) {
|
|
192
|
+
return attempt(() => Object.keys(value).length);
|
|
193
|
+
}
|
|
194
|
+
function isFrozen(value) {
|
|
195
|
+
return attempt(() => Object.isFrozen(value)) === true;
|
|
196
|
+
}
|
|
197
|
+
function dataOf(error) {
|
|
198
|
+
if (!isError(error)) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const data = property(error, "data");
|
|
202
|
+
return isRecord(data) ? data : undefined;
|
|
203
|
+
}
|
|
204
|
+
/** The one diagnostic each kind of infrastructure failure carries. */
|
|
205
|
+
const FATAL_DIAGNOSTICS = new Map([
|
|
206
|
+
["provider-unavailable", FILES_PROVIDER_UNAVAILABLE_MESSAGE],
|
|
207
|
+
["operation-denied", FILES_OPERATION_DENIED_MESSAGE],
|
|
208
|
+
["invariant", FILES_INVARIANT_MESSAGE],
|
|
209
|
+
]);
|
|
210
|
+
/** The one class name each kind of infrastructure failure carries. */
|
|
211
|
+
const FATAL_NAMES = new Map([
|
|
212
|
+
["provider-unavailable", "FilesProviderUnavailableError"],
|
|
213
|
+
["operation-denied", "FilesOperationDeniedError"],
|
|
214
|
+
["invariant", "FilesInvariantError"],
|
|
215
|
+
]);
|
|
216
|
+
/**
|
|
217
|
+
* Everything a constructor here puts on the Error itself, and nothing else.
|
|
218
|
+
*
|
|
219
|
+
* `message` and `stack` are non-enumerable own properties of every Error, so
|
|
220
|
+
* what remains enumerable is exactly what a constructor assigned. Anything more
|
|
221
|
+
* is payload the contract does not describe — and since recognition hands the
|
|
222
|
+
* object onward by identity, payload travels with it.
|
|
223
|
+
*/
|
|
224
|
+
const FATAL_MEMBERS = ["data", "name"];
|
|
225
|
+
/**
|
|
226
|
+
* Whether the Error carries only the members its constructor assigns.
|
|
227
|
+
*
|
|
228
|
+
* Symbols are checked as well as string keys: a symbol-keyed enumerable
|
|
229
|
+
* property survives spreading and appears in `Object.assign`'d copies, so
|
|
230
|
+
* leaving it unexamined would let a path ride along through exactly the
|
|
231
|
+
* mechanisms a consumer uses to inspect a failure.
|
|
232
|
+
*/
|
|
233
|
+
function hasOnlyContractMembers(error) {
|
|
234
|
+
const keys = attempt(() => [...Object.keys(error)].sort());
|
|
235
|
+
if (keys === undefined || keys.length !== FATAL_MEMBERS.length) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
if (!keys.every((key, index) => key === FATAL_MEMBERS[index])) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const payload = attempt(() => Object.getOwnPropertySymbols(error).filter((symbol) => Object.getOwnPropertyDescriptor(error, symbol)?.enumerable === true));
|
|
242
|
+
return payload !== undefined && payload.length === 0;
|
|
243
|
+
}
|
|
244
|
+
function reasonOf(value) {
|
|
245
|
+
return REASONS.find((reason) => reason === value);
|
|
246
|
+
}
|
|
247
|
+
function operationOf(value) {
|
|
248
|
+
return OPERATIONS.find((operation) => operation === value);
|
|
249
|
+
}
|
|
250
|
+
function phaseOf(value) {
|
|
251
|
+
return PHASES.find((phase) => phase === value);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The vocabularies, for a provider that reads a failure back out of storage.
|
|
255
|
+
*
|
|
256
|
+
* A transaction-bound provider retains what it refused rather than a serialized
|
|
257
|
+
* error, so restoring one means turning stored text back into the vocabulary.
|
|
258
|
+
* Parsing it here is what keeps one list of reasons and phases: a provider that
|
|
259
|
+
* declared its own copy would be a second list to keep in agreement with this.
|
|
260
|
+
*/
|
|
261
|
+
export function parseFilesReason(value) {
|
|
262
|
+
return reasonOf(value);
|
|
263
|
+
}
|
|
264
|
+
export function parseFilesPhase(value) {
|
|
265
|
+
return phaseOf(value);
|
|
266
|
+
}
|
|
267
|
+
export function parseFileWritePhase(value) {
|
|
268
|
+
return writePhaseOf(value)?.[0];
|
|
269
|
+
}
|
|
270
|
+
function invariantCategory(value) {
|
|
271
|
+
return INVARIANT_CATEGORIES.find((category) => category === value);
|
|
272
|
+
}
|
|
273
|
+
function deniableOperation(value) {
|
|
274
|
+
return value === "temporary-directory" ? "temporary-directory" : undefined;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The infrastructure failure data this Error carries, if it carries valid data.
|
|
278
|
+
*
|
|
279
|
+
* Every field is checked, the member count with them, and that the object is
|
|
280
|
+
* frozen: extra keys are not the shape this contract describes, and a mutable
|
|
281
|
+
* one is not the shape a constructor here produces. Accepting either would let
|
|
282
|
+
* a provider smuggle a path or a message through under a recognized tag.
|
|
283
|
+
*/
|
|
284
|
+
export function parseFilesFatal(error) {
|
|
285
|
+
return attempt(() => {
|
|
286
|
+
const data = dataOf(error);
|
|
287
|
+
if (data === undefined || property(data, "type") !== FILES_FATAL || !isFrozen(data)) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
const members = keyCount(data);
|
|
291
|
+
const kind = property(data, "kind");
|
|
292
|
+
if (kind === "provider-unavailable" && members === 2) {
|
|
293
|
+
return Object.freeze({ type: FILES_FATAL, kind: "provider-unavailable" });
|
|
294
|
+
}
|
|
295
|
+
if (kind === "operation-denied" && members === 3) {
|
|
296
|
+
const operation = deniableOperation(property(data, "operation"));
|
|
297
|
+
if (operation !== undefined) {
|
|
298
|
+
return Object.freeze({ type: FILES_FATAL, kind: "operation-denied", operation });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (kind === "invariant" && members === 3) {
|
|
302
|
+
const category = invariantCategory(property(data, "category"));
|
|
303
|
+
if (category !== undefined) {
|
|
304
|
+
return Object.freeze({ type: FILES_FATAL, kind: "invariant", category });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Whether this failure satisfies the whole public infrastructure-failure
|
|
312
|
+
* contract, not merely the tag.
|
|
313
|
+
*
|
|
314
|
+
* Recognition decides two different things at once, and the second is why this
|
|
315
|
+
* is stricter than `parseFilesFatal`. A recognized failure is **rethrown by
|
|
316
|
+
* identity** — the object that was thrown is the object a fail-stop records —
|
|
317
|
+
* so recognizing one is a decision to let that exact object travel onward. An
|
|
318
|
+
* Error that carries the right data but also a raw platform message, or a cause
|
|
319
|
+
* chain holding an errno and a path, would then carry all of that past the
|
|
320
|
+
* boundary the reason vocabulary exists to hold.
|
|
321
|
+
*
|
|
322
|
+
* So the whole object has to match what a constructor here produces: the fixed
|
|
323
|
+
* name and diagnostic for its kind, frozen structural data with exactly the
|
|
324
|
+
* fields the kind describes, no cause, and no other enumerable member — string
|
|
325
|
+
* or symbol. Anything else is a candidate that fails the contract, and
|
|
326
|
+
* `invokeFiles` replaces it with a fresh invariant rather than preserving it.
|
|
327
|
+
*
|
|
328
|
+
* Structural throughout, so a failure constructed by a separately loaded copy
|
|
329
|
+
* of this package is recognized on exactly the same terms as one constructed
|
|
330
|
+
* here — `instanceof` answers false across two copies, which is the case this
|
|
331
|
+
* has to survive. That is also why the `name` is checked rather than the class:
|
|
332
|
+
* a second copy's constructor is a different function producing the same name.
|
|
333
|
+
*/
|
|
334
|
+
export function isFilesFatal(error) {
|
|
335
|
+
return (attempt(() => {
|
|
336
|
+
const data = parseFilesFatal(error);
|
|
337
|
+
if (data === undefined || !isError(error)) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (property(error, "name") !== FATAL_NAMES.get(data.kind)) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
if (property(error, "message") !== FATAL_DIAGNOSTICS.get(data.kind)) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
if (property(error, "cause") !== undefined) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
return hasOnlyContractMembers(error);
|
|
350
|
+
}) === true);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The infrastructure failure this one is, by identity.
|
|
354
|
+
*
|
|
355
|
+
* The original object comes back rather than a replacement, because a fail-stop
|
|
356
|
+
* that records "the first error" has to record the one that was thrown.
|
|
357
|
+
*/
|
|
358
|
+
export function asFilesFatal(error) {
|
|
359
|
+
return isFilesFatal(error) ? error : undefined;
|
|
360
|
+
}
|
|
361
|
+
const WRITE_PHASES = new Map([
|
|
362
|
+
["lexical", { target: "unchanged", reason: "required", cleanup: "absent" }],
|
|
363
|
+
["resolution", { target: "unchanged", reason: "required", cleanup: "absent" }],
|
|
364
|
+
["target", { target: "unchanged", reason: "required", cleanup: "absent" }],
|
|
365
|
+
["parents", { target: "unchanged", reason: "required", cleanup: "absent" }],
|
|
366
|
+
["temporary", { target: "unchanged", reason: "required", cleanup: "optional" }],
|
|
367
|
+
["commit", { target: "commit-unknown", reason: "required", cleanup: "optional" }],
|
|
368
|
+
["cleanup", { target: "committed", reason: "absent", cleanup: "required" }],
|
|
369
|
+
["transaction", { target: "rolled-back", reason: "required", cleanup: "absent" }],
|
|
370
|
+
]);
|
|
371
|
+
function writePhaseOf(value) {
|
|
372
|
+
for (const entry of WRITE_PHASES) {
|
|
373
|
+
if (entry[0] === value) {
|
|
374
|
+
return entry;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
function violatesRule(rule, reason, cleanup) {
|
|
380
|
+
if (rule.reason === "required" && reason === undefined) {
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
if (rule.reason === "absent" && reason !== undefined) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
if (rule.cleanup === "required" && cleanup === undefined) {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
return rule.cleanup === "absent" && cleanup !== undefined;
|
|
390
|
+
}
|
|
391
|
+
function writeData(phase, rule, reason, cleanup) {
|
|
392
|
+
return Object.freeze({
|
|
393
|
+
type: FILES_ERROR,
|
|
394
|
+
operation: "write",
|
|
395
|
+
phase,
|
|
396
|
+
target: rule.target,
|
|
397
|
+
...(reason === undefined ? {} : { reason }),
|
|
398
|
+
...(cleanup === undefined ? {} : { cleanup }),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/** Build an ordinary non-write failure. */
|
|
402
|
+
export function filesFailure(input) {
|
|
403
|
+
const operation = operationOf(input.operation);
|
|
404
|
+
const phase = phaseOf(input.phase);
|
|
405
|
+
const reason = reasonOf(input.reason);
|
|
406
|
+
if (operation === undefined || phase === undefined || reason === undefined) {
|
|
407
|
+
throw new FilesInvariantError("protocol");
|
|
408
|
+
}
|
|
409
|
+
return new FilesError(Object.freeze({ type: FILES_ERROR, operation, phase, reason }));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Build a write failure, refusing any combination a consumer could not read.
|
|
413
|
+
*
|
|
414
|
+
* A write's report is the only place a document learns what became of a file it
|
|
415
|
+
* asked to replace, so an invalid combination is a provider bug rather than a
|
|
416
|
+
* value to pass along and interpret later.
|
|
417
|
+
*/
|
|
418
|
+
export function fileWriteFailure(input) {
|
|
419
|
+
const found = writePhaseOf(input.phase);
|
|
420
|
+
if (found === undefined) {
|
|
421
|
+
throw new FilesInvariantError("protocol");
|
|
422
|
+
}
|
|
423
|
+
const [phase, rule] = found;
|
|
424
|
+
const reason = input.reason === undefined ? undefined : reasonOf(input.reason);
|
|
425
|
+
const cleanup = input.cleanup === undefined ? undefined : reasonOf(input.cleanup);
|
|
426
|
+
if ((input.reason !== undefined && reason === undefined) ||
|
|
427
|
+
(input.cleanup !== undefined && cleanup === undefined)) {
|
|
428
|
+
throw new FilesInvariantError("protocol");
|
|
429
|
+
}
|
|
430
|
+
if (violatesRule(rule, reason, cleanup)) {
|
|
431
|
+
throw new FilesInvariantError("protocol");
|
|
432
|
+
}
|
|
433
|
+
return new FilesError(writeData(phase, rule, reason, cleanup));
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The non-write failure data this error carries, if it carries valid data.
|
|
437
|
+
*
|
|
438
|
+
* Malformed data is not fatal here — the consumer already has a sentence for
|
|
439
|
+
* "the operation failed" and nothing about a target is at stake — so this
|
|
440
|
+
* simply declines to recognize it.
|
|
441
|
+
*/
|
|
442
|
+
export function parseFilesFailure(error) {
|
|
443
|
+
return attempt(() => {
|
|
444
|
+
const data = dataOf(error);
|
|
445
|
+
if (data === undefined || property(data, "type") !== FILES_ERROR || keyCount(data) !== 4) {
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
const operation = operationOf(property(data, "operation"));
|
|
449
|
+
const phase = phaseOf(property(data, "phase"));
|
|
450
|
+
const reason = reasonOf(property(data, "reason"));
|
|
451
|
+
if (operation === undefined || phase === undefined || reason === undefined) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
return Object.freeze({ type: FILES_ERROR, operation, phase, reason });
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The write failure data this error carries, if it carries valid data.
|
|
459
|
+
*
|
|
460
|
+
* Unlike a non-write failure, malformed data here has no safe reading: every
|
|
461
|
+
* sentence a consumer could print makes a claim about whether the file was
|
|
462
|
+
* replaced. A caller treats `undefined` from a write as a protocol invariant
|
|
463
|
+
* rather than inventing a commit state.
|
|
464
|
+
*/
|
|
465
|
+
export function parseFileWriteFailure(error) {
|
|
466
|
+
return attempt(() => {
|
|
467
|
+
const data = dataOf(error);
|
|
468
|
+
if (data === undefined ||
|
|
469
|
+
property(data, "type") !== FILES_ERROR ||
|
|
470
|
+
property(data, "operation") !== "write") {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
const found = writePhaseOf(property(data, "phase"));
|
|
474
|
+
if (found === undefined) {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
const [phase, rule] = found;
|
|
478
|
+
if (property(data, "target") !== rule.target) {
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
const declared = property(data, "reason");
|
|
482
|
+
const declaredCleanup = property(data, "cleanup");
|
|
483
|
+
const reason = declared === undefined ? undefined : reasonOf(declared);
|
|
484
|
+
const cleanup = declaredCleanup === undefined ? undefined : reasonOf(declaredCleanup);
|
|
485
|
+
if ((declared !== undefined && reason === undefined) ||
|
|
486
|
+
(declaredCleanup !== undefined && cleanup === undefined) ||
|
|
487
|
+
violatesRule(rule, reason, cleanup)) {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
const members = 4 + (reason === undefined ? 0 : 1) + (cleanup === undefined ? 0 : 1);
|
|
491
|
+
if (keyCount(data) !== members) {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
return writeData(phase, rule, reason, cleanup);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/** A successful write's outcome. */
|
|
498
|
+
export function fileWriteSuccess(publication) {
|
|
499
|
+
const parsed = parseFileWriteSuccess({ type: FILES_WRITE_SUCCESS, publication });
|
|
500
|
+
if (parsed === undefined) {
|
|
501
|
+
throw new FilesInvariantError("protocol");
|
|
502
|
+
}
|
|
503
|
+
return parsed;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The write outcome this value is, if it is a valid one.
|
|
507
|
+
*
|
|
508
|
+
* A malformed success is as untrustworthy as a malformed failure: a provider
|
|
509
|
+
* that cannot describe what it did may not have done it, so a caller treats
|
|
510
|
+
* `undefined` here as a protocol invariant too.
|
|
511
|
+
*/
|
|
512
|
+
export function parseFileWriteSuccess(value) {
|
|
513
|
+
return attempt(() => {
|
|
514
|
+
if (!isRecord(value) ||
|
|
515
|
+
property(value, "type") !== FILES_WRITE_SUCCESS ||
|
|
516
|
+
keyCount(value) !== 2) {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
const publication = property(value, "publication");
|
|
520
|
+
if (publication === "host-committed") {
|
|
521
|
+
return Object.freeze({ type: FILES_WRITE_SUCCESS, publication: "host-committed" });
|
|
522
|
+
}
|
|
523
|
+
if (publication === "transaction-staged") {
|
|
524
|
+
return Object.freeze({ type: FILES_WRITE_SUCCESS, publication: "transaction-staged" });
|
|
525
|
+
}
|
|
526
|
+
return undefined;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* The document filesystem Api.
|
|
531
|
+
*
|
|
532
|
+
* The terminal handler throws for every operation, including `checkFilePath`.
|
|
533
|
+
* A default that reached the host would make an uninstalled provider
|
|
534
|
+
* indistinguishable from an installed one, and the whole point of the boundary
|
|
535
|
+
* is that a workflow run cannot silently touch the caller's filesystem.
|
|
536
|
+
*/
|
|
537
|
+
export const Files = createApi("executablemd.runtime.files", {
|
|
538
|
+
// deno-lint-ignore require-yield
|
|
539
|
+
*checkFilePath(_input) {
|
|
540
|
+
throw new FilesProviderUnavailableError();
|
|
541
|
+
},
|
|
542
|
+
// deno-lint-ignore require-yield
|
|
543
|
+
*readTextFile(_input) {
|
|
544
|
+
throw new FilesProviderUnavailableError();
|
|
545
|
+
},
|
|
546
|
+
// deno-lint-ignore require-yield
|
|
547
|
+
*writeTextFile(_input) {
|
|
548
|
+
throw new FilesProviderUnavailableError();
|
|
549
|
+
},
|
|
550
|
+
// deno-lint-ignore require-yield
|
|
551
|
+
*globFiles(_input) {
|
|
552
|
+
throw new FilesProviderUnavailableError();
|
|
553
|
+
},
|
|
554
|
+
// deno-lint-ignore require-yield
|
|
555
|
+
*temporaryDirectory() {
|
|
556
|
+
throw new FilesProviderUnavailableError();
|
|
557
|
+
},
|
|
558
|
+
});
|