Written by a codding-agent
Type-safe building blocks for managing projects composed of parts, versions, and version combos. The library focuses on deterministic snapshots, pluggable storage/adapters, and tooling-friendly lifecycle management.
- Strict TypeScript typings with branded identifiers for every domain entity.
- In-memory, localStorage, and SQLite storage providers for testing, browser, and server persistence.
- Runtime storage selection via a registry pattern for pluggable backends.
ProjectRegistryandProjectHandleabstractions to manage multiple open projects concurrently.- Deterministic project snapshot builder with validation for duplicate or unknown parts/versions.
- Utility helpers for ID generation, cloning, and concurrency control.
- Ready-to-extend hooks for future middleware integrations (commented TODOs denote insertion points).
The package ships both ESM and CommonJS bundles plus declaration files so it can be consumed from modern build tools or Node runtimes.
The following walkthrough demonstrates the full lifecycle: creating a project, defining parts and versions, composing combos, and persisting updates.
import {
InMemoryStorageProvider,
ProjectRegistry,
createAdapterId,
createComboId,
createPartId,
createPartVersionId,
} from 'version-container';
const storage = new InMemoryStorageProvider();
const registry = new ProjectRegistry({
storage,
adapters: [
// register real adapters here (e.g. Git, HTTP). For now we rely on IDs only.
],
});const enginePartId = createPartId('engine');
const engineV1Id = createPartVersionId('engine-v1');
const baselineComboId = createComboId('baseline');
const adapterId = createAdapterId('in-memory');
const handle = await registry.open({
name: 'Rocket Guidance System',
metadata: { owner: 'avionics-team' },
combos: [
{
id: baselineComboId,
name: 'Baseline',
bindings: [
{
partId: enginePartId,
versionId: engineV1Id,
},
],
},
],
});
await registry.addPart(handle.projectId, {
id: enginePartId,
name: 'Engine Controller',
adapterId,
});
await registry.addPartVersion(handle.projectId, enginePartId, {
id: engineV1Id,
label: '1.0.0',
locator: { uri: 'memory://engine@1.0.0' },
});
const snapshot = await handle.getSnapshot();
console.log(snapshot.project.name); // "Rocket Guidance System"Behind the scenes ProjectRegistry converts your initialization data into a validated ProjectSnapshot, persists it via the chosen storage provider, and returns a ProjectHandle wired with adapters, a clock, and concurrency guards.
Atomic helpers on the registry/handle let you add or refine parts and versions without re-sending the full snapshot.
const newVersionId = createPartVersionId('engine-v1.1.0');
await registry.addPartVersion(handle.projectId, enginePartId, {
id: newVersionId,
label: '1.1.0',
locator: { uri: 'memory://engine@1.1.0' },
});
await registry.updatePartVersion(handle.projectId, newVersionId, (version) => ({
...version,
metadata: { releaseNotes: 'Improved fuel mixture' },
}));Combos can be created during initialization (as shown above), but you can also add and update combos after the project is created:
// Add a new combo
const stagingCombo = await registry.addCombo(handle.projectId, {
name: 'Staging',
description: 'Pre-production configuration',
bindings: [
{
partId: enginePartId,
versionId: newVersionId,
},
],
});
// Update an existing combo's bindings
await registry.updateCombo(handle.projectId, stagingCombo.id, (combo) => ({
...combo,
name: 'Staging v2',
description: 'Updated staging configuration',
bindings: [
{
partId: enginePartId,
versionId: newVersionId,
},
],
}));
// Delete a combo that's no longer needed
await registry.deleteCombo(handle.projectId, baselineComboId);The addCombo and updateCombo methods validate that all referenced parts and versions exist, and that each version belongs to its corresponding part. The updateCombo method preserves the original createdAt timestamp while updating updatedAt.
Projects can define a custom ordering for parts that persists independently of the array order. This is useful for UI display or when the logical order differs from the default ID-based sorting:
// Get the current parts order (returns all parts if no custom order is set)
const order = await registry.getPartsOrder(handle.projectId);
console.log(order); // ['part-1', 'part-2', ...]
// Set a complete custom order
await registry.setPartsOrder(handle.projectId, [
createPartId('wheels'),
createPartId('engine'),
createPartId('chassis'),
]);
// Move a single part to a new position
await registry.movePartOrder(handle.projectId, createPartId('engine'), 0);The parts order is stored in project.metadata.partsOrder and validates that all referenced part IDs exist in the project. Changes to the order emit a partsOrder:updated event.
When deleting parts or versions, the library uses soft delete by default. Items are marked as deleted via metadata.deletedAt rather than being immediately removed:
// Soft delete a version (marks it as deleted but keeps it in the snapshot)
await registry.deletePartVersion(handle.projectId, engineV1Id);
// Soft delete a part (cascades to mark all its versions as deleted)
await registry.deletePart(handle.projectId, enginePartId);Soft-deleted items are excluded from queries by default:
// findParts excludes soft-deleted parts
const activeParts = await registry.findParts(handle.projectId);
console.log(activeParts.length); // Does not include deleted parts
// Include soft-deleted items with the includeDeleted option
const allParts = await registry.findParts(handle.projectId, {
includeDeleted: true,
});
console.log(allParts.length); // Includes deleted parts
// getPartById returns undefined for deleted items (default behavior)
const part = await registry.getPartById(handle.projectId, enginePartId);
console.log(part); // undefined
// Include deleted items when getting by ID
const deletedPart = await registry.getPartById(
handle.projectId,
enginePartId,
{ includeDeleted: true }
);
console.log(deletedPart?.metadata?.deletedAt); // ISO8601 timestampTo permanently remove soft-deleted items, use the clean operations:
// Permanently remove all soft-deleted parts from the project
const removedParts = await registry.cleanDeletedParts(handle.projectId);
console.log(removedParts); // Array of removed part definitions
// Permanently remove all soft-deleted versions
const removedVersions = await registry.cleanDeletedVersions(handle.projectId);
console.log(removedVersions); // Array of removed version definitionsClean operations permanently remove items from the snapshot. This is useful for reclaiming storage after soft-deleted items are no longer needed.
When parts or versions are no longer needed, you can delete them. The library enforces referential integrity—parts and versions referenced by any combo cannot be deleted:
// This will fail if the version is referenced by a combo
await registry.deletePartVersion(handle.projectId, engineV1Id);
// First, update or remove combos that reference it
await registry.updateCombo(handle.projectId, baselineComboId, (combo) => ({
...combo,
bindings: combo.bindings.map((b) =>
b.versionId === engineV1Id
? { ...b, versionId: newVersionId }
: b
),
}));
// Now the deletion succeeds (soft delete - marked as deleted)
await registry.deletePartVersion(handle.projectId, engineV1Id);
// Delete a part (cascades to mark all its versions as deleted)
await registry.deletePart(handle.projectId, enginePartId);For advanced scenarios, the handle still exposes update for direct snapshot manipulation:
await handle.update((snapshot) => ({
...snapshot,
project: {
...snapshot.project,
metadata: { ...snapshot.project.metadata, archived: true },
},
}));
await handle.save(); // persists only if the snapshot changedAll updates automatically receive a fresh updatedAt timestamp. Because mutations occur inside a mutex, concurrent callers cannot corrupt the in-memory cache.
ProjectRegistry.load rehydrates an existing project (or reuses the currently open handle). You can list or close open projects at any time:
const existingHandle = await registry.load(handle.projectId);
console.log(existingHandle === handle); // true, already open
console.log(registry.listOpenProjects()); // [handle.projectId]
await registry.close(handle.projectId); // closes and saves by defaultWhen close executes it optionally flushes dirty snapshots and releases cached resources so the project can be loaded elsewhere.
Every mutating operation emits typed events through a central dispatcher. Middleware and tooling can subscribe once and react to structural changes.
const events = registry.getEventDispatcher();
const unsubscribe = events.subscribe('version:updated', ({ projectId, version, snapshot }) => {
console.log(`Version ${version.id} for project ${projectId} updated`, snapshot.project.updatedAt);
});
await registry.updatePartVersion(handle.projectId, newVersionId, (current) => ({
...current,
label: '1.1.1',
}));
unsubscribe();Available events today include project:created, project:loaded, project:updated, project:closed, part:added, part:updated, part:removed, version:added, version:updated, version:removed, combo:added, combo:updated, combo:removed, and partsOrder:updated. Future middleware hooks will piggy-back on the same dispatcher.
The library provides synchronous query methods on ProjectHandle (and async delegations on ProjectRegistry) for finding entities by various criteria. Queries operate on the in-memory snapshot for fast lookups.
// Find parts by adapter, tags, or metadata
const gitParts = await registry.findParts(handle.projectId, {
adapterId: createAdapterId('git'),
});
const taggedParts = await registry.findParts(handle.projectId, {
tags: ['critical', 'production'],
});
// Find versions by part, label, or metadata
const engineVersions = await registry.findVersions(handle.projectId, {
partId: enginePartId,
});
// Find combos that reference a specific part or version
const combosUsingEngine = await registry.findCombos(handle.projectId, {
partId: enginePartId,
});
const combosUsingV1 = await registry.findCombos(handle.projectId, {
versionId: engineV1Id,
});The find* methods return arrays of entity IDs. Use the get*ById methods to retrieve full entities:
// Get full entity by ID
const part = await registry.getPartById(handle.projectId, enginePartId);
console.log(part?.name); // "Engine Controller"
// Get lightweight summary (id + name/description only)
const summary = await registry.getPartSummary(handle.projectId, enginePartId);
console.log(summary?.name); // "Engine Controller"Convenience methods are available for common queries:
// Get all versions for a part
const versionIds = await registry.getVersionsByPartId(handle.projectId, enginePartId);
// Get all combos that reference a part
const comboIds = await registry.getCombosByPartId(handle.projectId, enginePartId);
// Get all combos that reference a specific version
const comboIds = await registry.getCombosByVersionId(handle.projectId, engineV1Id);Filter behavior:
- adapterId: Exact match
- tags: Any match (returns entities that have at least one of the specified tags)
- metadata: Subset match (all filter key/values must exist in the target)
- partId/versionId: Exact match on the respective field
- includeDeleted: When true, includes soft-deleted items (default: false)
All library errors extend from VersionContainerError, enabling type-safe error handling:
import {
PartNotFoundError,
VersionNotFoundError,
VersionContainerError,
type VersionContainerErrorCode,
} from 'version-container';
try {
await registry.deletePart(projectId, partId);
} catch (error) {
if (error instanceof PartNotFoundError) {
// Handle missing part - error.partId is available
console.error(`Part ${error.partId} not found`);
} else if (error instanceof VersionContainerError) {
// Catch-all for any version-container error
console.error(`Error code: ${error.code}, entity: ${error.entityId}`);
} else {
throw error; // Re-throw unexpected errors
}
}| Error | Code | When Thrown |
|---|---|---|
PartNotFoundError |
PART_NOT_FOUND |
Part doesn't exist |
VersionNotFoundError |
VERSION_NOT_FOUND |
Version doesn't exist |
ComboNotFoundError |
COMBO_NOT_FOUND |
Combo doesn't exist |
ProjectNotFoundError |
PROJECT_NOT_FOUND |
Project not in storage |
PartAlreadyExistsError |
PART_ALREADY_EXISTS |
Part already exists |
VersionAlreadyExistsError |
VERSION_ALREADY_EXISTS |
Version already exists |
ComboAlreadyExistsError |
COMBO_ALREADY_EXISTS |
Combo already exists |
ProjectAlreadyOpenError |
PROJECT_ALREADY_OPEN |
Project already open |
UnknownVersionReferenceError |
UNKNOWN_VERSION_REFERENCE |
Combo references unknown version |
UnknownPartReferenceError |
UNKNOWN_PART_REFERENCE |
Combo references unknown part |
IdentifierChangeError |
IDENTIFIER_CHANGE |
Attempting to change entity ID |
VersionReassignmentError |
VERSION_REASSIGNMENT |
Moving version to different part |
ProjectClosedError |
PROJECT_CLOSED |
Operating on closed project |
ProjectNotInStorageError |
PROJECT_NOT_IN_STORAGE |
Project missing from storage |
DuplicateIdentifierError |
DUPLICATE_IDENTIFIER |
Duplicate ID in snapshot build |
PartAlreadyDeletedError |
PART_ALREADY_DELETED |
Part is already soft-deleted |
VersionAlreadyDeletedError |
VERSION_ALREADY_DELETED |
Version is already soft-deleted |
ProjectAccessDeniedError |
PROJECT_ACCESS_DENIED |
User doesn't match project owner |
All errors include a code property for programmatic handling and an entityId property with the relevant identifier.
The library includes built-in storage providers for different environments:
Fast, ephemeral storage ideal for testing and development:
import { InMemoryStorageProvider, ProjectRegistry } from 'version-container';
const storage = new InMemoryStorageProvider();
const registry = new ProjectRegistry({ storage });You can optionally pre-populate the storage with initial snapshots:
const storage = new InMemoryStorageProvider({
initialSnapshots: [existingSnapshot],
id: 'test-storage',
});Persists projects to browser localStorage for web applications:
import { LocalStorageStorageProvider, ProjectRegistry } from 'version-container';
const storage = new LocalStorageStorageProvider({
keyPrefix: 'my-app:', // Optional: customize key prefix (default: 'version-container:')
});
const registry = new ProjectRegistry({ storage });The localStorage provider handles:
- Automatic serialization/deserialization of snapshots
- Summary index for fast
listSummaries()queries - Index rebuild if corrupted
- Quota exceeded errors with descriptive messages
Server-side storage using SQLite with document-style storage, indexed search columns, and automatic schema migrations:
import { SqliteStorageProvider, ProjectRegistry } from 'version-container';
const storage = new SqliteStorageProvider({
filePath: './projects.db', // Optional: defaults to './version-container.db'
});
const registry = new ProjectRegistry({ storage });The SQLite provider stores full project snapshots as JSON documents while maintaining indexed columns for efficient queries:
CREATE TABLE snapshots (
project_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
owner_user_name TEXT,
owner_user_id TEXT,
owner_user_group_id TEXT,
parts_count INTEGER DEFAULT 0,
combos_count INTEGER DEFAULT 0,
data TEXT NOT NULL -- Full JSON document
);
CREATE INDEX idx_snapshots_name ON snapshots(name);
CREATE INDEX idx_snapshots_updated_at ON snapshots(updated_at DESC);
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at DESC);
CREATE INDEX idx_snapshots_owner_user_id ON snapshots(owner_user_id);
CREATE INDEX idx_snapshots_owner_user_group_id ON snapshots(owner_user_group_id);Features include:
- Document storage: Complete snapshot stored as JSON in
datacolumn (source of truth) - Indexed search: Fast lookups by
project_id,name,updated_at,created_at, and owner columns - Automatic migrations: Schema evolves via built-in migration system with version tracking
- Migration state:
_adapter_statetable tracks current schema version - Lazy initialization: Database connection created on first use
- Upsert semantics:
INSERT ... ON CONFLICT DO UPDATEfor save operations - Optional dependency injection: Pass an existing
Databaseinstance for testing - In-memory mode: Use
filePath: ':memory:'for testing
You can also use the provider with an external database instance:
import Database from 'better-sqlite3';
import { SqliteStorageProvider } from 'version-container';
const db = new Database('./my-app.db');
const storage = new SqliteStorageProvider({ db });
// When providing an external database, close() is a no-op
// You're responsible for closing the database yourself
await storage.close(); // Does nothing - you must call db.close()Server-side storage using MongoDB for scalable document persistence:
import { MongoDbStorageProvider, ProjectRegistry } from 'version-container';
const storage = new MongoDbStorageProvider({
connectionString: 'mongodb://localhost:27017',
database: 'my-app', // Optional: defaults to 'version-container'
collection: 'projects', // Optional: defaults to 'snapshots'
});
const registry = new ProjectRegistry({ storage });The MongoDB provider stores full project snapshots as documents in a collection. Each snapshot is stored as a single document with the project ID as the query key for efficient lookups.
Features include:
- Document storage: Complete snapshot stored as a BSON document
- Indexed queries: Uses
project.idfield for direct lookups - Lazy initialization: Connection created on first use
- Upsert semantics:
updateOnewithupsert: truefor save operations - Optional dependency injection: Pass an existing
MongoClientinstance for testing - Configurable connection: Customize connection string, database, and collection names
- Connection lifecycle:
close()method for cleanup (only closes internally-created clients)
You can also use the provider with an external MongoDB client:
import { MongoClient } from 'mongodb';
import { MongoDbStorageProvider } from 'version-container';
const client = new MongoClient('mongodb://localhost:27017');
const storage = new MongoDbStorageProvider({
client,
database: 'my-app'
});
// When providing an external client, close() is a no-op
// You're responsible for closing the client yourself
await storage.close(); // Does nothing - you must call client.close()Use the storage registry to select storage providers at runtime via string names:
import {
registerBuiltinStorageProviders,
createStorageProvider,
BuiltinStorageType,
ProjectRegistry,
} from 'version-container';
// Register built-in providers (in-memory, local-storage, mongodb, sqlite)
registerBuiltinStorageProviders();
// Select storage type at runtime (e.g., from config, environment, URL param)
const storageType = process.env.STORAGE_TYPE ?? BuiltinStorageType.SQLITE;
const storage = createStorageProvider(storageType);
const registry = new ProjectRegistry({ storage });Register your own storage provider for IndexedDB, remote APIs, or other backends:
import { registerStorageProvider, createStorageProvider, type StorageProvider } from 'version-container';
class IndexedDBStorageProvider implements StorageProvider {
readonly id = 'indexed-db';
async loadSnapshot(projectId: ProjectId): Promise<ProjectSnapshot | undefined> {
// ... IndexedDB implementation
}
async saveSnapshot(snapshot: ProjectSnapshot): Promise<void> {
// ... IndexedDB implementation
}
async listSummaries(): Promise<readonly ProjectSummary[]> {
// ... IndexedDB implementation
}
}
// Register the custom provider
registerStorageProvider('indexed-db', () => new IndexedDBStorageProvider());
// Use it
const storage = createStorageProvider('indexed-db');| Constant | Value | Class |
|---|---|---|
BuiltinStorageType.IN_MEMORY |
'in-memory' |
InMemoryStorageProvider |
BuiltinStorageType.LOCAL_STORAGE |
'local-storage' |
LocalStorageStorageProvider |
BuiltinStorageType.MONGODB |
'mongodb' |
MongoDbStorageProvider |
BuiltinStorageType.SQLITE |
'sqlite' |
SqliteStorageProvider |
The library supports tracking ownership information for all domain entities. This is useful for audit trails, access control, and reporting.
import {
createUserId,
createUserGroupId,
type OwnerInfo,
} from 'version-container';
// Create a project with owner information
const owner: OwnerInfo = {
userName: 'Jane Smith',
userId: createUserId('user-123'),
userGroupId: createUserGroupId('engineering-team'), // optional
};
const handle = await registry.open({
name: 'Rocket Guidance System',
owner,
});
// Create a part with owner
await registry.addPart(handle.projectId, {
id: createPartId('engine'),
name: 'Engine Controller',
adapterId,
owner: {
userName: 'John Doe',
userId: createUserId('user-456'),
},
});
// Create a version with owner
await registry.addPartVersion(handle.projectId, enginePartId, {
id: createPartVersionId('engine-v1'),
label: '1.0.0',
locator: { uri: 'memory://engine@1.0.0' },
owner: {
userName: 'John Doe',
userId: createUserId('user-456'),
},
});
// Create a combo with owner
await registry.addCombo(handle.projectId, {
name: 'Production',
bindings: [
{ partId: enginePartId, versionId: engineV1Id },
],
owner: {
userName: 'Jane Smith',
userId: createUserId('user-123'),
},
});Owner information is optional and mutable. You can update ownership at any time:
// Transfer ownership of a part
await registry.updatePart(projectId, partId, (part) => ({
...part,
owner: {
userName: 'New Owner',
userId: createUserId('user-789'),
},
}));Filter entities by user ID to find all items owned by a specific user:
// Find all parts owned by a specific user
const partsByUser = await registry.findParts(projectId, {
ownerUserId: createUserId('user-456'),
});
// Find all versions owned by a specific user
const versionsByUser = await registry.findVersions(projectId, {
ownerUserId: createUserId('user-456'),
});
// Find all combos owned by a specific user
const combosByUser = await registry.findCombos(projectId, {
ownerUserId: createUserId('user-456'),
});Lightweight summary types also include owner information for quick display:
const summary = await registry.getPartSummary(projectId, partId);
console.log(summary?.owner?.userName); // "John Doe"When a project has owner information, the library enforces access control. Users must provide their user ID when opening or loading projects that have an owner.
import { createUserId, type UserId } from 'version-container';
const myUserId = createUserId('user-123');
const otherUserId = createUserId('user-999');
// Open a project with owner - must provide matching user ID
const handle = await registry.open(
{
name: 'My Project',
owner: { userName: 'Jane Smith', userId: myUserId },
},
myUserId // Must match the owner's userId
);
// Auto-set owner when creating a project
const handle2 = await registry.open(
{
name: 'Another Project',
// No owner specified
},
myUserId // User is automatically set as owner
);
// Load a project with owner - must provide matching user ID
const loaded = await registry.load(handle.projectId, myUserId);
// Load a project without owner - no user ID needed
const noOwnerProject = await registry.load(someOtherProjectId);For admin or system operations, you can explicitly bypass ownership checks:
// Load any project regardless of ownership
const handle = await registry.load(
projectId,
undefined, // No user ID
{ ignoreOwnership: true } // Explicitly bypass
);When access control fails, a ProjectAccessDeniedError is thrown:
import { ProjectAccessDeniedError } from 'version-container';
try {
await registry.load(projectId, otherUserId);
} catch (error) {
if (error instanceof ProjectAccessDeniedError) {
console.log(`Access denied for project ${error.projectId}`);
console.log(`Required owner: ${error.requiredUserId}`);
}
}Important: Once a project is successfully loaded with proper credentials, subsequent operations on that registry instance work without re-specifying the user ID. The library tracks which user authenticated each project for internal operations.
The library provides a listProjects() API for querying projects across storage with support for filtering, sorting, and pagination. This is useful for building project browsers, dashboards, and admin interfaces.
import {
type ProjectsQuery,
type ProjectListResult,
createUserId,
createUserGroupId,
} from 'version-container';
// List all projects with default pagination (50 items per page)
const result = await registry.listProjects();
console.log(result.projects); // Array of project summaries
console.log(result.pagination); // { currentPage: 1, pageSize: 50, totalCount: 10, totalPages: 1, hasNext: false, hasPrevious: false }The listProjects() API enforces access control based on ownership:
- No filters provided: Returns only projects without owner information (public projects)
ownerUserIdspecified: Returns only projects owned by that userownerGroupIdspecified: Returns only projects owned by that groupincludeAll: true: Returns all projects (privileged operation - use with caution)
This default behavior prevents unauthorized access to projects owned by other users. For admin dashboards or system operations, explicitly set includeAll: true to bypass ownership filtering.
// Default: only projects without owner
const publicProjects = await registry.listProjects();
// Filter by user: only that user's projects
const myProjects = await registry.listProjects({
ownerUserId: myUserId,
});
// Admin: all projects (use carefully!)
const allProjects = await registry.listProjects({
includeAll: true,
});Find projects owned by a specific user or group:
// Get all projects owned by a user
const userProjects = await registry.listProjects({
ownerUserId: createUserId('user-123'),
});
// Get all projects owned by a group
const groupProjects = await registry.listProjects({
ownerGroupId: createUserGroupId('engineering-team'),
});Use case-insensitive pattern matching to find projects by name:
// Find all projects with "rocket" in the name
const rocketProjects = await registry.listProjects({
namePattern: 'rocket',
});Filter projects by creation or update date:
// Find projects created after a specific date
const recentProjects = await registry.listProjects({
createdAfter: '2024-01-01T00:00:00Z',
});
// Find projects updated within a date range
const updatedRecently = await registry.listProjects({
updatedAfter: '2024-06-01T00:00:00Z',
updatedBefore: '2024-12-31T23:59:59Z',
});Navigate through large result sets with page-based pagination:
// Get the first page with custom page size
const page1 = await registry.listProjects({
limit: 10,
page: 1,
});
// Navigate to next page if available
if (page1.pagination.hasNext) {
const page2 = await registry.listProjects({
limit: 10,
page: 2,
});
}
// Navigate to previous page
if (page2.pagination.hasPrevious) {
const page1Again = await registry.listProjects({
limit: 10,
page: 1,
});
}Multiple filters can be combined for refined queries:
// Find engineering team's "rocket" projects updated recently
const results = await registry.listProjects({
ownerGroupId: createUserGroupId('engineering-team'),
namePattern: 'rocket',
updatedAfter: '2024-06-01T00:00:00Z',
limit: 20,
});Each project in the results includes owner information and statistics:
const result = await registry.listProjects();
for (const project of result.projects) {
console.log(project.id); // Project ID
console.log(project.name); // Project name
console.log(project.description); // Optional description
console.log(project.createdAt); // Creation timestamp
console.log(project.updatedAt); // Last update timestamp
console.log(project.owner); // OwnerInfo or undefined
console.log(project.partsCount); // Number of parts
console.log(project.combosCount); // Number of combos
}The listProjects() API is supported by all built-in storage providers:
- SQLite: Uses indexed columns for efficient filtering and pagination. Includes automatic migration system for schema evolution.
- MongoDB: Uses aggregation pipelines with
$sizeoperator for computed stats. - In-Memory: Full-featured implementation for testing and development.
If a storage provider doesn't support listProjects(), calling the method will throw an error.
Middleware hooks are not yet implemented, but TODO markers in the code indicate where lifecycle events (project:create, project:load, project:save, etc.) will be exposed. These placeholders make it straightforward to add logging, validation, or policy enforcement layers in future iterations.
Key exports available today:
- Domain models –
ProjectInit,PartDefinition,VersionCombo,VersionComboInit, filter types (PartFilter,VersionFilter,ComboFilter,ProjectsQuery), summary types (PartSummary,VersionSummary,ComboSummary,ProjectListSummary,ProjectListResult), owner types (OwnerInfo,UserId,UserGroupId), and related branded ID types (seesrc/models). - Error types –
VersionContainerError(base class) and 15 specific error classes (PartNotFoundError,VersionNotFoundError, etc.) for type-safe error handling. - Utilities –
createPartId,createPartVersionId,createComboId,createAdapterId,createUserId,createUserGroupId,cloneValue, and theAsyncMutex. - Runtime services –
ProjectRegistry,ProjectHandle,ProjectEventDispatcher,buildProjectSnapshot, plus aSystemClockyou can replace with a deterministic clock in tests. - Storage providers –
InMemoryStorageProvider,LocalStorageStorageProviderfor browser persistence,SqliteStorageProviderandMongoDbStorageProviderfor server persistence. - Storage registry –
registerBuiltinStorageProviders,createStorageProvider,registerStorageProvider,BuiltinStorageTypefor runtime storage selection.
| Method | Description |
|---|---|
| Project Lifecycle | |
open(init) |
Create a new project |
load(projectId) |
Load an existing project |
close(projectId) |
Close a project |
| Part Management | |
addPart(projectId, init) |
Add a part to a project |
updatePart(projectId, id, mutator) |
Update a part |
deletePart(projectId, id) |
Soft delete a part (cascades to versions) |
| Version Management | |
addPartVersion(projectId, partId, init) |
Add a version to a part |
updatePartVersion(projectId, id, mutator) |
Update a version |
deletePartVersion(projectId, id) |
Soft delete a version |
| Combo Management | |
addCombo(projectId, init) |
Add a combo |
updateCombo(projectId, id, mutator) |
Update a combo |
deleteCombo(projectId, id) |
Delete a combo |
| Parts Order | |
getPartsOrder(projectId) |
Get current parts order |
setPartsOrder(projectId, partIds) |
Set complete parts order |
movePartOrder(projectId, partId, position) |
Move part to new position |
| Clean Operations | |
cleanDeletedParts(projectId) |
Permanently remove soft-deleted parts |
cleanDeletedVersions(projectId) |
Permanently remove soft-deleted versions |
| Query Methods | |
findParts(projectId, filter?) |
Find parts by adapter, tags, or metadata |
findVersions(projectId, filter?) |
Find versions by part, label, or metadata |
findCombos(projectId, filter?) |
Find combos by part/version reference or metadata |
getPartById(projectId, id, options?) |
Get full part entity by ID |
getVersionById(projectId, id, options?) |
Get full version entity by ID |
getComboById(projectId, id) |
Get full combo entity by ID |
getPartSummary(projectId, id) |
Get lightweight part summary |
getVersionSummary(projectId, id) |
Get lightweight version summary |
getComboSummary(projectId, id) |
Get lightweight combo summary |
getVersionsByPartId(projectId, partId) |
Get all version IDs for a part |
getCombosByPartId(projectId, partId) |
Get all combo IDs referencing a part |
getCombosByVersionId(projectId, versionId) |
Get all combo IDs referencing a version |
| Utility | |
listOpenProjects() |
List open projects |
listProjects(query?) |
List all projects with filtering and pagination |
getEventDispatcher() |
Get the event dispatcher |
Refer to the source modules for comprehensive type definitions and JSDoc comments.
/src
/lib # Runtime services, utilities, and clocks
/models # Domain model interfaces and README
/storages # Storage providers (in-memory MVP)
/index.ts # Public barrel exports
/tests # Legacy example-based tests
Each module ships with colocated unit tests (*.test.ts) and, where applicable, fixture/mocks directories.
npm run typecheck # TypeScript compile without emit
npm run lint # ESLint (flat config, TypeScript-aware)
npm run test # Vitest in watch mode
npm run test:ui # Vitest UI runner
npm run test:coverage # Coverage report
npm run build # Type declarations + Vite bundle
npm run format # Prettier write
npm run format:check # Prettier verifyFollow the architecture and testing principles documented in CLAUDE.md. Contributions should include updated tests, strict types, and JSDoc for any new public APIs.
ISC
In the next major release there is an idea to suppoort partial loading for projects.