Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
# crypto-sync
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`crypto-sync.ts` provides the AEAD-encrypted envelope used by `SyncEngine`
and `SyncQueue` to put plaintext sync payloads on the wire. Encryption is
XChaCha20-Poly1305 from `@suaveplan/crypto-cipher` with a 24-byte nonce and
an AAD-bound version string (`"v1"`). The version binding means a future
revision can change the wire layout without risk that a tampered v2
payload is silently decoded as v1. The module enforces a 10 MiB plaintext
cap to prevent memory abuse from oversized snapshots.
## Features
- `encryptSyncPayload(plaintext, key, options?)` — XChaCha20-Poly1305 AEAD
- `decryptSyncPayload(payload, key, options?)` — verified decrypt with AAD
- AAD = `"v1"` || optional caller-supplied bytes
- Random 24-byte nonce sourced via `@suaveplan/crypto-utils.randomBytes`
- Injectable `byteSource` for deterministic test nonces
- Hard cap of 10 MiB on plaintext (`SYNC.PAYLOAD_TOO_LARGE`)
- Structured errors: `SYNC.PAYLOAD_TAMPERED`, `SYNC.PAYLOAD_INVALID_VERSION`
- OTel histogram + outcome counter (no key bytes, no plaintext, no IDs)
## Usage: Basic Example
```ts
import { decryptSyncPayload, encryptSyncPayload } from "@suaveplan/vault-sync";
const key = new Uint8Array(32); // 32 bytes — ChaCha20 key
crypto.getRandomValues(key);
const plaintext = new TextEncoder().encode(JSON.stringify({ items: [] }));
const envelope = encryptSyncPayload(plaintext, key);
const recovered = decryptSyncPayload(envelope, key);
console.log(new TextDecoder().decode(recovered));
```
## Usage: Advanced Example
```ts
import { encryptSyncPayload } from "@suaveplan/vault-sync";
import { decryptSyncPayload } from "@suaveplan/vault-sync";
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
import { zeroize } from "@suaveplan/crypto-utils";
const key = crypto.getRandomValues(new Uint8Array(32));
const plaintext = new TextEncoder().encode("snapshot");
const aad = new TextEncoder().encode("vault-id-binding"); // domain separator
const envelope = encryptSyncPayload(plaintext, key, { additionalData: aad });
try {
// Caller MUST pass the same additionalData on decrypt — otherwise the
// tag check fails with SYNC.PAYLOAD_TAMPERED.
const round = decryptSyncPayload(envelope, key, { additionalData: aad });
zeroize(round); // clear plaintext when done
} catch (err) {
if (
err instanceof VaultSyncError &&
err.code === VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]
) {
// Telemetry already recorded; trigger a fresh full pull.
}
throw err;
}
```
## API Reference
- `encryptSyncPayload(plaintext: Uint8Array, key: Uint8Array, options?: EncryptSyncPayloadOptions): EncryptedSyncPayload`
— encrypts with XChaCha20-Poly1305. Throws
`VaultSyncError(SYNC.PAYLOAD_TOO_LARGE)` if `plaintext.length` exceeds
`MAX_SYNC_PAYLOAD_BYTES`.
- `decryptSyncPayload(payload: EncryptedSyncPayload, key: Uint8Array, options?): Uint8Array`
— validates the envelope shape via `EncryptedSyncPayloadSchema` then
verifies and decrypts. Throws
`VaultSyncError(SYNC.PAYLOAD_INVALID_VERSION)` for shape failures and
`VaultSyncError(SYNC.PAYLOAD_TAMPERED)` for tag mismatch (wrong key,
flipped byte, mismatched AAD).
- `EncryptSyncPayloadOptions` — `{ byteSource?, additionalData? }`.
- `MAX_SYNC_PAYLOAD_BYTES` — `10 * 1024 * 1024` (10 MiB).
- `SYNC_PAYLOAD_VERSION` — literal `"v1"`.
## Implementation Notes
- The package never generates a nonce internally beyond delegating to
`randomBytes` from `@suaveplan/crypto-utils`. Callers wanting determinism
in tests pass a custom `byteSource`. There is no notion of nonce reuse
detection at this layer — XChaCha20's 192-bit nonce makes random
selection collision-safe at any realistic call rate, and higher-level
wrapping is left to the consumer if needed.
- The plaintext returned by `decryptSyncPayload` is a fresh `Uint8Array`.
The caller is responsible for `zeroize`-ing it after use; the module
cannot do this safely because it does not know when the caller is done
reading the value.
- Both shape failures and tag failures emit `payload.outcome.total{status:
"tampered"}` so a single counter captures the integrity-violation rate.
- Decryption-side schema validation uses the same `EncryptedSyncPayloadSchema`
exposed publicly, ensuring schema and runtime checks are perfectly aligned.

View File

@@ -0,0 +1,132 @@
# engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`engine.ts` exposes `SyncEngine`, the in-memory orchestrator that turns a
local vault snapshot plus a network transport into safe, conflict-aware
synchronization. The engine itself is transport-agnostic — it accepts a
`SyncTransport` plus injectable `encrypt` / `decrypt` callbacks so callers
can wire in HTTP, WebSocket, or even an in-process loopback for tests.
Conflict resolution is delegated to `mergeVaultItems`, with the
`historyStrategy` automatically applied to items that carry append-only
history. Every public method emits OTel spans / histograms with
safe-by-construction attributes.
## Features
- `SyncEngine.pull()` — fetch, decrypt, merge into local state
- `SyncEngine.push()` — encrypt local snapshot, ship via transport
- `SyncEngine.sync()` — bidirectional reconciliation (push then pull)
- `SyncEngine.applyLocalMutation(item)` — fast-path local writes
- `SyncEngine.hydrate(snapshot)` — load saved snapshot at startup
- Per-item strategy selection: append for items with history, lww otherwise
- Atomic-conflict items are surfaced via `SyncResult.conflicts`
- Tombstone propagation: deleted items survive merge
- Injectable `Clock`, `Rng`
- OTel: `vault_sync.sync.duration_ms` per top-level operation
## Usage: Basic Example
```ts
import { SyncEngine } from "@suaveplan/vault-sync";
import {
decryptSyncPayload,
encryptSyncPayload,
} from "@suaveplan/vault-sync";
import type { VaultSnapshot } from "@suaveplan/vault-sync/types";
declare const transport: import("@suaveplan/vault-sync/types").SyncTransport;
declare const key: Uint8Array;
const engine = new SyncEngine({
nodeId: "device-1",
transport,
encrypt: async (snap: VaultSnapshot) =>
encryptSyncPayload(new TextEncoder().encode(JSON.stringify(snap)), key),
decrypt: async (env: unknown) => {
const bytes = decryptSyncPayload(
env as ReturnType<typeof encryptSyncPayload>,
key
);
return JSON.parse(new TextDecoder().decode(bytes)) as VaultSnapshot;
},
});
const result = await engine.sync();
console.log(result.merged, result.conflicts, result.deleted);
```
## Usage: Advanced Example
```ts
import { SyncEngine } from "@suaveplan/vault-sync";
import type {
EncryptedSyncPayload,
SyncTransport,
VaultItem,
VaultSnapshot,
} from "@suaveplan/vault-sync/types";
class MemoryTransport implements SyncTransport {
public stored: EncryptedSyncPayload | undefined;
public async fetchSnapshot(): Promise<EncryptedSyncPayload> {
if (!this.stored) throw new Error("no remote snapshot");
return this.stored;
}
public async pushSnapshot(p: EncryptedSyncPayload): Promise<void> {
this.stored = p;
}
}
const engine = new SyncEngine({
nodeId: "device-1",
transport: new MemoryTransport(),
encrypt: async (snap) => snap,
decrypt: async (env) => env as VaultSnapshot,
defaultStrategy: "lww",
historyStrategy: "append",
clock: () => 12_345,
});
declare const item: VaultItem;
engine.applyLocalMutation(item);
await engine.push();
```
## API Reference
- `class SyncEngine`
- `constructor(options: SyncEngineOptions)` — `nodeId`, `transport`,
`encrypt`, `decrypt` are required. Optional: `initialSnapshot`,
`defaultStrategy` (default `"lww"`), `historyStrategy` (default
`"append"`), `clock`, `rng`.
- `get currentSnapshot: VaultSnapshot` — read-only state.
- `get owner: string` — the engine's `nodeId`.
- `get randomSource: Rng | undefined` — the engine's optional `rng`.
- `hydrate(snapshot: VaultSnapshot): void` — load and validate.
- `applyLocalMutation(item: VaultItem): void` — write to local snapshot.
- `pull(): Promise<SyncResult>` — fetch, decrypt, merge.
- `push(): Promise<SyncResult>` — encrypt and ship.
- `sync(): Promise<SyncResult>` — push then pull.
- `SyncEngineOptions` — bag of dependencies + strategy knobs.
## Implementation Notes
- The engine separates strategy selection per item. Items with non-empty
history merge with the history strategy; items without history use the
default. This gives the password field's most common case (lww) the
fastest path while preserving append-only audit logs.
- Atomic conflicts are not fatal: when `mergeVaultItems` raises
`MERGE.CONFLICT_UNRESOLVED`, the engine retains the locally-dominant
copy (or, if neither dominates, the local copy with the merged clock)
and reports the id in `SyncResult.conflicts`. Callers can surface the
conflict in UI for manual resolution.
- Transport failures wrap the underlying error in
`VaultSyncError(SYNC.TRANSPORT_FAILED)` with `direction: "pull"` or
`"push"` in context. The original error is preserved as `cause`.
- The encrypt/decrypt callbacks are deliberately untyped on the wire side
(`unknown`). This lets callers pick their own envelope type without
forcing a particular wire schema on the engine.

View File

@@ -0,0 +1,74 @@
# Error code registry
## Purpose
`codes.ts` defines the canonical, frozen registry of every fully-qualified
error code emitted by `@suaveplan/vault-sync`. Codes are stable string
literals consumers can switch on — they are part of the public API contract.
The registry doubles as the source of truth for the
[`VaultSyncCode`](./crypto-vault-error.md) type union, so adding a new code
in a single place automatically widens the type and the
`spec-conformance.test.ts` "every code is thrown" gate must observe the new
value before the change can ship.
## Features
- Single source of truth for every `VAULTSYNC.*` code emitted from production
- Frozen registry produced via `defineCodes("VAULTSYNC", ...)` from `@suaveplan/error`
- Inferred literal-union type `VaultSyncCode` for exhaustive switch-narrowing
- Import-time freezing — codes cannot be added or removed at runtime
- Zero circular-dep risk: registry depends only on `@suaveplan/error`
## Usage: Basic Example
```ts
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
console.log(VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"]);
// → "VAULTSYNC.MERGE.STRATEGY_UNKNOWN"
```
## Usage: Advanced Example
```ts
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES, type VaultSyncCode } from "@suaveplan/vault-sync/errors";
function describeFailure(err: unknown): string {
if (!(err instanceof VaultSyncError)) return "non-vault-sync failure";
const code: VaultSyncCode = err.code;
switch (code) {
case VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"]:
return "Unknown CRDT merge strategy";
case VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]:
return "Sync payload integrity check failed";
case VAULT_SYNC_CODES["QUEUE.DEPTH_EXCEEDED"]:
return "Offline queue at hard cap";
default:
// Exhaustive: TypeScript will fail if a new code is unhandled.
return `unhandled vault-sync code: ${code as string}`;
}
}
```
## API Reference
- `VAULT_SYNC_CODES` — `Readonly<Record<reason, "VAULTSYNC.<REASON>">>`. Frozen
via `Object.freeze`. Iterating with `Object.values(VAULT_SYNC_CODES)` yields
every code that may be emitted from production paths.
- `type VaultSyncCode` — the literal union derived from `typeof
VAULT_SYNC_CODES[keyof typeof VAULT_SYNC_CODES]`. Use as the `TCode` type
parameter of `SuaveplanError`.
## Implementation Notes
The registry is intentionally flat — there is no nested structure or runtime
metadata. Metadata such as severity, retryability, and i18n message keys live
where they are needed (the throwing call site, the i18n catalog, the OTel
adapter), not on the code object itself.
Any new entry must be paired with:
1. A production path that throws the code under realistic conditions.
2. A new branch in `spec-conformance.test.ts` confirming the code is reachable.
3. An update to the codes table in the package `README.md`.

View File

@@ -0,0 +1,84 @@
# VaultSyncError
## Purpose
`VaultSyncError` is the single concrete error class thrown from every
production path inside `@suaveplan/vault-sync`. It extends the foundational
`SuaveplanError` from `@suaveplan/error` and parameterises the `code` field
on the literal union `VaultSyncCode` so consumers can switch on `err.code`
with full TypeScript exhaustiveness. A unique brand symbol is attached at
construction so cross-realm or duck-typed impersonators are rejected by the
`VaultSyncError.is(value)` guard.
## Features
- Extends `SuaveplanError<VaultSyncCode>` for narrowed `code` checks
- Brand symbol prevents prototype-chain impersonation across realms
- `VaultSyncError.is(value)` runs both `instanceof` and brand verification
- Carries structured `context`, `cause`, `traceId`, and `timestamp` from the base
- Pairs with the `VAULT_SYNC_CODES` registry so every code in the union is reachable
## Usage: Basic Example
```ts
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
throw new VaultSyncError(
VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"],
"error.vault-sync.merge.strategy-unknown",
{ context: { strategy: "smart-merge" } }
);
```
## Usage: Advanced Example
```ts
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
async function safeFlush(input: () => Promise<void>): Promise<{ ok: true } | { ok: false; code: string }> {
try {
await input();
return { ok: true };
} catch (err) {
// Defence-in-depth: instanceof can break across worker boundaries.
if (VaultSyncError.is(err)) {
return { ok: false, code: err.code };
}
throw err;
}
}
const result = await safeFlush(async () => {
throw new VaultSyncError(
VAULT_SYNC_CODES["QUEUE.DEPTH_EXCEEDED"],
"error.vault-sync.queue.depth-exceeded",
{ context: { depth: 10_001, limit: 10_000 } }
);
});
```
## API Reference
- `class VaultSyncError extends SuaveplanError<VaultSyncCode>` — concrete
error type for every failure inside `vault-sync`.
- `constructor(code: VaultSyncCode, messageKey: string, options?: SuaveplanErrorOptions)`
— `messageKey` is forwarded to the i18n resolver. `options.context` may
carry safe-by-construction fields (no key bytes, no plaintext, no item IDs).
- `static is(value: unknown): value is VaultSyncError` — narrow guard that
verifies both `instanceof` and the unique brand symbol attached at
construction. Use whenever errors might cross `iframe`, `Worker`, or
serialization boundaries.
- Inherited fields: `code`, `message`, `context`, `cause`, `timestamp`,
`traceId`. See `@suaveplan/error` for full semantics.
## Implementation Notes
The brand symbol is module-private — a third party cannot construct a
duck-typed value that satisfies `VaultSyncError.is`. Subclasses are
discouraged: if a more specific error is needed, attach metadata via
`options.context` rather than introducing a new class. Centralising on a
single throwable simplifies dependent-package error handling, reduces
`instanceof` chains, and keeps the public API surface minimal. All thrown
errors flow through this class — no plain `Error`, no anonymous throws.

View File

@@ -0,0 +1,109 @@
# merge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`merge.ts` exposes `mergeVaultItems(local, remote, strategy)`, the core
CRDT-style reconciler used by `SyncEngine` and `SyncQueue`. It picks one of
three domain-specific strategies — `"lww"`, `"append"`, `"atomic"` — and
returns a deterministic merged item whose vector clock is the component-wise
maximum of the inputs and whose history is the deduplicated union of both
sides. The merge is commutative and idempotent for `"lww"` and `"append"`;
`"atomic"` rejects concurrent edits to atomic-protected fields with
`VaultSyncError(MERGE.CONFLICT_UNRESOLVED)` so the caller can decide.
## Features
- `mergeVaultItems(local, remote, strategy)` — primary public entry point
- Three strategies: `"lww"`, `"append"`, `"atomic"`
- Commutative and idempotent for `"lww"` and `"append"`
- Vector-clock dominance preserved — never drops causal history
- Append-history dedup keyed on `historyId`, sorted by `(timestamp, historyId)`
- `assertMergeStrategy(value)` — runtime guard for caller-supplied strings
- OTel instrumentation: `merge.duration_ms`, outcome counter
- Integer-only comparisons; no secret-dependent control flow
## Usage: Basic Example
```ts
import { mergeVaultItems } from "@suaveplan/vault-sync";
const local = {
id: "i1",
title: "Email",
password: "old",
folderId: null,
history: [],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
};
const remote = { ...local, password: "new", updatedAt: 2, clock: { a: 2 } };
const merged = mergeVaultItems(local, remote, "lww");
console.log(merged.password); // "new"
```
## Usage: Advanced Example
```ts
import { assertMergeStrategy, mergeVaultItems } from "@suaveplan/vault-sync";
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
import type { VaultItem } from "@suaveplan/vault-sync/types";
function reconcile(local: VaultItem, remote: VaultItem, raw: string): VaultItem {
const strategy = assertMergeStrategy(raw); // throws on unknown
try {
return mergeVaultItems(local, remote, strategy);
} catch (err) {
if (
err instanceof VaultSyncError &&
err.code === VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
) {
// Atomic-strategy conflict: defer to user UI.
return promptUserToResolve(local, remote);
}
throw err;
}
}
declare function promptUserToResolve(a: VaultItem, b: VaultItem): VaultItem;
```
## API Reference
- `mergeVaultItems(local: VaultItem, remote: VaultItem, strategy: MergeStrategy): VaultItem`
— merges two copies of the same vault item according to `strategy`. The
inputs MUST have the same `id`; otherwise `MERGE.CONFLICT_UNRESOLVED` is
thrown. Throws `MERGE.STRATEGY_UNKNOWN` if `strategy` is not one of the
three documented values, and `MERGE.CONFLICT_UNRESOLVED` for `"atomic"`
when the two clocks are concurrent and any of `folderId`, `title`,
`password`, or `deleted` differs. Records `vault_sync.merge.duration_ms`
and increments `vault_sync.merge.outcome.total`.
- `assertMergeStrategy(value: string): MergeStrategy` — pre-flight guard
that returns `value` typed as `MergeStrategy` or throws
`MERGE.STRATEGY_UNKNOWN`.
- `MERGE_STRATEGIES: readonly MergeStrategy[]` — exported tuple for
exhaustive switch checks and UI dropdown population.
## Implementation Notes
- The `"lww"` strategy uses `lwwTotalOrder` from `vector-clock.ts`, which
combines vector-clock dominance, `updatedAt`, and `nodeId` for full
determinism. Two peers with identical inputs always elect the same winner.
- The `"append"` strategy still uses `lwwTotalOrder` for the non-history
fields and merges histories with stable sort. This means concurrent
edits to `password` resolve via the LWW tie-break, while `history` is a
proper append-only set.
- The `"atomic"` strategy distinguishes causal-precedence (linear, safe)
from concurrent-edit (raises). Identical clocks bypass the conflict
check because by causal definition they refer to identical state.
- Deleted-flag propagation: any `deleted: true` on either side propagates
to the merged result. This is intentional — undelete is an explicit
user action that must produce a fresh vector-clock advance, not a
silent revival via merge.
- The merger never emits `deleted: false` — undefined is preferred to
reduce wire-bytes and match the canonical `VaultItem` shape.

View File

@@ -0,0 +1,134 @@
# otel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` exposes the safe-by-construction OTel instruments emitted
from every `@suaveplan/vault-sync` production path. The instrument set is
deliberately narrow: only durations, outcome counters, and a queue-depth
gauge — no vault ids, no item ids, no key material, no vector-clock
values. Counters carry only a `status` attribute drawn from a fixed
allowlist (`success | conflict | rejected`, `encrypted | decrypted |
tampered`). The lazy singleton ensures the meter is acquired exactly once
per process; tests override the provider via `setVaultSyncMeterProvider`
to capture instrument calls deterministically.
## Features
- `getVaultSyncInstruments()` — lazy singleton accessor for the meter set
- `setVaultSyncMeterProvider(provider)` — inject test providers
- `resetVaultSyncInstruments()` — clear cache between test suites
- `getVaultSyncTracer()` / `setVaultSyncTracerProvider()` — span tracer access
- `resetVaultSyncTracer()` — clear tracer cache between suites
- Histogram trio for `sync`, `merge`, `patch.apply`, `payload.encrypt`,
`queue.flush` durations
- Observable gauge for queue depth (count only, no item identifiers)
- Outcome counters with bounded-string labels (no PII)
## Usage: Basic Example
```ts
import { getVaultSyncInstruments } from "@suaveplan/vault-sync/otel";
const i = getVaultSyncInstruments();
const start = Date.now();
// ... work ...
i.syncDurationMs.record(Date.now() - start);
i.mergeOutcomeTotal.add(1, { status: "success" });
```
## Usage: Advanced Example
```ts
import {
getVaultSyncInstruments,
resetVaultSyncInstruments,
setVaultSyncMeterProvider,
} from "@suaveplan/vault-sync/otel";
import type { MeterProvider } from "@opentelemetry/api";
interface Capture {
histograms: Map<string, number[]>;
counters: Map<string, Array<[number, Record<string, string> | undefined]>>;
}
function makeProvider(): { provider: MeterProvider; capture: Capture } {
const capture: Capture = {
histograms: new Map(),
counters: new Map(),
};
const meter = {
createHistogram: (name: string) => {
capture.histograms.set(name, []);
return { record: (v: number) => capture.histograms.get(name)?.push(v) };
},
createCounter: (name: string) => {
capture.counters.set(name, []);
return {
add: (v: number, attrs?: Record<string, string>) => {
capture.counters.get(name)?.push([v, attrs]);
},
};
},
createObservableGauge: (name: string) => ({
addCallback: () => {
capture.histograms.set(name, []);
},
}),
};
return {
provider: {
getMeter: () => meter as unknown as ReturnType<MeterProvider["getMeter"]>,
} as unknown as MeterProvider,
capture,
};
}
const { provider, capture } = makeProvider();
setVaultSyncMeterProvider(provider);
const i = getVaultSyncInstruments();
i.syncDurationMs.record(42);
console.log(capture.histograms.get("vault_sync.sync.duration_ms")); // [42]
setVaultSyncMeterProvider(undefined);
resetVaultSyncInstruments();
```
## API Reference
- `getVaultSyncInstruments(): VaultSyncInstruments` — lazily resolves the
meter and caches the instrument bundle. Returns the cached value on
subsequent calls.
- `setVaultSyncMeterProvider(provider: MeterProvider | undefined): void` —
rebuilds the cache against the provided meter; pass `undefined` to fall
back to the global default.
- `resetVaultSyncInstruments(): void` — clears the cached bundle so the
next `get` call rebinds to the (possibly new) global meter.
- `getVaultSyncTracer(): Tracer` — lazy tracer singleton.
- `setVaultSyncTracerProvider(provider): void` — inject test tracer.
- `resetVaultSyncTracer(): void` — clear tracer cache.
Instrument names:
- `vault_sync.sync.duration_ms` (Histogram, ms)
- `vault_sync.merge.duration_ms` (Histogram, ms)
- `vault_sync.patch.apply.duration_ms` (Histogram, ms)
- `vault_sync.payload.encrypt.duration_ms` (Histogram, ms)
- `vault_sync.queue.flush.duration_ms` (Histogram, ms)
- `vault_sync.queue.depth` (ObservableGauge, count)
- `vault_sync.merge.outcome.total` (Counter, status ∈ {success, conflict, rejected})
- `vault_sync.payload.outcome.total` (Counter, status ∈ {encrypted, decrypted, tampered})
Span names emitted by the consuming modules: `sync.pull`, `sync.merge`,
`sync.push`, `queue.flush`, `payload.encrypt`.
## Implementation Notes
The lazy singleton lets the package be imported even when no OTel SDK is
registered — `metrics.getMeter` returns a no-op meter in that case, and
recording through it is a fast inline call. Counters never carry vault or
item identifiers; the only label is `status` drawn from a documented
allowlist. This is enforced by code review — adding a new label requires
an OpenSpec amendment to the observability plan in the package proposal.

View File

@@ -0,0 +1,108 @@
# patch
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`patch.ts` re-exports the RFC 6902 JSON Patch primitives provided by
`@suaveplan/diff`, narrowed to the `VaultItem` domain. It exists so the
sync engine and the offline queue can compute and apply small, structural
deltas instead of shipping whole items across the wire — saving bandwidth
on the most common case (a single field change) and making conflict
detection precise per-path. Every applied patch is re-validated against
`VaultItemSchema` so a malformed delta cannot poison local state.
## Features
- `diffVaultItems(before, after)` — minimal JSON Patch via `@suaveplan/diff`
- `applyPatch(item, patch)` — applies and re-validates against `VaultItemSchema`
- Empty diff for deeply-equal inputs (no spurious operations)
- Structured `VaultSyncError(PATCH.INVALID)` for bad ops and shape failures
- OTel histogram `vault_sync.patch.apply.duration_ms` per apply call
## Usage: Basic Example
```ts
import { applyPatch, diffVaultItems } from "@suaveplan/vault-sync";
const before = {
id: "i1",
title: "Old",
password: "p",
folderId: null,
history: [],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
};
const after = { ...before, title: "New", updatedAt: 2 };
const patch = diffVaultItems(before, after);
console.log(patch);
// [{ op: "replace", path: ["title"], value: "New" },
// { op: "replace", path: ["updatedAt"], value: 2 }]
const applied = applyPatch(before, patch);
console.log(applied.title); // "New"
```
## Usage: Advanced Example
```ts
import { applyPatch, diffVaultItems } from "@suaveplan/vault-sync";
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
import type { VaultItem } from "@suaveplan/vault-sync/types";
interface Mutation {
itemId: string;
patch: ReturnType<typeof diffVaultItems>;
}
function recordMutation(before: VaultItem, after: VaultItem): Mutation {
return { itemId: before.id, patch: diffVaultItems(before, after) };
}
function safeApply(item: VaultItem, mutation: Mutation): VaultItem {
try {
return applyPatch(item, mutation.patch);
} catch (err) {
if (
err instanceof VaultSyncError &&
err.code === VAULT_SYNC_CODES["PATCH.INVALID"]
) {
// Skip this mutation; the queue will retry on the next pull.
return item;
}
throw err;
}
}
```
## API Reference
- `diffVaultItems(before: VaultItem, after: VaultItem): JsonPatch` — returns
an RFC 6902 patch (`Operation[]`). Empty array when `before` deeply equals
`after`. Internally serialises both via `JSON.parse(JSON.stringify(...))`
to coerce undefined-valued fields to canonical absent form.
- `applyPatch(item: VaultItem, patch: JsonPatch): VaultItem` — applies the
patch and parses the result through `VaultItemSchema`. Throws
`VaultSyncError(PATCH.INVALID)` for: structurally-invalid patches
(`reason: "structural"`), failures during apply (`reason: "apply"`,
e.g. missing path, out-of-bounds array index), and post-apply shape
failures (`reason: "shape"`).
- `JsonPatch` — re-exported from `@suaveplan/diff` for downstream
consumers that want one import path for the whole patch surface.
## Implementation Notes
The package leans on `@suaveplan/diff` rather than re-implementing patch
math. The thin wrapper here is responsible for: (a) erasing
`@suaveplan/diff`'s richer error types into `VaultSyncError`, (b)
re-running `VaultItemSchema` after each apply, and (c) recording the
single OTel duration histogram. Re-validating after apply is paranoid but
cheap — the schema run is microseconds — and it is the only line of
defence between a malformed remote patch and corrupt local state. The
`asJson` helper round-trips through JSON to drop `undefined` values, which
is consistent with what the sync wire format does anyway.

View File

@@ -0,0 +1,118 @@
# queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`queue.ts` exposes `SyncQueue`, the durable, serializable mutation buffer
used by `SyncEngine` to absorb writes while the network is down. Every
mutation is captured as a `SyncOperation` carrying its causally-correct
vector-clock value, the partial vault-item fields, and a fresh idempotency
key. On flush, the queue collapses redundant operations so that, e.g.,
three successive updates to the same item's password become one. The
queue serializes to and restores from a `Uint8Array` so callers can persist
it via `@suaveplan/db-sqlite` or any byte store.
## Features
- `SyncQueue.enqueue(input)` — append a mutation, advancing the vector clock
- `SyncQueue.flush()` — drain the queue with redundant-op collapsing
- `SyncQueue.peek()` — non-destructive read of pending operations
- `SyncQueue.serialize()` / `SyncQueue.deserialize(bytes)` — JSON round-trip
- Hard cap of 10,000 pending operations (`QUEUE.DEPTH_EXCEEDED`)
- Injectable `Clock` for deterministic `enqueuedAt` timestamps
- Injectable `Rng` for deterministic idempotency keys (32-hex-char default)
- Concurrent-edit collision resolution by `enqueuedAt`, ties → second wins
- Delete dominance: once a `delete` is queued, subsequent updates collapse out
- OTel: `vault_sync.queue.flush.duration_ms`
## Usage: Basic Example
```ts
import { SyncQueue } from "@suaveplan/vault-sync";
const queue = new SyncQueue({ nodeId: "device-1" });
queue.enqueue({ kind: "create", itemId: "i1", fields: { title: "x" } });
queue.enqueue({ kind: "update", itemId: "i1", fields: { password: "p" } });
const drained = queue.flush();
console.log(drained.length); // 1 (collapsed)
```
## Usage: Advanced Example
```ts
import { SyncQueue } from "@suaveplan/vault-sync";
import type { Rng } from "@suaveplan/types";
class SeededRng implements Rng {
private state = 0x9e3779b1;
public nextUint32(): number {
this.state = (this.state + 0x6d2b79f5) >>> 0;
let t = this.state;
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
}
public nextFloat(): number {
return this.nextUint32() / 0x1_00_00_00_00;
}
}
const queue = new SyncQueue({
nodeId: "device-1",
clock: () => 100,
rng: new SeededRng(),
});
queue.enqueue({ kind: "create", itemId: "i1", fields: { title: "x" } });
const bytes = queue.serialize();
// ...persist `bytes` via @suaveplan/db-sqlite, restart the process...
const restored = SyncQueue.deserialize(bytes);
console.log(restored.depth); // 1
```
## API Reference
- `class SyncQueue`
- `constructor(options: SyncQueueOptions)` — `nodeId` is required;
`clock` defaults to `Date.now`; `rng` defaults to
`crypto.getRandomValues`; `maxDepth` defaults to
`DEFAULT_MAX_QUEUE_DEPTH = 10_000`.
- `get depth: number` — number of pending ops.
- `get vectorClock: VectorClockState` — fresh snapshot of the local clock.
- `enqueue(input: EnqueueInput): SyncOperation` — appends a mutation.
Throws `VaultSyncError(QUEUE.DEPTH_EXCEEDED)` at capacity.
- `flush(): SyncOperation[]` — drains with collapsing applied.
- `peek(): readonly SyncOperation[]` — observe without draining.
- `serialize(): Uint8Array` — UTF-8 JSON. Throws
`VaultSyncError(QUEUE.SERIALIZATION_FAILED)` if encode fails.
- `static deserialize(bytes, options?): SyncQueue` — restores a queue,
re-validating each operation through `SyncOperationSchema`. Throws
`QUEUE.SERIALIZATION_FAILED` on JSON / shape failure.
- `collapseOperations(ops): SyncOperation[]` — pure helper exposed for
callers that already manage their own queue but want the same dedup
semantics.
- `EnqueueInput` — `{ kind, itemId, fields }`.
- `SyncQueueOptions` — `{ nodeId, clock?, rng?, maxDepth? }`.
- `DEFAULT_MAX_QUEUE_DEPTH` — `10_000`.
## Implementation Notes
- The vector clock advances on every `enqueue` for the queue's `nodeId`.
This makes collapsing safe even across many enqueues for the same item:
the latest enqueue causally dominates earlier ones from the same node.
- `collapseOperations` keeps a per-`itemId` slot. Comparing the candidate
to the slot uses `compare(previous.clock, candidate.clock)`. Concurrent
collisions break ties by `enqueuedAt`; identical timestamps → second
wins so subsequent enqueues from the same node (which by construction
have higher clock counters) always replace the slot.
- A `delete` operation is sticky: once recorded, subsequent
non-delete operations for that id are dropped, since the consumer's
contract is that delete is terminal for the id (a re-create must use a
new id).
- Serialization is intentionally JSON-based to keep the wire format
inspectable in tests and during incident response. The `serialize` /
`deserialize` pair fully round-trips: clock, operations, and order.

View File

@@ -0,0 +1,99 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` provides Zod runtime validators for every public boundary type
declared in `types.ts`. The validators are the contract enforced at API
entry and exit: a `VaultItem` reaching `mergeVaultItems`, a `SyncOperation`
queued by `SyncQueue.enqueue`, an `EncryptedSyncPayload` decoded by
`decryptSyncPayload` — all are first parsed by the appropriate schema so a
malformed input fails fast with a structured error rather than producing a
hard-to-debug shape mismatch later in the pipeline.
## Features
- `VectorClockSchema` — `Record<string, non-negative-int>`
- `VaultHistoryEntrySchema` — `{ historyId; timestamp; note }`, strict
- `VaultItemSchema` — full canonical vault-item shape, strict
- `MergeStrategySchema` — `"lww" | "append" | "atomic"`
- `SyncOperationKindSchema` — `"create" | "update" | "delete"`
- `SyncOperationSchema` — durable-queue entry shape with partial `fields`
- `VaultSnapshotSchema` — `{ items; clock; snapshotAt }`, strict
- `EncryptedSyncPayloadSchema` — version + 24-byte nonce + ciphertext
- All schemas are `.strict()` — extra keys raise an error
## Usage: Basic Example
```ts
import { VaultItemSchema } from "@suaveplan/vault-sync/schemas/schemas";
const parsed = VaultItemSchema.safeParse({
id: "i1",
title: "Email",
password: "hunter2",
folderId: null,
history: [],
nodeId: "n1",
updatedAt: 0,
clock: { n1: 1 },
});
if (!parsed.success) throw new Error("invalid item");
```
## Usage: Advanced Example
```ts
import {
EncryptedSyncPayloadSchema,
SyncOperationSchema,
VaultSnapshotSchema,
} from "@suaveplan/vault-sync/schemas/schemas";
import { VaultSyncError } from "@suaveplan/vault-sync";
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
function asPayload(input: unknown): import("@suaveplan/vault-sync/types").EncryptedSyncPayload {
const parsed = EncryptedSyncPayloadSchema.safeParse(input);
if (!parsed.success) {
throw new VaultSyncError(
VAULT_SYNC_CODES["SYNC.PAYLOAD_INVALID_VERSION"],
"error.vault-sync.sync.payload-invalid-version",
{ context: { issues: parsed.error.issues.length } }
);
}
return parsed.data;
}
declare const wireSnapshot: unknown;
declare const wireOperation: unknown;
const snapshot = VaultSnapshotSchema.parse(wireSnapshot);
const operation = SyncOperationSchema.parse(wireOperation);
```
## API Reference
- `VectorClockSchema: ZodSchema<Record<string, number>>` — keys must be
non-empty strings; values must be non-negative integers.
- `VaultHistoryEntrySchema: ZodSchema<VaultHistoryEntry>` — strict.
- `VaultItemSchema: ZodSchema<VaultItem>` — strict; `folderId` is `string |
null`; `history` is an array; `clock` validated by `VectorClockSchema`.
- `MergeStrategySchema: ZodUnion<[lww, append, atomic]>`.
- `SyncOperationKindSchema: ZodUnion<[create, update, delete]>`.
- `SyncOperationSchema: ZodSchema<SyncOperation>` — strict; `fields` validated
with `VaultItemSchema.partial()` so any subset of vault fields is acceptable.
- `VaultSnapshotSchema: ZodSchema<VaultSnapshot>` — strict.
- `EncryptedSyncPayloadSchema: ZodSchema<EncryptedSyncPayload>` — `version`
must equal `"v1"`; `nonce` must be exactly 24 bytes (XChaCha20).
## Implementation Notes
Strict mode (`.strict()`) is intentional — `vault-sync` is a wire
boundary, and silently accepting unknown fields opens a downgrade attack
where a malicious peer adds a future-version field that gets ignored. The
`nonce` byte-length check is duplicated (also enforced by
`crypto-cipher`'s XChaCha primitive) so callers parsing a payload without
attempting to decrypt still see a clear schema-level failure. All schemas
are pure values — they take no constructor arguments, they have no shared
state, and they are safe to use in any execution context.

View File

@@ -0,0 +1,124 @@
# vault-sync types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`types.ts` is the single source of truth for every wire-level and in-memory
contract shared across the `@suaveplan/vault-sync` modules. It declares the
canonical `VaultItem` shape consumed by `merge`, `patch`, `engine`, and
`queue`; the `SyncOperation` and `VaultSnapshot` envelopes; and the
`EncryptedSyncPayload` / `SyncTransport` interfaces that draw the boundary
between encryption and network transport. Keeping these types in one file
allows downstream consumers (and the package's own Zod schemas) to import
from a stable subpath without pulling in runtime modules.
## Features
- Canonical `VaultItem`, `VaultHistoryEntry`, and `VaultSnapshot` shapes
- `MergeStrategy` literal union (`"lww" | "append" | "atomic"`)
- `VectorClockState` and `VectorClockRelation` aliases used package-wide
- `SyncOperation` and `SyncOperationKind` queue-entry contracts
- `EncryptedSyncPayload` AEAD envelope with explicit `version` discriminator
- `SyncTransport` protocol — pure interface, no implementation coupling
- Pure types only — no runtime imports, zero side effects
## Usage: Basic Example
```ts
import type { VaultItem } from "@suaveplan/vault-sync/types";
const item: VaultItem = {
id: "i1",
title: "Example",
password: "hunter2",
folderId: null,
history: [],
nodeId: "node-a",
updatedAt: 0,
clock: { "node-a": 1 },
};
```
## Usage: Advanced Example
```ts
import type {
EncryptedSyncPayload,
MergeStrategy,
SyncOperation,
SyncTransport,
VaultItem,
} from "@suaveplan/vault-sync/types";
class HttpTransport implements SyncTransport {
public constructor(private readonly url: string) {}
public async fetchSnapshot(): Promise<EncryptedSyncPayload> {
const res = await fetch(`${this.url}/snapshot`);
const body = (await res.json()) as {
version: "v1";
nonce: number[];
ciphertext: number[];
};
return {
version: body.version,
nonce: new Uint8Array(body.nonce),
ciphertext: new Uint8Array(body.ciphertext),
};
}
public async pushSnapshot(payload: EncryptedSyncPayload): Promise<void> {
await fetch(`${this.url}/snapshot`, {
method: "POST",
body: JSON.stringify({
version: payload.version,
nonce: Array.from(payload.nonce),
ciphertext: Array.from(payload.ciphertext),
}),
});
}
}
function strategyForField(field: keyof VaultItem): MergeStrategy {
switch (field) {
case "history":
return "append";
case "folderId":
return "atomic";
default:
return "lww";
}
}
declare const op: SyncOperation;
```
## API Reference
- `VectorClockState` — `Readonly<Record<string, number>>` mapping node id to
monotonic counter.
- `VectorClockRelation` — `"<" | ">" | "==" | "concurrent"`.
- `MergeStrategy` — `"lww" | "append" | "atomic"`.
- `VaultHistoryEntry` — `{ historyId; timestamp; note }`. Sort key for the
`"append"` strategy is `timestamp`; deduplication key is `historyId`.
- `VaultItem` — fields: `id`, `title`, `password`, `folderId`, `history`,
`nodeId`, `updatedAt`, `clock`, optional `deleted`.
- `SyncOperationKind` — `"create" | "update" | "delete"`.
- `SyncOperation` — queue entry with `kind`, `itemId`, `nodeId`, `clock`,
`enqueuedAt`, partial `fields` payload, `idempotencyKey`.
- `VaultSnapshot` — exchange envelope: `items`, `clock`, `snapshotAt`.
- `SyncResult` — outcome record: `merged`, `conflicts`, `deleted`, `durationMs`.
- `EncryptedSyncPayload` — `{ version: "v1"; nonce; ciphertext }`.
- `SyncTransport` — `fetchSnapshot()` / `pushSnapshot(payload)`.
## Implementation Notes
Types are deliberately conservative: the wire schema is small and stable so
that downstream tools can validate it without coupling to the engine. Items
are modelled as `readonly` records — every mutation goes through `patch.ts`
or `engine.ts`, both of which re-emit fresh frozen-shape values. The
`version` discriminator on `EncryptedSyncPayload` lets future revisions add
fields without breaking decryption: the AEAD AAD is bound to the version
string so the AAD check rejects tampered version bumps.

View File

@@ -0,0 +1,108 @@
# vector-clock
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`vector-clock.ts` implements the causal-ordering primitives used by every
other module in `@suaveplan/vault-sync`. Vector clocks let two divergent
peers detect whether one branch is the causal ancestor of the other or
whether the edits were concurrent — a prerequisite for safe CRDT merges,
durable queue ordering, and explicit conflict reporting in `SyncEngine`.
The implementation deliberately avoids secret-dependent branches: every
comparison uses integer arithmetic against `nodeId`-keyed counters with no
short-circuit on values that could leak side-channel information.
## Features
- `increment(clock, nodeId)` — pure, allocates a fresh clock
- `merge(left, right)` — component-wise maximum, missing keys treated as zero
- `compare(left, right)` — returns `"<" | ">" | "==" | "concurrent"`
- `happensBefore(left, right)` — Lamport happens-before predicate
- `lwwTotalOrder(left, right)` — total ordering for the `"lww"` strategy with
`(clock, updatedAt, nodeId)` tie-breaking
- `empty()` — canonical empty vector clock factory
- All operations are pure and side-effect free; inputs are never mutated
- Saturation guard: counter never exceeds `Number.MAX_SAFE_INTEGER`
## Usage: Basic Example
```ts
import {
compare,
increment,
merge,
} from "@suaveplan/vault-sync/vector-clock/vector-clock";
const a = increment({}, "node-a");
const b = increment({}, "node-b");
console.log(compare(a, b)); // "concurrent"
console.log(merge(a, b)); // { "node-a": 1, "node-b": 1 }
```
## Usage: Advanced Example
```ts
import {
increment,
lwwTotalOrder,
merge,
} from "@suaveplan/vault-sync/vector-clock/vector-clock";
import type { VectorClockState } from "@suaveplan/vault-sync/types";
interface Snapshot {
clock: VectorClockState;
updatedAt: number;
nodeId: string;
}
function applyMutation(local: Snapshot, nodeId: string, now: number): Snapshot {
return {
clock: increment(local.clock, nodeId),
updatedAt: now,
nodeId,
};
}
function pickWinner(left: Snapshot, right: Snapshot): Snapshot {
return lwwTotalOrder(left, right) === -1 ? left : right;
}
// Reconcile a remote snapshot into local state.
function reconcile(local: Snapshot, remote: Snapshot): Snapshot {
const winner = pickWinner(local, remote);
return {
clock: merge(local.clock, remote.clock),
updatedAt: winner.updatedAt,
nodeId: winner.nodeId,
};
}
```
## API Reference
- `increment(clock: VectorClockState, nodeId: string): VectorClockState` —
returns a new clock with `nodeId`'s counter incremented by `1`. Throws
`VaultSyncError(VECTOR_CLOCK.REGRESSION)` if the counter is already at
`Number.MAX_SAFE_INTEGER`.
- `merge(left, right): VectorClockState` — element-wise max of two clocks.
- `compare(left, right): "<" | ">" | "==" | "concurrent"` — total relation
classifying causal order.
- `happensBefore(left, right): boolean` — `true` iff `compare === "<"`.
- `lwwTotalOrder(left, right): -1 | 0 | 1` — total order for last-write-wins
resolution: vector-clock dominance, then `updatedAt`, then `nodeId`.
- `empty(): VectorClockState` — `{}` literal helper.
## Implementation Notes
The clock is modelled as `Readonly<Record<string, number>>` rather than a
class so the data is trivially serialisable and comparable by deep equality
without custom `equals()`. `compare` enumerates the union of node ids; both
absent counters default to `0`, which preserves the identity that an empty
clock causally precedes any non-empty one. The `lwwTotalOrder` resolves
remaining ties deterministically so two peers with identical inputs always
elect the same winner — an essential property for offline-first
convergence. Saturation at `Number.MAX_SAFE_INTEGER` is treated as a hard
error rather than rolling over, since silent rollover would silently break
causal ordering.