Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .codex/environments/environment.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This file is shared by Codex desktop local environments.
version = 1

name = "compendium"

[setup]
script = '''
set -euo pipefail

# Codex creates each agent worktree at a new path. This repository is
# explicitly trusted, so register only its checked-in mise configuration
# before using mise to install the local quality gates.
mise trust --yes mise.toml
npm ci
mise x prek@0.4.8 -- prek install --hook-type pre-commit --hook-type pre-push
'''

[[actions]]
name = "Pre-commit"
icon = "check"
command = "mise x prek@0.4.8 -- prek run --all-files"

[[actions]]
name = "Type check"
icon = "check"
command = "npm run check"

[[actions]]
name = "Coverage"
icon = "test"
command = "npm run test:coverage"

[[actions]]
name = "End-to-end tests"
icon = "test"
command = "sh scripts/pre-push-check.sh"
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ repos:
files: \.(ts|svelte)$
exclude: ^(node_modules|\.svelte-kit|build)
pass_filenames: false
- id: e2e
name: Build and run end-to-end tests
language: system
entry: sh scripts/pre-push-check.sh
pass_filenames: false
stages: [pre-push]
8 changes: 4 additions & 4 deletions docs/specifications/audit-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ The fix is a generic observer on the server's own `Y.Doc` (`src/lib/server/audit

## 2. How the observer attributes without new tagging

y-protocols/sync applies an incoming client update via `Y.applyUpdate(doc, update, ws)` (`yjs-ws-server.ts`), so the resulting transaction's `origin` is that connection's own `ws` object. Every service-layer write, by contrast, calls `doc.transact(fn)` with no origin, which defaults to `null` (yjs's own `transact(doc, f, origin = null)`). That distinction already existed, unmodified, on every write path in this codebase — `attachDocAuditObserver` (called once per resolved `Y.Doc`, from `workspace-store.ts`'s `createContext()`, after the initial snapshot load) just reads it:
`src/lib/mutation-origin.ts` defines the closed mutation-origin contract. The WebSocket server uses a named `remote-ui` origin, the browser uses `local-ui`, services use `service`, and migration/replay/undo-redo/test paths have their own explicit origins. `attachDocAuditObserver` rejects an unrecognized origin rather than inferring meaning from nullability or object shape.

- `transaction.origin == null` → a service-layer write. Already audited by its own `logAudit` call — the observer does nothing, so nothing is double-counted.
- `service`, `migration`, `replay`, and `undo-redo` are intentionally not observer-audited. Service calls retain their own `logAudit` operation, preventing duplicates.
- `transaction.origin` is anything else → a real y-websocket client wrote directly to the doc. The observer resolves what changed and logs it, attributed to `CURRENT_USER` (Phase 0/1 is single-tenant — every live UI connection is the one workspace owner; see `service-layer.md` §3's note that UI writes are currently unscoped).

Resolving _what_ changed walks `transaction.changed` (the Yjs-native per-transaction diff): a key added/removed directly on `documents`/`collections`/`records` (the three top-level maps) is a whole entry created or deleted; anything else is walked up via `.parent` until it reaches one of those three maps, attributing the change to whichever entry owns it — e.g. a record's `content` Y.Text's parent is that record's own `Y.Map`, whose parent is the top-level `records` map. A document's `recordIds` reorder (dragging a block, or a record being added/removed from it) resolves to that _document_, not the moved record — reordering is a structural fact about the document distinct from the record's own content, and is logged as its own `update_document`/`update_collection` event alongside whatever `create_record`/`delete_record` event fired in the same transaction.
Expand Down Expand Up @@ -50,12 +50,12 @@ Create and delete are discrete, comparatively rare events — logged immediately
Two operational details this implies:

- `flushPendingAuditEvents()` (exported from `audit-observer.ts`) writes any pending debounced events immediately instead of waiting out the window — called on process shutdown (`ydoc.ts`'s `SIGINT`/`SIGTERM` handler) so a debounced edit made just before shutdown isn't lost, and available to tests that don't want to wait out real time.
- The debounce is in-memory and per-process — a pending event is lost (not written) if the process crashes ungracefully before the window elapses or before `flushPendingAuditEvents` runs. Acceptable for Phase 0/1's local-trust, single-process scope; revisit if audit completeness under crash needs a stronger guarantee than "graceful shutdown flushes."
- The audit debounce is in-memory and per-process; graceful shutdown flushes it. Catalog projection does **not** share this limitation: direct UI metadata changes project synchronously (§3).

Retention itself (pruning old rows) is out of scope for this feature — `queryAuditLog`'s existing `since`/`until`/`limit` filtering (`persistence.md` §1, `src/lib/server/audit.ts`) is the only volume control today beyond the debounce above; see the tracked follow-up issue for periodic pruning.

## 5. Testing

- `src/lib/server/audit-observer.test.ts` — unit tests against a bare `Y.Doc` (no server, no websocket): create/update/delete for all three entry kinds, the null-origin no-op, debounce coalescing (including a content-edit-plus-field-edit-in-one-transaction case, proving exactly one event per entry per burst, not one per changed Yjs type), `flushPendingAuditEvents`, and the `recordIds`-reorder-attributes-to-the-parent-not-the-record case.
- `src/lib/server/audit-observer.test.ts` — unit tests cover named remote/service/test origins, rejection of unknown origins, debounce coalescing, explicit flush, and parent record-order attribution.
- `src/lib/services/services.test.ts` — the new denied-attempt audit tests (`create_record_denied`, `get_document_denied`, no denial logged for a human caller, a denial for a nonexistent record carries no extra metadata).
- `tests/e2e/tier-a.test.ts` (#11) — a _real_ y-websocket client (the same harness every other Tier A test uses) mutates its own `Y.Doc` directly, with zero MCP/service-layer calls, mirroring exactly what `BlockEditor.svelte`/`Sidebar.svelte` do today; asserts the server's `audit_log` picks up `create_document`, `create_record`, a debounced-then-flushed `update_record`, and `delete_record`, each written exactly once, and that the live record projection carries the editor and newer timestamp.
2 changes: 1 addition & 1 deletion docs/specifications/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

**Idle unload (#122, implementing §6's "unloads only when it has no live connections, no active holds, and no unflushed state"):** `workspace-store.ts` runs a process-wide `sweepIdleContexts()` on a 60s (`IDLE_SWEEP_INTERVAL_MS`) timer, wired once per process (`wireIdleSweepOnce()`, mirroring the existing shutdown-hook wiring). Each tick re-evaluates every currently-resolved context and releases (`releaseContextIfIdle`) any with zero live WebSocket connections _and_ zero active holds — checked separately, because an MCP agent's hold is a synthetic Awareness client with no WebSocket connection at all (`holds.ts`), so "zero connections" alone is not a safe unload signal. A release always flushes first, so an idle-unloaded context never loses dirty state. No context is exempt, including the Phase 0 default key — once genuinely idle it unloads and reloads like any other.

**Recovery on restart or reload:** there is no separate "restart mode" — `resolveWorkspaceContext()`'s existing lazy-load path (load-latest-snapshot-if-present, else start empty) is the same code whether the calling process just started or a given key was idle-unloaded ten seconds ago and is now being re-resolved by a new connection. **Data-loss bound:** a graceful shutdown (`SIGINT`/`SIGTERM`, already wired) flushes every resolved context plus pending audit/catalog-mirror events before exit, so it loses nothing. An ungraceful crash can lose up to `SAVE_INTERVAL_MS` (30s) of changes to a context that was neither idle-released (which flushes first) nor yet due for its periodic save tick — the same class of bound `audit-coverage.md` already documents for its own debounce window, not a new risk this introduces.
**Recovery on restart or reload:** there is no separate "restart mode" — `resolveWorkspaceContext()`'s existing lazy-load path (load-latest-snapshot-if-present, else start empty) is the same code whether the calling process just started or a given key was idle-unloaded ten seconds ago and is now being re-resolved by a new connection. Snapshot replay is explicitly tagged `replay`; graceful shutdown flushes every resolved context and pending audit events. Catalog metadata projection is synchronous and has no pending debounce queue. An ungraceful crash can lose up to `SAVE_INTERVAL_MS` (30s) of Yjs changes not yet snapshotted.

**Compaction: not applicable.** Each snapshot is a complete `Y.encodeStateAsUpdate` dump on every save, not an incremental update log, so there is nothing to compact — this is a deliberate consequence of the periodic-full-snapshot design above, not a gap.

Expand Down
2 changes: 1 addition & 1 deletion docs/specifications/undo-redo.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Each browser tab tracks only its own locally-originated Yjs transactions — nev

`src/lib/client/undo.ts` constructs one [`Y.UndoManager`](https://docs.yjs.dev/api/undo-manager) per `Y.Doc` (see §5: since #120, that's per-Document-shard, not a single workspace-wide doc), scoped to the three top-level shared types every local write lands in: the Documents index, the Collections index, and Records (`doc.getMap('documents' | 'collections' | 'records')`). Y.UndoManager tracks a transaction if any type it touched has one of the scope types as an ancestor, not just the scope types themselves — so this also covers every block's nested `Y.Text` content, every record's properties, and each parent's block/row-order array, without needing a separate UndoManager per block.

**Why this requires no new origin-tagging convention:** Y.UndoManager's own default `trackedOrigins` is `new Set([null])` — it only tracks transactions whose origin is `null`. Every local write already goes through an untagged `doc.transact(...)` call (or a single untransacted `.set()`, which Yjs auto-wraps the same way) in `src/lib/data/records.ts` and the block editor, and Yjs assigns those the `null` origin by default. Remote edits — from a collaborator's browser tab or an MCP agent — always arrive over this tab's y-websocket connection and are applied by y-protocols' sync handler with the `WebsocketProvider` instance itself as the transaction origin (see y-websocket's `readSyncMessage` call), which is never `null` and so is never tracked. The local/remote split that "local, per-actor" requires falls directly out of the existing transport architecture (`architecture.md` §1) — no call site needs to opt in.
**Explicit origin contract:** `Y.UndoManager` tracks only `LOCAL_UI_ORIGIN`. Remote UI, service/MCP, migration, replay, and test transactions use distinct named origins from `mutation-origin.ts`; undo/redo transactions are registered as `undo-redo`. No production behavior infers actor class from `null` or an object identity.

## 3. Grouping and correctness under concurrent edits

Expand Down
5 changes: 5 additions & 0 deletions scripts/pre-push-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu

npm run build
npm run test:e2e
30 changes: 19 additions & 11 deletions src/lib/client/undo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '$lib/data/records';
import type { ActorId } from '$lib/data/types';
import { createUndoManager, redo, subscribeUndoRedoState, undo } from './undo';
import { LOCAL_UI_ORIGIN, remoteUiOrigin } from '../mutation-origin';

// createUndoManager(doc) is exercised directly against real Y.Doc instances
// here — these are the tests that prove the actual CRDT-level guarantees the
Expand All @@ -26,23 +27,30 @@ const REMOTE_ACTOR: ActorId = { kind: 'human', userId: 'collaborator' };

/**
* Applies `sourceDoc`'s current state to `doc` as a single incoming update,
* with a non-null object identity as the transaction origin — standing in
* for the WebsocketProvider instance that origin would be on a real client
* (see undo.ts's module comment). This is the same mechanism a real second
* browser tab's edit — or an MCP agent's edit relayed through the shared
* Y.Doc — arrives by, so it's the right way to simulate "a collaborator's
* intervening work" in a unit test without booting a real server/websocket.
* with the named remote-UI origin used by the WebSocket server. This is the
* same transaction class a collaborator's browser edit arrives as.
*/
function deliverAsRemoteUpdate(doc: Y.Doc, sourceDoc: Y.Doc): void {
const update = Y.encodeStateAsUpdate(sourceDoc, Y.encodeStateVector(doc));
Y.applyUpdate(doc, update, {});
Y.applyUpdate(doc, update, remoteUiOrigin('undo-test-peer'));
}

/** Legacy data helpers intentionally omit an origin; make their test-side
* direct calls model the browser boundary, where every local action is named. */
function makeTestDoc(): Y.Doc {
const doc = new Y.Doc();
const transact = doc.transact.bind(doc);
vi.spyOn(doc, 'transact').mockImplementation((fn, origin) =>
transact(fn, origin ?? LOCAL_UI_ORIGIN)
);
return doc;
}

describe('createUndoManager: scope', () => {
let doc: Y.Doc;

beforeEach(() => {
doc = new Y.Doc();
doc = makeTestDoc();
});

afterEach(() => {
Expand Down Expand Up @@ -177,7 +185,7 @@ describe('createUndoManager: per-actor isolation', () => {
let doc: Y.Doc;

beforeEach(() => {
doc = new Y.Doc();
doc = makeTestDoc();
});

afterEach(() => {
Expand Down Expand Up @@ -328,8 +336,8 @@ describe('undo/redo per-doc manager cache', () => {
let docB: Y.Doc;

beforeEach(() => {
docA = new Y.Doc();
docB = new Y.Doc();
docA = makeTestDoc();
docB = makeTestDoc();
});

afterEach(() => {
Expand Down
25 changes: 12 additions & 13 deletions src/lib/client/undo.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import * as Y from 'yjs';
import { LOCAL_UI_ORIGIN, registerUndoRedoOrigin } from '../mutation-origin.js';

// Local, per-actor undo/redo (#8): each browser tab tracks only its own
// locally-originated Yjs transactions, never a collaborator's — including a
// stateless MCP agent's writes, which is exactly the "never a collaborator's
// intervening work" guarantee the issue asks for.
//
// This falls out of Y.UndoManager's own default `trackedOrigins: new
// Set([null])` combined with how every local write already runs through an
// untagged `doc.transact(...)` call (or a single untransacted `.set()`,
// which Yjs auto-wraps the same way) in src/lib/data/records.ts and the
// block editor — Yjs assigns those the `null` origin. Remote edits, whether
// from a collaborator's browser tab or an MCP agent, always arrive over this
// tab's y-websocket connection and are applied by y-protocols' sync handler
// with the WebsocketProvider instance itself as the transaction origin (see
// y-websocket's `readSyncMessage` call), which is never `null` and so is
// never tracked. No extra origin-tagging is needed on either side for that
// separation to hold — it's a byproduct of the existing transact() calls,
// not a new convention call sites need to opt into.
// Local UI writes use LOCAL_UI_ORIGIN. Remote, service, migration, and replay
// transactions have their own named origins and therefore cannot enter this
// tab's undo history.
const SCOPE_MAP_NAMES = ['documents', 'collections', 'records'] as const;

/**
Expand All @@ -30,7 +22,14 @@ const SCOPE_MAP_NAMES = ['documents', 'collections', 'records'] as const;
* scope types themselves.
*/
export function createUndoManager(doc: Y.Doc): Y.UndoManager {
return new Y.UndoManager(SCOPE_MAP_NAMES.map((name) => doc.getMap(name)));
const manager = new Y.UndoManager(
SCOPE_MAP_NAMES.map((name) => doc.getMap(name)),
{
trackedOrigins: new Set([LOCAL_UI_ORIGIN])
}
);
registerUndoRedoOrigin(manager);
return manager;
}

// Keyed by Y.Doc instance, not a single module-level singleton: each
Expand Down
24 changes: 23 additions & 1 deletion src/lib/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,29 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { createMcpServer } from './server';
import { createToken } from './tokens';
import { resolveWorkspaceContext } from '$lib/server/workspace-store';
import { createCollection, createDocument, createRecord, setPrimaryField } from '$lib/data/records';
import {
createCollection as rawCreateCollection,
createDocument as rawCreateDocument,
createRecord as rawCreateRecord,
setPrimaryField as rawSetPrimaryField
} from '$lib/data/records';
import { TEST_ORIGIN, transactWithOrigin } from '$lib/mutation-origin';

function createDocument(...args: Parameters<typeof rawCreateDocument>) {
return transactWithOrigin(args[0], TEST_ORIGIN, () => rawCreateDocument(...args));
}

function createCollection(...args: Parameters<typeof rawCreateCollection>) {
return transactWithOrigin(args[0], TEST_ORIGIN, () => rawCreateCollection(...args));
}

function createRecord(...args: Parameters<typeof rawCreateRecord>) {
return transactWithOrigin(args[0], TEST_ORIGIN, () => rawCreateRecord(...args));
}

function setPrimaryField(...args: Parameters<typeof rawSetPrimaryField>) {
return transactWithOrigin(args[0], TEST_ORIGIN, () => rawSetPrimaryField(...args));
}

interface ToolHolder {
_registeredTools: Record<
Expand Down
76 changes: 76 additions & 0 deletions src/lib/mutation-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as Y from 'yjs';

/** Every Yjs write source accepted by Compendium's projection observers. */
export type MutationSource =
'local-ui' | 'remote-ui' | 'service' | 'migration' | 'replay' | 'undo-redo' | 'test';

export interface MutationOrigin {
readonly source: MutationSource;
readonly detail?: string;
}

// The E2E browser harness loads the WebSocket server from source and the
// SvelteKit request handler from `build/handler.js`. Those module graphs each
// evaluate this module, but their Yjs transactions meet in the same Node
// process. Keep the identity registry on globalThis so an origin registered
// by one graph remains recognizable by observers in the other. A plain object
// carrying a matching `source` property is still rejected: only objects added
// to this shared WeakMap are accepted.
const ORIGIN_REGISTRY = Symbol.for('compendium.mutation-origin-registry');

type GlobalWithOriginRegistry = typeof globalThis & {
[ORIGIN_REGISTRY]?: WeakMap<object, MutationSource>;
};

const originRegistry = globalThis as GlobalWithOriginRegistry;
const origins = (originRegistry[ORIGIN_REGISTRY] ??= new WeakMap<object, MutationSource>());

function origin(source: MutationSource, detail?: string): MutationOrigin {
const value = Object.freeze({ source, detail });
origins.set(value, source);
return value;
}

export const LOCAL_UI_ORIGIN = origin('local-ui');
export const SERVICE_ORIGIN = origin('service');
export const MIGRATION_ORIGIN = origin('migration');
export const REPLAY_ORIGIN = origin('replay');
export const TEST_ORIGIN = origin('test');

/** A connection-specific server-side origin for an update received over y-websocket. */
export function remoteUiOrigin(connectionId: string): MutationOrigin {
return origin('remote-ui', connectionId);
}

/** Runs a mutation under one of the named origins; nested Yjs transactions retain it. */
export function transactWithOrigin<T>(
doc: Y.Doc,
transactionOrigin: MutationOrigin,
mutate: () => T
): T {
let result!: T;
doc.transact(() => {
result = mutate();
}, transactionOrigin);
return result;
}

/** Registers a Y.UndoManager's internal transaction origin with the shared classifier. */
export function registerUndoRedoOrigin(originObject: object): void {
origins.set(originObject, 'undo-redo');
}

/** Returns the recognized source for a Yjs transaction origin, if any. */
export function mutationSource(originValue: unknown): MutationSource | undefined {
return typeof originValue === 'object' && originValue !== null
? origins.get(originValue)
: undefined;
}

/** Raised when an observer sees a mutation that bypasses the origin contract. */
export class UnknownMutationOriginError extends Error {
constructor(originValue: unknown) {
super(`Yjs mutation used an unrecognized origin: ${String(originValue)}`);
this.name = 'UnknownMutationOriginError';
}
}
Loading
Loading