@executablemd/runtime 0.7.0 → 0.8.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/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
+ });