@factoidal/core 0.10.0 → 0.12.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/CHANGELOG.md +496 -1
- package/README.md +70 -4
- package/api.d.ts +2 -3
- package/api.mjs +2 -2
- package/bin/ext.mjs +16 -20
- package/bin/factoidal.mjs +308 -69
- package/browser.d.ts +7 -2
- package/browser.js +5 -2
- package/fn.d.ts +1 -1
- package/fn.js +33 -100
- package/index.d.ts +105 -29
- package/index.mjs +5 -2
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.mjs +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +6 -6
- package/lib/api.mjs +214 -287
- package/lib/dataset.mjs +164 -75
- package/lib/delta-log-hosts.mjs +9 -8
- package/lib/errors.mjs +97 -0
- package/lib/lean-entry.mjs +36 -22
- package/lib/store.mjs +4 -5
- package/lib/version.mjs +1 -1
- package/lws/README.md +114 -7
- package/lws/persist.mjs +309 -0
- package/lws/server.mjs +364 -17
- package/package.json +2 -1
- package/rdfjs.mjs +151 -226
- package/solid/client/index.mjs +25 -9
- package/solid/server/index.mjs +99 -15
package/lws/README.md
CHANGED
|
@@ -27,12 +27,17 @@ host or the engine failed rather than answered.
|
|
|
27
27
|
| op | arguments | answer |
|
|
28
28
|
| --- | --- | --- |
|
|
29
29
|
| `lwsOpen` | `[configJson]` | `{ ok, handle, root }` |
|
|
30
|
-
| `lwsStep` | `[handle, requestJson]` | `{ ok, response }` |
|
|
30
|
+
| `lwsStep` | `[handle, requestJson]` | `{ ok, response, changed, deliveries?, wake? }`, or `{ ok, resolve: { host } }` |
|
|
31
31
|
| `lwsClose` | `[handle]` | `{ ok }` |
|
|
32
|
+
| `lwsSnapshot` | `[handle]` | `{ ok, snapshot }` |
|
|
33
|
+
| `lwsFetched` | `[handle, documentJson]` | `{ ok, fetch? , installed?, refused? }` |
|
|
34
|
+
| `lwsDelivered` | `[handle, reportJson]` | `{ ok, changed, retry? }` |
|
|
35
|
+
| `lwsFlush` | `[handle, { now? }]` | `{ ok, changed, deliveries?, wake? }` |
|
|
32
36
|
|
|
33
37
|
```
|
|
34
|
-
request { method, target, headers: [[name, value]]
|
|
35
|
-
response { status, headers: [[name, value]],
|
|
38
|
+
request { method, target, headers: [[name, value]] } + the content as bytes
|
|
39
|
+
response { status, headers: [[name, value]], bodyBytes } + the content as bytes
|
|
40
|
+
(through callBlobIO; https://github.com/danbri/factoidal/issues/889)
|
|
36
41
|
```
|
|
37
42
|
|
|
38
43
|
`headers` is a list of pairs, not an object: HTTP allows a field name to
|
|
@@ -43,7 +48,11 @@ unchanged and this host composes no IRI.
|
|
|
43
48
|
`configJson` members, all optional: `baseIri` (the absolute IRI the
|
|
44
49
|
storage root maps to), `now` (the engine clock in seconds, which advances
|
|
45
50
|
by one second per mutation so a write sequence has increasing
|
|
46
|
-
`Last-Modified` values with no real clock), `owner`, `agent
|
|
51
|
+
`Last-Modified` values with no real clock), `owner`, `agent`, `pageSize`
|
|
52
|
+
(the container pagination threshold and page size, default 100, 0 for no
|
|
53
|
+
pagination), `rootAcl`, `auth` and `insecureNoAuth` (below),
|
|
54
|
+
`notifications` (default true) and `entropy` (random bytes, base64, which
|
|
55
|
+
this host makes for every open). A request
|
|
47
56
|
may carry `now` and `agent` of its own to override the handle's for one
|
|
48
57
|
step; `listen({realClock: true})` is how a server with a real clock feeds
|
|
49
58
|
one in, and a test leaves it off to keep the engine's deterministic
|
|
@@ -55,12 +64,103 @@ A WebAssembly module built before these ops landed answers `unknown op`.
|
|
|
55
64
|
and every test in `tests/lws/` skips with the reason printed rather than
|
|
56
65
|
passing on an engine that cannot answer.
|
|
57
66
|
|
|
67
|
+
## Authorization
|
|
68
|
+
|
|
69
|
+
Authorization follows the draft's "Authorization" section
|
|
70
|
+
(https://github.com/danbri/factoidal/issues/886) and is decided by
|
|
71
|
+
`formal/lean4/L4Factoidal/LWS/Authorization.lean`. It is ON by default.
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
const running = await listen({
|
|
75
|
+
port: 3000,
|
|
76
|
+
owner: 'https://id.example/alice', // root ACL: Read, Write, Control
|
|
77
|
+
auth: { issuers: [{ issuer: 'https://as.example' }] },
|
|
78
|
+
realClock: true
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
* Clients send `Authorization: Bearer <access token>` (RFC 6750). The
|
|
83
|
+
token is a JWT (RFC 9068) signed with ES256, RS256 or EdDSA by a trusted
|
|
84
|
+
issuer; `aud` must contain the storage IRI (`baseIri`, or `auth.audience`).
|
|
85
|
+
* Without a valid token the answer is 401 with
|
|
86
|
+
`WWW-Authenticate: Bearer resource_metadata="…"`; the RFC 9728 metadata
|
|
87
|
+
document is served at `/.well-known/oauth-protected-resource<path>`.
|
|
88
|
+
* The host fetches the issuer's `/.well-known/lws-configuration` and its
|
|
89
|
+
`jwks_uri` when the engine asks (`lwsStep` answers `{ fetch }`), passes
|
|
90
|
+
the bytes to `lwsFetched`, and steps again. `auth.issuers[].jwks` gives
|
|
91
|
+
the keys inline instead.
|
|
92
|
+
* `insecureNoAuth: true` serves every request with no authorization. It is
|
|
93
|
+
for development only, and must be asked for.
|
|
94
|
+
|
|
95
|
+
## Pagination and client links
|
|
96
|
+
|
|
97
|
+
Both are decided in Lean; this host passes the fields through.
|
|
98
|
+
|
|
99
|
+
* A container with more than `pageSize` members (default 100) is listed in
|
|
100
|
+
pages (LWS §LWS Media Type, Pagination;
|
|
101
|
+
https://github.com/danbri/factoidal/issues/887). Each page answers 200
|
|
102
|
+
with the `Link` relations `first` and `last`, `next` on every page but the
|
|
103
|
+
last, and `prev` on every page but the first; the page URIs are
|
|
104
|
+
`CONTAINER?page=N`. `totalItems` counts the full membership. The members
|
|
105
|
+
are in increasing path order, each once; the theorem `pageItems_concat` in
|
|
106
|
+
`L4Factoidal/LWS/Operations.lean` states that the pages of an unchanged
|
|
107
|
+
container concatenate to the full listing. Each page has its own `ETag`,
|
|
108
|
+
which starts with the container's clock; the clock changes when the
|
|
109
|
+
membership changes.
|
|
110
|
+
* A client sets its own links (`license`, `author`, any extension relation)
|
|
111
|
+
with `Link` and `Prefer: set-linkset` on PUT (replace) or PATCH (add),
|
|
112
|
+
with `Link` on POST, or through the linkset resource `PATH?linkset` with
|
|
113
|
+
JSON Patch or a PUT of `application/linkset+json` (LWS §Metadata, §Update
|
|
114
|
+
Resource; https://github.com/danbri/factoidal/issues/888). The relations
|
|
115
|
+
`linkset`, `type`, `format`, `size`, `modified`, `up`, `items` and
|
|
116
|
+
`lws#storage` are server-managed: a request that sets or changes one gets
|
|
117
|
+
409. A malformed link gets 400. Without `Prefer: set-linkset`, a `Link`
|
|
118
|
+
field on PUT and PATCH is not stored.
|
|
119
|
+
|
|
120
|
+
## Notifications
|
|
121
|
+
|
|
122
|
+
LWS notifications and the webhook notification suite
|
|
123
|
+
(https://github.com/danbri/factoidal/issues/890) are decided by
|
|
124
|
+
`formal/lean4/L4Factoidal/LWS/Notifications.lean`. They are on unless
|
|
125
|
+
`notifications: false` is passed.
|
|
126
|
+
|
|
127
|
+
* The storage description at `/.well-known/lws-storage` names the
|
|
128
|
+
`NotificationService`: endpoint `/.well-known/lws-subscriptions/`, type
|
|
129
|
+
`WebhookSubscription`, and the Ed25519 key that signs deliveries.
|
|
130
|
+
* A client POSTs `{"type":"WebhookSubscription","topic":[IRI…],"inbox":URL}`
|
|
131
|
+
(`application/lws+json`, with a token when authorization is on) and gets
|
|
132
|
+
201 with the subscription URL. GET on the endpoint lists the agent's
|
|
133
|
+
subscriptions; GET and DELETE manage one.
|
|
134
|
+
* After a write, `lwsStep` names the deliveries: `{ id, url, headers, body }`.
|
|
135
|
+
This host POSTs each one exactly as given, after the response, and reports
|
|
136
|
+
the status to `lwsDelivered`; when the engine answers `retry`, it waits
|
|
137
|
+
`after` seconds and sends the delivery it names. `storage().settled()`
|
|
138
|
+
resolves when no delivery is in flight.
|
|
139
|
+
* Rate limit (https://github.com/danbri/factoidal/issues/915): at most
|
|
140
|
+
`deliveryLimit` deliveries (default 10) per subscription in a window of
|
|
141
|
+
`deliveryWindow` seconds (default 60; `deliveryLimit: 0` turns it off).
|
|
142
|
+
The rest wait; an answer then carries `wake: SECONDS`, and this host calls
|
|
143
|
+
`lwsFlush` after that many seconds and sends the one batched delivery it
|
|
144
|
+
names.
|
|
145
|
+
* Inbox check (issue 915): when an inbox host is a name, `lwsStep` answers
|
|
146
|
+
`resolve: { host }`; this host resolves it (`dns.lookup`, every address)
|
|
147
|
+
and steps the request again with `resolved: [{ host, addresses }]`.
|
|
148
|
+
`listen()` passes `listen: { address, port, addresses }` to `lwsOpen`. The
|
|
149
|
+
engine refuses a container of this storage under another name of this
|
|
150
|
+
server.
|
|
151
|
+
* `controllerInbox: URL` (https://github.com/danbri/factoidal/issues/920):
|
|
152
|
+
each new access request is announced there with a `Create` notification.
|
|
153
|
+
* After a move, a request to a previous path answers 301 with `Location`
|
|
154
|
+
(https://github.com/danbri/factoidal/issues/914); `redirectsMax` (default
|
|
155
|
+
256) bounds the table.
|
|
156
|
+
|
|
58
157
|
## Use
|
|
59
158
|
|
|
60
159
|
```js
|
|
61
160
|
import { listen } from '@factoidal/core/lws'
|
|
62
161
|
|
|
63
|
-
const running = await listen({ port: 3000, baseIri: 'https://storage.example/'
|
|
162
|
+
const running = await listen({ port: 3000, baseIri: 'https://storage.example/',
|
|
163
|
+
insecureNoAuth: true }) // development only; see Authorization
|
|
64
164
|
console.log(running.origin)
|
|
65
165
|
await running.close()
|
|
66
166
|
```
|
|
@@ -68,9 +168,13 @@ await running.close()
|
|
|
68
168
|
From the command:
|
|
69
169
|
|
|
70
170
|
```
|
|
71
|
-
factoidal lws-serve /var/lib/lws --port 3000
|
|
171
|
+
factoidal lws-serve /var/lib/lws --port 3000 --issuer https://as.example --owner https://id.example/alice
|
|
172
|
+
factoidal lws-serve /tmp/dev --port 3000 --insecure-no-auth
|
|
72
173
|
```
|
|
73
174
|
|
|
175
|
+
One of `--issuer`, `--auth-config FILE` or `--insecure-no-auth` is
|
|
176
|
+
required.
|
|
177
|
+
|
|
74
178
|
`--port 0` takes an ephemeral port and the command prints the one it got;
|
|
75
179
|
`--base IRI` sets `baseIri`, which defaults to the bound origin. `DIR`
|
|
76
180
|
names the storage directory, but the first slice keeps the resource tree
|
|
@@ -86,12 +190,15 @@ in the engine handle, so nothing is read from it or written to it yet
|
|
|
86
190
|
| `openLwsStorage(options)` | open a handle and get `step` / `close` |
|
|
87
191
|
| `createLwsServer(...).setBaseIri(iri)` | set `baseIri` before the first request |
|
|
88
192
|
| `lwsOpsAvailable(engine)` | probe the loaded module for the ops |
|
|
89
|
-
| `LWS_OPS` | the
|
|
193
|
+
| `LWS_OPS` | the op names |
|
|
90
194
|
| `LwsHostError` | a host failure, with `unknownOp` set when the op is absent |
|
|
91
195
|
|
|
92
196
|
## Tests
|
|
93
197
|
|
|
94
198
|
```
|
|
95
199
|
node tests/lws/server.mjs
|
|
200
|
+
node tests/lws/draft-binding.mjs
|
|
201
|
+
node tests/lws/binary-bodies.mjs
|
|
202
|
+
node tests/lws/authorization.mjs
|
|
96
203
|
deno run --allow-read --allow-net --allow-write --allow-env tests/lws/server.mjs
|
|
97
204
|
```
|
package/lws/persist.mjs
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Durable storage for `factoidal lws-serve DIR` and `solid-serve DIR`.
|
|
2
|
+
// https://github.com/danbri/factoidal/issues/659
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
5
|
+
// Move bytes between one file and the engine. The engine decides what
|
|
6
|
+
// the bytes are (`L4Factoidal/LWS/Snapshot.lean`): the snapshot document,
|
|
7
|
+
// which paths it may hold (no `..`, no absolute or encoded traversal), and
|
|
8
|
+
// when the storage changed (the `changed` member of the step envelope).
|
|
9
|
+
// This file decides only where the one file lives and how it is written:
|
|
10
|
+
//
|
|
11
|
+
// DIR/lws-state.json the snapshot, replaced atomically
|
|
12
|
+
//
|
|
13
|
+
// No request target ever becomes a file name here, so a request cannot
|
|
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,
|
|
24
|
+
// fsync, rename over the state file, fsync of DIR. A crash leaves the old
|
|
25
|
+
// snapshot or the new one, never a torn one.
|
|
26
|
+
|
|
27
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, existsSync } from 'node:fs'
|
|
28
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
29
|
+
|
|
30
|
+
export const STATE_FILE = 'lws-state.json'
|
|
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
|
+
|
|
91
|
+
/** The absolute path of the state file of a storage directory. */
|
|
92
|
+
export function stateFileOf (dir) {
|
|
93
|
+
return join(resolve(dir), STATE_FILE)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The parsed snapshot in DIR, or `null` when DIR holds none yet. The
|
|
98
|
+
* engine checks the document; a file that does not parse as JSON is an
|
|
99
|
+
* error of this host, reported as such.
|
|
100
|
+
*/
|
|
101
|
+
export function readSnapshot (dir) {
|
|
102
|
+
const file = stateFileOf(dir)
|
|
103
|
+
if (!existsSync(file)) return null
|
|
104
|
+
const text = readFileSync(file, 'utf8')
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw Object.assign(new Error(`${file} is not JSON: ${error.message}`), { stateFile: true })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Write a snapshot document to DIR, atomically. */
|
|
113
|
+
export function writeSnapshot (dir, snapshot) {
|
|
114
|
+
const root = resolve(dir)
|
|
115
|
+
mkdirSync(root, { recursive: true })
|
|
116
|
+
const file = stateFileOf(root)
|
|
117
|
+
const temporary = join(root, `.${STATE_FILE}.${process.pid}.tmp`)
|
|
118
|
+
const fd = openSync(temporary, 'w', 0o600)
|
|
119
|
+
try {
|
|
120
|
+
writeSync(fd, JSON.stringify(snapshot))
|
|
121
|
+
fsyncSync(fd)
|
|
122
|
+
} finally {
|
|
123
|
+
closeSync(fd)
|
|
124
|
+
}
|
|
125
|
+
renameSync(temporary, file)
|
|
126
|
+
try {
|
|
127
|
+
const dirFd = openSync(root, 'r')
|
|
128
|
+
try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
|
|
129
|
+
} catch (_error) {
|
|
130
|
+
// Some platforms cannot fsync a directory; the rename stands.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* After one step: when the engine says the storage changed, ask it for
|
|
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.
|
|
138
|
+
*/
|
|
139
|
+
export function persistIfChanged (engine, snapshotOp, handle, envelope, dir) {
|
|
140
|
+
if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
|
|
141
|
+
if (envelope.changed !== true) return
|
|
142
|
+
const answer = engine.call(snapshotOp, [handle, JSON.stringify({ nonce: nonceBase64() })])
|
|
143
|
+
if (answer === null || typeof answer !== 'object' || answer.snapshot === undefined) {
|
|
144
|
+
throw new Error(`${snapshotOp} answered with no snapshot`)
|
|
145
|
+
}
|
|
146
|
+
writeSnapshot(dir, answer.snapshot)
|
|
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;
|
|
159
|
+
// * a plan of file operations, in order, with their bytes in one region:
|
|
160
|
+
// write {path, temp, offset, length} parent directory, temp file,
|
|
161
|
+
// fsync, rename, fsync directory
|
|
162
|
+
// append {path, offset, length} append, fsync
|
|
163
|
+
// remove {path} remove; a missing file is no error
|
|
164
|
+
// rename {from, to} rename, fsync directory; a missing
|
|
165
|
+
// file is no error
|
|
166
|
+
//
|
|
167
|
+
// A name that leaves DIR is refused as a failure of this host; the engine
|
|
168
|
+
// never names one.
|
|
169
|
+
|
|
170
|
+
/** The absolute path of a name the engine gave, inside DIR. */
|
|
171
|
+
function insideDir (root, name) {
|
|
172
|
+
if (typeof name !== 'string' || name === '') throw new Error('lws files: the engine named no file')
|
|
173
|
+
const abs = resolve(root, name)
|
|
174
|
+
if (!abs.startsWith(root + sep)) throw new Error(`lws files: ${name} is outside the storage directory`)
|
|
175
|
+
return abs
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** fsync a directory; some platforms cannot, and the rename stands. */
|
|
179
|
+
function fsyncDir (dir) {
|
|
180
|
+
try {
|
|
181
|
+
const fd = openSync(dir, 'r')
|
|
182
|
+
try { fsyncSync(fd) } finally { closeSync(fd) }
|
|
183
|
+
} catch (_error) {
|
|
184
|
+
// Some platforms cannot fsync a directory.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `seed` for one engine call: 32 random bytes, base64. Pure I/O. */
|
|
189
|
+
function seedBase64 () {
|
|
190
|
+
const bytes = new Uint8Array(32)
|
|
191
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
192
|
+
let text = ''
|
|
193
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
194
|
+
return btoa(text)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read the files the engine named. Returns the `files` member of the load
|
|
199
|
+
* document and the bytes of the present files, one after the other.
|
|
200
|
+
*/
|
|
201
|
+
export function readNamedFiles (dir, names) {
|
|
202
|
+
const root = resolve(dir)
|
|
203
|
+
const files = []
|
|
204
|
+
const parts = []
|
|
205
|
+
let total = 0
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
const abs = insideDir(root, name)
|
|
208
|
+
if (!existsSync(abs)) {
|
|
209
|
+
files.push({ path: name, present: false })
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
const bytes = readFileSync(abs)
|
|
213
|
+
files.push({ path: name, present: true, length: bytes.length })
|
|
214
|
+
parts.push(bytes)
|
|
215
|
+
total += bytes.length
|
|
216
|
+
}
|
|
217
|
+
const region = new Uint8Array(total)
|
|
218
|
+
let at = 0
|
|
219
|
+
for (const part of parts) { region.set(part, at); at += part.length }
|
|
220
|
+
return { files, region }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Perform a plan of file operations exactly as the engine gave it. */
|
|
224
|
+
export function performPlan (dir, plan, bytes) {
|
|
225
|
+
const root = resolve(dir)
|
|
226
|
+
mkdirSync(root, { recursive: true })
|
|
227
|
+
const slice = (op) => bytes.subarray(op.offset, op.offset + op.length)
|
|
228
|
+
for (const op of Array.isArray(plan) ? plan : []) {
|
|
229
|
+
switch (op.op) {
|
|
230
|
+
case 'write': {
|
|
231
|
+
const path = insideDir(root, op.path)
|
|
232
|
+
const temp = insideDir(root, op.temp)
|
|
233
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
234
|
+
const fd = openSync(temp, 'w', 0o600)
|
|
235
|
+
try {
|
|
236
|
+
writeSync(fd, slice(op))
|
|
237
|
+
fsyncSync(fd)
|
|
238
|
+
} finally {
|
|
239
|
+
closeSync(fd)
|
|
240
|
+
}
|
|
241
|
+
renameSync(temp, path)
|
|
242
|
+
fsyncDir(dirname(path))
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
case 'append': {
|
|
246
|
+
const path = insideDir(root, op.path)
|
|
247
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
248
|
+
const fd = openSync(path, 'a', 0o600)
|
|
249
|
+
try {
|
|
250
|
+
writeSync(fd, slice(op))
|
|
251
|
+
fsyncSync(fd)
|
|
252
|
+
} finally {
|
|
253
|
+
closeSync(fd)
|
|
254
|
+
}
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
case 'remove': {
|
|
258
|
+
try { unlinkSync(insideDir(root, op.path)) } catch (error) {
|
|
259
|
+
if (error.code !== 'ENOENT') throw error
|
|
260
|
+
}
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
case 'rename': {
|
|
264
|
+
const to = insideDir(root, op.to)
|
|
265
|
+
try { renameSync(insideDir(root, op.from), to) } catch (error) {
|
|
266
|
+
if (error.code !== 'ENOENT') throw error
|
|
267
|
+
}
|
|
268
|
+
fsyncDir(dirname(to))
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
default:
|
|
272
|
+
throw new Error(`lws files: the engine named an unknown file operation ${String(op.op)}`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Open a storage kept in files: read what `lwsOpen` named, give it to
|
|
279
|
+
* `lwsLoad`, read what that names, until the engine is ready; then perform
|
|
280
|
+
* the plan of the start. An engine refusal is thrown as the engine's error.
|
|
281
|
+
*/
|
|
282
|
+
export function loadFiles (engine, handle, opened, dir) {
|
|
283
|
+
let want = opened.load
|
|
284
|
+
for (let round = 0; round < 8; round += 1) {
|
|
285
|
+
const { files, region } = readNamedFiles(dir, Array.isArray(want.read) ? want.read : [])
|
|
286
|
+
const answered = engine.callBlobIO('lwsLoad', [handle, JSON.stringify({ files, seed: seedBase64() })], region)
|
|
287
|
+
const envelope = answered.envelope
|
|
288
|
+
if (envelope.ready === true) {
|
|
289
|
+
performPlan(dir, envelope.plan, answered.bytes)
|
|
290
|
+
return envelope
|
|
291
|
+
}
|
|
292
|
+
if (envelope.load === undefined || envelope.load === null) {
|
|
293
|
+
throw new Error('lwsLoad answered neither load nor ready')
|
|
294
|
+
}
|
|
295
|
+
want = envelope.load
|
|
296
|
+
}
|
|
297
|
+
throw new Error('lwsLoad asked for more reads than one start may make')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* After one step of a storage kept in files: when the engine says the
|
|
302
|
+
* storage changed, ask it for the write plan (`lwsPersist`) and perform it.
|
|
303
|
+
*/
|
|
304
|
+
export function persistFilesIfChanged (engine, handle, envelope, dir) {
|
|
305
|
+
if (typeof dir !== 'string' || envelope === null || typeof envelope !== 'object') return
|
|
306
|
+
if (envelope.changed !== true) return
|
|
307
|
+
const answered = engine.callBlobIO('lwsPersist', [handle, JSON.stringify({ seed: seedBase64() })], new Uint8Array(0))
|
|
308
|
+
performPlan(dir, answered.envelope.plan, answered.bytes)
|
|
309
|
+
}
|