attach-primitive
$
npx mdskill add EpicenterHQ/epicenter/attach-primitiveDefine and apply contracts for attach* and create* workspace primitives
- Clarify when to use side-effectful attach* vs pure create* functions
- Includes primitives like attachIndexedDb, attachSqlite, and attachBroadcastChannel
- Enforces naming and behavior invariants for workspace composition
- Guides listener registration and resource management decisions
SKILL.md
.github/skills/attach-primitiveView on GitHub ↗
---
name: attach-primitive
description: Contract and invariants for `attach*` composition primitives in `packages/workspace` (side-effectful building blocks like attachIndexedDb, attachSqlite, attachBroadcastChannel, attachEncryption, attachTable, openCollaboration), and when to use `create*` (pure construction) instead. Use when writing or reviewing an `attach*` or `create*` function, naming a new workspace primitive, composing inside a workspace builder, or deciding whether a primitive registers listeners at call time.
---
# Attach Primitives
Every persistence, sync, materializer, and binding in `packages/workspace` (plus session-shaped primitives in `packages/cli`) follows one shape. Match the invariants exactly.
## Naming
| Prefix | Meaning |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `attach*` | Side-effectful. Registers observers, destroy listeners, or subscription state. Return shape is free: fixed surface *or* chainable builder, both are `attach*`. |
| `create*` | Pure construction. No listeners, no subscriptions, no destroy registration at call time. Cache constructors qualify, e.g. `createFileContentDocs` returns a `createDisposableCache` result; nothing attaches until `.open(id)` is called. |
Both return plain objects. The distinction is **what happens at call time**, not what the return value looks like. A chainable builder with `.table()/.kv()` that registers `table.observe(...)` is still `attach*`; chainability is a return-shape concern, orthogonal to naming.
## The shape
```ts
export function attachX(subject: TSubject, opts: XOptions): XAttachment;
```
**One rule.** The first argument is the subject being modified. The subject is almost always a `Y.Doc`. In the rare case where a primitive operates on an existing attachment rather than a ydoc, the subject is that attachment.
Examples of the common form:
```ts
attachIndexedDb(ydoc) // Y.Doc subject
attachSqlite(ydoc, { filePath })
openCollaboration(ydoc, { url, openWebSocket, replicaId, actions })
attachBroadcastChannel(ydoc)
attachEncryption(ydoc, { encryptionKeys })
attachTable(ydoc, name, def) // Y.Doc subject + slot key + def
attachTables(ydoc, defs)
attachKv(ydoc, defs)
attachAwareness(ydoc, defs)
attachRichText(ydoc) / attachPlainText(ydoc) / attachTimeline(ydoc)
```
**Chainable return is allowed** when per-entity configuration is incremental. Materializers follow the same `attachX(ydoc, opts)` shape: the builder registers specific table/kv references via `.table(ref, cfg)`:
```ts
attachMarkdownMaterializer(ydoc, { dir, waitFor })
.table(tables.files, {
filename: slugFilename('title'),
// Most real tables store body content in a separate Y.Doc (via
// createDisposableCache), so toMarkdown / fromMarkdown are typically
// bespoke callbacks; no sugar helper can abstract the async
// open/await/dispose cycle usefully.
toMarkdown: async (row) => {
using doc = fileContentDocs.open(row.id);
await doc.whenReady;
return { frontmatter: { id: row.id, name: row.name }, body: doc.content.read() };
},
})
.kv(kv);
attachSqliteMaterializer(ydoc, { db, waitFor })
.table(tables.posts, { fts: ['title'] });
```
Passing `tables.files` directly (rather than a string name) mirrors y-prosemirror / y-codemirror: take the specific shared resource, not a bag plus a lookup key. The materializer reads `table.name` and `table.definition` off the reference internally. All `.table()` / `.kv()` registrations must happen synchronously after construction; calls after `whenFlushed` resolves throw.
## The One Exception: Non-Ydoc Subject
When a primitive modifies a sibling attachment and the coordination is cross-package (so it can't be a method on the coordinator), it's a top-level function with the attachment as first arg:
```ts
attachSessionUnlock(encryption, { sessions, serverUrl, waitFor })
```
This is rare: one example in the whole codebase. It lives in `@epicenter/cli` and operates on an `EncryptionAttachment` defined in `@epicenter/workspace`; making it a method on the workspace type would couple packages backwards, so it stays a top-level function.
If the primitive is in-package with its coordinator, prefer method-on-coordinator (below) over a top-level `attachX(attachment, opts)`.
## Coordinator pattern (the encryption case)
When one attachment registers additional sibling attachments into itself, it owns the method surface:
```ts
const encryption = attachEncryption(ydoc, { encryptionKeys });
const tables = encryption.attachTables(defs); // method on coordinator
const kv = encryption.attachKv(defs);
```
vs. the top-level form that would read:
```ts
const tables = attachEncryptedTables(ydoc, encryption, defs); // ← not used; coordinator owns this
```
The method form says "use the encryption's attach-tables" directly. Preferred when the coordinator and its siblings live in the same package.
## Invariants
1. **Synchronous return.** Construction never awaits. Startup work goes into semantic `when*` promises on the returned object. Genuine async teardown exposes a `whenDisposed` promise field resolved from the `ydoc.destroy()` cascade.
2. **Teardown hooked to the subject's lifecycle.**
- Y.Doc subject: `ydoc.once('destroy', ...)`. Never expose a `.destroy()` method on the attachment.
- Attachment subject: use the subject attachment's disposal signal; or no teardown if there are no listeners.
- Chainable builders (materializers): `[Symbol.dispose]()` method on the builder, unsubscribes observers.
3. **Idempotent cleanup.** If the underlying library also registers a destroy handler (like `y-indexeddb`), your handler must be safe to run alongside it.
4. **Plain data returned.** The attachment is a record of promises, functions, and occasionally mutable state. No ES classes, no getters that lazy-init.
5. **No id option on ydoc-bound primitives.** `ydoc.guid` is the identity. Read it off the doc.
6. **Barrier naming is semantic, not mechanical.** Pick the name that describes the actual event:
- `whenLoaded`: local state replayed into the ydoc (IDB, SQLite)
- `whenConnected`: remote transport up + first exchange done (sync)
- `whenChecked`: configuration action settled (session-unlock; resolves even if nothing was applied)
- `whenFlushed`: initial side-effect pass done (materializer)
- `whenReady`: bundle-level aggregate only; not on individual attachments
## Composition inside a workspace builder
Primitives compose inside a build closure:
```ts
const cache = createDisposableCache((id: string) => {
const ydoc = new Y.Doc({ guid: id, gc: false });
const encryption = attachEncryption(ydoc, { encryptionKeys });
const tables = encryption.attachTables(schema); // coordinator method
const idb = attachIndexedDb(ydoc);
const unlock = attachSessionUnlock(encryption, { // non-ydoc subject
sessions, serverUrl, waitFor: idb.whenLoaded,
});
const collaboration = openCollaboration(ydoc, {
url, openWebSocket, replicaId, actions,
waitFor: Promise.all([idb.whenLoaded, unlock.whenChecked]),
});
const markdown = attachMarkdownMaterializer(ydoc, { // chainable return
dir, waitFor: collaboration.whenConnected,
}).table(tables.posts, { filename: slugFilename('title') });
return {
ydoc, tables, encryption, idb, collaboration, markdown,
whenReady: Promise.all([idb.whenLoaded, unlock.whenChecked, collaboration.whenConnected]),
async wipe() {
ydoc.destroy();
await collaboration.whenDisposed;
await idb.whenDisposed;
await idb.clearLocal();
},
[Symbol.dispose]() { ydoc.destroy(); },
};
});
export const workspace = cache.open('my-app');
```
The bundle aggregates child `whenLoaded` / `whenConnected` / `whenChecked` into one `whenReady`. Browser bundles expose `wipe()` for reset flows that must dispose and delete local storage in the right order. Daemon bundles expose `[Symbol.asyncDispose]()` as the trigger and await attachment `whenDisposed` barriers before process exit.
## The `waitFor` convention
Primitives that perform a gated startup (collaboration, session-unlock) accept `waitFor?: Promise<unknown>` in their options. The primitive awaits it before taking its first action. This replaces the old extension-chain "init pipeline": sequencing is now explicit at the call site, visible in one file, with no hidden ordering.
Use it whenever a primitive's startup must follow another's. Examples:
- `openCollaboration` after local hydrate: `waitFor: idb.whenLoaded`
- `attachSessionUnlock` after hydrate (so stored keys don't clobber freshly-hydrated plaintext mid-replay): `waitFor: persistence.whenLoaded`
- `openCollaboration` after both hydrate AND unlock: `waitFor: Promise.all([idb.whenLoaded, unlock.whenChecked])`
## Anti-patterns
- **Don't revive `ExtensionContext` / `RawExtension` / `defineExtension`.** Those were deleted for a reason: the lifecycle framework added a registration indirection that primitives don't need.
- **Don't wrap attachments in a `createWorkspace().with(...)` chain.** Compose inline in the factory.
- **Don't expose `dispose()` on a ydoc-bound attachment.** Destroy the Y.Doc.
- **Don't duck-type an attachment.** If you need to brand it, use a `Symbol.for` marker. See `skills/typescript`: runtime shape-checking is a code smell.
- **Don't take an `id` on a ydoc-bound primitive.** Use `ydoc.guid`.
- **Don't use `createX` for a side-effectful primitive.** If it registers listeners, it's `attach*`.
- **Don't introduce `attachEncryptedX(ydoc, encryption, ...)` top-level exports.** Use `encryption.attachX(...)`: the coordinator owns its siblings.
## Reference implementations
- `packages/workspace/src/document/attach-indexed-db.ts` ; the canonical 40-line example.
- `packages/workspace/src/document/open-collaboration.ts` ; document collaboration surface with sync, presence, peers, and action dispatch.
- `packages/workspace/src/document/attach-encryption.ts` ; state-owning coordinator; exposes `attachTable` / `attachTables` / `attachKv` as methods.
- `packages/workspace/src/document/materializer/markdown/materializer.ts` ; chainable builder with `.table()/.kv()`.
- `apps/whispering/src/lib/client.ts`: full singleton composition.
More from EpicenterHQ/epicenter
- agent-goalWrite `/goal` prompts for long-running agent work in Codex or Claude Code. Use for slash goal, agent goal, durable objective, autonomous coding run.
- approachability-auditReview code as a new TypeScript developer. Use when code feels indirect, clever, hard to follow, or needs a pass on abstractions, names, first-read clarity.
- arktypeArktype: runtime validation, discriminated unions with .merge()/.or(), spread keys. Use when mentioning arktype, type(), union types, command/event schemas.
- authEpicenter auth packages: `@epicenter/auth`, `@epicenter/auth-svelte`, OAuth sessions, identity state, auth-owned fetch/WebSocket, and workspace lifecycle binding. Use when editing Epicenter auth clients, session state, hosted sign-in, or auth/workspace integration.
- autumnAutumn billing in Epicenter: `autumn.config.ts`, `autumn-js` credit checks, `atmn` CLI, plan gates, and metered AI usage. Use when changing billing, pricing, credits, plan access, refunds, or usage events.
- better-auth-best-practicesBetter Auth server/client setup: `auth.ts`, generated schema, DB adapters, sessions, cookies, env vars, and plugins. Use when mentioning Better Auth, betterauth, auth handlers, OAuth, email/password, or session configuration.
- better-auth-security-best-practicesBetter Auth security hardening: rate limits, secrets, CSRF, trusted origins, cookies, sessions, OAuth tokens, and audit logging. Use when reviewing auth security, brute-force protection, token handling, or deployment safety.
- change-proposalPresent proposed code changes visually before implementing. Use when: "show me options", "compare approaches", "what should we do", or when changes need before/after comparison.
- claude-code-consultUse this skill when the user asks to consult Claude, ask Claude Code, get another model's take, run a taste check, find cleaner options, or prepare a Claude prompt. Create a bounded second-opinion prompt or run a read-only Claude Code consult, then verify Claude's claims against local files.
- cloudflare-workersCloudflare Workers patterns for Worker runtime APIs, Durable Objects, KV, R2, D1, Queues, WebSockets, streaming responses, bindings, wrangler configuration, and deployment limits. Use when users mention Cloudflare Workers, Durable Objects, KV, R2, D1, Queues, wrangler, or edge runtime behavior.