@factoidal/core 0.11.0 → 0.13.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/lws/client.mjs ADDED
@@ -0,0 +1,319 @@
1
+ // A `fetch` host for the Linked Web Storage 1.0 client.
2
+ // https://github.com/danbri/factoidal/issues/917
3
+ // Design record: docs/designissues/2026-10-22-lws-client.md
4
+ // Drafts: https://w3c.github.io/lws-protocol/lws10-core/ (and lws10-index,
5
+ // lws10-notifications-webhook)
6
+ //
7
+ // WHAT THIS FILE IS ALLOWED TO DO
8
+ // Call `lwsClientStart` with the caller's operation, then loop: when the
9
+ // engine's action is `fetch`, send exactly that request with the `fetch`
10
+ // it was given and pass the answer to `lwsClientStep`; when it is `done` or
11
+ // `fail`, return or throw. It is a socket and nothing else.
12
+ //
13
+ // Every decision is in formal/lean4/L4Factoidal/LWS/Client/ (iron rule 7
14
+ // of CLAUDE.md, anti-pattern 36): which request comes next, every header
15
+ // field, every URL, every media type, the authorization flow and its
16
+ // discovery checks, preconditions, retries, paging, and whether a
17
+ // notification's signature verifies. This file reads no header, composes no
18
+ // URL and chooses no media type. A reviewer who finds a header name, a
19
+ // status code, a media type or a link relation here has found a rule
20
+ // violation.
21
+ //
22
+ // THE WASM CONTRACT (docs/lws-solid-conformance.md, section "wasm ABI")
23
+ // lwsClientStart [opJson, sessionJson] -> { ok, state, action }
24
+ // lwsClientStep [stateJson, eventJson] -> { ok, state, action }
25
+ //
26
+ // action { fetch: { method, url, headers: [[name, value]], bodyBase64? } }
27
+ // | { done: RESULT, session } | { fail: { code, ... }, session }
28
+ // event { status, headers: [[name, value]], bodyBase64 } | { error }
29
+ //
30
+ // `state` is passed back unread. `session` (access tokens, the subject
31
+ // token) is kept here and given to the next operation's start, unread.
32
+ //
33
+ // `fetch` is any function with the WHATWG fetch signature: Node's, a
34
+ // browser's, or one that carries each request as a message over another
35
+ // transport (XMPP, a worker port). The client needs no global fetch.
36
+
37
+ import { loadEngine } from '../bin/engine.mjs'
38
+ import { FactoidalError } from '../lib/errors.mjs'
39
+
40
+ /** The ops this host needs from the engine. */
41
+ export const LWS_CLIENT_OPS = ['lwsClientStart', 'lwsClientStep']
42
+
43
+ /** The operation kinds of the ABI. Listed so a caller can be checked
44
+ * against the contract; this host attaches no meaning to any of them. */
45
+ export const LWS_CLIENT_KINDS = [
46
+ 'discover', 'read', 'head', 'create', 'update', 'patch', 'delete', 'readLinkset',
47
+ 'updateLinkset', 'move', 'list', 'typeIndex', 'search', 'subscribe', 'unsubscribe',
48
+ 'requestAccess', 'grantAccess', 'getAccess', 'revokeAccess', 'verifyNotification'
49
+ ]
50
+
51
+ /** The error an operation ends with. `reason` is the engine's code
52
+ * (`discoveryValidation`, `preconditionFailed`, ...); `detail` is the
53
+ * engine's whole `fail` document. */
54
+ export class LwsClientError extends FactoidalError {
55
+ constructor (fail, extra = {}) {
56
+ const reason = fail && typeof fail.code === 'string' ? fail.code : 'unknown'
57
+ super(`lws client: ${reason}`, { code: 'lws.client' })
58
+ this.name = 'LwsClientError'
59
+ this.reason = reason
60
+ this.detail = fail
61
+ this.unknownOp = extra.unknownOp === true
62
+ }
63
+ }
64
+
65
+ function isUnknownOp (error) {
66
+ const text = error && error.message ? String(error.message) : String(error)
67
+ return text.indexOf('unknown op') >= 0
68
+ }
69
+
70
+ /** Bytes as base64 (RFC 4648 section 4): the transport encoding of the
71
+ * string ABI, not a reading of the bytes. */
72
+ function base64OfBytes (bytes) {
73
+ if (typeof Buffer !== 'undefined') {
74
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64')
75
+ }
76
+ let text = ''
77
+ for (let index = 0; index < bytes.length; index += 1) text += String.fromCharCode(bytes[index])
78
+ return btoa(text)
79
+ }
80
+
81
+ function bytesOfBase64 (text) {
82
+ if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(text, 'base64'))
83
+ const raw = atob(text)
84
+ const out = new Uint8Array(raw.length)
85
+ for (let index = 0; index < raw.length; index += 1) out[index] = raw.charCodeAt(index)
86
+ return out
87
+ }
88
+
89
+ /** A caller's body (string, Uint8Array, ArrayBuffer) as bytes. */
90
+ function bytesOf (body) {
91
+ if (body === undefined || body === null) return null
92
+ if (typeof body === 'string') return new TextEncoder().encode(body)
93
+ if (body instanceof Uint8Array) return body
94
+ if (body instanceof ArrayBuffer) return new Uint8Array(body)
95
+ throw new TypeError('a body is a string, a Uint8Array or an ArrayBuffer')
96
+ }
97
+
98
+ /** An operation document: the caller's members, with a body carried as
99
+ * base64. */
100
+ function opDocument (op) {
101
+ const doc = { ...op }
102
+ if (doc.body !== undefined) {
103
+ const bytes = bytesOf(doc.body)
104
+ delete doc.body
105
+ if (bytes !== null) doc.bodyBase64 = base64OfBytes(bytes)
106
+ }
107
+ return doc
108
+ }
109
+
110
+ /**
111
+ * Whether the loaded engine carries the client operations.
112
+ *
113
+ * @param {object} engine the handle from `loadEngine()`
114
+ * @returns {Promise<{available: boolean, reason: string}>}
115
+ */
116
+ export async function lwsClientOpsAvailable (engine) {
117
+ try {
118
+ engine.call('lwsClientStart', [JSON.stringify({ kind: 'head', url: 'http://localhost/' }), ''])
119
+ return { available: true, reason: 'lwsClientStart answered' }
120
+ } catch (error) {
121
+ if (isUnknownOp(error)) {
122
+ return {
123
+ available: false,
124
+ reason: 'the loaded l4factoidal WebAssembly module does not carry the LWS client ops ' +
125
+ '(rebuild formal/lean4/Wasm)'
126
+ }
127
+ }
128
+ return { available: true, reason: 'lwsClientStart answered with an engine error' }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * An LWS client bound to one engine and one `fetch`.
134
+ *
135
+ * @param {object} [options]
136
+ * @param {object} [options.engine] an engine handle to reuse
137
+ * @param {Function} [options.fetch] the fetch to use (default `globalThis.fetch`)
138
+ * @param {string} [options.subjectToken] the authentication credential to
139
+ * exchange for access tokens (RFC 8693 subject_token)
140
+ * @param {string} [options.subjectTokenType] its token type URI
141
+ * @param {object} [options.selfIssued] `{ subject, keyId, jwk }`: a P-256 key
142
+ * to sign self-issued credentials with, instead of a subject token
143
+ * @param {object} [options.session] a session from an earlier client
144
+ * @param {number} [options.maxSteps] the loop cap of one operation
145
+ * @returns {Promise<object>} the client
146
+ */
147
+ export async function createLwsClient (options = {}) {
148
+ const engine = options.engine !== undefined ? options.engine : await loadEngine()
149
+ const wire = typeof options.fetch === 'function' ? options.fetch : globalThis.fetch
150
+ if (typeof wire !== 'function') throw new TypeError('no fetch is available in this runtime')
151
+ const maxSteps = typeof options.maxSteps === 'number' ? options.maxSteps : 1024
152
+ let session = options.session !== undefined && options.session !== null
153
+ ? { ...options.session }
154
+ : {}
155
+ if (typeof options.subjectToken === 'string') session.subjectToken = options.subjectToken
156
+ if (typeof options.subjectTokenType === 'string') session.subjectTokenType = options.subjectTokenType
157
+ // `{ subject, keyId, jwk }`: a P-256 key the engine signs self-issued
158
+ // credentials with (lws10-authn-ssi-cid), passed through unread.
159
+ if (options.selfIssued !== undefined) session.selfIssued = options.selfIssued
160
+
161
+ function call (op, args) {
162
+ try {
163
+ return engine.call(op, args)
164
+ } catch (error) {
165
+ if (isUnknownOp(error)) {
166
+ throw new LwsClientError({ code: 'unknownOp', op }, { unknownOp: true })
167
+ }
168
+ throw error
169
+ }
170
+ }
171
+
172
+ /** Send one request exactly as the engine wrote it; the answer as an
173
+ * event document. */
174
+ async function exchange (request) {
175
+ const init = {
176
+ method: request.method,
177
+ headers: Array.isArray(request.headers) ? request.headers : [],
178
+ redirect: 'manual'
179
+ }
180
+ if (typeof request.bodyBase64 === 'string') init.body = bytesOfBase64(request.bodyBase64)
181
+ let reply
182
+ try {
183
+ reply = await wire(request.url, init)
184
+ } catch (error) {
185
+ return { error: error && error.message ? String(error.message) : String(error) }
186
+ }
187
+ const headers = []
188
+ reply.headers.forEach((value, name) => { headers.push([name, value]) })
189
+ const bytes = new Uint8Array(await reply.arrayBuffer())
190
+ // The host's clock and random source, which the engine cannot reach: the
191
+ // iat, exp, nonce and jti of a self-issued credential.
192
+ const random = new Uint8Array(48)
193
+ globalThis.crypto.getRandomValues(random)
194
+ return {
195
+ status: reply.status,
196
+ headers,
197
+ bodyBase64: base64OfBytes(bytes),
198
+ now: Math.floor(Date.now() / 1000),
199
+ randomBase64: base64OfBytes(random)
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Run one operation to its end.
205
+ *
206
+ * @param {object} op `{ kind, url, ... }`; `body` may be a string or bytes
207
+ * @returns {Promise<object>} the engine's `done` document
208
+ */
209
+ async function run (op) {
210
+ let envelope = call('lwsClientStart', [JSON.stringify(opDocument(op)), JSON.stringify(session)])
211
+ for (let step = 0; step < maxSteps; step += 1) {
212
+ const action = envelope.action || {}
213
+ if (action.fetch !== undefined) {
214
+ const event = await exchange(action.fetch)
215
+ envelope = call('lwsClientStep', [JSON.stringify(envelope.state), JSON.stringify(event)])
216
+ continue
217
+ }
218
+ if (action.session !== undefined) session = action.session
219
+ if (action.done !== undefined) return action.done
220
+ throw new LwsClientError(action.fail)
221
+ }
222
+ throw new LwsClientError({ code: 'hostStepLimit', steps: maxSteps })
223
+ }
224
+
225
+ const with_ = (kind) => (url, args = {}) => run({ ...args, kind, url })
226
+
227
+ return {
228
+ engine,
229
+ run,
230
+ /** The session (access tokens and subject token), for a later client. */
231
+ session: () => ({ ...session }),
232
+ discover: with_('discover'),
233
+ read: with_('read'),
234
+ head: with_('head'),
235
+ /** `create(containerUrl, { body, mediaType, slug, container, types, links })` */
236
+ create: with_('create'),
237
+ /** `update(url, { body, mediaType, precondition, tag, retry412, links })` */
238
+ update: with_('update'),
239
+ patch: with_('patch'),
240
+ /** `delete(url, { recursive, precondition, tag })` */
241
+ delete: with_('delete'),
242
+ readLinkset: with_('readLinkset'),
243
+ updateLinkset: with_('updateLinkset'),
244
+ /** `move(url, { destination })` */
245
+ move: with_('move'),
246
+ list: with_('list'),
247
+ typeIndex: with_('typeIndex'),
248
+ /** `search(anyUrlInTheStorage, { query })` */
249
+ search: with_('search'),
250
+ subscribe: with_('subscribe'),
251
+ unsubscribe: with_('unsubscribe'),
252
+ requestAccess: with_('requestAccess'),
253
+ grantAccess: with_('grantAccess'),
254
+ getAccess: with_('getAccess'),
255
+ revokeAccess: with_('revokeAccess'),
256
+ /** `verifyNotification(baseUrl, { requestTarget, method, headers, body })`:
257
+ * the engine's verdict `{ accept, status, reason? }`. */
258
+ verifyNotification: with_('verifyNotification')
259
+ }
260
+ }
261
+
262
+ /**
263
+ * An HTTP receiver for webhook notifications. For each request it asks the
264
+ * engine (`verifyNotification`) and answers with the status of the
265
+ * verdict; an accepted notification goes to `onNotification`. The receiver
266
+ * reads no header and decides nothing.
267
+ *
268
+ * @param {object} options
269
+ * @param {object} options.client a client from `createLwsClient`
270
+ * @param {Function} [options.onNotification] `(verdict, bodyBytes, request)`
271
+ * @param {number} [options.port] default 0 (an ephemeral port)
272
+ * @param {string} [options.host] default 127.0.0.1
273
+ * @returns {Promise<{ url: string, close: Function, received: Function }>}
274
+ * `url` is the receiver's base URL; any path under it receives
275
+ */
276
+ export async function createNotificationReceiver (options) {
277
+ const { createServer } = await import('node:http')
278
+ const client = options.client
279
+ const host = typeof options.host === 'string' ? options.host : '127.0.0.1'
280
+ let base = ''
281
+ const verdicts = []
282
+ const server = createServer((request, response) => {
283
+ const chunks = []
284
+ request.on('data', (chunk) => chunks.push(chunk))
285
+ request.on('end', async () => {
286
+ const body = new Uint8Array(Buffer.concat(chunks))
287
+ const headers = []
288
+ for (let index = 0; index + 1 < request.rawHeaders.length; index += 2) {
289
+ headers.push([request.rawHeaders[index], request.rawHeaders[index + 1]])
290
+ }
291
+ let verdict
292
+ try {
293
+ verdict = await client.verifyNotification(base, {
294
+ requestTarget: request.url, method: request.method, headers, body
295
+ })
296
+ } catch (error) {
297
+ response.statusCode = 500
298
+ response.end()
299
+ return
300
+ }
301
+ verdicts.push(verdict)
302
+ if (verdict.accept === true && typeof options.onNotification === 'function') {
303
+ try { options.onNotification(verdict, body, { target: request.url, headers }) } catch (_e) { /* the caller's */ }
304
+ }
305
+ response.statusCode = verdict.status
306
+ response.end()
307
+ })
308
+ })
309
+ await new Promise((resolve, reject) => {
310
+ server.once('error', reject)
311
+ server.listen(typeof options.port === 'number' ? options.port : 0, host, resolve)
312
+ })
313
+ base = `http://${host}:${server.address().port}/`
314
+ return {
315
+ url: base,
316
+ received: () => verdicts.slice(),
317
+ close: () => new Promise((resolve) => server.close(resolve))
318
+ }
319
+ }
package/lws/persist.mjs CHANGED
@@ -11,15 +11,83 @@
11
11
  // DIR/lws-state.json the snapshot, replaced atomically
12
12
  //
13
13
  // No request target ever becomes a file name here, so a request cannot
14
- // address a file outside DIR. The write is: a temporary file in DIR,
14
+ // address a file outside DIR.
15
+ //
16
+ // ENCRYPTION AT REST (https://github.com/danbri/factoidal/issues/921)
17
+ // This file also reads the environment variable FACTOIDAL_LWS_KEY and
18
+ // draws 12 random bytes for every write. Both go to the engine unread: the
19
+ // key text as the open configuration's `storageKey`, the bytes as the
20
+ // `nonce` of the snapshot call. The engine decides everything else
21
+ // (`L4Factoidal/LWS/SnapshotSeal.lean`): whether the key is valid, whether
22
+ // the file in DIR is sealed, whether it authenticates, and the bytes of
23
+ // the sealed document. This file never inspects the document it writes. The write is: a temporary file in DIR,
15
24
  // fsync, rename over the state file, fsync of DIR. A crash leaves the old
16
25
  // snapshot or the new one, never a torn one.
17
26
 
18
- import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, existsSync } from 'node:fs'
19
- import { join, resolve } from 'node:path'
27
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, existsSync } from 'node:fs'
28
+ import { dirname, join, resolve, sep } from 'node:path'
20
29
 
21
30
  export const STATE_FILE = 'lws-state.json'
22
31
 
32
+ /** The environment variable that holds the storage key. */
33
+ export const STORAGE_KEY_VARIABLE = 'FACTOIDAL_LWS_KEY'
34
+
35
+ /**
36
+ * The text of the storage key: `explicit` when the caller gave a string,
37
+ * none when the caller gave `null`, and otherwise the environment variable
38
+ * (Node `process.env`, or `Deno.env` when only Deno's API is present). An
39
+ * empty value is no key. The engine checks the text.
40
+ */
41
+ export function storageKeyText (explicit) {
42
+ if (typeof explicit === 'string') return explicit
43
+ if (explicit === null) return undefined
44
+ let value
45
+ try {
46
+ if (globalThis.process !== undefined && globalThis.process.env !== undefined) {
47
+ value = globalThis.process.env[STORAGE_KEY_VARIABLE]
48
+ } else if (globalThis.Deno !== undefined) {
49
+ value = globalThis.Deno.env.get(STORAGE_KEY_VARIABLE)
50
+ }
51
+ } catch (_error) {
52
+ value = undefined
53
+ }
54
+ return typeof value === 'string' && value !== '' ? value : undefined
55
+ }
56
+
57
+ /** The environment variable that sets where `lws-serve DIR` keeps the
58
+ * storage when the caller names no `storage` (issue 918). */
59
+ export const STORAGE_MODE_VARIABLE = 'FACTOIDAL_LWS_STORAGE'
60
+
61
+ /**
62
+ * The text of the storage mode: `explicit` when the caller gave a string,
63
+ * and otherwise the environment variable, passed unread; the engine checks
64
+ * it. An empty value is none (the engine's default, the snapshot).
65
+ */
66
+ export function storageModeText (explicit) {
67
+ if (typeof explicit === 'string') return explicit
68
+ let value
69
+ try {
70
+ if (globalThis.process !== undefined && globalThis.process.env !== undefined) {
71
+ value = globalThis.process.env[STORAGE_MODE_VARIABLE]
72
+ } else if (globalThis.Deno !== undefined) {
73
+ value = globalThis.Deno.env.get(STORAGE_MODE_VARIABLE)
74
+ }
75
+ } catch (_error) {
76
+ value = undefined
77
+ }
78
+ return typeof value === 'string' && value !== '' ? value : undefined
79
+ }
80
+
81
+ /** 12 bytes from the platform's random source, base64: the nonce of one
82
+ * snapshot write. Pure I/O. */
83
+ function nonceBase64 () {
84
+ const bytes = new Uint8Array(12)
85
+ globalThis.crypto.getRandomValues(bytes)
86
+ let text = ''
87
+ for (const b of bytes) text += String.fromCharCode(b)
88
+ return btoa(text)
89
+ }
90
+
23
91
  /** The absolute path of the state file of a storage directory. */
24
92
  export function stateFileOf (dir) {
25
93
  return join(resolve(dir), STATE_FILE)
@@ -37,7 +105,7 @@ export function readSnapshot (dir) {
37
105
  try {
38
106
  return JSON.parse(text)
39
107
  } catch (error) {
40
- throw new Error(`${file} is not JSON: ${error.message}`)
108
+ throw Object.assign(new Error(`${file} is not JSON: ${error.message}`), { stateFile: true })
41
109
  }
42
110
  }
43
111
 
@@ -65,14 +133,193 @@ export function writeSnapshot (dir, snapshot) {
65
133
 
66
134
  /**
67
135
  * After one step: when the engine says the storage changed, ask it for
68
- * the snapshot (`snapshotOp`) and write it.
136
+ * the snapshot (`snapshotOp`) and write it. The call carries a fresh random
137
+ * nonce; the engine uses it when the storage is sealed under a key.
69
138
  */
70
139
  export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
71
140
  if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
72
141
  if (envelope.changed !== true) return
73
- const answer = engine.call(snapshotOp, [handle])
142
+ const answer = engine.call(snapshotOp, [handle, JSON.stringify({ nonce: nonceBase64() })])
74
143
  if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
75
144
  throw new Error(`${snapshotOp} answered with no snapshot`)
76
145
  }
77
146
  writeSnapshot(dir, answer.snapshot)
78
147
  }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // A STORAGE KEPT IN FILES (https://github.com/danbri/factoidal/issues/918)
151
+ //
152
+ // With the open configuration `storage: "files"` the engine decides the
153
+ // directory layout and every byte (formal/lean4/L4Factoidal/LWS/FileLayout.lean,
154
+ // design record docs/designissues/2026-10-22-lws-storage-on-disk.md). This
155
+ // part of the file only performs what the engine names:
156
+ //
157
+ // * reads: the engine names files (paths relative to DIR); each is read
158
+ // whole, or reported absent. At start (`lwsLoad`) and before a step that
159
+ // needs the bytes of a resource (a `read` answer of `lwsStep`, given to
160
+ // `lwsRead`, stage 2 of issue 918);
161
+ // * a plan of file operations, in order, with their bytes in one region:
162
+ // write {path, temp, offset, length} parent directory, temp file,
163
+ // fsync, rename, fsync directory
164
+ // append {path, offset, length} append, fsync
165
+ // remove {path} remove; a missing file is no error
166
+ // rename {from, to} rename, fsync directory; a missing
167
+ // file is no error
168
+ //
169
+ // A name that leaves DIR is refused as a failure of this host; the engine
170
+ // never names one.
171
+
172
+ /** The absolute path of a name the engine gave, inside DIR. */
173
+ function insideDir (root, name) {
174
+ if (typeof name !== 'string' || name === '') throw new Error('lws files: the engine named no file')
175
+ const abs = resolve(root, name)
176
+ if (!abs.startsWith(root + sep)) throw new Error(`lws files: ${name} is outside the storage directory`)
177
+ return abs
178
+ }
179
+
180
+ /** fsync a directory; some platforms cannot, and the rename stands. */
181
+ function fsyncDir (dir) {
182
+ try {
183
+ const fd = openSync(dir, 'r')
184
+ try { fsyncSync(fd) } finally { closeSync(fd) }
185
+ } catch (_error) {
186
+ // Some platforms cannot fsync a directory.
187
+ }
188
+ }
189
+
190
+ /** `seed` for one engine call: 32 random bytes, base64. Pure I/O. */
191
+ function seedBase64 () {
192
+ const bytes = new Uint8Array(32)
193
+ globalThis.crypto.getRandomValues(bytes)
194
+ let text = ''
195
+ for (const b of bytes) text += String.fromCharCode(b)
196
+ return btoa(text)
197
+ }
198
+
199
+ /**
200
+ * Read the files the engine named. Returns the `files` member of the load
201
+ * document and the bytes of the present files, one after the other.
202
+ */
203
+ export function readNamedFiles (dir, names) {
204
+ const root = resolve(dir)
205
+ const files = []
206
+ const parts = []
207
+ let total = 0
208
+ for (const name of names) {
209
+ const abs = insideDir(root, name)
210
+ if (!existsSync(abs)) {
211
+ files.push({ path: name, present: false })
212
+ continue
213
+ }
214
+ const bytes = readFileSync(abs)
215
+ files.push({ path: name, present: true, length: bytes.length })
216
+ parts.push(bytes)
217
+ total += bytes.length
218
+ }
219
+ const region = new Uint8Array(total)
220
+ let at = 0
221
+ for (const part of parts) { region.set(part, at); at += part.length }
222
+ return { files, region }
223
+ }
224
+
225
+ /** Perform a plan of file operations exactly as the engine gave it. */
226
+ export function performPlan (dir, plan, bytes) {
227
+ const root = resolve(dir)
228
+ mkdirSync(root, { recursive: true })
229
+ const slice = (op) => bytes.subarray(op.offset, op.offset + op.length)
230
+ for (const op of Array.isArray(plan) ? plan : []) {
231
+ switch (op.op) {
232
+ case 'write': {
233
+ const path = insideDir(root, op.path)
234
+ const temp = insideDir(root, op.temp)
235
+ mkdirSync(dirname(path), { recursive: true })
236
+ const fd = openSync(temp, 'w', 0o600)
237
+ try {
238
+ writeSync(fd, slice(op))
239
+ fsyncSync(fd)
240
+ } finally {
241
+ closeSync(fd)
242
+ }
243
+ renameSync(temp, path)
244
+ fsyncDir(dirname(path))
245
+ break
246
+ }
247
+ case 'append': {
248
+ const path = insideDir(root, op.path)
249
+ mkdirSync(dirname(path), { recursive: true })
250
+ const fd = openSync(path, 'a', 0o600)
251
+ try {
252
+ writeSync(fd, slice(op))
253
+ fsyncSync(fd)
254
+ } finally {
255
+ closeSync(fd)
256
+ }
257
+ break
258
+ }
259
+ case 'remove': {
260
+ try { unlinkSync(insideDir(root, op.path)) } catch (error) {
261
+ if (error.code !== 'ENOENT') throw error
262
+ }
263
+ break
264
+ }
265
+ case 'rename': {
266
+ const to = insideDir(root, op.to)
267
+ try { renameSync(insideDir(root, op.from), to) } catch (error) {
268
+ if (error.code !== 'ENOENT') throw error
269
+ }
270
+ fsyncDir(dirname(to))
271
+ break
272
+ }
273
+ default:
274
+ throw new Error(`lws files: the engine named an unknown file operation ${String(op.op)}`)
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Open a storage kept in files: read what `lwsOpen` named, give it to
281
+ * `lwsLoad`, read what that names, until the engine is ready; then perform
282
+ * the plan of the start. An engine refusal is thrown as the engine's error.
283
+ */
284
+ export function loadFiles (engine, handle, opened, dir) {
285
+ let want = opened.load
286
+ for (let round = 0; round < 8; round += 1) {
287
+ const { files, region } = readNamedFiles(dir, Array.isArray(want.read) ? want.read : [])
288
+ const answered = engine.callBlobIO('lwsLoad', [handle, JSON.stringify({ files, seed: seedBase64() })], region)
289
+ const envelope = answered.envelope
290
+ if (envelope.ready === true) {
291
+ performPlan(dir, envelope.plan, answered.bytes)
292
+ return envelope
293
+ }
294
+ if (envelope.load === undefined || envelope.load === null) {
295
+ throw new Error('lwsLoad answered neither load nor ready')
296
+ }
297
+ want = envelope.load
298
+ }
299
+ throw new Error('lwsLoad asked for more reads than one start may make')
300
+ }
301
+
302
+ /**
303
+ * Before one step of a storage kept in files (stage 2): read the body files
304
+ * the engine named in a `read` answer and give them to `lwsRead`. The engine
305
+ * checks each one (seal, length, digests); a refusal is thrown as its error.
306
+ */
307
+ export function readStepBodies (engine, handle, names, dir) {
308
+ const { files, region } = readNamedFiles(dir, Array.isArray(names) ? names : [])
309
+ const answered = engine.callBlobIO('lwsRead', [handle, JSON.stringify({ files })], region)
310
+ const envelope = answered.envelope
311
+ if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true) {
312
+ throw new Error(`lwsRead: ${envelope !== null && typeof envelope === 'object' ? envelope.error : 'no answer'}`)
313
+ }
314
+ }
315
+
316
+ /**
317
+ * After one step of a storage kept in files: when the engine says the
318
+ * storage changed, ask it for the write plan (`lwsPersist`) and perform it.
319
+ */
320
+ export function persistFilesIfChanged (engine, handle, envelope, dir) {
321
+ if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
322
+ if (envelope.changed !== true) return
323
+ const answered = engine.callBlobIO('lwsPersist', [handle, JSON.stringify({ seed: seedBase64() })], new Uint8Array(0))
324
+ performPlan(dir, answered.envelope.plan, answered.bytes)
325
+ }