Status: Implemented (2026-03-24). API surface (addDependency, replaceDependency, removeDependency, getDependencyEntity) all working. 21 cross-document tests pass including transitive deps.
Overview #
Dependency management layer supporting cross-document references, minor version updates, and transitive dependency resolution. Prioritizes minimal Yjs diff on updates and self-contained snapshots for integrity.
Key Design Decision: projectId as Cross-Doc Key #
Cross-document references use [entityUuid, projectId] tuples, NOT [entityUuid, documentGuid]. This means when a dependency is updated to a new minor version (new Y.Doc with different guid), all existing reference tuples remain valid — they point to the same projectId, which now resolves to a newer snapshot.
This eliminates pointer rewriting on dependency updates. The Yjs diff for a minor update is one blob replacement, not N reference tuple rewrites.
Storage Format #
yDependencies Map #
Y.Map<string, Uint8Array> // key: projectId, value: singular snapshot blob
Each entry is a singular snapshot blob — one Uint8Array containing the full dependency including its transitive deps, serialized as a self-contained bundle. This differs from the current design where each dependency gets a Y.Map<Uint8Array> of individual entity entries.
Snapshot Blob Format #
The blob is an encoded bundle containing:
[header]
version: u8 // format version (1)
rootUuid: string // UUID of the root entity (the Site/ProjectPackage)
[entities]
count: varint // number of entities in this bundle
for each entity:
uuid: string // entity UUID
sourceProjectId: string | null // which project this entity originally belongs to
// (null = belongs to this dependency's own project)
type: string // model type name (e.g. "Component", "Mixin")
attributes: any // serialized field values
parentUuid: string | null // parent entity UUID
The sourceProjectId field on each entity enables transitive dep resolution: when package A carries entities from package B, each B-entity is tagged with B's projectId. This allows the resolver to build a logical boundary map without needing separate storage per transitive dep.
Why Singular Blob #
- Space: one compressed blob per dependency vs one Y.Map entry per entity. The blob compresses well (entity types and field names repeat). Y.Map has per-key overhead in the Yjs CRDT structure.
- Atomicity: replacing a dependency is one Y.Map.set() call with one blob. Minimal Yjs operation log entry.
- Simplicity: loading order doesn't matter. Each blob is self-contained.
- Tradeoff: the entire blob must be deserialized to access any entity. For large dependencies this adds latency on first access. Acceptable for now; can move to a worker later.
Reference Resolution #
Tuple Format #
References to entities in dependencies use the tuple [entityUuid, projectId]. This is stored in Yjs attribute values wherever a reference field points to a dependency entity.
References to local (own-document) entities use [entityUuid] (no second element), same as today.
Resolution Algorithm #
function deref(doc, pointer, contextualProjectId?):
if not isTupleReference(pointer): return pointer
entityUuid = pointer[0]
projectId = pointer[1] ?? contextualProjectId
if projectId:
// Cross-document: resolve from dependency blob
blob = yDependencies.get(projectId)
if !blob: throw "dependency not loaded"
return materializeFromBlob(blob, entityUuid)
else:
// Local: resolve from own document (existing path)
return existingLocalDeref(doc, entityUuid)
Materialization from Blob #
function materializeFromBlob(blob, entityUuid):
// Check cache first
if entityCache.has(projectId, entityUuid): return cached
// Decode blob (lazy — only on first access per blob)
entities = decodeBlobIfNeeded(blob)
// Find entity
entry = entities.get(entityUuid)
if !entry: throw "entity not found in dependency"
// Materialize with isDependency: true
model = PlexusModel.__materializePredefined__(constructor, {
isDependency: true,
documentId: entry.sourceProjectId ?? projectId, // original project for transitive deps
uuid: entityUuid,
parent: entry.parentUuid ? materializeFromBlob(blob, entry.parentUuid) : null,
reference: [entityUuid, projectId], // standard tuple order; NOT sourceProjectId — we resolve via the dep that carries it
})
// Hydrate fields, resolving references with context
for each field in entry.attributes:
if isReference(field.value):
// The reference might point to:
// 1. Another entity in this same blob (same or different sourceProjectId)
// 2. An entity in a DIFFERENT dependency blob
// Use sourceProjectId as contextualProjectId for resolution
resolve with deref(doc, field.value, entry.sourceProjectId ?? projectId)
cache.set(projectId, entityUuid, model)
return model
Transitive Dependency Resolution #
When package A depends on package B:
- A's blob contains A's own entities (sourceProjectId = null)
- A's blob ALSO contains B's entities (sourceProjectId = B's projectId)
- References within A's entities to B's entities use
[uuid, B-projectId]
When the consumer loads A, and also independently loads B:
- B's entities exist in TWO places: inside A's blob (as carried transitive deps) and in B's own blob
- Resolution prefers the independently loaded blob when available (it may be newer)
- Falls back to the carried copy if B is not independently loaded
Priority order for resolving [entityUuid, projectId]:
- Independently loaded blob for that projectId (if exists) — this is the "highest minor wins" rule
- Entity tagged with that sourceProjectId inside any loaded blob — transitive fallback
API Surface #
New Methods #
class Plexus<Root> {
/**
* Add a dependency. Stores the blob in yDependencies keyed by projectId.
* Throws if projectId already exists (use replaceDependency for updates).
*/
addDependency(projectId: string, blob: Uint8Array): Root;
/**
* Replace an existing dependency with a new version.
* Invalidates all materialization caches for this projectId.
* Existing [uuid, projectId] references auto-resolve to new entities.
* Yjs diff: one map entry replacement.
*/
replaceDependency(projectId: string, blob: Uint8Array): Root;
/**
* Remove a dependency. References become dangling (deref throws).
*/
removeDependency(projectId: string): void;
/**
* Resolve a specific entity from a dependency.
* Used by deref for cross-document resolution.
*/
getDependencyEntity(projectId: string, entityUuid: string): PlexusModel;
}
Changed Behavior #
addDependencysignature changes: first param isprojectId(stable identifier) instead ofdocumentGuid(changes per version)- Blob format changes from
Y.Map<Uint8Array>(per-entity entries) toUint8Array(singular encoded blob) derefusesprojectIdfor cross-doc resolution instead ofdocumentGuid
Blob Creation (Publish Side) #
When a project publishes, the snapshot blob is created:
function createDependencyBlob(plexus, exportList):
entities = []
// 1. Collect exported entities and their owned subtrees
for each exported entity:
walk ownership tree, add each entity with sourceProjectId = null
// 2. Collect referenced dependency entities (transitive)
for each reference to a dependency entity found during walk:
add entity with sourceProjectId = that dependency's projectId
recursively include its owned subtree
// 3. Encode as singular blob
return encodeBlob(rootUuid, entities)
This produces a self-contained blob: all entities needed to fully materialize the package, including transitively referenced entities from other packages.
Cache Invalidation on Replace #
When replaceDependency is called:
- Remove all entries for this projectId from the entity materialization cache
- Replace the blob in
yDependencies - Any MobX observers tracking dependency entities will re-trigger on next access (the cached instances are gone, new materialization happens)
- Existing reference tuples
[uuid, projectId]remain unchanged in the Yjs doc — zero pointer rewriting
Migration Path #
From Current Format #
Current: yDependencies: Y.Map<Y.Map<Uint8Array>> — outer key is documentGuid, inner map is per-entity.
New: yDependencies: Y.Map<Uint8Array> — key is projectId, value is singular blob.
Migration: on load, detect format (check if value is Y.Map or Uint8Array). If old format, re-encode to new format in a transaction. This is a one-time migration per document.
Reference Tuple Migration #
Current references use documentGuid as the second tuple element. New references use projectId. During migration, rewrite reference tuples to use projectId. This requires a mapping from documentGuid → projectId, available from the dependency metadata.
Test Plan #
Unit Tests (packages/plexus) #
- Basic add/resolve: add dependency blob, resolve entities by UUID
- Read-only enforcement: dependency entities reject writes
- Replace dependency: replace blob, verify new entities materialize, old cache invalidated
- Remove dependency: remove blob, verify deref throws for removed projectId
- Cross-doc references: entity A references entity B via
[uuid, projectId], both in same blob - Transitive deps (P0 fix): package A carries entities from package B (different sourceProjectId). Load A's blob. Resolve B's entities via A's blob.
- Transitive deps with independent load: load both A (carries B) and B independently. Verify B's entities resolve from B's own blob (higher priority).
- Replace with new entities: replace dependency, verify new entities added since last version are accessible
- Replace with removed entities: replace dependency, verify removed entities cause deref failure
- Multiple dependencies: load A, B, C. Cross-references between them all resolve.
- Blob round-trip: create blob → load blob → verify all entities match original
Integration Tests (consumer apps) #
Consumers that mount multiple documents should cover: add/remove dependency, blob refresh after remote updates, and resolution of entities across the dependency edge. Keep those tests in the consumer repo — not here.
Open Questions #
-
Blob compression: should the blob be zlib/brotli compressed inside the Uint8Array? Reduces storage and sync payload but adds CPU cost on decode. Probably worth it for large deps.
-
Lazy blob decoding: the entire blob must be decoded on first access to any entity. For a dependency with 500 entities, this is noticeable. Should we decode on demand (per entity) or eagerly (on load)? Per-entity decoding needs an index/offset table in the blob format.
-
Worker offloading: blob decoding could run in a Web Worker to avoid main thread blocking. The entity cache would need to be shared or message-passing-based. Deferred to later.
-
Garbage collection of carried transitive deps: if A's blob carries B's entities, and B is also independently loaded, the carried copies in A's blob are redundant. Should they be evicted from A's blob on next publish of A? Or kept for integrity?