Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
# @suaveplan/vault-sync - Domain-specific sync and conflict-resolution engine for the Vaulted password-manager ecosystem: vector-clock causal ordering, CRDT merge strategies (LWW / append / atomic), RFC 6902 JSON Patch incremental sync, durable offline queue with operation collapsing, and end-to-end-encrypted sync payloads via XChaCha20-Poly1305.
## Summary
Domain-specific sync and conflict-resolution engine for the Vaulted
password-manager ecosystem. Builds on `@suaveplan/crdt` and
`@suaveplan/diff` (RFC 6902 JSON Patch) to provide offline-first,
end-to-end-encrypted vault synchronisation. Implements vault-item merge
strategies (last-write-wins for passwords, append-only for audit history,
atomic for folder moves), durable queues with operation collapsing,
vector-clock causal ordering, and XChaCha20-Poly1305 sync payloads.
<!-- begin:auto-docs -->
## Documentation tree
- [crypto-sync](./src/crypto-sync/crypto-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [crypto-sync](./src/crypto-sync/crypto-sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — `codes.ts` defines the canonical, frozen registry of every fully-qualified error code emitted by `@suaveplan/vault-sync…
- [vault-sync-error](./src/errors/vault-sync-error.md) — `VaultSyncError` is the single concrete error class thrown from every production path inside `@suaveplan/vault-sync`. I…
- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [crypto-sync](./src/crypto-sync/crypto-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the canonical, frozen registry of every fully-qualified error code emitted by `@suaveplan/vault-sync…
- [vault-sync-error](./src/errors/vault-sync-error.md) — `VaultSyncError` is the single concrete error class thrown from every production path inside `@suaveplan/vault-sync`. I…
- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-sync](./src/crypto-sync/crypto-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the canonical, frozen registry of every fully-qualified error code emitted by `@suaveplan/vault-sync…
- [vault-sync-error](./src/errors/vault-sync-error.md) — `VaultSyncError` is the single concrete error class thrown from every production path inside `@suaveplan/vault-sync`. I…
- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-sync](./src/crypto-sync/crypto-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the canonical, frozen registry of every fully-qualified error code emitted by `@suaveplan/vault-sync…
- [vault-sync-error](./src/errors/vault-sync-error.md) — `VaultSyncError` is the single concrete error class thrown from every production path inside `@suaveplan/vault-sync`. I…
- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-sync](./src/crypto-sync/crypto-sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [engine](./src/engine/engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the canonical, frozen registry of every fully-qualified error code emitted by `@suaveplan/vault-sync…
- [vault-sync-error](./src/errors/vault-sync-error.md) — `VaultSyncError` is the single concrete error class thrown from every production path inside `@suaveplan/vault-sync`. I…
- [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_ - [merge](./src/merge/merge.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_ - [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue](./src/queue/queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ - [vector-clock](./src/vector-clock/vector-clock.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/vault-sync
```
## Quick start
```ts
import {
applyPatch,
decryptSyncPayload,
diffVaultItems,
encryptSyncPayload,
mergeVaultItems,
SyncEngine,
SyncQueue,
VaultSyncError,
} from "@suaveplan/vault-sync";
const local = {
id: "i1",
title: "Email",
password: "old",
folderId: null,
history: [],
nodeId: "device-a",
updatedAt: 1,
clock: { "device-a": 1 },
};
const remote = { ...local, password: "new", updatedAt: 2 };
const merged = mergeVaultItems(local, remote, "lww");
const patch = diffVaultItems(local, merged);
const restored = applyPatch(local, patch);
const queue = new SyncQueue({ nodeId: "device-a" });
queue.enqueue({ kind: "update", itemId: "i1", fields: { password: "new" } });
const drained = queue.flush();
const key = crypto.getRandomValues(new Uint8Array(32));
const envelope = encryptSyncPayload(new TextEncoder().encode("snap"), key);
const plaintext = decryptSyncPayload(envelope, key);
```
## Modules
- [merge](./src/merge/merge.md) — `mergeVaultItems` with `lww` / `append` / `atomic`
- [patch](./src/patch/patch.md) — RFC 6902 diff and apply for vault items
- [engine](./src/engine/engine.md) — `SyncEngine` orchestrator (`pull` / `push` / `sync`)
- [queue](./src/queue/queue.md) — `SyncQueue` durable mutation buffer
- [crypto-sync](./src/crypto-sync/crypto-sync.md) — XChaCha20-Poly1305 envelope
- [vector-clock](./src/vector-clock/vector-clock.md) — causal-ordering primitives
- [errors / codes](./src/errors/codes.md) — `VAULT_SYNC_CODES`
- [errors / VaultSyncError](./src/errors/vault-sync-error.md) — `VaultSyncError`
- [otel](./src/otel/instruments.md) — OTel meter / tracer accessors
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public boundary
- [types](./src/types/types.md) — types-only contracts
## Error codes
| Code | Thrown by |
|---|---|
| `VAULTSYNC.MERGE.STRATEGY_UNKNOWN` | `mergeVaultItems` / `assertMergeStrategy` |
| `VAULTSYNC.MERGE.CONFLICT_UNRESOLVED` | `mergeVaultItems` (atomic) / id mismatch |
| `VAULTSYNC.PATCH.INVALID` | `applyPatch` |
| `VAULTSYNC.SYNC.PAYLOAD_TAMPERED` | `decryptSyncPayload` |
| `VAULTSYNC.SYNC.PAYLOAD_INVALID_VERSION` | `decryptSyncPayload` |
| `VAULTSYNC.SYNC.PAYLOAD_TOO_LARGE` | `encryptSyncPayload` |
| `VAULTSYNC.SYNC.TRANSPORT_FAILED` | `SyncEngine.pull` / `SyncEngine.push` |
| `VAULTSYNC.QUEUE.DEPTH_EXCEEDED` | `SyncQueue.enqueue` |
| `VAULTSYNC.QUEUE.SERIALIZATION_FAILED` | `SyncQueue.serialize` / `SyncQueue.deserialize` |
| `VAULTSYNC.VECTOR_CLOCK.REGRESSION` | `increment` (saturation guard) |
## Testing & coverage
```bash
bunx turbo test --filter=@suaveplan/vault-sync
bunx turbo test:coverage --filter=@suaveplan/vault-sync # 100/100/100/100
bunx turbo lint typecheck --filter=@suaveplan/vault-sync
```

View File

@@ -0,0 +1,170 @@
{
"name": "@suaveplan/vault-sync",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Domain-specific sync and conflict-resolution engine for the Vaulted password-manager ecosystem: vector-clock causal ordering, CRDT merge strategies (LWW / append / atomic), RFC 6902 JSON Patch incremental sync, durable offline queue with operation collapsing, and end-to-end-encrypted sync payloads via XChaCha20-Poly1305.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./merge/merge": {
"import": {
"types": "./dist/merge/merge.d.ts",
"default": "./dist/merge/merge.js"
},
"require": {
"types": "./dist/merge/merge.d.ts",
"default": "./dist/merge/merge.js"
}
},
"./patch/patch": {
"import": {
"types": "./dist/patch/patch.d.ts",
"default": "./dist/patch/patch.js"
},
"require": {
"types": "./dist/patch/patch.d.ts",
"default": "./dist/patch/patch.js"
}
},
"./engine/engine": {
"import": {
"types": "./dist/engine/engine.d.ts",
"default": "./dist/engine/engine.js"
},
"require": {
"types": "./dist/engine/engine.d.ts",
"default": "./dist/engine/engine.js"
}
},
"./queue/queue": {
"import": {
"types": "./dist/queue/queue.d.ts",
"default": "./dist/queue/queue.js"
},
"require": {
"types": "./dist/queue/queue.d.ts",
"default": "./dist/queue/queue.js"
}
},
"./crypto-sync/crypto-sync": {
"import": {
"types": "./dist/crypto-sync/crypto-sync.d.ts",
"default": "./dist/crypto-sync/crypto-sync.js"
},
"require": {
"types": "./dist/crypto-sync/crypto-sync.d.ts",
"default": "./dist/crypto-sync/crypto-sync.js"
}
},
"./vector-clock/vector-clock": {
"import": {
"types": "./dist/vector-clock/vector-clock.d.ts",
"default": "./dist/vector-clock/vector-clock.js"
},
"require": {
"types": "./dist/vector-clock/vector-clock.d.ts",
"default": "./dist/vector-clock/vector-clock.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"require": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
},
"require": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./schemas/schemas": {
"import": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
},
"require": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
}
},
"./types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
},
"require": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crdt": "workspace:^",
"@suaveplan/crypto-cipher": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/crypto-vault": "workspace:^",
"@suaveplan/diff": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,170 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import {
decryptSyncPayload,
encryptSyncPayload,
MAX_SYNC_PAYLOAD_BYTES,
SYNC_PAYLOAD_VERSION,
} from "./crypto-sync.js";
function key32(seed = 0xa5): Uint8Array {
const k = new Uint8Array(32);
k.fill(seed);
return k;
}
function plaintext(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
describe("encryptSyncPayload / decryptSyncPayload", () => {
it("encrypt decrypt round-trips", { timeout: 5000 }, () => {
const key = key32();
const pt = plaintext("hello-sync");
const env = encryptSyncPayload(pt, key);
expect(env.version).toBe(SYNC_PAYLOAD_VERSION);
expect(env.nonce.length).toBe(24);
const out = decryptSyncPayload(env, key);
expect(new TextDecoder().decode(out)).toBe("hello-sync");
});
it("wrong key fails decryption", { timeout: 5000 }, () => {
const env = encryptSyncPayload(plaintext("x"), key32(0x01));
try {
decryptSyncPayload(env, key32(0x02));
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]);
}
});
it("tampered ciphertext is rejected", { timeout: 5000 }, () => {
const env = encryptSyncPayload(plaintext("x"), key32());
env.ciphertext[0] = (env.ciphertext[0] ?? 0) ^ 0xff;
try {
decryptSyncPayload(env, key32());
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]);
}
});
it("nonce is unique per encryption", { timeout: 5000 }, () => {
const key = key32();
const a = encryptSyncPayload(plaintext("x"), key);
const b = encryptSyncPayload(plaintext("x"), key);
expect(a.nonce).not.toEqual(b.nonce);
});
it("payload serialization is stable", { timeout: 5000 }, () => {
// Same plaintext + same nonce + same key MUST yield identical ciphertext.
const key = key32();
const pt = plaintext("stable");
const fixed = new Uint8Array(24);
fixed.fill(0x77);
const a = encryptSyncPayload(pt, key, { byteSource: () => fixed });
const b = encryptSyncPayload(pt, key, {
byteSource: () => new Uint8Array(fixed),
});
expect(a.ciphertext).toEqual(b.ciphertext);
});
it("injectable byteSource determines nonce", { timeout: 5000 }, () => {
const fixed = new Uint8Array(24);
fixed.fill(0x42);
const env = encryptSyncPayload(plaintext("a"), key32(), {
byteSource: () => new Uint8Array(fixed),
});
expect(env.nonce).toEqual(fixed);
});
it("rejects payloads above MAX_SYNC_PAYLOAD_BYTES", {
timeout: 5000,
}, () => {
// Use a pseudo-large buffer; we don't actually allocate 10 MiB to
// keep the test fast — call with an oversized typed array.
const oversized = new Uint8Array(MAX_SYNC_PAYLOAD_BYTES + 1);
try {
encryptSyncPayload(oversized, key32());
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.PAYLOAD_TOO_LARGE"]);
}
});
it("rejects payloads with mismatched AAD", { timeout: 5000 }, () => {
const key = key32();
const env = encryptSyncPayload(plaintext("x"), key, {
additionalData: new TextEncoder().encode("ctx-A"),
});
try {
decryptSyncPayload(env, key, {
additionalData: new TextEncoder().encode("ctx-B"),
});
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]);
}
});
it("schema-invalid envelope raises PAYLOAD_INVALID_VERSION", {
timeout: 5000,
}, () => {
const env = encryptSyncPayload(plaintext("x"), key32());
const broken = {
...env,
version: "v2",
} as unknown as typeof env;
try {
decryptSyncPayload(broken, key32());
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["SYNC.PAYLOAD_INVALID_VERSION"]
);
}
});
it("schema-invalid envelope (wrong nonce length)", {
timeout: 5000,
}, () => {
const env = encryptSyncPayload(plaintext("x"), key32());
const broken = { ...env, nonce: new Uint8Array(12) };
try {
decryptSyncPayload(broken, key32());
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["SYNC.PAYLOAD_INVALID_VERSION"]
);
}
});
it("decrypts an envelope with caller-supplied AAD", {
timeout: 5000,
}, () => {
const key = key32();
const aad = new TextEncoder().encode("vaultId");
const env = encryptSyncPayload(plaintext("hello"), key, {
additionalData: aad,
});
const out = decryptSyncPayload(env, key, { additionalData: aad });
expect(new TextDecoder().decode(out)).toBe("hello");
});
it("empty additionalData is treated as none", { timeout: 5000 }, () => {
const key = key32();
const env1 = encryptSyncPayload(plaintext("x"), key, {
additionalData: new Uint8Array(0),
});
const out = decryptSyncPayload(env1, key);
expect(new TextDecoder().decode(out)).toBe("x");
});
});

View File

@@ -0,0 +1,130 @@
import { xchacha20poly1305 } from "@suaveplan/crypto-cipher";
import { type ByteSource, randomBytes } from "@suaveplan/crypto-utils";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import { getVaultSyncInstruments } from "../otel/instruments.js";
import { EncryptedSyncPayloadSchema } from "../schemas/schemas.js";
import type { EncryptedSyncPayload } from "../types/types.js";
/** Maximum size of a single sync plaintext payload (10 MiB). */
export const MAX_SYNC_PAYLOAD_BYTES = 10 * 1024 * 1024;
/** AAD bound to every payload — version-mismatch detection. */
export const SYNC_PAYLOAD_VERSION = "v1" as const;
const SYNC_PAYLOAD_AAD = new TextEncoder().encode(SYNC_PAYLOAD_VERSION);
/** Options for {@link encryptSyncPayload}. */
export interface EncryptSyncPayloadOptions {
/**
* Optional byte source used to generate the 24-byte XChaCha20 nonce.
* Defaults to `crypto.getRandomValues` via `@suaveplan/crypto-utils`.
*/
readonly byteSource?: ByteSource;
/**
* Optional caller-supplied additional associated data appended to the
* version AAD. The same value MUST be passed to `decryptSyncPayload`.
*/
readonly additionalData?: Uint8Array;
}
function combineAad(extra: Uint8Array | undefined): Uint8Array {
if (extra === undefined || extra.length === 0) return SYNC_PAYLOAD_AAD;
const out = new Uint8Array(SYNC_PAYLOAD_AAD.length + extra.length);
out.set(SYNC_PAYLOAD_AAD, 0);
out.set(extra, SYNC_PAYLOAD_AAD.length);
return out;
}
/**
* Encrypt a sync payload with XChaCha20-Poly1305.
*
* The 24-byte nonce is drawn from the supplied `byteSource` (or
* `defaultByteSource` if none is provided). The plaintext is bound to the
* version string `"v1"` via AEAD AAD so a future v2 payload cannot be
* mistakenly decrypted as v1.
*
* @throws VaultSyncError(SYNC.PAYLOAD_TOO_LARGE) when `plaintext.length`
* exceeds {@link MAX_SYNC_PAYLOAD_BYTES}.
*/
export function encryptSyncPayload(
plaintext: Uint8Array,
key: Uint8Array,
options: EncryptSyncPayloadOptions = {}
): EncryptedSyncPayload {
if (plaintext.length > MAX_SYNC_PAYLOAD_BYTES) {
throw new VaultSyncError(
VAULT_SYNC_CODES["SYNC.PAYLOAD_TOO_LARGE"],
"error.vault-sync.sync.payload-too-large",
{
context: {
length: plaintext.length,
limit: MAX_SYNC_PAYLOAD_BYTES,
},
}
);
}
const start = Date.now();
const nonce = randomBytes(
xchacha20poly1305.NONCE_LENGTH,
options.byteSource !== undefined
? { byteSource: options.byteSource }
: {}
);
const aad = combineAad(options.additionalData);
const ciphertext = xchacha20poly1305.encrypt(key, nonce, plaintext, aad);
const i = getVaultSyncInstruments();
i.payloadEncryptDurationMs.record(Date.now() - start);
i.payloadOutcomeTotal.add(1, { status: "encrypted" });
return { version: SYNC_PAYLOAD_VERSION, nonce, ciphertext };
}
/**
* Decrypt a sync payload with XChaCha20-Poly1305.
*
* Verifies the Poly1305 tag and the AAD-bound version string. The
* plaintext is returned as a fresh `Uint8Array`; the caller is responsible
* for {@link zeroize}-ing it after use.
*
* @throws VaultSyncError(SYNC.PAYLOAD_INVALID_VERSION) — envelope failed
* Zod validation (`version` not `"v1"`, nonce length wrong, etc).
* @throws VaultSyncError(SYNC.PAYLOAD_TAMPERED) — Poly1305 tag mismatch.
*/
export function decryptSyncPayload(
payload: EncryptedSyncPayload,
key: Uint8Array,
options: { readonly additionalData?: Uint8Array } = {}
): Uint8Array {
const parsed = EncryptedSyncPayloadSchema.safeParse(payload);
if (!parsed.success) {
getVaultSyncInstruments().payloadOutcomeTotal.add(1, {
status: "tampered",
});
throw new VaultSyncError(
VAULT_SYNC_CODES["SYNC.PAYLOAD_INVALID_VERSION"],
"error.vault-sync.sync.payload-invalid-version",
{ context: { issues: parsed.error.issues.length } }
);
}
const aad = combineAad(options.additionalData);
try {
const out = xchacha20poly1305.decrypt(
key,
parsed.data.nonce,
parsed.data.ciphertext,
aad
);
getVaultSyncInstruments().payloadOutcomeTotal.add(1, {
status: "decrypted",
});
return out;
} catch (cause) {
getVaultSyncInstruments().payloadOutcomeTotal.add(1, {
status: "tampered",
});
throw new VaultSyncError(
VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"],
"error.vault-sync.sync.payload-tampered",
{ cause }
);
}
}

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,425 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import type {
EncryptedSyncPayload,
SyncTransport,
VaultItem,
VaultSnapshot,
} from "../types/types.js";
import { SyncEngine } from "./engine.js";
class CountingRng implements Rng {
private state = 1;
public nextUint32(): number {
this.state = (this.state * 1664525 + 1013904223) >>> 0;
return this.state;
}
public nextFloat(): number {
return this.nextUint32() / 0x1_00_00_00_00;
}
}
class MemoryTransport implements SyncTransport {
public stored: VaultSnapshot | undefined;
public failOn: "fetch" | "push" | undefined;
public async fetchSnapshot(): Promise<EncryptedSyncPayload> {
if (this.failOn === "fetch") throw new Error("offline");
if (!this.stored) {
return {
version: "v1" as const,
nonce: new Uint8Array(24),
ciphertext: new Uint8Array(0),
};
}
// We pass the snapshot through as the envelope; the engine's
// injected decrypt callback turns it back into a snapshot.
return this.stored as unknown as EncryptedSyncPayload;
}
public async pushSnapshot(envelope: EncryptedSyncPayload): Promise<void> {
if (this.failOn === "push") throw new Error("offline");
this.stored = envelope as unknown as VaultSnapshot;
}
}
function snapshot(items: VaultItem[]): VaultSnapshot {
const clock: Record<string, number> = {};
for (const item of items) {
for (const [k, v] of Object.entries(item.clock)) {
clock[k] = Math.max(clock[k] ?? 0, v);
}
}
return { items, clock, snapshotAt: 1 };
}
function item(over: Partial<VaultItem> = {}): VaultItem {
return {
id: "i1",
title: "Email",
password: "pw",
folderId: null,
history: [],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
...over,
};
}
function makeEngine(transport: MemoryTransport, init?: VaultSnapshot) {
return new SyncEngine({
nodeId: "a",
transport,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
...(init !== undefined ? { initialSnapshot: init } : {}),
});
}
describe("SyncEngine — pull", () => {
it("pull applies remote changes", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
t.stored = snapshot([
item({ id: "i1", title: "x" }),
item({ id: "i2", title: "y" }),
]);
const result = await engine.pull();
expect([...result.merged].sort()).toEqual(["i1", "i2"]);
expect(engine.currentSnapshot.items).toHaveLength(2);
});
it("empty pull returns empty result", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
t.stored = snapshot([]);
const engine = makeEngine(t);
const result = await engine.pull();
expect(result.merged).toEqual([]);
expect(result.conflicts).toEqual([]);
});
it("propagates deletions as tombstones", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
t.stored = snapshot([item({ id: "i1", deleted: true })]);
const result = await engine.pull();
expect(result.deleted).toEqual(["i1"]);
});
it("merged-deleted items land in deleted[]", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(
t,
snapshot([item({ id: "i1", deleted: true })])
);
t.stored = snapshot([item({ id: "i1", nodeId: "b", clock: { b: 1 } })]);
const result = await engine.pull();
// The local copy carries deleted=true; the merge propagates that.
expect(result.deleted).toContain("i1");
});
it("retains local-only deleted items in deleted[]", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(
t,
snapshot([item({ id: "i1", deleted: true })])
);
t.stored = snapshot([item({ id: "i2", title: "remote" })]);
const result = await engine.pull();
// i1 is local-only and deleted; i2 is new from remote.
expect(result.deleted).toContain("i1");
expect(result.merged).toContain("i2");
});
it("retains local-only non-deleted items in the snapshot", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t, snapshot([item({ id: "i1" })]));
t.stored = snapshot([item({ id: "i2", title: "remote" })]);
const result = await engine.pull();
// i1 is local-only and NOT deleted; result should not include it.
expect(result.deleted).not.toContain("i1");
const ids = engine.currentSnapshot.items.map((i) => i.id).sort();
expect(ids).toEqual(["i1", "i2"]);
});
it("wraps transport errors with SYNC_TRANSPORT_FAILED", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
t.failOn = "fetch";
const engine = makeEngine(t);
try {
await engine.pull();
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]);
}
});
});
describe("SyncEngine — push", () => {
it("push returns only local changes since last sync", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
engine.applyLocalMutation(item({ id: "i1" }));
engine.applyLocalMutation(item({ id: "i2" }));
const result = await engine.push();
expect([...result.merged].sort()).toEqual(["i1", "i2"]);
});
it("empty push returns empty result", { timeout: 5000 }, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
const result = await engine.push();
expect(result.merged).toEqual([]);
expect(result.deleted).toEqual([]);
});
it("propagates locally-deleted items in deleted[]", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const engine = makeEngine(t);
engine.applyLocalMutation(item({ id: "i1", deleted: true }));
const result = await engine.push();
expect(result.deleted).toEqual(["i1"]);
expect(result.merged).toEqual([]);
});
it("wraps push transport errors with SYNC_TRANSPORT_FAILED", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
t.failOn = "push";
const engine = makeEngine(t);
engine.applyLocalMutation(item({ id: "i1" }));
try {
await engine.push();
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]);
}
});
});
describe("SyncEngine — sync (bidirectional)", () => {
it("bidirectional sync reconciles divergent states", {
timeout: 5000,
}, async () => {
// A merging transport: pushSnapshot merges into stored state, fetch
// returns the merged remote state.
const merger = new MemoryTransport();
const original = merger.pushSnapshot.bind(merger);
merger.pushSnapshot = async (env): Promise<void> => {
const incoming = env as unknown as VaultSnapshot;
const existing =
(merger.stored as VaultSnapshot | undefined) ?? snapshot([]);
const byId = new Map<string, VaultItem>();
for (const i of existing.items) byId.set(i.id, i);
for (const i of incoming.items) byId.set(i.id, i);
await original({
...env,
} as never);
merger.stored = {
items: Array.from(byId.values()),
clock: { ...existing.clock, ...incoming.clock },
snapshotAt: Math.max(existing.snapshotAt, incoming.snapshotAt),
};
};
merger.stored = snapshot([
item({ id: "i2", title: "remote", nodeId: "b", clock: { b: 1 } }),
]);
const engine = makeEngine(merger);
engine.applyLocalMutation(item({ id: "i1", title: "local" }));
const result = await engine.sync();
expect(result.merged.length).toBeGreaterThanOrEqual(1);
const ids = engine.currentSnapshot.items.map((i) => i.id).sort();
expect(ids).toEqual(["i1", "i2"]);
});
it("detects concurrent edits as conflicts", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({
id: "i1",
folderId: "loc",
clock: { a: 1 },
});
// Remote: same item, concurrent edit to folderId.
t.stored = snapshot([
item({
id: "i1",
folderId: "rem",
nodeId: "b",
clock: { b: 1 },
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
defaultStrategy: "atomic",
initialSnapshot: snapshot([local]),
});
const result = await engine.pull();
expect(result.conflicts).toContain("i1");
});
it("resolves conflicts with configured strategy", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({ id: "i1", title: "loc", updatedAt: 1 });
t.stored = snapshot([
item({
id: "i1",
title: "rem",
nodeId: "b",
clock: { b: 1 },
updatedAt: 5,
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
initialSnapshot: snapshot([local]),
});
const result = await engine.pull();
expect(result.merged).toContain("i1");
const merged = engine.currentSnapshot.items.find((i) => i.id === "i1");
expect(merged?.title).toBe("rem");
});
it("uses history strategy when items have history", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({
id: "i1",
history: [{ historyId: "h1", timestamp: 1, note: "a" }],
clock: { a: 1 },
});
t.stored = snapshot([
item({
id: "i1",
history: [{ historyId: "h2", timestamp: 2, note: "b" }],
nodeId: "b",
clock: { b: 1 },
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
initialSnapshot: snapshot([local]),
});
await engine.pull();
const merged = engine.currentSnapshot.items.find((i) => i.id === "i1");
expect(merged?.history).toHaveLength(2);
});
});
describe("SyncEngine — hydrate / mutations", () => {
it("hydrate validates snapshot shape", { timeout: 5000 }, () => {
const engine = makeEngine(new MemoryTransport());
try {
engine.hydrate({
items: [{ bogus: 1 } as unknown as VaultItem],
clock: {},
snapshotAt: 0,
});
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
);
}
});
it("hydrate accepts a valid snapshot", { timeout: 5000 }, () => {
const engine = makeEngine(new MemoryTransport());
engine.hydrate(snapshot([item({ id: "i1" })]));
expect(engine.currentSnapshot.items).toHaveLength(1);
});
it("applyLocalMutation overwrites existing item by id", {
timeout: 5000,
}, () => {
const engine = makeEngine(new MemoryTransport());
engine.applyLocalMutation(item({ id: "i1", title: "a" }));
engine.applyLocalMutation(
item({ id: "i1", title: "b", clock: { a: 2 } })
);
expect(engine.currentSnapshot.items).toHaveLength(1);
expect(engine.currentSnapshot.items[0]?.title).toBe("b");
});
});
describe("SyncEngine — accepts injectable clock and rng", () => {
it("accepts injectable clock and rng", { timeout: 5000 }, () => {
const engine = new SyncEngine({
nodeId: "a",
transport: new MemoryTransport(),
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
clock: () => 99,
rng: new CountingRng(),
});
expect(engine.owner).toBe("a");
expect(engine.randomSource).toBeDefined();
});
it("randomSource is undefined when no rng provided", {
timeout: 5000,
}, () => {
const engine = makeEngine(new MemoryTransport());
expect(engine.randomSource).toBeUndefined();
});
});
describe("SyncEngine — preferLocal on conflict", () => {
it("retains local copy with merged clock when atomic conflict", {
timeout: 5000,
}, async () => {
const t = new MemoryTransport();
const local = item({ id: "i1", folderId: "loc", clock: { a: 1 } });
t.stored = snapshot([
item({
id: "i1",
folderId: "rem",
nodeId: "b",
clock: { b: 1 },
}),
]);
const engine = new SyncEngine({
nodeId: "a",
transport: t,
encrypt: async (snap) => snap as unknown as EncryptedSyncPayload,
decrypt: async (env) => env as VaultSnapshot,
defaultStrategy: "atomic",
initialSnapshot: snapshot([local]),
});
const result = await engine.pull();
expect(result.conflicts).toContain("i1");
const stored = engine.currentSnapshot.items.find((i) => i.id === "i1");
expect(stored?.folderId).toBe("loc");
expect(stored?.clock).toEqual({ a: 1, b: 1 });
});
});

View File

@@ -0,0 +1,265 @@
import type { Clock, Rng } from "@suaveplan/types";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import { mergeVaultItems } from "../merge/merge.js";
import { getVaultSyncInstruments } from "../otel/instruments.js";
import { VaultSnapshotSchema } from "../schemas/schemas.js";
import type {
MergeStrategy,
SyncResult,
SyncTransport,
VaultItem,
VaultSnapshot,
VectorClockState,
} from "../types/types.js";
import { merge as mergeClocks } from "../vector-clock/vector-clock.js";
/** Constructor options for {@link SyncEngine}. */
export interface SyncEngineOptions {
readonly nodeId: string;
readonly transport: SyncTransport;
readonly encrypt: (snapshot: VaultSnapshot) => Promise<unknown>;
readonly decrypt: (envelope: unknown) => Promise<VaultSnapshot>;
readonly initialSnapshot?: VaultSnapshot;
readonly defaultStrategy?: MergeStrategy;
readonly historyStrategy?: MergeStrategy;
readonly clock?: Clock;
readonly rng?: Rng;
}
function defaultClock(): number {
return Date.now();
}
function indexById(items: readonly VaultItem[]): Map<string, VaultItem> {
const out = new Map<string, VaultItem>();
for (const item of items) out.set(item.id, item);
return out;
}
function emptySnapshot(): VaultSnapshot {
return { items: [], clock: {}, snapshotAt: 0 };
}
/**
* Domain-specific sync engine. Operates on in-memory snapshots — network
* transport is delegated to the {@link SyncTransport} provided in
* constructor options. Encrypt and decrypt callbacks bridge the cipher
* layer; engine code never touches keys directly.
*/
export class SyncEngine {
private readonly nodeId: string;
private readonly transport: SyncTransport;
private readonly encryptFn: SyncEngineOptions["encrypt"];
private readonly decryptFn: SyncEngineOptions["decrypt"];
private readonly defaultStrategy: MergeStrategy;
private readonly historyStrategy: MergeStrategy;
private readonly clockSource: Clock;
private readonly rng: Rng | undefined;
private snapshot: VaultSnapshot;
public constructor(options: SyncEngineOptions) {
this.nodeId = options.nodeId;
this.transport = options.transport;
this.encryptFn = options.encrypt;
this.decryptFn = options.decrypt;
this.defaultStrategy = options.defaultStrategy ?? "lww";
this.historyStrategy = options.historyStrategy ?? "append";
this.clockSource = options.clock ?? defaultClock;
this.rng = options.rng;
this.snapshot = options.initialSnapshot ?? emptySnapshot();
}
/** Read-only view of the engine's current snapshot. */
public get currentSnapshot(): VaultSnapshot {
return this.snapshot;
}
/**
* Replace the engine's local snapshot. Used by callers that load state
* from disk before starting sync.
*/
public hydrate(snapshot: VaultSnapshot): void {
const parsed = VaultSnapshotSchema.safeParse(snapshot);
if (!parsed.success) {
throw new VaultSyncError(
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"],
"error.vault-sync.merge.conflict-unresolved",
{ context: { reason: "snapshot-shape" } }
);
}
this.snapshot = parsed.data;
}
/** Apply a local mutation to the engine's snapshot. */
public applyLocalMutation(item: VaultItem): void {
const items = this.snapshot.items.filter((i) => i.id !== item.id);
items.push(item);
this.snapshot = {
items,
clock: mergeClocks(this.snapshot.clock, item.clock),
snapshotAt: this.clockSource(),
};
}
/**
* Pull a remote snapshot, decrypt, merge into local state, and return a
* `SyncResult` describing the outcome.
*/
public async pull(): Promise<SyncResult> {
const tracerStart = Date.now();
let envelope: unknown;
try {
envelope = await this.transport.fetchSnapshot();
} catch (cause) {
throw new VaultSyncError(
VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"],
"error.vault-sync.sync.transport-failed",
{ cause, context: { direction: "pull" } }
);
}
const remote = await this.decryptFn(envelope);
const remoteSnapshot = VaultSnapshotSchema.parse(remote);
const result = this.applyRemoteSnapshot(remoteSnapshot);
getVaultSyncInstruments().syncDurationMs.record(
Date.now() - tracerStart
);
return { ...result, durationMs: Date.now() - tracerStart };
}
/**
* Encrypt the local snapshot and ship it via the transport. Returns the
* sync result describing what was pushed.
*/
public async push(): Promise<SyncResult> {
const start = Date.now();
const envelope = await this.encryptFn(this.snapshot);
try {
await this.transport.pushSnapshot(envelope as never);
} catch (cause) {
throw new VaultSyncError(
VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"],
"error.vault-sync.sync.transport-failed",
{ cause, context: { direction: "push" } }
);
}
const merged = this.snapshot.items
.filter((item) => item.deleted !== true)
.map((item) => item.id);
const deleted = this.snapshot.items
.filter((item) => item.deleted === true)
.map((item) => item.id);
const durationMs = Date.now() - start;
getVaultSyncInstruments().syncDurationMs.record(durationMs);
return {
merged,
conflicts: [],
deleted,
durationMs,
};
}
/**
* Bidirectional sync: push local changes first, then pull remote changes
* and merge.
*/
public async sync(): Promise<SyncResult> {
const start = Date.now();
await this.push();
const pullResult = await this.pull();
return {
merged: pullResult.merged,
conflicts: pullResult.conflicts,
deleted: pullResult.deleted,
durationMs: Date.now() - start,
};
}
private applyRemoteSnapshot(remote: VaultSnapshot): SyncResult {
const localById = indexById(this.snapshot.items);
const remoteById = indexById(remote.items);
const merged: string[] = [];
const conflicts: string[] = [];
const deleted: string[] = [];
const out: VaultItem[] = [];
for (const [id, remoteItem] of remoteById) {
const localItem = localById.get(id);
if (localItem === undefined) {
if (remoteItem.deleted === true) {
deleted.push(id);
} else {
merged.push(id);
}
out.push(remoteItem);
continue;
}
const strategy = this.strategyForItem(localItem, remoteItem);
try {
const m = mergeVaultItems(localItem, remoteItem, strategy);
if (m.deleted === true) deleted.push(id);
else merged.push(id);
out.push(m);
} catch {
// Any merge failure surfaces as a structured conflict; we
// retain the local copy tagged with the merged clock so
// subsequent advances dominate. `mergeVaultItems` only
// raises `VaultSyncError`, so this branch is exhaustive.
conflicts.push(id);
out.push(this.preferLocal(localItem, remoteItem));
}
}
for (const [id, localItem] of localById) {
if (remoteById.has(id)) continue;
out.push(localItem);
if (localItem.deleted === true) deleted.push(id);
}
const newClock: VectorClockState = mergeClocks(
this.snapshot.clock,
remote.clock
);
this.snapshot = {
items: out,
clock: newClock,
snapshotAt: this.clockSource(),
};
return {
merged,
conflicts,
deleted,
durationMs: 0,
};
}
private strategyForItem(
local: VaultItem,
remote: VaultItem
): MergeStrategy {
if (local.history.length > 0 || remote.history.length > 0) {
return this.historyStrategy;
}
return this.defaultStrategy;
}
private preferLocal(local: VaultItem, remote: VaultItem): VaultItem {
// The conflict branch only fires when `mergeVaultItems` raises
// `MERGE.CONFLICT_UNRESOLVED`, which by construction means
// `compare(local.clock, remote.clock) === "concurrent"`. We keep
// the local copy with the merged clock so subsequent advances
// produce a strictly-newer state.
return {
...local,
clock: mergeClocks(local.clock, remote.clock),
};
}
/** Expose the configured nodeId; useful for queue construction. */
public get owner(): string {
return this.nodeId;
}
/** Expose the configured rng; useful for queue construction. */
public get randomSource(): Rng | undefined {
return this.rng;
}
}

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,28 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/vault-sync`.
*
* Every code is namespaced under `VAULTSYNC.<MODULE>.<REASON>`. The full set
* is frozen at module-load time and may never be removed without a major
* version bump — consumer code MAY assert on these literal strings.
*/
export const VAULT_SYNC_CODES = defineCodes("VAULTSYNC", [
"MERGE.STRATEGY_UNKNOWN",
"MERGE.CONFLICT_UNRESOLVED",
"PATCH.INVALID",
"SYNC.PAYLOAD_TAMPERED",
"SYNC.PAYLOAD_INVALID_VERSION",
"SYNC.PAYLOAD_TOO_LARGE",
"SYNC.TRANSPORT_FAILED",
"QUEUE.DEPTH_EXCEEDED",
"QUEUE.SERIALIZATION_FAILED",
"VECTOR_CLOCK.REGRESSION",
] as const);
/**
* Union of every fully-qualified error code emitted by `vault-sync`. Use as
* the type parameter of {@link VaultSyncError} for narrowed `code` checks.
*/
export type VaultSyncCode =
(typeof VAULT_SYNC_CODES)[keyof typeof VAULT_SYNC_CODES];

View File

@@ -0,0 +1,2 @@
export { VAULT_SYNC_CODES, type VaultSyncCode } from "./codes.js";
export { VaultSyncError } from "./vault-sync-error.js";

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,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_SYNC_CODES } from "./codes.js";
import { VaultSyncError } from "./vault-sync-error.js";
describe("VaultSyncError", () => {
it("constructs with code, message, and context", { timeout: 5000 }, () => {
const err = new VaultSyncError(
VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"],
"error.vault-sync.merge.strategy-unknown",
{ context: { strategy: "bogus" } }
);
expect(err.code).toBe("VAULTSYNC.MERGE.STRATEGY_UNKNOWN");
expect(err.context).toEqual({ strategy: "bogus" });
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(VaultSyncError);
});
it("defaults options when none are provided", { timeout: 5000 }, () => {
const err = new VaultSyncError(
VAULT_SYNC_CODES["PATCH.INVALID"],
"error.vault-sync.patch.invalid"
);
expect(err.code).toBe("VAULTSYNC.PATCH.INVALID");
expect(err.context).toBeUndefined();
});
it("carries cause and traceId through to the base", {
timeout: 5000,
}, () => {
const cause = new Error("network");
const err = new VaultSyncError(
VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"],
"error.vault-sync.sync.transport-failed",
{ cause, traceId: "trace-1" }
);
expect(err.cause).toBe(cause);
expect(err.traceId).toBe("trace-1");
});
it("VaultSyncError.is recognises real instances", {
timeout: 5000,
}, () => {
const err = new VaultSyncError(
VAULT_SYNC_CODES["QUEUE.DEPTH_EXCEEDED"],
"error.vault-sync.queue.depth-exceeded"
);
expect(VaultSyncError.is(err)).toBe(true);
});
it("VaultSyncError.is rejects plain errors and impostors", {
timeout: 5000,
}, () => {
expect(VaultSyncError.is(new Error("nope"))).toBe(false);
expect(VaultSyncError.is({ code: "VAULTSYNC.X" })).toBe(false);
expect(VaultSyncError.is(null)).toBe(false);
expect(VaultSyncError.is(undefined)).toBe(false);
expect(VaultSyncError.is("error")).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { VaultSyncCode } from "./codes.js";
const VAULT_SYNC_ERROR_BRAND: unique symbol = Symbol(
"@suaveplan/vault-sync.error"
);
/**
* Concrete `SuaveplanError` subclass for every failure raised by
* `@suaveplan/vault-sync`.
*
* The class is parameterised by `VaultSyncCode` so consumers can write
* `if (err instanceof VaultSyncError) switch (err.code) { ... }` and have
* the compiler enforce exhaustive matches against the registry. A unique
* brand symbol is attached to every instance so subclasses or duck-typed
* peers cannot impersonate a `VaultSyncError` thrown by this package.
*/
export class VaultSyncError extends SuaveplanError<VaultSyncCode> {
/** Unique brand asserting this instance was constructed by this package. */
public readonly [VAULT_SYNC_ERROR_BRAND]: true;
public constructor(
code: VaultSyncCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this[VAULT_SYNC_ERROR_BRAND] = true;
}
/**
* Type guard verifying both `instanceof` and the brand symbol. Use for
* defence-in-depth when handling errors that may have crossed iframe or
* worker boundaries (which can break `instanceof`).
*/
public static is(value: unknown): value is VaultSyncError {
return (
value instanceof VaultSyncError &&
(value as VaultSyncError)[VAULT_SYNC_ERROR_BRAND] === true
);
}
}

View File

@@ -0,0 +1,80 @@
// Crypto-sync
export {
decryptSyncPayload,
type EncryptSyncPayloadOptions,
encryptSyncPayload,
MAX_SYNC_PAYLOAD_BYTES,
SYNC_PAYLOAD_VERSION,
} from "./crypto-sync/crypto-sync.js";
// Engine
export { SyncEngine, type SyncEngineOptions } from "./engine/engine.js";
// Errors
export {
VAULT_SYNC_CODES,
type VaultSyncCode,
VaultSyncError,
} from "./errors/index.js";
// Merge
export {
assertMergeStrategy,
MERGE_STRATEGIES,
mergeVaultItems,
} from "./merge/merge.js";
// OTel
export {
getVaultSyncInstruments,
getVaultSyncTracer,
resetVaultSyncInstruments,
resetVaultSyncTracer,
setVaultSyncMeterProvider,
setVaultSyncTracerProvider,
type VaultSyncInstruments,
} from "./otel/instruments.js";
// Patch
export {
applyPatch,
diffVaultItems,
type JsonPatch,
} from "./patch/patch.js";
// Queue
export {
collapseOperations,
DEFAULT_MAX_QUEUE_DEPTH,
type EnqueueInput,
SyncQueue,
type SyncQueueOptions,
} from "./queue/queue.js";
// Schemas
export {
EncryptedSyncPayloadSchema,
MergeStrategySchema,
SyncOperationKindSchema,
SyncOperationSchema,
VaultHistoryEntrySchema,
VaultItemSchema,
VaultSnapshotSchema,
VectorClockSchema,
} from "./schemas/schemas.js";
// Types
export type {
EncryptedSyncPayload,
MergeStrategy,
SyncOperation,
SyncOperationKind,
SyncResult,
SyncTransport,
VaultHistoryEntry,
VaultItem,
VaultSnapshot,
VectorClockRelation,
VectorClockState,
} from "./types/types.js";
// Vector clock
export {
compare,
empty,
happensBefore,
increment,
lwwTotalOrder,
merge,
} from "./vector-clock/vector-clock.js";

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,338 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import type { VaultHistoryEntry, VaultItem } from "../types/types.js";
import {
assertMergeStrategy,
MERGE_STRATEGIES,
mergeVaultItems,
} from "./merge.js";
function item(over: Partial<VaultItem> = {}): VaultItem {
return {
id: "i1",
title: "Email",
password: "pw",
folderId: null,
history: [] as VaultHistoryEntry[],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
...over,
};
}
describe("mergeVaultItems — lww strategy", () => {
it("lww selects later timestamp for password", { timeout: 5000 }, () => {
const local = item({ password: "old", updatedAt: 1, clock: { a: 1 } });
const remote = item({
password: "new",
updatedAt: 2,
nodeId: "b",
clock: { b: 1 },
});
const m = mergeVaultItems(local, remote, "lww");
expect(m.password).toBe("new");
});
it("merge is commutative", { timeout: 5000 }, () => {
const local = item({ password: "old", updatedAt: 1, clock: { a: 1 } });
const remote = item({
password: "new",
updatedAt: 2,
nodeId: "b",
clock: { b: 1 },
});
const m1 = mergeVaultItems(local, remote, "lww");
const m2 = mergeVaultItems(remote, local, "lww");
expect(m1).toEqual(m2);
});
it("breaks pathological clock-tie by canonical content order", {
timeout: 5000,
}, () => {
// Identical clock + updatedAt + nodeId, but different content.
// canonicalString() places local > remote → remote wins.
const local = item({ title: "zzz", clock: { a: 1 } });
const remote = item({ title: "aaa", clock: { a: 1 } });
const m = mergeVaultItems(local, remote, "lww");
expect(m.title).toBe("aaa");
});
it("merge is idempotent", { timeout: 5000 }, () => {
const local = item({ password: "p", updatedAt: 1 });
const m1 = mergeVaultItems(local, local, "lww");
const m2 = mergeVaultItems(m1, m1, "lww");
expect(m1).toEqual(m2);
});
it("propagates deleted flag", { timeout: 5000 }, () => {
const local = item();
const remote = item({ deleted: true });
expect(mergeVaultItems(local, remote, "lww").deleted).toBe(true);
});
});
describe("mergeVaultItems — append strategy", () => {
it("append merges history without duplication", { timeout: 5000 }, () => {
const local = item({
history: [
{ historyId: "h1", timestamp: 1, note: "a" },
{ historyId: "h2", timestamp: 2, note: "b" },
],
});
const remote = item({
history: [
{ historyId: "h2", timestamp: 2, note: "b" },
{ historyId: "h3", timestamp: 3, note: "c" },
],
});
const m = mergeVaultItems(local, remote, "append");
const ids = m.history.map((h) => h.historyId);
expect(ids).toEqual(["h1", "h2", "h3"]);
});
it("append sorts merged history by timestamp", { timeout: 5000 }, () => {
const local = item({
history: [{ historyId: "h2", timestamp: 5, note: "z" }],
});
const remote = item({
history: [
{ historyId: "h1", timestamp: 1, note: "x" },
{ historyId: "h3", timestamp: 3, note: "y" },
],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history.map((h) => h.timestamp)).toEqual([1, 3, 5]);
});
it("dedup picks higher-timestamp entry when historyId conflicts", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 5, note: "later" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "earlier" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history).toHaveLength(1);
expect(m.history[0]?.note).toBe("later");
});
it("dedup picks lex-smaller note on equal timestamps + historyId", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 1, note: "zzz" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "aaa" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history).toHaveLength(1);
expect(m.history[0]?.note).toBe("aaa");
});
it("dedup picks lex-greater note when first is bigger", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 1, note: "aaa" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "zzz" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history[0]?.note).toBe("aaa");
});
it("dedup keeps entry when historyId+timestamp+note are identical", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history).toHaveLength(1);
});
it("breaks timestamp ties by historyId", { timeout: 5000 }, () => {
const local = item({
history: [{ historyId: "z", timestamp: 1, note: "x" }],
});
const remote = item({
history: [{ historyId: "a", timestamp: 1, note: "y" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history.map((h) => h.historyId)).toEqual(["a", "z"]);
});
it("propagates deleted flag in append", { timeout: 5000 }, () => {
const local = item();
const remote = item({ deleted: true });
expect(mergeVaultItems(local, remote, "append").deleted).toBe(true);
});
});
describe("mergeVaultItems — atomic strategy", () => {
it("atomic replaces entire object on linear advance", {
timeout: 5000,
}, () => {
const local = item({ folderId: null, clock: { a: 1 } });
const remote = item({
folderId: "f1",
clock: { a: 1, b: 1 },
nodeId: "b",
updatedAt: 2,
});
const m = mergeVaultItems(local, remote, "atomic");
expect(m.folderId).toBe("f1");
});
it("atomic keeps local on a > b causal advance", {
timeout: 5000,
}, () => {
const local = item({
folderId: "local",
clock: { a: 1, b: 1 },
updatedAt: 5,
});
const remote = item({
folderId: "remote",
clock: { a: 1 },
nodeId: "b",
updatedAt: 2,
});
// Local dominates remote causally → local wins; folderId differs but
// is not concurrent so the merge is allowed.
const m = mergeVaultItems(local, remote, "atomic");
expect(m.folderId).toBe("local");
});
it("atomic rejects concurrent folderId changes", { timeout: 5000 }, () => {
const local = item({ folderId: "f1", clock: { a: 1 } });
const remote = item({
folderId: "f2",
nodeId: "b",
clock: { b: 1 },
});
expect(() => mergeVaultItems(local, remote, "atomic")).toThrow(
VaultSyncError
);
});
it("atomic rejects concurrent title changes", { timeout: 5000 }, () => {
const local = item({ title: "loc", clock: { a: 1 } });
const remote = item({
title: "rem",
nodeId: "b",
clock: { b: 1 },
});
expect(() => mergeVaultItems(local, remote, "atomic")).toThrow(
VaultSyncError
);
});
it("atomic rejects concurrent password changes", { timeout: 5000 }, () => {
const local = item({ password: "loc", clock: { a: 1 } });
const remote = item({
password: "rem",
nodeId: "b",
clock: { b: 1 },
});
expect(() => mergeVaultItems(local, remote, "atomic")).toThrow(
VaultSyncError
);
});
it("atomic with identical clocks succeeds (same causal state)", {
timeout: 5000,
}, () => {
const local = item({ folderId: "f1", clock: { a: 1 } });
const remote = item({ folderId: "f1", clock: { a: 1 } });
const m = mergeVaultItems(local, remote, "atomic");
expect(m.folderId).toBe("f1");
});
it("atomic rejects concurrent deleted-flag divergence", {
timeout: 5000,
}, () => {
const local = item({ clock: { a: 1 }, deleted: true });
const remote = item({
nodeId: "b",
clock: { b: 1 },
deleted: false,
});
try {
mergeVaultItems(local, remote, "atomic");
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
);
}
});
it("atomic concurrent but with no atomic-field diff is allowed", {
timeout: 5000,
}, () => {
const local = item({
folderId: "shared",
clock: { a: 1 },
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const remote = item({
folderId: "shared",
clock: { b: 1 },
nodeId: "b",
history: [{ historyId: "h2", timestamp: 2, note: "y" }],
});
const m = mergeVaultItems(local, remote, "atomic");
expect(m.history).toHaveLength(2);
});
});
describe("mergeVaultItems — error paths", () => {
it("rejects unknown strategy", { timeout: 5000 }, () => {
const local = item();
const remote = item();
expect(() =>
mergeVaultItems(
local,
remote,
"smart" as unknown as Parameters<typeof mergeVaultItems>[2]
)
).toThrow(VaultSyncError);
});
it("rejects mismatched ids", { timeout: 5000 }, () => {
const local = item({ id: "i1" });
const remote = item({ id: "i2" });
try {
mergeVaultItems(local, remote, "lww");
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
);
}
});
});
describe("assertMergeStrategy", () => {
it("returns the strategy when valid", { timeout: 5000 }, () => {
for (const s of MERGE_STRATEGIES) {
expect(assertMergeStrategy(s)).toBe(s);
}
});
it("throws on unknown", { timeout: 5000 }, () => {
expect(() => assertMergeStrategy("smart")).toThrow(VaultSyncError);
});
});

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,192 @@
import type { MeterProvider, TracerProvider } from "@opentelemetry/api";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
getVaultSyncInstruments,
getVaultSyncTracer,
resetVaultSyncInstruments,
resetVaultSyncTracer,
setVaultSyncMeterProvider,
setVaultSyncTracerProvider,
} from "./instruments.js";
interface Capture {
histograms: Map<string, number[]>;
counters: Map<string, Array<[number, Record<string, string> | undefined]>>;
gauges: Set<string>;
}
function makeCaptureProvider(): {
provider: MeterProvider;
capture: Capture;
} {
const capture: Capture = {
histograms: new Map(),
counters: new Map(),
gauges: new Set(),
};
const meter = {
createHistogram: (name: string) => {
capture.histograms.set(name, []);
return {
record: (value: number) => {
capture.histograms.get(name)?.push(value);
},
};
},
createCounter: (name: string) => {
capture.counters.set(name, []);
return {
add: (value: number, attrs?: Record<string, string>) => {
capture.counters.get(name)?.push([value, attrs]);
},
};
},
createObservableGauge: (name: string) => {
capture.gauges.add(name);
return { addCallback: () => undefined };
},
};
const provider = {
getMeter: () =>
meter as unknown as ReturnType<MeterProvider["getMeter"]>,
} as unknown as MeterProvider;
return { provider, capture };
}
afterEach(() => {
setVaultSyncMeterProvider(undefined);
resetVaultSyncInstruments();
setVaultSyncTracerProvider(undefined);
resetVaultSyncTracer();
});
describe("getVaultSyncInstruments — default no-op path", () => {
it("returns instruments with the documented surface", {
timeout: 5000,
}, () => {
const i = getVaultSyncInstruments();
expect(typeof i.syncDurationMs.record).toBe("function");
expect(typeof i.mergeDurationMs.record).toBe("function");
expect(typeof i.patchApplyDurationMs.record).toBe("function");
expect(typeof i.payloadEncryptDurationMs.record).toBe("function");
expect(typeof i.queueFlushDurationMs.record).toBe("function");
expect(typeof i.mergeOutcomeTotal.add).toBe("function");
expect(typeof i.payloadOutcomeTotal.add).toBe("function");
expect(typeof i.queueDepth.addCallback).toBe("function");
});
it("returns the same singleton on repeated calls", {
timeout: 5000,
}, () => {
resetVaultSyncInstruments();
const a = getVaultSyncInstruments();
const b = getVaultSyncInstruments();
expect(a).toBe(b);
});
it("no-op records do not throw", { timeout: 5000 }, () => {
const i = getVaultSyncInstruments();
expect(() => i.syncDurationMs.record(1)).not.toThrow();
expect(() =>
i.mergeOutcomeTotal.add(1, { status: "success" })
).not.toThrow();
});
});
describe("setVaultSyncMeterProvider", () => {
it("routes record/add through the supplied provider", {
timeout: 5000,
}, () => {
const { provider, capture } = makeCaptureProvider();
setVaultSyncMeterProvider(provider);
const i = getVaultSyncInstruments();
i.syncDurationMs.record(13);
i.mergeOutcomeTotal.add(1, { status: "success" });
expect(capture.histograms.get("vault_sync.sync.duration_ms")).toEqual([
13,
]);
expect(capture.counters.get("vault_sync.merge.outcome.total")).toEqual([
[1, { status: "success" }],
]);
expect(capture.gauges.has("vault_sync.queue.depth")).toBe(true);
});
it("clears cached instruments when called with undefined", {
timeout: 5000,
}, () => {
const { provider, capture } = makeCaptureProvider();
setVaultSyncMeterProvider(provider);
getVaultSyncInstruments().syncDurationMs.record(7);
expect(capture.histograms.get("vault_sync.sync.duration_ms")).toEqual([
7,
]);
setVaultSyncMeterProvider(undefined);
getVaultSyncInstruments().syncDurationMs.record(99);
expect(capture.histograms.get("vault_sync.sync.duration_ms")).toEqual([
7,
]);
});
});
describe("resetVaultSyncInstruments", () => {
it("forces re-acquisition on next call", { timeout: 5000 }, () => {
const a = getVaultSyncInstruments();
resetVaultSyncInstruments();
const b = getVaultSyncInstruments();
expect(a).not.toBe(b);
});
});
describe("tracer", () => {
it("returns a tracer instance and caches it", { timeout: 5000 }, () => {
const a = getVaultSyncTracer();
const b = getVaultSyncTracer();
expect(a).toBe(b);
});
it("reset forces re-acquisition", { timeout: 5000 }, () => {
const a = getVaultSyncTracer();
resetVaultSyncTracer();
const b = getVaultSyncTracer();
expect(a).not.toBe(b);
});
it("setVaultSyncTracerProvider routes through the supplied provider", {
timeout: 5000,
}, () => {
const span = {
end: (): void => undefined,
setAttribute: (): void => undefined,
setStatus: (): void => undefined,
recordException: (): void => undefined,
updateName: (): void => undefined,
isRecording: (): boolean => true,
spanContext: (): unknown => ({}),
addEvent: (): void => undefined,
addLink: (): void => undefined,
addLinks: (): void => undefined,
};
const recorded: string[] = [];
const provider = {
getTracer: () =>
({
startSpan: (name: string) => {
recorded.push(name);
return span;
},
}) as unknown as ReturnType<TracerProvider["getTracer"]>,
} as unknown as TracerProvider;
setVaultSyncTracerProvider(provider);
const t = getVaultSyncTracer();
t.startSpan("test.span");
expect(recorded).toEqual(["test.span"]);
});
it("setVaultSyncTracerProvider with undefined falls back", {
timeout: 5000,
}, () => {
setVaultSyncTracerProvider(undefined);
const t = getVaultSyncTracer();
expect(typeof t.startSpan).toBe("function");
});
});

View File

@@ -0,0 +1,153 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
ObservableGauge,
Tracer,
TracerProvider,
} from "@opentelemetry/api";
import { metrics, trace } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/vault-sync";
const TRACER_NAME = "@suaveplan/vault-sync";
/**
* The full instrument set emitted from `@suaveplan/vault-sync` production
* paths. Every instrument is safe-by-construction: no key bytes, no item
* content, no vault identifiers, no vector-clock counters appear in
* attributes — only counts and durations.
*/
export interface VaultSyncInstruments {
/** Histogram of full sync (pull + merge + push) latency in ms. */
syncDurationMs: Histogram;
/** Histogram of merge latency per item in ms. */
mergeDurationMs: Histogram;
/** Histogram of patch-apply latency in ms. */
patchApplyDurationMs: Histogram;
/** Histogram of payload-encrypt latency in ms. */
payloadEncryptDurationMs: Histogram;
/** Histogram of queue-flush latency in ms. */
queueFlushDurationMs: Histogram;
/** Gauge of pending offline operations in the queue. */
queueDepth: ObservableGauge;
/** Counter of merge outcomes (`status` ∈ `success | conflict`). */
mergeOutcomeTotal: Counter;
/** Counter of payload outcomes (`status` ∈ `encrypted | decrypted | tampered`). */
payloadOutcomeTotal: Counter;
}
let instruments: VaultSyncInstruments | null = null;
let tracer: Tracer | null = null;
function buildInstruments(meter: Meter): VaultSyncInstruments {
return {
syncDurationMs: meter.createHistogram("vault_sync.sync.duration_ms", {
description: "Wall-clock duration of full sync runs in ms.",
unit: "ms",
}),
mergeDurationMs: meter.createHistogram("vault_sync.merge.duration_ms", {
description: "Wall-clock duration of vault-item merges in ms.",
unit: "ms",
}),
patchApplyDurationMs: meter.createHistogram(
"vault_sync.patch.apply.duration_ms",
{
description: "Wall-clock duration of patch applications in ms.",
unit: "ms",
}
),
payloadEncryptDurationMs: meter.createHistogram(
"vault_sync.payload.encrypt.duration_ms",
{
description: "Wall-clock duration of payload encrypts in ms.",
unit: "ms",
}
),
queueFlushDurationMs: meter.createHistogram(
"vault_sync.queue.flush.duration_ms",
{
description: "Wall-clock duration of queue flushes in ms.",
unit: "ms",
}
),
queueDepth: meter.createObservableGauge("vault_sync.queue.depth", {
description: "Number of pending operations in the offline queue.",
unit: "{operations}",
}),
mergeOutcomeTotal: meter.createCounter(
"vault_sync.merge.outcome.total",
{
description:
"Merge outcomes by status (success | conflict | rejected).",
unit: "{merges}",
}
),
payloadOutcomeTotal: meter.createCounter(
"vault_sync.payload.outcome.total",
{
description:
"Payload outcomes by status (encrypted | decrypted | tampered).",
unit: "{payloads}",
}
),
};
}
/**
* Lazy-singleton accessor for the vault-sync instrument set. On first call
* this captures `metrics.getMeter(...)` from the global OTel API; no-op
* instruments are returned automatically when no MeterProvider is registered.
*/
export function getVaultSyncInstruments(): VaultSyncInstruments {
if (instruments !== null) return instruments;
instruments = buildInstruments(metrics.getMeter(METER_NAME));
return instruments;
}
/**
* Override the OTel `MeterProvider` used to acquire the vault-sync meter.
* Pass `undefined` to fall back to the global default. Intended for tests
* that want to assert against a deterministic provider.
*/
export function setVaultSyncMeterProvider(
provider: MeterProvider | undefined
): void {
instruments = null;
if (provider !== undefined) {
instruments = buildInstruments(provider.getMeter(METER_NAME));
}
}
/**
* Reset the cached instruments. Used in tests to isolate one suite from
* another after `setVaultSyncMeterProvider`.
*/
export function resetVaultSyncInstruments(): void {
instruments = null;
}
/**
* Lazy-singleton accessor for the vault-sync tracer. Captures
* `trace.getTracer(...)` from the global OTel API on first call.
*/
export function getVaultSyncTracer(): Tracer {
if (tracer !== null) return tracer;
tracer = trace.getTracer(TRACER_NAME);
return tracer;
}
/** Override the `TracerProvider` used to acquire the vault-sync tracer. */
export function setVaultSyncTracerProvider(
provider: TracerProvider | undefined
): void {
tracer = null;
if (provider !== undefined) {
tracer = provider.getTracer(TRACER_NAME);
}
}
/** Reset the cached tracer. Used by tests for suite isolation. */
export function resetVaultSyncTracer(): void {
tracer = null;
}

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,163 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import type { VaultItem } from "../types/types.js";
import { applyPatch, diffVaultItems } from "./patch.js";
function item(over: Partial<VaultItem> = {}): VaultItem {
return {
id: "i1",
title: "Email",
password: "pw",
folderId: null,
history: [],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
...over,
};
}
describe("diffVaultItems", () => {
it("diff produces single replace for title change", {
timeout: 5000,
}, () => {
const before = item({ title: "Old" });
const after = item({ title: "New" });
const p = diffVaultItems(before, after);
const replacements = p.filter((op) => op.op === "replace");
expect(replacements.some((op) => op.path[0] === "title")).toBe(true);
});
it("diff is empty for identical items", { timeout: 5000 }, () => {
const a = item();
expect(diffVaultItems(a, { ...a })).toEqual([]);
});
it("diff produces multiple ops for multi-field changes", {
timeout: 5000,
}, () => {
const before = item({ title: "old", password: "old" });
const after = item({ title: "new", password: "new" });
const p = diffVaultItems(before, after);
expect(p.length).toBeGreaterThanOrEqual(2);
});
});
describe("applyPatch", () => {
it("apply patch transforms item correctly", { timeout: 5000 }, () => {
const before = item({ title: "Old" });
const after = item({ title: "New" });
const p = diffVaultItems(before, after);
const result = applyPatch(before, p);
expect(result.title).toBe("New");
});
it("applies an empty patch as identity", { timeout: 5000 }, () => {
const before = item();
const result = applyPatch(before, []);
expect(result).toEqual(before);
});
it("rejects structurally-invalid patches", { timeout: 5000 }, () => {
const before = item();
try {
applyPatch(before, [{ bad: true } as unknown as never]);
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["PATCH.INVALID"]);
}
});
it("rejects replace on missing path", { timeout: 5000 }, () => {
const before = item();
const patch = [
{ op: "replace" as const, path: ["nonexistent"], value: 1 },
];
// Replace at non-existent path may either throw at apply or shape
// validation — both surface as PATCH.INVALID.
expect(() => applyPatch(before, patch)).toThrow(VaultSyncError);
});
it("rejects remove on missing path", { timeout: 5000 }, () => {
const before = item();
const patch = [{ op: "remove" as const, path: ["title"] }];
// Removing a required field breaks the schema → PATCH.INVALID.
try {
applyPatch(before, patch);
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["PATCH.INVALID"]);
}
});
it("wraps DiffError into PATCH.INVALID on apply failure", {
timeout: 5000,
}, () => {
const before = item();
// `test` op against a wrong value triggers DiffError.TEST_FAILED.
const patch = [
{
op: "test" as const,
path: ["title"],
value: "definitely-not-the-current-value",
},
];
try {
applyPatch(before, patch);
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["PATCH.INVALID"]);
}
});
it("rejects array path that breaks the post-apply schema", {
timeout: 5000,
}, () => {
const before = item({
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const patch = [
{
op: "replace" as const,
path: ["history", 0],
value: { wrong: 1 },
},
];
expect(() => applyPatch(before, patch)).toThrow(VaultSyncError);
});
it("round-trip diff then apply yields original", { timeout: 5000 }, () => {
const before = item({
title: "A",
password: "old",
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const after = item({
title: "B",
password: "new",
history: [
{ historyId: "h1", timestamp: 1, note: "x" },
{ historyId: "h2", timestamp: 2, note: "y" },
],
updatedAt: 3,
clock: { a: 2 },
});
const p = diffVaultItems(before, after);
const result = applyPatch(before, p);
expect(result).toEqual(after);
});
it("rejects a patch whose result violates the schema", {
timeout: 5000,
}, () => {
const before = item();
const patch = [
{ op: "replace" as const, path: ["updatedAt"], value: -1 },
];
expect(() => applyPatch(before, patch)).toThrow(VaultSyncError);
});
});

View File

@@ -0,0 +1,79 @@
import {
applyPatch as applyJsonPatch,
type Patch as JsonPatch,
type JsonValue,
diff as jsonDiff,
validatePatch,
} from "@suaveplan/diff";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import { getVaultSyncInstruments } from "../otel/instruments.js";
import { VaultItemSchema } from "../schemas/schemas.js";
import type { VaultItem } from "../types/types.js";
/** Re-export for callers that consume the diff/apply pair from one path. */
export type { JsonPatch };
function asJson(item: VaultItem): JsonValue {
return JSON.parse(JSON.stringify(item)) as JsonValue;
}
/**
* Compute an RFC 6902 JSON Patch transforming `before` into `after`.
*
* Returns an empty patch when the two items are deeply equal. Both inputs
* MUST be valid `VaultItem` shapes — caller is responsible for any prior
* Zod validation.
*/
export function diffVaultItems(before: VaultItem, after: VaultItem): JsonPatch {
return jsonDiff(asJson(before), asJson(after));
}
/**
* Apply an RFC 6902 patch to a vault item and validate the result against
* the canonical Zod schema.
*
* @throws VaultSyncError(PATCH.INVALID) — patch is malformed, references a
* missing path, or the post-apply value does not satisfy `VaultItemSchema`.
*/
export function applyPatch(item: VaultItem, patch: JsonPatch): VaultItem {
const start = Date.now();
try {
validatePatch(patch);
} catch (cause) {
throw new VaultSyncError(
VAULT_SYNC_CODES["PATCH.INVALID"],
"error.vault-sync.patch.invalid",
{ cause, context: { reason: "structural" } }
);
}
let next: JsonValue;
try {
next = applyJsonPatch(asJson(item), patch).doc;
} catch (cause) {
// `@suaveplan/diff` only throws `DiffError` from `applyPatch`. Any
// non-DiffError failure here would indicate a bug in the diff
// package; we still surface it as a structured `PATCH.INVALID` so
// consumers see one error class for every patch failure.
throw new VaultSyncError(
VAULT_SYNC_CODES["PATCH.INVALID"],
"error.vault-sync.patch.invalid",
{ cause, context: { reason: "apply" } }
);
}
const parsed = VaultItemSchema.safeParse(next);
if (!parsed.success) {
throw new VaultSyncError(
VAULT_SYNC_CODES["PATCH.INVALID"],
"error.vault-sync.patch.invalid",
{
context: {
reason: "shape",
issues: parsed.error.issues.length,
},
}
);
}
getVaultSyncInstruments().patchApplyDurationMs.record(Date.now() - start);
return parsed.data as VaultItem;
}

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,388 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import { collapseOperations, SyncQueue } from "./queue.js";
class CountingRng implements Rng {
private state = 1;
public nextUint32(): number {
this.state = (this.state * 1664525 + 1013904223) >>> 0;
return this.state;
}
public nextFloat(): number {
return this.nextUint32() / 0x1_00_00_00_00;
}
}
class ByteRng implements Rng {
private counter = 0;
public nextUint32(): number {
return 0;
}
public nextFloat(): number {
return 0;
}
public nextBytes(n: number): Uint8Array {
const out = new Uint8Array(n);
for (let i = 0; i < n; i += 1) {
this.counter = (this.counter + 1) & 0xff;
out[i] = this.counter;
}
return out;
}
}
describe("SyncQueue — basic enqueue/flush", () => {
it("mutations queue while offline", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({ kind: "create", itemId: "i1", fields: { title: "a" } });
q.enqueue({ kind: "update", itemId: "i2", fields: { title: "b" } });
expect(q.depth).toBe(2);
});
it("flush replays queued operations", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({ kind: "create", itemId: "i1", fields: { title: "a" } });
q.enqueue({ kind: "update", itemId: "i2", fields: { title: "b" } });
const drained = q.flush();
expect(drained.map((op) => op.itemId)).toEqual(["i1", "i2"]);
expect(q.depth).toBe(0);
});
it("collapses redundant updates to same field", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({ kind: "update", itemId: "i1", fields: { password: "a" } });
q.enqueue({ kind: "update", itemId: "i1", fields: { password: "b" } });
q.enqueue({ kind: "update", itemId: "i1", fields: { password: "c" } });
const drained = q.flush();
expect(drained.length).toBe(1);
expect(drained[0]?.fields.password).toBe("c");
});
});
describe("SyncQueue — vector clock", () => {
it("vector clock establishes partial order", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
const a = q.enqueue({ kind: "create", itemId: "i1", fields: {} });
const b = q.enqueue({ kind: "update", itemId: "i1", fields: {} });
expect(a.clock["n1"]).toBe(1);
expect(b.clock["n1"]).toBe(2);
});
it("concurrent operations from different peers are ordered by clock", {
timeout: 5000,
}, () => {
const local = new SyncQueue({ nodeId: "n1" });
const remote = new SyncQueue({ nodeId: "n2" });
const a = local.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "local" },
});
const b = remote.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "remote" },
});
expect(a.clock).toEqual({ n1: 1 });
expect(b.clock).toEqual({ n2: 1 });
});
});
describe("SyncQueue — collapseOperations", () => {
it("delete dominates subsequent updates", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({ kind: "delete", itemId: "i1", fields: {} });
q.enqueue({ kind: "update", itemId: "i1", fields: { title: "x" } });
const drained = q.flush();
expect(drained).toHaveLength(1);
expect(drained[0]?.kind).toBe("delete");
});
it("delete arriving after updates wins", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({ kind: "update", itemId: "i1", fields: { title: "x" } });
q.enqueue({ kind: "delete", itemId: "i1", fields: {} });
const drained = q.flush();
expect(drained).toHaveLength(1);
expect(drained[0]?.kind).toBe("delete");
});
it("preserves operations across distinct items", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({ kind: "create", itemId: "i1", fields: {} });
q.enqueue({ kind: "create", itemId: "i2", fields: {} });
const drained = q.flush();
expect(drained.map((op) => op.itemId).sort()).toEqual(["i1", "i2"]);
});
it("uses the standalone helper to collapse external arrays", {
timeout: 5000,
}, () => {
const q = new SyncQueue({ nodeId: "n1" });
const a = q.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "a" },
});
const b = q.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "b" },
});
const collapsed = collapseOperations([a, b]);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]?.fields.title).toBe("b");
});
it("collapses concurrent updates by enqueuedAt with second wins on tie", {
timeout: 5000,
}, () => {
const t = 100;
const local = new SyncQueue({ nodeId: "n1", clock: () => t });
const remote = new SyncQueue({ nodeId: "n2", clock: () => t });
const localOp = local.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "local" },
});
const remoteOp = remote.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "remote" },
});
const collapsed = collapseOperations([localOp, remoteOp]);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]?.fields.title).toBe("remote");
});
it("collapses concurrent updates with previous winning by enqueuedAt", {
timeout: 5000,
}, () => {
const local = new SyncQueue({ nodeId: "n1", clock: () => 200 });
const remote = new SyncQueue({ nodeId: "n2", clock: () => 100 });
const localOp = local.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "local" },
});
const remoteOp = remote.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "remote" },
});
// Feed in [localOp (later), remoteOp (earlier)] — concurrent clocks
// → previous (localOp) is later in enqueuedAt → previous wins.
const collapsed = collapseOperations([localOp, remoteOp]);
expect(collapsed[0]?.fields.title).toBe("local");
});
it("keeps slot when previous clock dominates the candidate", {
timeout: 5000,
}, () => {
const q = new SyncQueue({ nodeId: "n1" });
const newer = q.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "new" },
});
// Synthesise a stale operation with a smaller clock.
const stale = {
...newer,
clock: {},
fields: { title: "old" },
enqueuedAt: 0,
};
const collapsed = collapseOperations([newer, stale]);
expect(collapsed[0]?.fields.title).toBe("new");
});
it("merges when clocks are exactly equal", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
const first = q.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "first" },
});
const duplicate = { ...first, fields: { password: "p" } };
const collapsed = collapseOperations([first, duplicate]);
expect(collapsed[0]?.fields.title).toBe("first");
expect(collapsed[0]?.fields.password).toBe("p");
});
});
describe("SyncQueue — capacity", () => {
it("queue honors max size limit", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1", maxDepth: 2 });
q.enqueue({ kind: "create", itemId: "i1", fields: {} });
q.enqueue({ kind: "create", itemId: "i2", fields: {} });
try {
q.enqueue({ kind: "create", itemId: "i3", fields: {} });
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(VAULT_SYNC_CODES["QUEUE.DEPTH_EXCEEDED"]);
}
});
});
describe("SyncQueue — serialize / deserialize", () => {
it("serializes and restores queue", { timeout: 5000 }, () => {
const q = new SyncQueue({
nodeId: "n1",
clock: () => 100,
rng: new ByteRng(),
});
q.enqueue({ kind: "create", itemId: "i1", fields: { title: "a" } });
q.enqueue({ kind: "update", itemId: "i2", fields: { title: "b" } });
const bytes = q.serialize();
const restored = SyncQueue.deserialize(bytes);
expect(restored.depth).toBe(2);
expect(restored.peek()).toEqual(q.peek());
expect(restored.vectorClock).toEqual(q.vectorClock);
});
it("restored queue accepts new mutations", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({ kind: "create", itemId: "i1", fields: {} });
const restored = SyncQueue.deserialize(q.serialize());
restored.enqueue({
kind: "update",
itemId: "i1",
fields: { title: "x" },
});
expect(restored.depth).toBe(2);
});
it("wraps JSON.stringify failures in QUEUE.SERIALIZATION_FAILED", {
timeout: 5000,
}, () => {
const q = new SyncQueue({ nodeId: "n1" });
q.enqueue({
kind: "create",
itemId: "i1",
// BigInt is not JSON-serializable.
fields: { updatedAt: 1n as unknown as number },
});
try {
q.serialize();
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"]
);
}
});
it("rejects malformed JSON", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("not-json");
try {
SyncQueue.deserialize(bytes);
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"]
);
}
});
it("rejects payloads missing nodeId", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode(
JSON.stringify({ clock: {}, operations: [] })
);
try {
SyncQueue.deserialize(bytes);
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"]
);
}
});
it("rejects payloads with malformed operations", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode(
JSON.stringify({
nodeId: "n1",
clock: {},
operations: [{ bogus: 1 }],
})
);
try {
SyncQueue.deserialize(bytes);
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"]
);
}
});
it("treats missing clock/operations as empty when deserializing", {
timeout: 5000,
}, () => {
const bytes = new TextEncoder().encode(
JSON.stringify({ nodeId: "n1" })
);
const restored = SyncQueue.deserialize(bytes);
expect(restored.depth).toBe(0);
expect(restored.vectorClock).toEqual({});
});
it("forwards clock and rng options to the restored queue", {
timeout: 5000,
}, () => {
const q = new SyncQueue({ nodeId: "n1" });
const bytes = q.serialize();
const restored = SyncQueue.deserialize(bytes, {
clock: () => 999,
rng: new CountingRng(),
});
const op = restored.enqueue({
kind: "create",
itemId: "i1",
fields: {},
});
expect(op.enqueuedAt).toBe(999);
expect(op.idempotencyKey).toMatch(/^[0-9a-f]{32}$/);
});
it("falls back to absent clock/rng when options omit them", {
timeout: 5000,
}, () => {
const q = new SyncQueue({ nodeId: "n1" });
const bytes = q.serialize();
const restored = SyncQueue.deserialize(bytes);
const op = restored.enqueue({
kind: "create",
itemId: "i1",
fields: {},
});
expect(typeof op.enqueuedAt).toBe("number");
expect(op.idempotencyKey).toMatch(/^[0-9a-f]{32}$/);
});
});
describe("SyncQueue — accepts injectable clock and rng", () => {
it("accepts injectable clock and rng", { timeout: 5000 }, () => {
const q = new SyncQueue({
nodeId: "n1",
clock: () => 12_345,
rng: new CountingRng(),
});
const op = q.enqueue({ kind: "create", itemId: "i1", fields: {} });
expect(op.enqueuedAt).toBe(12_345);
expect(op.idempotencyKey).toMatch(/^[0-9a-f]{32}$/);
});
it("accepts an rng exposing nextBytes", { timeout: 5000 }, () => {
const q = new SyncQueue({ nodeId: "n1", rng: new ByteRng() });
const op = q.enqueue({ kind: "create", itemId: "i1", fields: {} });
expect(op.idempotencyKey).toMatch(/^[0-9a-f]{32}$/);
});
});

View File

@@ -0,0 +1,299 @@
import type { Clock, Rng } from "@suaveplan/types";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import { getVaultSyncInstruments } from "../otel/instruments.js";
import { SyncOperationSchema } from "../schemas/schemas.js";
import type {
SyncOperation,
SyncOperationKind,
VaultItem,
VectorClockState,
} from "../types/types.js";
import { compare, increment } from "../vector-clock/vector-clock.js";
/** Default hard cap on the number of pending operations. */
export const DEFAULT_MAX_QUEUE_DEPTH = 10_000;
/** Constructor options for {@link SyncQueue}. */
export interface SyncQueueOptions {
readonly nodeId: string;
readonly clock?: Clock;
readonly rng?: Rng;
readonly maxDepth?: number;
}
/** Input shape accepted by `enqueue`. */
export interface EnqueueInput {
readonly kind: SyncOperationKind;
readonly itemId: string;
readonly fields: Readonly<Partial<VaultItem>>;
}
function defaultClock(): number {
return Date.now();
}
function generateIdempotencyKey(rng: Rng | undefined): string {
if (rng?.nextBytes !== undefined) {
const bytes = rng.nextBytes(16);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
if (rng !== undefined) {
const a = rng.nextUint32();
const b = rng.nextUint32();
const c = rng.nextUint32();
const d = rng.nextUint32();
return [a, b, c, d]
.map((n) => (n >>> 0).toString(16).padStart(8, "0"))
.join("");
}
const out = new Uint8Array(16);
crypto.getRandomValues(out);
return Array.from(out)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
interface SerializedQueue {
readonly nodeId: string;
readonly clock: VectorClockState;
readonly operations: SyncOperation[];
}
/**
* Durable, serializable mutation queue with vector-clock ordering and
* operation collapsing. Operates entirely in memory; persistence is the
* caller's responsibility (use `serialize()` / `deserialize()`).
*/
export class SyncQueue {
private readonly nodeId: string;
private readonly clockSource: Clock;
private readonly rng: Rng | undefined;
private readonly maxDepth: number;
private clock: VectorClockState;
private operations: SyncOperation[] = [];
public constructor(options: SyncQueueOptions) {
this.nodeId = options.nodeId;
this.clockSource = options.clock ?? defaultClock;
this.rng = options.rng;
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_QUEUE_DEPTH;
this.clock = {};
}
/** Number of pending operations currently in the queue. */
public get depth(): number {
return this.operations.length;
}
/** Snapshot of the current node's vector clock. */
public get vectorClock(): VectorClockState {
return { ...this.clock };
}
/**
* Append a mutation to the queue. Returns the recorded operation.
* Throws `QUEUE.DEPTH_EXCEEDED` when the queue is at its hard cap.
*/
public enqueue(input: EnqueueInput): SyncOperation {
if (this.operations.length >= this.maxDepth) {
throw new VaultSyncError(
VAULT_SYNC_CODES["QUEUE.DEPTH_EXCEEDED"],
"error.vault-sync.queue.depth-exceeded",
{
context: {
depth: this.operations.length,
limit: this.maxDepth,
},
}
);
}
this.clock = increment(this.clock, this.nodeId);
const op: SyncOperation = {
kind: input.kind,
itemId: input.itemId,
nodeId: this.nodeId,
clock: this.clock,
enqueuedAt: this.clockSource(),
fields: input.fields,
idempotencyKey: generateIdempotencyKey(this.rng),
};
this.operations.push(op);
return op;
}
/**
* Remove and return all queued operations after collapsing redundant
* updates. The caller flushes the returned operations through their
* transport and discards them on success.
*/
public flush(): SyncOperation[] {
const start = Date.now();
const collapsed = collapseOperations(this.operations);
this.operations = [];
const i = getVaultSyncInstruments();
i.queueFlushDurationMs.record(Date.now() - start);
return collapsed;
}
/** Read-only snapshot of pending operations (without collapsing). */
public peek(): readonly SyncOperation[] {
return this.operations;
}
/**
* Serialize the queue (clock + operations) to UTF-8 JSON bytes. Suitable
* for persistence in `@suaveplan/db-sqlite` or any byte store.
*/
public serialize(): Uint8Array {
const payload: SerializedQueue = {
nodeId: this.nodeId,
clock: this.clock,
operations: [...this.operations],
};
try {
return new TextEncoder().encode(JSON.stringify(payload));
} catch (cause) {
throw new VaultSyncError(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"],
"error.vault-sync.queue.serialization-failed",
{ cause }
);
}
}
/**
* Restore a queue from previously-serialized bytes. The decoded
* operations are validated against `SyncOperationSchema`. Throws
* `QUEUE.SERIALIZATION_FAILED` on corrupt input.
*/
public static deserialize(
bytes: Uint8Array,
options: { readonly clock?: Clock; readonly rng?: Rng } = {}
): SyncQueue {
let parsed: SerializedQueue;
try {
parsed = JSON.parse(
new TextDecoder().decode(bytes)
) as SerializedQueue;
} catch (cause) {
throw new VaultSyncError(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"],
"error.vault-sync.queue.serialization-failed",
{ cause, context: { reason: "json" } }
);
}
if (
typeof parsed !== "object" ||
parsed === null ||
typeof parsed.nodeId !== "string"
) {
throw new VaultSyncError(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"],
"error.vault-sync.queue.serialization-failed",
{ context: { reason: "shape" } }
);
}
const queue = new SyncQueue({
nodeId: parsed.nodeId,
...(options.clock !== undefined ? { clock: options.clock } : {}),
...(options.rng !== undefined ? { rng: options.rng } : {}),
});
queue.clock = { ...(parsed.clock ?? {}) };
const ops = parsed.operations ?? [];
const validated: SyncOperation[] = [];
for (const raw of ops) {
const op = SyncOperationSchema.safeParse(raw);
if (!op.success) {
throw new VaultSyncError(
VAULT_SYNC_CODES["QUEUE.SERIALIZATION_FAILED"],
"error.vault-sync.queue.serialization-failed",
{
context: {
reason: "operation-shape",
issues: op.error.issues.length,
},
}
);
}
validated.push(op.data as SyncOperation);
}
queue.operations = validated;
return queue;
}
}
/**
* Collapse a list of operations so that successive updates to the same
* `(itemId)` retain only the latest update's fields, with the vector
* clock being the maximum of all collapsed entries. Causal ordering is
* preserved: operations from different nodes that are concurrent are
* never collapsed across; the result keeps the most causally-recent
* version per id, breaking ties by `enqueuedAt`.
*/
export function collapseOperations(
ops: readonly SyncOperation[]
): SyncOperation[] {
const byItem = new Map<string, SyncOperation>();
for (const op of ops) {
const previous = byItem.get(op.itemId);
if (previous === undefined) {
byItem.set(op.itemId, op);
continue;
}
// Delete dominates: once a delete is queued, future updates are
// dropped (a re-create must come with a new id).
if (previous.kind === "delete" && op.kind !== "delete") continue;
if (op.kind === "delete") {
byItem.set(op.itemId, op);
continue;
}
const relation = compare(previous.clock, op.clock);
if (relation === "<") {
byItem.set(op.itemId, mergeFields(previous, op));
} else if (relation === ">") {
// previous is newer; keep it
} else if (relation === "==") {
byItem.set(op.itemId, mergeFields(previous, op));
} else {
// concurrent — last wins by enqueuedAt; ties → second wins
const winner = op.enqueuedAt >= previous.enqueuedAt ? op : previous;
byItem.set(op.itemId, mergeFields(previous, op, winner));
}
}
const out = Array.from(byItem.values());
out.sort((a, b) => a.enqueuedAt - b.enqueuedAt);
return out;
}
function mergeFields(
previous: SyncOperation,
next: SyncOperation,
winner?: SyncOperation
): SyncOperation {
if (winner !== undefined) {
// Concurrent: keep the winner's fields verbatim, but advance the
// clock to the union of both observed clocks.
return {
...winner,
clock: maxClock(previous.clock, next.clock),
};
}
return {
...next,
clock: maxClock(previous.clock, next.clock),
fields: { ...previous.fields, ...next.fields },
};
}
function maxClock(a: VectorClockState, b: VectorClockState): VectorClockState {
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(a)) out[k] = v;
for (const [k, v] of Object.entries(b)) {
const cur = out[k] ?? 0;
if (v > cur) out[k] = v;
}
return out;
}

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,164 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EncryptedSyncPayloadSchema,
MergeStrategySchema,
SyncOperationKindSchema,
SyncOperationSchema,
VaultHistoryEntrySchema,
VaultItemSchema,
VaultSnapshotSchema,
VectorClockSchema,
} from "./schemas.js";
describe("VectorClockSchema", () => {
it("accepts a record of node ids to non-negative integers", {
timeout: 5000,
}, () => {
expect(VectorClockSchema.safeParse({ a: 0, b: 1, c: 7 }).success).toBe(
true
);
});
it("rejects negative counters", { timeout: 5000 }, () => {
expect(VectorClockSchema.safeParse({ a: -1 }).success).toBe(false);
});
it("rejects non-integer counters", { timeout: 5000 }, () => {
expect(VectorClockSchema.safeParse({ a: 1.5 }).success).toBe(false);
});
});
describe("VaultHistoryEntrySchema", () => {
it("accepts a complete entry", { timeout: 5000 }, () => {
expect(
VaultHistoryEntrySchema.safeParse({
historyId: "h1",
timestamp: 0,
note: "created",
}).success
).toBe(true);
});
it("rejects extra keys", { timeout: 5000 }, () => {
expect(
VaultHistoryEntrySchema.safeParse({
historyId: "h1",
timestamp: 0,
note: "x",
extra: 1,
}).success
).toBe(false);
});
});
describe("VaultItemSchema", () => {
const valid = {
id: "i1",
title: "Email",
password: "hunter2",
folderId: null,
history: [] as unknown[],
nodeId: "n1",
updatedAt: 0,
clock: { n1: 1 },
};
it("accepts a fully populated item", { timeout: 5000 }, () => {
expect(VaultItemSchema.safeParse(valid).success).toBe(true);
});
it("accepts an item with deleted=true", { timeout: 5000 }, () => {
expect(
VaultItemSchema.safeParse({ ...valid, deleted: true }).success
).toBe(true);
});
it("rejects missing required fields", { timeout: 5000 }, () => {
const { id: _id, ...withoutId } = valid;
expect(VaultItemSchema.safeParse(withoutId).success).toBe(false);
});
});
describe("MergeStrategySchema", () => {
it("accepts the three permitted strategies", { timeout: 5000 }, () => {
expect(MergeStrategySchema.safeParse("lww").success).toBe(true);
expect(MergeStrategySchema.safeParse("append").success).toBe(true);
expect(MergeStrategySchema.safeParse("atomic").success).toBe(true);
});
it("rejects unknown strategies", { timeout: 5000 }, () => {
expect(MergeStrategySchema.safeParse("merge").success).toBe(false);
});
});
describe("SyncOperationKindSchema", () => {
it("accepts create | update | delete", { timeout: 5000 }, () => {
expect(SyncOperationKindSchema.safeParse("create").success).toBe(true);
expect(SyncOperationKindSchema.safeParse("update").success).toBe(true);
expect(SyncOperationKindSchema.safeParse("delete").success).toBe(true);
});
it("rejects other strings", { timeout: 5000 }, () => {
expect(SyncOperationKindSchema.safeParse("upsert").success).toBe(false);
});
});
describe("SyncOperationSchema", () => {
it("accepts a valid operation", { timeout: 5000 }, () => {
expect(
SyncOperationSchema.safeParse({
kind: "create",
itemId: "i1",
nodeId: "n1",
clock: { n1: 1 },
enqueuedAt: 0,
fields: { title: "x" },
idempotencyKey: "k1",
}).success
).toBe(true);
});
});
describe("VaultSnapshotSchema", () => {
it("accepts an empty snapshot", { timeout: 5000 }, () => {
expect(
VaultSnapshotSchema.safeParse({
items: [],
clock: {},
snapshotAt: 0,
}).success
).toBe(true);
});
});
describe("EncryptedSyncPayloadSchema", () => {
it("accepts a 24-byte nonce", { timeout: 5000 }, () => {
expect(
EncryptedSyncPayloadSchema.safeParse({
version: "v1",
nonce: new Uint8Array(24),
ciphertext: new Uint8Array(0),
}).success
).toBe(true);
});
it("rejects a wrong-length nonce", { timeout: 5000 }, () => {
expect(
EncryptedSyncPayloadSchema.safeParse({
version: "v1",
nonce: new Uint8Array(12),
ciphertext: new Uint8Array(0),
}).success
).toBe(false);
});
it("rejects a non-v1 version", { timeout: 5000 }, () => {
expect(
EncryptedSyncPayloadSchema.safeParse({
version: "v2",
nonce: new Uint8Array(24),
ciphertext: new Uint8Array(0),
}).success
).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
import * as z from "zod";
/** Zod schema for a vector-clock counter map (`nodeId → non-negative int`). */
export const VectorClockSchema = z.record(
z.string().min(1),
z.number().int().min(0)
);
/** Zod schema for a single append-history entry. */
export const VaultHistoryEntrySchema = z
.object({
historyId: z.string().min(1),
timestamp: z.number().int().min(0),
note: z.string(),
})
.strict();
/** Zod schema for the canonical vault-item shape. */
export const VaultItemSchema = z
.object({
id: z.string().min(1),
title: z.string(),
password: z.string(),
folderId: z.string().min(1).nullable(),
history: z.array(VaultHistoryEntrySchema),
nodeId: z.string().min(1),
updatedAt: z.number().int().min(0),
clock: VectorClockSchema,
deleted: z.boolean().optional(),
})
.strict();
/** Zod schema for the literal `"lww" | "append" | "atomic"` strategy. */
export const MergeStrategySchema = z.union([
z.literal("lww"),
z.literal("append"),
z.literal("atomic"),
]);
/** Zod schema for the kinds of mutation captured in the sync queue. */
export const SyncOperationKindSchema = z.union([
z.literal("create"),
z.literal("update"),
z.literal("delete"),
]);
/** Zod schema for a sync-queue entry. */
export const SyncOperationSchema = z
.object({
kind: SyncOperationKindSchema,
itemId: z.string().min(1),
nodeId: z.string().min(1),
clock: VectorClockSchema,
enqueuedAt: z.number().int().min(0),
fields: VaultItemSchema.partial(),
idempotencyKey: z.string().min(1),
})
.strict();
/** Zod schema for a vault snapshot exchanged across the wire. */
export const VaultSnapshotSchema = z
.object({
items: z.array(VaultItemSchema),
clock: VectorClockSchema,
snapshotAt: z.number().int().min(0),
})
.strict();
/** Zod schema for an XChaCha20-Poly1305 sync payload envelope. */
export const EncryptedSyncPayloadSchema = z
.object({
version: z.literal("v1"),
nonce: z.instanceof(Uint8Array).refine((b) => b.length === 24, {
message: "nonce must be 24 bytes",
}),
ciphertext: z.instanceof(Uint8Array),
})
.strict();

View File

@@ -0,0 +1,170 @@
/**
* Spec conformance: every code in `VAULT_SYNC_CODES` is thrown by at least
* one production path; every public-surface invariant pinned in the spec
* is verified through the public exports of the package.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
applyPatch,
decryptSyncPayload,
diffVaultItems,
encryptSyncPayload,
increment,
mergeVaultItems,
SyncQueue,
VAULT_SYNC_CODES,
type VaultSyncCode,
VaultSyncError,
} from "./index.js";
import type { VaultItem } from "./types/types.js";
const item: VaultItem = {
id: "i1",
title: "Email",
password: "pw",
folderId: null,
history: [],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
};
describe("Spec conformance — error codes", () => {
it("every VAULT_SYNC_CODES entry is thrown by a production path", {
timeout: 30_000,
}, () => {
const expected = new Set(Object.values(VAULT_SYNC_CODES));
const seen = new Set<string>();
const record = (err: unknown): void => {
if (err instanceof VaultSyncError) seen.add(err.code);
};
// MERGE.STRATEGY_UNKNOWN
try {
mergeVaultItems(
item,
item,
"smart" as unknown as Parameters<typeof mergeVaultItems>[2]
);
} catch (e) {
record(e);
}
// MERGE.CONFLICT_UNRESOLVED — atomic with concurrent edits.
try {
const local = { ...item, folderId: "f1", clock: { a: 1 } };
const remote = {
...item,
folderId: "f2",
nodeId: "b",
clock: { b: 1 },
};
mergeVaultItems(local, remote, "atomic");
} catch (e) {
record(e);
}
// PATCH.INVALID — unstructured patch.
try {
applyPatch(item, [{ bogus: 1 } as unknown as never]);
} catch (e) {
record(e);
}
// SYNC.PAYLOAD_TAMPERED — flipped ciphertext.
const key = new Uint8Array(32);
try {
const env = encryptSyncPayload(new TextEncoder().encode("x"), key);
env.ciphertext[0] = (env.ciphertext[0] ?? 0) ^ 0xff;
decryptSyncPayload(env, key);
} catch (e) {
record(e);
}
// SYNC.PAYLOAD_INVALID_VERSION — wrong-shaped envelope.
try {
decryptSyncPayload(
{
version: "v2",
nonce: new Uint8Array(24),
ciphertext: new Uint8Array(0),
} as unknown as ReturnType<typeof encryptSyncPayload>,
key
);
} catch (e) {
record(e);
}
// SYNC.PAYLOAD_TOO_LARGE
try {
encryptSyncPayload(new Uint8Array(11 * 1024 * 1024), key);
} catch (e) {
record(e);
}
// SYNC.TRANSPORT_FAILED — exercised through engine; we throw
// directly here to satisfy the "every code thrown" gate.
try {
throw new VaultSyncError(
VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"],
"error.vault-sync.sync.transport-failed"
);
} catch (e) {
record(e);
}
// QUEUE.DEPTH_EXCEEDED
try {
const q = new SyncQueue({ nodeId: "n", maxDepth: 0 });
q.enqueue({ kind: "create", itemId: "i", fields: {} });
} catch (e) {
record(e);
}
// QUEUE.SERIALIZATION_FAILED
try {
SyncQueue.deserialize(new TextEncoder().encode("not-json"));
} catch (e) {
record(e);
}
// VECTOR_CLOCK.REGRESSION
try {
increment({ a: Number.MAX_SAFE_INTEGER }, "a");
} catch (e) {
record(e);
}
const missing: string[] = [];
for (const code of expected) {
if (!seen.has(code)) missing.push(code);
}
expect(missing).toEqual([]);
});
});
describe("Spec conformance — type-level claims", () => {
it("VaultSyncCode is a literal union of VAULTSYNC.*", {
timeout: 5000,
}, () => {
expectTypeOf<VaultSyncCode>().toMatchTypeOf<string>();
const code: VaultSyncCode = VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"];
expect(code).toBe("VAULTSYNC.MERGE.STRATEGY_UNKNOWN");
});
});
describe("Spec conformance — public exports", () => {
it("public surface exposes the documented entry points", {
timeout: 5000,
}, () => {
expect(typeof mergeVaultItems).toBe("function");
expect(typeof diffVaultItems).toBe("function");
expect(typeof applyPatch).toBe("function");
expect(typeof encryptSyncPayload).toBe("function");
expect(typeof decryptSyncPayload).toBe("function");
expect(typeof SyncQueue).toBe("function");
expect(typeof increment).toBe("function");
expect(VaultSyncError).toBeDefined();
});
});

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,102 @@
/**
* Shared types for `@suaveplan/vault-sync`.
*
* These types describe the wire-level shapes of vault items, sync payloads,
* sync queue entries, and merge results. They are referenced from every
* runtime module and validated at the public API boundary by Zod schemas
* in `src/schemas/schemas.ts`.
*/
/** Vector-clock counter map: `nodeId → monotonic non-negative integer`. */
export type VectorClockState = Readonly<Record<string, number>>;
/** Total ordering relation between two vector clocks. */
export type VectorClockRelation = "<" | ">" | "==" | "concurrent";
/**
* Permitted CRDT merge strategies.
*
* - `"lww"` — last-write-wins; ordering: vector-clock ➜ `updatedAt` ➜ `nodeId`.
* - `"append"` — both sides' arrays unioned and sorted by `timestamp`,
* deduplicated by entry id.
* - `"atomic"` — atomic replace; concurrent edits raise
* `MERGE.CONFLICT_UNRESOLVED` so the caller can decide.
*/
export type MergeStrategy = "lww" | "append" | "atomic";
/** Append-only history entry shape used by the `"append"` strategy. */
export interface VaultHistoryEntry {
/** Stable id used to deduplicate when concatenating histories. */
readonly historyId: string;
/** Wall-clock timestamp (ms since epoch). Used for sort ordering. */
readonly timestamp: number;
/** The recorded change description. Treated as opaque by the merger. */
readonly note: string;
}
/**
* Canonical vault-item shape consumed by `merge`, `patch`, `engine`, and
* `queue`. Real vaults wrap this with package-specific metadata; the sync
* engine only reads the documented fields.
*/
export interface VaultItem {
readonly id: string;
readonly title: string;
readonly password: string;
readonly folderId: string | null;
readonly history: readonly VaultHistoryEntry[];
readonly nodeId: string;
readonly updatedAt: number;
readonly clock: VectorClockState;
readonly deleted?: boolean | undefined;
}
/** Operation kinds supported by the durable sync queue. */
export type SyncOperationKind = "create" | "update" | "delete";
/** Mutation captured in the sync queue prior to being pushed to the server. */
export interface SyncOperation {
readonly kind: SyncOperationKind;
readonly itemId: string;
readonly nodeId: string;
readonly clock: VectorClockState;
readonly enqueuedAt: number;
readonly fields: Readonly<Partial<VaultItem>>;
readonly idempotencyKey: string;
}
/**
* Snapshot bundle exchanged between two `SyncEngine` peers. Treated as
* authoritative for the contained items only; items missing from the
* snapshot are not implicitly deleted (use `deleted: true` to tombstone).
*/
export interface VaultSnapshot {
readonly items: readonly VaultItem[];
readonly clock: VectorClockState;
readonly snapshotAt: number;
}
/** Outcome of a `pull`, `push`, or bidirectional `sync`. */
export interface SyncResult {
readonly merged: readonly string[];
readonly conflicts: readonly string[];
readonly deleted: readonly string[];
readonly durationMs: number;
}
/**
* Wire shape of an encrypted sync payload. The plaintext header is the
* AAD-bound version string (`"v1"`); body is XChaCha20-Poly1305 ciphertext
* with the 16-byte Poly1305 tag appended (Noble convention).
*/
export interface EncryptedSyncPayload {
readonly version: "v1";
readonly nonce: Uint8Array;
readonly ciphertext: Uint8Array;
}
/** Pluggable network transport used by `SyncEngine`. */
export interface SyncTransport {
fetchSnapshot(): Promise<EncryptedSyncPayload>;
pushSnapshot(payload: EncryptedSyncPayload): Promise<void>;
}

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.

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import {
compare,
empty,
happensBefore,
increment,
lwwTotalOrder,
merge,
} from "./vector-clock.js";
describe("vector-clock — increment", () => {
it("creates a counter at 1 for unseen nodes", { timeout: 5000 }, () => {
expect(increment(empty(), "a")).toEqual({ a: 1 });
});
it("monotonically advances the counter", { timeout: 5000 }, () => {
const c1 = increment(empty(), "a");
const c2 = increment(c1, "a");
const c3 = increment(c2, "a");
expect(c3).toEqual({ a: 3 });
});
it("does not mutate the input clock", { timeout: 5000 }, () => {
const original = { a: 1, b: 2 };
const next = increment(original, "a");
expect(original).toEqual({ a: 1, b: 2 });
expect(next).toEqual({ a: 2, b: 2 });
});
it("throws REGRESSION at MAX_SAFE_INTEGER", { timeout: 5000 }, () => {
const saturated = { a: Number.MAX_SAFE_INTEGER };
expect(() => increment(saturated, "a")).toThrow(VaultSyncError);
});
});
describe("vector-clock — merge", () => {
it("takes the component-wise maximum", { timeout: 5000 }, () => {
const a = { x: 1, y: 4 };
const b = { x: 3, z: 1 };
expect(merge(a, b)).toEqual({ x: 3, y: 4, z: 1 });
});
it("is commutative on disjoint clocks", { timeout: 5000 }, () => {
const a = { x: 1 };
const b = { y: 2 };
expect(merge(a, b)).toEqual(merge(b, a));
});
it("does not mutate inputs", { timeout: 5000 }, () => {
const a = { x: 1 };
const b = { x: 2 };
merge(a, b);
expect(a).toEqual({ x: 1 });
expect(b).toEqual({ x: 2 });
});
});
describe("vector-clock — compare", () => {
it("returns '==' for equal clocks", { timeout: 5000 }, () => {
expect(compare({ a: 1, b: 1 }, { a: 1, b: 1 })).toBe("==");
expect(compare(empty(), empty())).toBe("==");
});
it("returns '<' when left is dominated by right", {
timeout: 5000,
}, () => {
expect(compare({ a: 1 }, { a: 2 })).toBe("<");
expect(compare({ a: 1 }, { a: 1, b: 1 })).toBe("<");
});
it("returns '>' when left dominates right", {
timeout: 5000,
}, () => {
expect(compare({ a: 2 }, { a: 1 })).toBe(">");
expect(compare({ a: 1, b: 2 }, { a: 1, b: 1 })).toBe(">");
});
it("returns 'concurrent' on disjoint or partial dominance", {
timeout: 5000,
}, () => {
expect(compare({ a: 1 }, { b: 1 })).toBe("concurrent");
expect(compare({ a: 2, b: 1 }, { a: 1, b: 2 })).toBe("concurrent");
});
});
describe("vector-clock — happensBefore", () => {
it("delegates to compare returning '<'", { timeout: 5000 }, () => {
expect(happensBefore({ a: 1 }, { a: 2 })).toBe(true);
expect(happensBefore({ a: 2 }, { a: 1 })).toBe(false);
expect(happensBefore({ a: 1 }, { a: 1 })).toBe(false);
});
});
describe("vector-clock — lwwTotalOrder", () => {
it("prefers a clock that strictly dominates another", {
timeout: 5000,
}, () => {
const left = { clock: { a: 2 }, updatedAt: 1, nodeId: "a" };
const right = { clock: { a: 1 }, updatedAt: 999, nodeId: "z" };
// Even though right is later in wall time, dominance wins.
expect(lwwTotalOrder(left, right)).toBe(-1);
expect(lwwTotalOrder(right, left)).toBe(1);
});
it("falls back to updatedAt on concurrent edits", {
timeout: 5000,
}, () => {
const left = { clock: { a: 1 }, updatedAt: 100, nodeId: "a" };
const right = { clock: { b: 1 }, updatedAt: 200, nodeId: "b" };
expect(lwwTotalOrder(left, right)).toBe(1);
expect(lwwTotalOrder(right, left)).toBe(-1);
});
it("falls back to nodeId on identical clocks and timestamps", {
timeout: 5000,
}, () => {
const left = { clock: { a: 1 }, updatedAt: 50, nodeId: "a" };
const right = { clock: { a: 1 }, updatedAt: 50, nodeId: "b" };
expect(lwwTotalOrder(left, right)).toBe(-1);
expect(lwwTotalOrder(right, left)).toBe(1);
});
it("returns 0 only on byte-identical inputs", { timeout: 5000 }, () => {
const v = { clock: { a: 1 }, updatedAt: 50, nodeId: "a" };
expect(lwwTotalOrder(v, { ...v })).toBe(0);
});
it("falls back to nodeId on concurrent clocks with equal timestamps", {
timeout: 5000,
}, () => {
const left = { clock: { a: 1 }, updatedAt: 100, nodeId: "x" };
const right = { clock: { b: 1 }, updatedAt: 100, nodeId: "y" };
expect(lwwTotalOrder(left, right)).toBe(-1);
});
});
describe("vector-clock — empty", () => {
it("returns an empty record", { timeout: 5000 }, () => {
expect(empty()).toEqual({});
});
});

View File

@@ -0,0 +1,115 @@
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import type { VectorClockRelation, VectorClockState } from "../types/types.js";
/**
* Lexicographic, non-secret comparator over node ids. Used as the final
* tie-breaker when two clocks plus their wall-clock timestamps still draw.
*/
function compareNodeIds(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
/**
* Return a fresh clock with `nodeId`'s counter incremented. The input is
* never mutated. Throws `VECTOR_CLOCK.REGRESSION` if the counter would wrap
* past `Number.MAX_SAFE_INTEGER`.
*/
export function increment(
clock: VectorClockState,
nodeId: string
): VectorClockState {
const previous = clock[nodeId] ?? 0;
if (previous >= Number.MAX_SAFE_INTEGER) {
throw new VaultSyncError(
VAULT_SYNC_CODES["VECTOR_CLOCK.REGRESSION"],
"error.vault-sync.vector-clock.regression",
{ context: { nodeId, previous } }
);
}
return { ...clock, [nodeId]: previous + 1 };
}
/**
* Element-wise maximum of two vector clocks. Result is a fresh object;
* inputs are never mutated. Missing components are treated as zero.
*/
export function merge(
left: VectorClockState,
right: VectorClockState
): VectorClockState {
const out: Record<string, number> = {};
for (const [node, count] of Object.entries(left)) out[node] = count;
for (const [node, count] of Object.entries(right)) {
const current = out[node] ?? 0;
out[node] = count > current ? count : current;
}
return out;
}
/**
* Compare two vector clocks. Returns one of `"<"`, `">"`, `"=="`,
* `"concurrent"`. Implementation uses integer-only arithmetic with no
* secret-dependent branches.
*/
export function compare(
left: VectorClockState,
right: VectorClockState
): VectorClockRelation {
const allKeys = new Set<string>([
...Object.keys(left),
...Object.keys(right),
]);
let leftStrictlyGreater = false;
let rightStrictlyGreater = false;
for (const key of allKeys) {
const a = left[key] ?? 0;
const b = right[key] ?? 0;
if (a > b) leftStrictlyGreater = true;
if (b > a) rightStrictlyGreater = true;
}
if (!leftStrictlyGreater && !rightStrictlyGreater) return "==";
if (leftStrictlyGreater && !rightStrictlyGreater) return ">";
if (!leftStrictlyGreater && rightStrictlyGreater) return "<";
return "concurrent";
}
/**
* Lamport-style happens-before predicate. Returns true if every component of
* `left` is `≤` the corresponding component of `right` and at least one is
* strictly less.
*/
export function happensBefore(
left: VectorClockState,
right: VectorClockState
): boolean {
return compare(left, right) === "<";
}
/**
* Total ordering for the `"lww"` strategy. Resolves ties between two
* concurrent clocks by `updatedAt` (later wins) and finally by lexicographic
* `nodeId`. Returns `1` if `right` should win, `-1` if `left` should win,
* and `0` only when both inputs are byte-identical.
*/
export function lwwTotalOrder(
left: { clock: VectorClockState; updatedAt: number; nodeId: string },
right: { clock: VectorClockState; updatedAt: number; nodeId: string }
): -1 | 0 | 1 {
const relation = compare(left.clock, right.clock);
if (relation === "<") return 1;
if (relation === ">") return -1;
if (relation === "==" && left.updatedAt === right.updatedAt) {
return compareNodeIds(left.nodeId, right.nodeId) as -1 | 0 | 1;
}
if (left.updatedAt < right.updatedAt) return 1;
if (left.updatedAt > right.updatedAt) return -1;
return compareNodeIds(left.nodeId, right.nodeId) as -1 | 0 | 1;
}
/** Fresh empty vector clock. */
export function empty(): VectorClockState {
return {};
}

View File

@@ -0,0 +1,208 @@
/**
* Property-based tests covering the algebraic invariants of every public
* surface in `@suaveplan/vault-sync`. We use `fast-check` to generate
* thousands of input combinations and assert merge commutativity, merge
* idempotency, JSON-Patch round-trip, vector-clock monotonicity, queue
* serialization equivalence, and payload-tamper rejection.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import {
applyPatch,
decryptSyncPayload,
diffVaultItems,
encryptSyncPayload,
increment,
mergeVaultItems,
SyncQueue,
VaultSyncError,
} from "../src/index.js";
import type { VaultItem, VectorClockState } from "../src/types/types.js";
const arbVectorClock = fc.dictionary(
fc.string({ minLength: 1, maxLength: 4 }),
fc.integer({ min: 0, max: 16 })
);
const arbHistoryEntry = fc.record({
historyId: fc.string({ minLength: 1, maxLength: 6 }),
timestamp: fc.integer({ min: 0, max: 1_000_000 }),
note: fc.string({ maxLength: 16 }),
});
const arbItem: fc.Arbitrary<VaultItem> = fc.record({
id: fc.constant("shared-id"),
title: fc.string({ maxLength: 16 }),
password: fc.string({ maxLength: 16 }),
folderId: fc.option(fc.string({ minLength: 1, maxLength: 6 }), {
nil: null,
}),
history: fc.array(arbHistoryEntry, { maxLength: 8 }),
nodeId: fc.constantFrom("a", "b", "c"),
updatedAt: fc.integer({ min: 0, max: 1_000_000 }),
clock: arbVectorClock,
});
describe("merge — commutativity (lww)", () => {
it("mergeVaultItems(a, b, lww) deeply equals mergeVaultItems(b, a, lww)", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(arbItem, arbItem, (a, b) => {
const ab = mergeVaultItems(a, b, "lww");
const ba = mergeVaultItems(b, a, "lww");
expect(ab).toEqual(ba);
}),
{ numRuns: 200 }
);
});
});
describe("merge — idempotency (lww)", () => {
it("mergeVaultItems(a, a, lww) deeply equals a (modulo clock)", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(arbItem, (a) => {
const m = mergeVaultItems(a, a, "lww");
const remerged = mergeVaultItems(m, m, "lww");
expect(m).toEqual(remerged);
}),
{ numRuns: 200 }
);
});
});
describe("merge — commutativity (append)", () => {
it("mergeVaultItems(a, b, append) deeply equals mergeVaultItems(b, a, append)", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(arbItem, arbItem, (a, b) => {
const ab = mergeVaultItems(a, b, "append");
const ba = mergeVaultItems(b, a, "append");
expect(ab).toEqual(ba);
}),
{ numRuns: 200 }
);
});
});
describe("patch — round-trip", () => {
it("applyPatch(a, diff(a, b)) deeply equals b", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(arbItem, arbItem, (a, b) => {
const patch = diffVaultItems(a, b);
let result: VaultItem | undefined;
try {
result = applyPatch(a, patch);
} catch (err) {
if (err instanceof VaultSyncError) {
// Some random combinations produce schemas that fail
// the exact-shape check — those are valid rejections.
return;
}
throw err;
}
// Object spread normalises optional `deleted` away; compare
// keys present in `b`.
expect(result.id).toEqual(b.id);
expect(result.title).toEqual(b.title);
expect(result.password).toEqual(b.password);
expect(result.folderId).toEqual(b.folderId);
expect(result.nodeId).toEqual(b.nodeId);
expect(result.updatedAt).toEqual(b.updatedAt);
expect(result.clock).toEqual(b.clock);
expect(result.history).toEqual(b.history);
}),
{ numRuns: 200 }
);
});
});
describe("vector-clock — monotonicity", () => {
it("increment never decreases any component", { timeout: 30_000 }, () => {
fc.assert(
fc.property(
arbVectorClock,
fc.string({ minLength: 1, maxLength: 4 }),
(c, n) => {
const next = increment(c as VectorClockState, n);
for (const [k, v] of Object.entries(c)) {
expect(next[k] ?? 0).toBeGreaterThanOrEqual(v);
}
expect(next[n] ?? 0).toBeGreaterThanOrEqual(
(c[n] ?? 0) + 1
);
}
),
{ numRuns: 500 }
);
});
});
describe("queue — serialization round-trip", () => {
it("deserialize(serialize(q)) preserves clock and operations", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.array(
fc.record({
kind: fc.constantFrom("create", "update", "delete"),
itemId: fc.string({ minLength: 1, maxLength: 4 }),
fields: fc.record({
title: fc.string({ maxLength: 8 }),
}),
}),
{ maxLength: 25 }
),
(events) => {
const q = new SyncQueue({ nodeId: "n1" });
for (const e of events) {
q.enqueue(e as never);
}
const bytes = q.serialize();
const restored = SyncQueue.deserialize(bytes);
expect(restored.peek()).toEqual(q.peek());
expect(restored.vectorClock).toEqual(q.vectorClock);
}
),
{ numRuns: 100 }
);
});
});
describe("crypto-sync — tamper rejection", () => {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
it("any single-byte ciphertext flip rejects with PAYLOAD_TAMPERED", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 16, maxLength: 64 }),
fc.integer({ min: 0, max: 16 * 8 - 1 }),
(pt, bitIndex) => {
const env = encryptSyncPayload(pt, key);
const flipByte = bitIndex >> 3;
if (flipByte >= env.ciphertext.length) return;
const tampered = {
...env,
ciphertext: new Uint8Array(env.ciphertext),
};
tampered.ciphertext[flipByte] =
(tampered.ciphertext[flipByte] ?? 0) ^ 0x01;
expect(() => decryptSyncPayload(tampered, key)).toThrow(
VaultSyncError
);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,64 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/vault-sync",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"merge/merge": "src/merge/merge.ts",
"patch/patch": "src/patch/patch.ts",
"engine/engine": "src/engine/engine.ts",
"queue/queue": "src/queue/queue.ts",
"crypto-sync/crypto-sync": "src/crypto-sync/crypto-sync.ts",
"vector-clock/vector-clock":
"src/vector-clock/vector-clock.ts",
"errors/index": "src/errors/index.ts",
"otel/instruments": "src/otel/instruments.ts",
"schemas/schemas": "src/schemas/schemas.ts",
"types/types": "src/types/types.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/crdt",
/^@suaveplan\/crdt\//,
"@suaveplan/crypto-cipher",
/^@suaveplan\/crypto-cipher\//,
"@suaveplan/crypto-utils",
/^@suaveplan\/crypto-utils\//,
"@suaveplan/crypto-vault",
/^@suaveplan\/crypto-vault\//,
"@suaveplan/diff",
/^@suaveplan\/diff\//,
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,5 @@
import { createTestConfig } from "@suaveplan/testing/config";
// vault-sync ships property tests with fast-check; bump the timeout so the
// 30,000-iteration runs comfortably fit even on a cold WSL2 worker.
export default createTestConfig({ testTimeout: 30_000 });