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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user