Date: 2026-02-01
Status: Implemented
Branch: feat/auto-injected-version-discriminant
PR: Pending
This specification has been fully implemented with the following changes:
- Removed
HeadDocentirely from the codebase - Removed
createHeadDoc()function - Removed
registrymodule (workspace discovery via JSON files now) createWorkspace()now takesWorkspaceDefinitiondirectly (not{ headDoc, definition })- Removed epoch-based folder structure (flat structure now)
- SQLite materialized views (
tables.sqlite) - placeholder for now - Versioned workspaces with
versionControl: trueflag - archived patterns preserved indocs/articles/archived-head-registry-patterns.md
- Package:
create-workspace.ts,types.ts,index.ts(HeadDoc removed) - App: New
services/workspaces.ts, simplifiedworkspace.ts, updated persistence - Deleted:
head-doc.ts,head.ts,head-persistence.ts,registry.ts,registry-persistence.ts,reactive-*.svelte.ts
Introduce a simplified createWorkspace(definition) API that takes a WorkspaceDefinition directly, without requiring a HeadDoc. This enables a clean, definition-first pattern where workspace schema lives in JSON files and the Y.Doc contains only data.
The current API requires creating a HeadDoc even for simple workspaces that don't need epoch-based versioning:
// Current: Ceremony even for simple use cases
const head = createHeadDoc({ workspaceId, providers: { persistence } });
await head.whenSynced;
const workspace = createWorkspace({ headDoc: head, definition });Most workspaces don't need:
- Epoch-based versioning
- Time-travel / snapshots
- Schema migrations across epochs
For these simple cases, the HeadDoc is overhead. The WorkspaceDefinition already contains the workspace ID, so we can derive everything from it.
import { createWorkspace } from '@epicenter/workspace/dynamic';
const workspace = createWorkspace(definition)
.withExtension('persistence', (ctx) => workspacePersistence(ctx))
.withExtension('sqlite', sqlite);
await workspace.whenSynced;type WorkspaceDefinition = {
id: string; // Workspace identifier (Y.Doc guid)
name: string; // Display name
description: string; // Description
icon: Icon | null; // Emoji or Lucide icon
tables: TableDefinition[];
kv: KvField[];
};// Simple mode: gc: true for efficient storage
const ydoc = new Y.Doc({
guid: definition.id,
gc: true, // Tombstones get merged → 200-1000x smaller
});With gc: true, YKeyValueLww is extremely efficient:
- Tombstones from updates get merged into tiny metadata
- 200-1000x smaller than Y.Map for update-heavy data
- Trade-off: No snapshot/time-travel capability
{appDataDir}/workspaces/
└── {workspaceId}/
├── definition.json # WorkspaceDefinition (schema + metadata)
├── workspace.yjs # Y.Doc binary (source of truth)
├── tables.sqlite # Materialized view for queries
└── kv.json # KV values mirror
Example:
workspaces/
├── blog-workspace/
│ ├── definition.json
│ ├── workspace.yjs
│ ├── tables.sqlite
│ └── kv.json
└── notes-app/
├── definition.json
├── workspace.yjs
├── tables.sqlite
└── kv.json
{workspaceId}/definition.json:
{
"id": "blog-workspace",
"name": "My Blog",
"description": "Personal blog content",
"icon": "emoji:📝",
"tables": [
{
"id": "posts",
"name": "Posts",
"icon": "emoji:📄",
"fields": [
{ "id": "id", "type": "id" },
{ "id": "title", "type": "text", "name": "Title" },
{ "id": "content", "type": "text", "name": "Content" },
{ "id": "published", "type": "boolean", "default": false }
]
}
],
"kv": [
{
"id": "theme",
"type": "select",
"options": ["light", "dark"],
"default": "light"
},
{ "id": "postsPerPage", "type": "integer", "default": 10 }
]
}List all workspaces by globbing JSON files:
import { readDir } from '@tauri-apps/plugin-fs';
async function listWorkspaces(): Promise<WorkspaceDefinition[]> {
const baseDir = await appLocalDataDir();
const workspacesDir = await join(baseDir, 'workspaces');
const entries = await readDir(workspacesDir);
const definitions: WorkspaceDefinition[] = [];
for (const entry of entries) {
if (entry.name?.endsWith('.json')) {
const content = await readTextFile(await join(workspacesDir, entry.name));
definitions.push(JSON.parse(content));
}
}
return definitions;
}Add new overload that takes definition directly:
/**
* Create a simple workspace from a definition.
*
* This is the recommended API for workspaces that don't need:
* - Epoch-based versioning
* - Time-travel / snapshots
* - Schema migrations
*
* The Y.Doc is created with gc: true for efficient storage.
*
* @example
* ```typescript
* const workspace = createWorkspace(definition)
* .withExtension('persistence', persistence);
*
* workspace.tables.get('posts').upsert({ id: '1', title: 'Hello' });
* ```
*/
export function createWorkspace<
const TTableDefinitions extends readonly TableDefinition[],
const TKvFields extends readonly KvField[],
>(
definition: WorkspaceDefinition<TTableDefinitions, TKvFields>,
): WorkspaceClientBuilder<TTableDefinitions, TKvFields>;
/**
* Create a versioned workspace with HeadDoc for epoch management.
*
* Use this when you need:
* - Epoch-based versioning
* - Time-travel / snapshots (requires gc: false internally)
* - Schema migrations across epochs
*
* @example
* ```typescript
* const head = createHeadDoc({ workspaceId, providers: {...} });
* const workspace = createWorkspace({ headDoc: head, definition });
* ```
*/
export function createWorkspace<
const TTableDefinitions extends readonly TableDefinition[],
const TKvFields extends readonly KvField[],
>(
config: CreateWorkspaceConfig<TTableDefinitions, TKvFields>,
): WorkspaceClientBuilder<TTableDefinitions, TKvFields>;
// Implementation
export function createWorkspace<
const TTableDefinitions extends readonly TableDefinition[],
const TKvFields extends readonly KvField[],
>(
configOrDefinition:
| WorkspaceDefinition<TTableDefinitions, TKvFields>
| CreateWorkspaceConfig<TTableDefinitions, TKvFields>,
): WorkspaceClientBuilder<TTableDefinitions, TKvFields> {
// Detect which overload was called
const isSimpleMode =
'id' in configOrDefinition && !('headDoc' in configOrDefinition);
if (isSimpleMode) {
// Simple mode: definition only, gc: true
const definition = configOrDefinition as WorkspaceDefinition<
TTableDefinitions,
TKvFields
>;
const workspaceId = definition.id;
// gc: true for efficient YKeyValueLww storage
const ydoc = new Y.Doc({ guid: workspaceId, gc: true });
const tables = createTables(ydoc, definition.tables ?? []);
const kv = createKv(ydoc, definition.kv ?? []);
// ... rest of implementation (same pattern as current)
} else {
// Versioned mode: existing implementation with HeadDoc
const config = configOrDefinition as CreateWorkspaceConfig<
TTableDefinitions,
TKvFields
>;
// ... existing implementation
}
}Add definition to ExtensionContext for persistence access:
export type ExtensionContext<
TTableDefinitions extends readonly TableDefinition[] =
readonly TableDefinition[],
TKvFields extends readonly KvField[] = readonly KvField[],
> = {
ydoc: Y.Doc;
workspaceId: string;
epoch: number; // 0 for simple mode
tables: Tables<TTableDefinitions>;
kv: Kv<TKvFields>;
extensionId: string;
definition: WorkspaceDefinition<TTableDefinitions, TKvFields>; // ADD THIS
};Ensure exports are correct:
export { createWorkspace } from './workspace/create-workspace';
export type { WorkspaceDefinition } from '../core/schema/workspace-definition';
// ... rest unchangedSimplify to use new API:
import {
createWorkspace,
type WorkspaceDefinition,
} from '@epicenter/workspace/dynamic';
import { workspacePersistence } from './workspace-persistence';
/**
* Create a workspace client with persistence.
*
* Loads definition from JSON file if not provided.
*/
export function createWorkspaceClient(definition: WorkspaceDefinition) {
return createWorkspace(definition).withExtension('persistence', (ctx) =>
workspacePersistence(ctx),
);
}Update to work with simple mode:
import {
defineExports,
type ExtensionContext,
type Lifecycle,
} from '@epicenter/workspace/dynamic';
import { appLocalDataDir, join } from '@tauri-apps/api/path';
import { mkdir, readFile, writeFile } from '@tauri-apps/plugin-fs';
import * as Y from 'yjs';
export type WorkspacePersistenceConfig = {
/** Debounce interval for SQLite/JSON writes. @default 500 */
debounceMs?: number;
};
const FILE_NAMES = {
WORKSPACE_YJS: 'workspace.yjs',
TABLES_SQLITE: 'tables.sqlite',
KV_JSON: 'kv.json',
} as const;
export function workspacePersistence<TTableDefs, TKvFields>(
ctx: ExtensionContext<TTableDefs, TKvFields>,
config: WorkspacePersistenceConfig = {},
): Lifecycle {
const { ydoc, workspaceId, tables, kv } = ctx;
const { debounceMs = 500 } = config;
// Resolve paths
const pathsPromise = (async () => {
const baseDir = await appLocalDataDir();
const workspaceDir = await join(baseDir, 'workspaces', workspaceId);
return {
workspaceDir,
yjsPath: await join(workspaceDir, FILE_NAMES.WORKSPACE_YJS),
sqlitePath: await join(workspaceDir, FILE_NAMES.TABLES_SQLITE),
kvPath: await join(workspaceDir, FILE_NAMES.KV_JSON),
};
})();
// ─────────────────────────────────────────────────────────────────────────
// 1. Y.Doc Binary Persistence (immediate on every update)
// ─────────────────────────────────────────────────────────────────────────
const saveYDoc = async () => {
const { yjsPath } = await pathsPromise;
const state = Y.encodeStateAsUpdate(ydoc);
await writeFile(yjsPath, state);
};
ydoc.on('update', saveYDoc);
// ─────────────────────────────────────────────────────────────────────────
// 2. SQLite Materialized View (debounced full dump)
// ─────────────────────────────────────────────────────────────────────────
let sqliteTimer: ReturnType<typeof setTimeout> | null = null;
const saveSqlite = async () => {
const { sqlitePath } = await pathsPromise;
// Full dump of all tables to SQLite
// Implementation: iterate tables.names(), get all rows, write to SQLite
// For now, this is a placeholder - actual SQLite impl needed
console.log(`[Persistence] Would save SQLite to ${sqlitePath}`);
};
const scheduleSqliteSave = () => {
if (sqliteTimer) clearTimeout(sqliteTimer);
sqliteTimer = setTimeout(() => {
sqliteTimer = null;
saveSqlite();
}, debounceMs);
};
// Observe all table changes
for (const tableName of tables.names()) {
tables.get(tableName).observe(scheduleSqliteSave);
}
// ─────────────────────────────────────────────────────────────────────────
// 3. KV JSON Persistence (debounced)
// ─────────────────────────────────────────────────────────────────────────
let kvTimer: ReturnType<typeof setTimeout> | null = null;
const saveKvJson = async () => {
const { kvPath } = await pathsPromise;
const kvData = kv.toJSON();
const json = JSON.stringify(kvData, null, '\t');
await writeFile(kvPath, new TextEncoder().encode(json));
};
const scheduleKvSave = () => {
if (kvTimer) clearTimeout(kvTimer);
kvTimer = setTimeout(() => {
kvTimer = null;
saveKvJson();
}, debounceMs);
};
kv.observe(scheduleKvSave);
// ─────────────────────────────────────────────────────────────────────────
// Return Lifecycle
// ─────────────────────────────────────────────────────────────────────────
return defineExports({
whenSynced: (async () => {
const { workspaceDir, yjsPath } = await pathsPromise;
// Ensure directory exists
await mkdir(workspaceDir, { recursive: true }).catch(() => {});
// Load existing Y.Doc state
try {
const savedState = await readFile(yjsPath);
Y.applyUpdate(ydoc, new Uint8Array(savedState));
console.log(`[Persistence] Loaded ${workspaceId}/workspace.yjs`);
} catch {
console.log(`[Persistence] Creating new ${workspaceId}/workspace.yjs`);
await saveYDoc();
}
// Initial saves
await saveKvJson();
})(),
destroy() {
ydoc.off('update', saveYDoc);
if (sqliteTimer) clearTimeout(sqliteTimer);
if (kvTimer) clearTimeout(kvTimer);
},
});
}New service for workspace management:
import type { WorkspaceDefinition } from '@epicenter/workspace/dynamic';
import { appLocalDataDir, join } from '@tauri-apps/api/path';
import {
readDir,
readTextFile,
writeTextFile,
mkdir,
remove,
} from '@tauri-apps/plugin-fs';
import { generateGuid } from '@epicenter/workspace';
const WORKSPACES_DIR = 'workspaces';
/**
* Get the workspaces directory path.
*/
async function getWorkspacesDir(): Promise<string> {
const baseDir = await appLocalDataDir();
return join(baseDir, WORKSPACES_DIR);
}
/**
* List all workspace definitions by reading JSON files.
*/
export async function listWorkspaces(): Promise<WorkspaceDefinition[]> {
const dir = await getWorkspacesDir();
try {
const entries = await readDir(dir);
const definitions: WorkspaceDefinition[] = [];
for (const entry of entries) {
if (entry.name?.endsWith('.json') && entry.isFile) {
try {
const filePath = await join(dir, entry.name);
const content = await readTextFile(filePath);
definitions.push(JSON.parse(content));
} catch (e) {
console.warn(`Failed to parse ${entry.name}:`, e);
}
}
}
return definitions;
} catch {
// Directory doesn't exist yet
return [];
}
}
/**
* Get a single workspace definition by ID.
*/
export async function getWorkspace(
id: string,
): Promise<WorkspaceDefinition | null> {
const dir = await getWorkspacesDir();
const filePath = await join(dir, id, 'definition.json');
try {
const content = await readTextFile(filePath);
return JSON.parse(content);
} catch {
return null;
}
}
/**
* Create a new workspace.
*/
export async function createWorkspaceDefinition(
input: Omit<WorkspaceDefinition, 'id'> & { id?: string },
): Promise<WorkspaceDefinition> {
const dir = await getWorkspacesDir();
const definition: WorkspaceDefinition = {
id: input.id ?? generateGuid(),
name: input.name,
description: input.description ?? '',
icon: input.icon ?? null,
tables: input.tables ?? [],
kv: input.kv ?? [],
};
// Create workspace folder
const workspaceDir = await join(dir, definition.id);
await mkdir(workspaceDir, { recursive: true });
// Write definition.json inside the folder
const filePath = await join(workspaceDir, 'definition.json');
await writeTextFile(filePath, JSON.stringify(definition, null, '\t'));
return definition;
}
/**
* Update a workspace definition.
*/
export async function updateWorkspaceDefinition(
id: string,
updates: Partial<Omit<WorkspaceDefinition, 'id'>>,
): Promise<WorkspaceDefinition | null> {
const existing = await getWorkspace(id);
if (!existing) return null;
const updated: WorkspaceDefinition = {
...existing,
...updates,
id, // Ensure ID doesn't change
};
const dir = await getWorkspacesDir();
const filePath = await join(dir, id, 'definition.json');
await writeTextFile(filePath, JSON.stringify(updated, null, '\t'));
return updated;
}
/**
* Delete a workspace and all its data.
*/
export async function deleteWorkspace(id: string): Promise<boolean> {
const dir = await getWorkspacesDir();
try {
// Delete workspace folder (includes definition.json)
await remove(await join(dir, id), { recursive: true });
return true;
} catch {
return false;
}
}Update route loader:
import { error } from '@sveltejs/kit';
import { getWorkspace } from '$lib/services/workspaces';
import { createWorkspaceClient } from '$lib/docs/workspace';
export async function load({ params }) {
const { id } = params;
// Load definition from JSON file
const definition = await getWorkspace(id);
if (!definition) {
throw error(404, `Workspace "${id}" not found`);
}
// Create workspace client
const client = createWorkspaceClient(definition);
await client.whenSynced;
return {
definition,
client,
};
}-
Update
createWorkspacewith overload- Add function overload for
createWorkspace(definition) - Detect simple vs versioned mode
- Create Y.Doc with
gc: truefor simple mode - Pass
definitionthrough to ExtensionContext
- Add function overload for
-
Update
ExtensionContexttype- Add
definitionfield
- Add
-
Update exports
- Ensure all needed types are exported from
/dynamic
- Ensure all needed types are exported from
-
Create workspaces service
listWorkspaces()- glob JSON filesgetWorkspace(id)- read single definitioncreateWorkspaceDefinition()- write new JSON + create folderupdateWorkspaceDefinition()- update JSONdeleteWorkspace()- remove JSON + folder
-
Update workspace persistence
- Remove HeadDoc dependency
- Remove epoch folders (flat structure)
- Add SQLite materialized view (placeholder for now)
-
Update workspace client factory
- Use new
createWorkspace(definition)API - Remove HeadDoc creation
- Use new
-
Update route loaders
- Load definition from JSON
- Pass to createWorkspaceClient
-
Update UI components
- Use workspaces service for CRUD
- Remove HeadDoc-related code
-
Remove dead code
head.ts/head-persistence.ts(not needed for simple mode)registry.ts/registry-persistence.ts(using JSON files instead)- Any epoch-related code in persistence
- Update template format
- Ensure templates match WorkspaceDefinition structure
- Fix field definitions (array format, explicit IDs)
| File | Action |
|---|---|
src/dynamic/workspace/create-workspace.ts |
Add overload for simple mode |
src/dynamic/workspace/types.ts |
Add definition to ExtensionContext |
src/dynamic/index.ts |
Verify exports |
| File | Action |
|---|---|
src/lib/services/workspaces.ts |
NEW - Workspace CRUD via JSON files |
src/lib/docs/workspace.ts |
Simplify to use new API |
src/lib/docs/workspace-persistence.ts |
Remove epochs, add SQLite placeholder |
src/lib/docs/head.ts |
DELETE (not needed) |
src/lib/docs/head-persistence.ts |
DELETE (not needed) |
src/lib/docs/registry.ts |
DELETE (using JSON files) |
src/lib/docs/registry-persistence.ts |
DELETE (using JSON files) |
src/lib/docs/reactive-head.svelte.ts |
DELETE (not needed) |
src/lib/templates/*.ts |
Fix to match WorkspaceDefinition format |
src/lib/query/workspaces.ts |
Update to use workspaces service |
src/routes/**/+layout.ts |
Update loaders |
Later, we can add a Registry Y.Doc that stores definitions for sync:
// Future: Registry stores definitions in YKeyValueLww
const registry = createRegistry();
registry.set('workspace-abc', definition); // LWW, syncs across devices
// Still write JSON mirrors for debugging/backupWhen versionControl: true is needed:
// Future: Separate function or flag
const workspace = createVersionedWorkspace({ headDoc, definition });
// Or flag in definition
const definition = { ...def, versionControl: true };
const workspace = createWorkspace(definition); // Detects flag, uses HeadDoc internallyThe SQLite materialized view needs:
- Drizzle schema generation from TableDefinition
- Full table dump on debounced save
- Query API via
workspace.extensions.sqlite.db
This can be a separate extension or built into persistence.
- Unit tests for new
createWorkspace(definition)overload - Integration tests for workspace service CRUD
- E2E tests for workspace creation/loading flow
- Migration test - existing workspaces should still work (or have migration path)
Existing workspaces use the epoch folder structure:
workspaces/{id}/head.yjs
workspaces/{id}/{epoch}/workspace.yjs
Migration options:
- Manual: User exports and reimports
- Automatic: Detect old structure, migrate on first load
- Parallel: Support both structures during transition
Recommendation: Start fresh for now (dev mode), add migration later if needed.
- Handoff Prompt:
specs/20260201T120000-simple-definition-first-workspace.handoff.md- Copy-paste prompt for agent execution - Archived Patterns:
docs/articles/archived-head-registry-patterns.md- HeadDoc and Registry patterns preserved for future versioned workspace implementation - GC Decision Guide:
docs/articles/ykeyvalue-vs-ymap-decision-guide.md- Why we usegc: truewith YKeyValueLww - GC Deep Dive:
docs/articles/ykeyvalue-gc-the-hidden-variable.md- The hidden variable that determines data structure choice - Storage Guide:
docs/articles/yjs-gc-on-vs-off-storage-guide.md- Complete guide to GC on vs off