@nuucognition/mesh-convex-component
v1.0.4-beta.0
Published
The standard Convex component for mesh storage. One component, mounted by every NUU app that hosts meshes.
Keywords
Readme
@nuucognition/mesh-convex-component
The standard Convex component for mesh storage. One component, mounted by every NUU app that hosts meshes. Design: (Proposal) 001 Mesh Convex Component in the NUU Mesh Flint; extraction plan: (Plan) 014 Mesh Service Extraction and Relocation to NUU Docs.
What it stores
Five tables: meshes, documents, batches (the oplog), blobs, structuralLeases.
A document row is title + raw markdown content, stored unsplit, plus three derived identities: docId, contentHash (body-only, the previousHash target), and frontmatterHash. The component never stores parsed structure — mesh-core rebuilds tags, links, embeds, and all indexes client-side from raw content.
The ensure-id rule: a create whose content has no frontmatter id gets one minted and injected into the stored content. Identity is durable in the markdown itself; docId is a mirror.
What it guarantees
- Batches apply atomically and in order. A rejection rolls back with zero partial writes.
previousHashoptimistic concurrency;invaliddominateshash-conflict; rejection reaches the host as typed data.- Returned hashes are server-authoritative (server re-derives; client claims are recorded, never trusted).
- Document ids are unique per mesh. A
createorupdatewhose id would collide with another document rejects asinvalid. - The id invariant holds in the bytes: content whose frontmatter fails to parse rejects as
invalid— the ensure-id rule cannot make an id durable in malformed frontmatter, so the stored content always carries a parseableid:block. - Blob bytes never ride a mutation. Clients pre-upload to component-scoped storage; every
createBlobis validated againstblobStorageIdsin the same transaction. The oplog stores refs, never bytes. - Blob
sizeandhashare server-derived from the validated storage object (hash as lowercase SHA-256 hex); client claims land on the drift tripwire, never in the row. - A storage object backs exactly one blob row in the whole component, plus that row's
meshes.clonecopies —createBlobwith a storageId that ANY existing blob row already references rejects asinvalid, whatever mesh that row belongs to. Cross-mesh sharing is the clone mechanism and nothing else, and a clone copy is verbatim. SodeleteBlob(and mesh teardown) deletes the stored bytes only with the LAST blob row referencing them (blobs.by_storageId), and a mesh can never destroy another mesh's media. - Declared metadata bounds.
meshIdandblobKeycap at 1024 characters, blobnameat 1024, blobtypeandmimeat 256.createBlobandrenameBlobrefuse an oversizedname,typeormimeasinvalid, naming the field and the cap. TheblobKeycap is whatrenameBlobenforces, where the key rides inside the operations JSON. An oversized key never reachescreateBlob: the key is an object FIELD NAME inblobStorageIds, and the platform refuses a field name over 64 characters before the component runs.meshes.createandmeshes.clonerefuse an oversized mesh id. The caps apply to the values as STORED, so a name or type derived from the blob key meets the same cap as a supplied one. They exist so the component's own arithmetic is static: every chunked sweep, and the blob-delete charge that pays forby_storageIdprobe reads it cannot see, is bounded by construction rather than by a Convex index-key limit the component never stated. The caps are far above real data — a file path, a file name, an extension, an IANA media type. Rows written by an older component version are out of contract (no backcompat): a deployment carrying old data audits or resets it before upgrading. - One
batchesrow per applied non-empty batch,seq === version. Empty batches are explicit no-ops. - Structural leases gate multi-document gestures, with a 45s TTL crash backstop.
meshes.removeis paginated internally: bounded chunks per transaction, residue swept by self-scheduled continuations, the mesh row deleted last. Its FIRST transaction marks the meshremovingbefore it deletes anything, so nothing can mistake a half-emptied mesh for a whole one. Pollmeshes.getfor completion.meshes.clonecopies a mesh (row, documents, blob rows) into a new host-mintedtargetMeshId, paginated the same way — each chunk is bounded by BOTH a row count and the accumulated size of the WHOLE rows it carries (content, titles, keys, hashes, and a flat per-row allowance), so no shape of mesh can build an oversized transaction. The copy is born frozen andcloning. The oplog and leases are not copied.meshes.clonerefuses a source whose teardown has started, with a typedMESH_REMOVINGConvexError(isMeshRemovingError); a copy already in flight against such a source endscloneFailed. Teardown outlives the mesh row, so a source under teardown holds residue, not a version.- The copy is a snapshot of one source version. Each continuation compares the captured source version against the live one; a source batch landing mid-copy makes the copy delete what it wrote and start again (bounded restarts). So a finished copy is a state the source really had, never a mix of two.
- Completion contract: poll
meshes.getuntilcloning === false, then readcloneFailed.cloning === false && cloneFailed === falsemeans the copy is whole.cloneFailed === trueis terminal: the copy stopped partial (the source was removed or entered teardown, or changed past the restart budget) and the target must be discarded withmeshes.remove.updatedAtmoves on every continuation, so a stalled copy shows as staleness. - A frozen mesh is immutable, permanently:
operations.apply,leases.acquire,leases.extend,blobs.generateUploadUrlandmeshes.renamerefuse it with a typedMESH_FROZENConvexError. Reads are unaffected,leases.releasestill works (dropping a lock is not a write), andmeshes.removestill works. This guarantee holds inside the component, so no host bug can mutate a published copy.
Direct component callers: operations.apply reports a rejection by THROWING a typed ConvexError — that throw is what rolls the component sub-transaction back. The MeshComponent client class catches it and returns the contract's rejection-as-data union; a host calling the component function directly must catch the typed error itself.
What it does NOT do
- No auth. Components cannot read
ctx.auth. The host authorizes and passesmeshId(+author/principal). Never re-export raw component functions. - No parsing, no indexes, no sync/session tier, no history projections — hosts and clients own those.
Mounting
// convex/convex.config.ts (host app)
import { defineApp } from "convex/server"
import mesh from "@nuucognition/mesh-convex-component/convex.config"
const app = defineApp()
app.use(mesh)
export default app// convex/mesh.ts (host app) — authorized public surface
import { components } from "./_generated/api"
import { exposeMeshApi } from "@nuucognition/mesh-convex-component"
export const {
getManifest, loadIndex, getContent, getContents, listBatches,
applyOperations, generateUploadUrl, getBlobData,
acquireLease, extendLease, releaseLease,
} = exposeMeshApi(components.mesh, {
requireAccess: async (ctx, meshId, mode) => {
// Host ACL check. Throw to deny.
// return { author, principal } to bind identity into the write path.
},
})Host code with its own authorization can use the thin client directly:
import { MeshComponent } from "@nuucognition/mesh-convex-component"
const mesh = new MeshComponent(components.mesh)
const result = await mesh.applyOperations(ctx, { meshId, operationsJson, ... })cloneMesh is deliberately absent from exposeMeshApi: cloning is a host-authorized operation (publish, fork), never a remote client capability. Call it through MeshComponent after the host's own check.
const clone = await mesh.cloneMesh(ctx, { sourceMeshId, targetMeshId, name })
// clone.done === false → the copy continues in the background.
// Poll mesh.getMesh(ctx, targetMeshId) until `cloning` is false, then:
// cloneFailed === false → the version is complete and readable;
// cloneFailed === true → the copy stopped partial; report the failure and
// mesh.removeMesh(ctx, targetMeshId).Codegen
# from any Convex app directory:
pnpm exec convex codegen --component-dir <path-to>/src/component