Owner, 2026-09-04:
"a page ought to be able to import incrementally and write data into whatever cloud rest api it can?"
Yes. This document states what already supports it, what one piece does not, and what is still in the way.
npm/factoidal/store-host realises four primitives from
formal/lean4/Harness/PosixRangeIO.lean. One of them is
readRange(path, offset, length) -> exactly length bytes
which is an HTTP Range request written in POSIX terms. The query path
uses it because the manifest already commits, for every artifact, its
name, its byte length and its SHA-256; storeQueryPlan chooses which
artifacts a query needs BEFORE any of them is read.
The consequence is that a plain static object store — S3, R2, GCS, or GitHub Pages — is a queryable RDF store with no server process. The client:
CURRENT (a few bytes) and then the manifest;Step 4 is what makes an untrusted transport acceptable. A corrupted or
substituted artifact is refused by name, and that refusal already has a
test (tests/store-host/cli.mjs, "a damaged artifact is refused and the
key is named").
Measured on the skosdex store (2,771,180 quads, 279,574,394 bytes, 182 blocks), a bound-predicate query read 4,547,916 bytes — 1.6 percent of the store. That ratio is what makes a ranged read over a network worthwhile rather than merely possible.
packBegin / packFeed / packNext / packFinish never write
anything. The engine answers {name, bytes} and the host puts the bytes
somewhere. The whole filesystem dependency of a pack is writeNew in
store-host. An HTTP host realises it as a PUT.
Two properties make this safe over a network:
CURRENT is the single mutable byte range in the design. Activation
must be atomic, or a reader sees a generation that is half uploaded.
Locally this is atomicReplace: write a temporary, fsync, rename, fsync
the directory. On an S3-compatible service the counterpart is a
conditional write — If-Match on the current ETag, or If-None-Match: * for the first write. That is a compare-and-swap, which is what the
local rename gives us, so the primitive survives the move rather than
being weakened.
A service with no conditional write cannot host an activated collection
safely. It can still host an immutable generation that readers name
directly (--generation NAME already exists for this), which is enough
for a published dataset that never changes.
The same reasoning applies to the delta log, whose local primitive
appendSyncAtSize is already a compare-and-swap on the file size. That
is the shape a conditional PUT gives, so a durable-update store over
HTTP is possible; it is not attempted here.
See https://github.com/danbri/factoidal/issues/644 for the single-writer
gap that exists even locally.
Incremental import does not by itself let a browser pack. The recursion
that overflows the stack happens INSIDE the wasm module during
packFeed, so it is unaffected by where the artifacts go afterwards.
Measured 2026-09-04 (https://github.com/danbri/factoidal/issues/649): on
the default stack of Node and of Deno, a pack fails above roughly
500,000 bytes of input with Maximum call stack size exceeded.
node --stack-size=8000 packs an 888,949-triple file correctly in
51.76 s, byte-identical to the native packer.
A host flag rescues Node and Deno. A browser tab has a fixed frame budget and no such flag, so an in-page packer needs the recursion depth reduced in the Lean source. Do not promise in-page import before that is measured.
The suspected cause, stated as a hypothesis and not yet confirmed:
L4Factoidal/Storage/TermLocalIndex.lean:130's entriesGo builds a
cons after its recursive call, so its depth is one frame per dictionary
term in a publication batch. That is the same function that dominates
the pack profile in https://github.com/danbri/factoidal/issues/647. If
the hypothesis holds, the two issues are one defect and its repair helps
the browser, where no flag exists.
entriesGo hypothesis
(https://github.com/danbri/factoidal/issues/649).store-host: readRange as a ranged GET,
writeNew as a PUT, atomicReplace as a conditional PUT. It goes
beside node.mjs and deno.mjs and shares index.mjs, so nothing
above it changes.query command
at a bucket URL and answer from it, with digest verification on. That
needs no write path and no conditional write, and it is the half with
the most value.No HTTP host exists yet. Every number above is measured on the local filesystem host. The ranged-read ratio is a property of the planner and the manifest, and it is the reason to expect this to work; it is not a measurement of it working over a network.