Initial Commit
This commit is contained in:
126
packages/security-stack/security/vault-sync/README.md
Normal file
126
packages/security-stack/security/vault-sync/README.md
Normal 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
|
||||
```
|
||||
|
||||
170
packages/security-stack/security/vault-sync/package.json
Normal file
170
packages/security-stack/security/vault-sync/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# crypto-sync
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`crypto-sync.ts` provides the AEAD-encrypted envelope used by `SyncEngine`
|
||||
and `SyncQueue` to put plaintext sync payloads on the wire. Encryption is
|
||||
XChaCha20-Poly1305 from `@suaveplan/crypto-cipher` with a 24-byte nonce and
|
||||
an AAD-bound version string (`"v1"`). The version binding means a future
|
||||
revision can change the wire layout without risk that a tampered v2
|
||||
payload is silently decoded as v1. The module enforces a 10 MiB plaintext
|
||||
cap to prevent memory abuse from oversized snapshots.
|
||||
|
||||
## Features
|
||||
|
||||
- `encryptSyncPayload(plaintext, key, options?)` — XChaCha20-Poly1305 AEAD
|
||||
- `decryptSyncPayload(payload, key, options?)` — verified decrypt with AAD
|
||||
- AAD = `"v1"` || optional caller-supplied bytes
|
||||
- Random 24-byte nonce sourced via `@suaveplan/crypto-utils.randomBytes`
|
||||
- Injectable `byteSource` for deterministic test nonces
|
||||
- Hard cap of 10 MiB on plaintext (`SYNC.PAYLOAD_TOO_LARGE`)
|
||||
- Structured errors: `SYNC.PAYLOAD_TAMPERED`, `SYNC.PAYLOAD_INVALID_VERSION`
|
||||
- OTel histogram + outcome counter (no key bytes, no plaintext, no IDs)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decryptSyncPayload, encryptSyncPayload } from "@suaveplan/vault-sync";
|
||||
|
||||
const key = new Uint8Array(32); // 32 bytes — ChaCha20 key
|
||||
crypto.getRandomValues(key);
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify({ items: [] }));
|
||||
|
||||
const envelope = encryptSyncPayload(plaintext, key);
|
||||
const recovered = decryptSyncPayload(envelope, key);
|
||||
|
||||
console.log(new TextDecoder().decode(recovered));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { encryptSyncPayload } from "@suaveplan/vault-sync";
|
||||
import { decryptSyncPayload } from "@suaveplan/vault-sync";
|
||||
import { VaultSyncError } from "@suaveplan/vault-sync";
|
||||
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
|
||||
import { zeroize } from "@suaveplan/crypto-utils";
|
||||
|
||||
const key = crypto.getRandomValues(new Uint8Array(32));
|
||||
const plaintext = new TextEncoder().encode("snapshot");
|
||||
const aad = new TextEncoder().encode("vault-id-binding"); // domain separator
|
||||
|
||||
const envelope = encryptSyncPayload(plaintext, key, { additionalData: aad });
|
||||
|
||||
try {
|
||||
// Caller MUST pass the same additionalData on decrypt — otherwise the
|
||||
// tag check fails with SYNC.PAYLOAD_TAMPERED.
|
||||
const round = decryptSyncPayload(envelope, key, { additionalData: aad });
|
||||
zeroize(round); // clear plaintext when done
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof VaultSyncError &&
|
||||
err.code === VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]
|
||||
) {
|
||||
// Telemetry already recorded; trigger a fresh full pull.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encryptSyncPayload(plaintext: Uint8Array, key: Uint8Array, options?: EncryptSyncPayloadOptions): EncryptedSyncPayload`
|
||||
— encrypts with XChaCha20-Poly1305. Throws
|
||||
`VaultSyncError(SYNC.PAYLOAD_TOO_LARGE)` if `plaintext.length` exceeds
|
||||
`MAX_SYNC_PAYLOAD_BYTES`.
|
||||
- `decryptSyncPayload(payload: EncryptedSyncPayload, key: Uint8Array, options?): Uint8Array`
|
||||
— validates the envelope shape via `EncryptedSyncPayloadSchema` then
|
||||
verifies and decrypts. Throws
|
||||
`VaultSyncError(SYNC.PAYLOAD_INVALID_VERSION)` for shape failures and
|
||||
`VaultSyncError(SYNC.PAYLOAD_TAMPERED)` for tag mismatch (wrong key,
|
||||
flipped byte, mismatched AAD).
|
||||
- `EncryptSyncPayloadOptions` — `{ byteSource?, additionalData? }`.
|
||||
- `MAX_SYNC_PAYLOAD_BYTES` — `10 * 1024 * 1024` (10 MiB).
|
||||
- `SYNC_PAYLOAD_VERSION` — literal `"v1"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The package never generates a nonce internally beyond delegating to
|
||||
`randomBytes` from `@suaveplan/crypto-utils`. Callers wanting determinism
|
||||
in tests pass a custom `byteSource`. There is no notion of nonce reuse
|
||||
detection at this layer — XChaCha20's 192-bit nonce makes random
|
||||
selection collision-safe at any realistic call rate, and higher-level
|
||||
wrapping is left to the consumer if needed.
|
||||
- The plaintext returned by `decryptSyncPayload` is a fresh `Uint8Array`.
|
||||
The caller is responsible for `zeroize`-ing it after use; the module
|
||||
cannot do this safely because it does not know when the caller is done
|
||||
reading the value.
|
||||
- Both shape failures and tag failures emit `payload.outcome.total{status:
|
||||
"tampered"}` so a single counter captures the integrity-violation rate.
|
||||
- Decryption-side schema validation uses the same `EncryptedSyncPayloadSchema`
|
||||
exposed publicly, ensuring schema and runtime checks are perfectly aligned.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
132
packages/security-stack/security/vault-sync/src/engine/engine.md
Normal file
132
packages/security-stack/security/vault-sync/src/engine/engine.md
Normal 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.
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
265
packages/security-stack/security/vault-sync/src/engine/engine.ts
Normal file
265
packages/security-stack/security/vault-sync/src/engine/engine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Error code registry
|
||||
|
||||
## Purpose
|
||||
|
||||
`codes.ts` defines the canonical, frozen registry of every fully-qualified
|
||||
error code emitted by `@suaveplan/vault-sync`. Codes are stable string
|
||||
literals consumers can switch on — they are part of the public API contract.
|
||||
The registry doubles as the source of truth for the
|
||||
[`VaultSyncCode`](./crypto-vault-error.md) type union, so adding a new code
|
||||
in a single place automatically widens the type and the
|
||||
`spec-conformance.test.ts` "every code is thrown" gate must observe the new
|
||||
value before the change can ship.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for every `VAULTSYNC.*` code emitted from production
|
||||
- Frozen registry produced via `defineCodes("VAULTSYNC", ...)` from `@suaveplan/error`
|
||||
- Inferred literal-union type `VaultSyncCode` for exhaustive switch-narrowing
|
||||
- Import-time freezing — codes cannot be added or removed at runtime
|
||||
- Zero circular-dep risk: registry depends only on `@suaveplan/error`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
|
||||
|
||||
console.log(VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"]);
|
||||
// → "VAULTSYNC.MERGE.STRATEGY_UNKNOWN"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { VaultSyncError } from "@suaveplan/vault-sync";
|
||||
import { VAULT_SYNC_CODES, type VaultSyncCode } from "@suaveplan/vault-sync/errors";
|
||||
|
||||
function describeFailure(err: unknown): string {
|
||||
if (!(err instanceof VaultSyncError)) return "non-vault-sync failure";
|
||||
const code: VaultSyncCode = err.code;
|
||||
switch (code) {
|
||||
case VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"]:
|
||||
return "Unknown CRDT merge strategy";
|
||||
case VAULT_SYNC_CODES["SYNC.PAYLOAD_TAMPERED"]:
|
||||
return "Sync payload integrity check failed";
|
||||
case VAULT_SYNC_CODES["QUEUE.DEPTH_EXCEEDED"]:
|
||||
return "Offline queue at hard cap";
|
||||
default:
|
||||
// Exhaustive: TypeScript will fail if a new code is unhandled.
|
||||
return `unhandled vault-sync code: ${code as string}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VAULT_SYNC_CODES` — `Readonly<Record<reason, "VAULTSYNC.<REASON>">>`. Frozen
|
||||
via `Object.freeze`. Iterating with `Object.values(VAULT_SYNC_CODES)` yields
|
||||
every code that may be emitted from production paths.
|
||||
- `type VaultSyncCode` — the literal union derived from `typeof
|
||||
VAULT_SYNC_CODES[keyof typeof VAULT_SYNC_CODES]`. Use as the `TCode` type
|
||||
parameter of `SuaveplanError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally flat — there is no nested structure or runtime
|
||||
metadata. Metadata such as severity, retryability, and i18n message keys live
|
||||
where they are needed (the throwing call site, the i18n catalog, the OTel
|
||||
adapter), not on the code object itself.
|
||||
|
||||
Any new entry must be paired with:
|
||||
|
||||
1. A production path that throws the code under realistic conditions.
|
||||
2. A new branch in `spec-conformance.test.ts` confirming the code is reachable.
|
||||
3. An update to the codes table in the package `README.md`.
|
||||
@@ -0,0 +1,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];
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VAULT_SYNC_CODES, type VaultSyncCode } from "./codes.js";
|
||||
export { VaultSyncError } from "./vault-sync-error.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# VaultSyncError
|
||||
|
||||
## Purpose
|
||||
|
||||
`VaultSyncError` is the single concrete error class thrown from every
|
||||
production path inside `@suaveplan/vault-sync`. It extends the foundational
|
||||
`SuaveplanError` from `@suaveplan/error` and parameterises the `code` field
|
||||
on the literal union `VaultSyncCode` so consumers can switch on `err.code`
|
||||
with full TypeScript exhaustiveness. A unique brand symbol is attached at
|
||||
construction so cross-realm or duck-typed impersonators are rejected by the
|
||||
`VaultSyncError.is(value)` guard.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `SuaveplanError<VaultSyncCode>` for narrowed `code` checks
|
||||
- Brand symbol prevents prototype-chain impersonation across realms
|
||||
- `VaultSyncError.is(value)` runs both `instanceof` and brand verification
|
||||
- Carries structured `context`, `cause`, `traceId`, and `timestamp` from the base
|
||||
- Pairs with the `VAULT_SYNC_CODES` registry so every code in the union is reachable
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { VaultSyncError } from "@suaveplan/vault-sync";
|
||||
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
|
||||
|
||||
throw new VaultSyncError(
|
||||
VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"],
|
||||
"error.vault-sync.merge.strategy-unknown",
|
||||
{ context: { strategy: "smart-merge" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { VaultSyncError } from "@suaveplan/vault-sync";
|
||||
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
|
||||
|
||||
async function safeFlush(input: () => Promise<void>): Promise<{ ok: true } | { ok: false; code: string }> {
|
||||
try {
|
||||
await input();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
// Defence-in-depth: instanceof can break across worker boundaries.
|
||||
if (VaultSyncError.is(err)) {
|
||||
return { ok: false, code: err.code };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await safeFlush(async () => {
|
||||
throw new VaultSyncError(
|
||||
VAULT_SYNC_CODES["QUEUE.DEPTH_EXCEEDED"],
|
||||
"error.vault-sync.queue.depth-exceeded",
|
||||
{ context: { depth: 10_001, limit: 10_000 } }
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class VaultSyncError extends SuaveplanError<VaultSyncCode>` — concrete
|
||||
error type for every failure inside `vault-sync`.
|
||||
- `constructor(code: VaultSyncCode, messageKey: string, options?: SuaveplanErrorOptions)`
|
||||
— `messageKey` is forwarded to the i18n resolver. `options.context` may
|
||||
carry safe-by-construction fields (no key bytes, no plaintext, no item IDs).
|
||||
- `static is(value: unknown): value is VaultSyncError` — narrow guard that
|
||||
verifies both `instanceof` and the unique brand symbol attached at
|
||||
construction. Use whenever errors might cross `iframe`, `Worker`, or
|
||||
serialization boundaries.
|
||||
- Inherited fields: `code`, `message`, `context`, `cause`, `timestamp`,
|
||||
`traceId`. See `@suaveplan/error` for full semantics.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The brand symbol is module-private — a third party cannot construct a
|
||||
duck-typed value that satisfies `VaultSyncError.is`. Subclasses are
|
||||
discouraged: if a more specific error is needed, attach metadata via
|
||||
`options.context` rather than introducing a new class. Centralising on a
|
||||
single throwable simplifies dependent-package error handling, reduces
|
||||
`instanceof` chains, and keeps the public API surface minimal. All thrown
|
||||
errors flow through this class — no plain `Error`, no anonymous throws.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
80
packages/security-stack/security/vault-sync/src/index.ts
Normal file
80
packages/security-stack/security/vault-sync/src/index.ts
Normal 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";
|
||||
109
packages/security-stack/security/vault-sync/src/merge/merge.md
Normal file
109
packages/security-stack/security/vault-sync/src/merge/merge.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
BIN
packages/security-stack/security/vault-sync/src/merge/merge.ts
Normal file
BIN
packages/security-stack/security/vault-sync/src/merge/merge.ts
Normal file
Binary file not shown.
@@ -0,0 +1,134 @@
|
||||
# otel instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`instruments.ts` exposes the safe-by-construction OTel instruments emitted
|
||||
from every `@suaveplan/vault-sync` production path. The instrument set is
|
||||
deliberately narrow: only durations, outcome counters, and a queue-depth
|
||||
gauge — no vault ids, no item ids, no key material, no vector-clock
|
||||
values. Counters carry only a `status` attribute drawn from a fixed
|
||||
allowlist (`success | conflict | rejected`, `encrypted | decrypted |
|
||||
tampered`). The lazy singleton ensures the meter is acquired exactly once
|
||||
per process; tests override the provider via `setVaultSyncMeterProvider`
|
||||
to capture instrument calls deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- `getVaultSyncInstruments()` — lazy singleton accessor for the meter set
|
||||
- `setVaultSyncMeterProvider(provider)` — inject test providers
|
||||
- `resetVaultSyncInstruments()` — clear cache between test suites
|
||||
- `getVaultSyncTracer()` / `setVaultSyncTracerProvider()` — span tracer access
|
||||
- `resetVaultSyncTracer()` — clear tracer cache between suites
|
||||
- Histogram trio for `sync`, `merge`, `patch.apply`, `payload.encrypt`,
|
||||
`queue.flush` durations
|
||||
- Observable gauge for queue depth (count only, no item identifiers)
|
||||
- Outcome counters with bounded-string labels (no PII)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getVaultSyncInstruments } from "@suaveplan/vault-sync/otel";
|
||||
|
||||
const i = getVaultSyncInstruments();
|
||||
const start = Date.now();
|
||||
// ... work ...
|
||||
i.syncDurationMs.record(Date.now() - start);
|
||||
i.mergeOutcomeTotal.add(1, { status: "success" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getVaultSyncInstruments,
|
||||
resetVaultSyncInstruments,
|
||||
setVaultSyncMeterProvider,
|
||||
} from "@suaveplan/vault-sync/otel";
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
|
||||
interface Capture {
|
||||
histograms: Map<string, number[]>;
|
||||
counters: Map<string, Array<[number, Record<string, string> | undefined]>>;
|
||||
}
|
||||
|
||||
function makeProvider(): { provider: MeterProvider; capture: Capture } {
|
||||
const capture: Capture = {
|
||||
histograms: new Map(),
|
||||
counters: new Map(),
|
||||
};
|
||||
const meter = {
|
||||
createHistogram: (name: string) => {
|
||||
capture.histograms.set(name, []);
|
||||
return { record: (v: number) => capture.histograms.get(name)?.push(v) };
|
||||
},
|
||||
createCounter: (name: string) => {
|
||||
capture.counters.set(name, []);
|
||||
return {
|
||||
add: (v: number, attrs?: Record<string, string>) => {
|
||||
capture.counters.get(name)?.push([v, attrs]);
|
||||
},
|
||||
};
|
||||
},
|
||||
createObservableGauge: (name: string) => ({
|
||||
addCallback: () => {
|
||||
capture.histograms.set(name, []);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return {
|
||||
provider: {
|
||||
getMeter: () => meter as unknown as ReturnType<MeterProvider["getMeter"]>,
|
||||
} as unknown as MeterProvider,
|
||||
capture,
|
||||
};
|
||||
}
|
||||
|
||||
const { provider, capture } = makeProvider();
|
||||
setVaultSyncMeterProvider(provider);
|
||||
const i = getVaultSyncInstruments();
|
||||
i.syncDurationMs.record(42);
|
||||
console.log(capture.histograms.get("vault_sync.sync.duration_ms")); // [42]
|
||||
|
||||
setVaultSyncMeterProvider(undefined);
|
||||
resetVaultSyncInstruments();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getVaultSyncInstruments(): VaultSyncInstruments` — lazily resolves the
|
||||
meter and caches the instrument bundle. Returns the cached value on
|
||||
subsequent calls.
|
||||
- `setVaultSyncMeterProvider(provider: MeterProvider | undefined): void` —
|
||||
rebuilds the cache against the provided meter; pass `undefined` to fall
|
||||
back to the global default.
|
||||
- `resetVaultSyncInstruments(): void` — clears the cached bundle so the
|
||||
next `get` call rebinds to the (possibly new) global meter.
|
||||
- `getVaultSyncTracer(): Tracer` — lazy tracer singleton.
|
||||
- `setVaultSyncTracerProvider(provider): void` — inject test tracer.
|
||||
- `resetVaultSyncTracer(): void` — clear tracer cache.
|
||||
|
||||
Instrument names:
|
||||
|
||||
- `vault_sync.sync.duration_ms` (Histogram, ms)
|
||||
- `vault_sync.merge.duration_ms` (Histogram, ms)
|
||||
- `vault_sync.patch.apply.duration_ms` (Histogram, ms)
|
||||
- `vault_sync.payload.encrypt.duration_ms` (Histogram, ms)
|
||||
- `vault_sync.queue.flush.duration_ms` (Histogram, ms)
|
||||
- `vault_sync.queue.depth` (ObservableGauge, count)
|
||||
- `vault_sync.merge.outcome.total` (Counter, status ∈ {success, conflict, rejected})
|
||||
- `vault_sync.payload.outcome.total` (Counter, status ∈ {encrypted, decrypted, tampered})
|
||||
|
||||
Span names emitted by the consuming modules: `sync.pull`, `sync.merge`,
|
||||
`sync.push`, `queue.flush`, `payload.encrypt`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy singleton lets the package be imported even when no OTel SDK is
|
||||
registered — `metrics.getMeter` returns a no-op meter in that case, and
|
||||
recording through it is a fast inline call. Counters never carry vault or
|
||||
item identifiers; the only label is `status` drawn from a documented
|
||||
allowlist. This is enforced by code review — adding a new label requires
|
||||
an OpenSpec amendment to the observability plan in the package proposal.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
108
packages/security-stack/security/vault-sync/src/patch/patch.md
Normal file
108
packages/security-stack/security/vault-sync/src/patch/patch.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
118
packages/security-stack/security/vault-sync/src/queue/queue.md
Normal file
118
packages/security-stack/security/vault-sync/src/queue/queue.md
Normal 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.
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
299
packages/security-stack/security/vault-sync/src/queue/queue.ts
Normal file
299
packages/security-stack/security/vault-sync/src/queue/queue.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`schemas.ts` provides Zod runtime validators for every public boundary type
|
||||
declared in `types.ts`. The validators are the contract enforced at API
|
||||
entry and exit: a `VaultItem` reaching `mergeVaultItems`, a `SyncOperation`
|
||||
queued by `SyncQueue.enqueue`, an `EncryptedSyncPayload` decoded by
|
||||
`decryptSyncPayload` — all are first parsed by the appropriate schema so a
|
||||
malformed input fails fast with a structured error rather than producing a
|
||||
hard-to-debug shape mismatch later in the pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- `VectorClockSchema` — `Record<string, non-negative-int>`
|
||||
- `VaultHistoryEntrySchema` — `{ historyId; timestamp; note }`, strict
|
||||
- `VaultItemSchema` — full canonical vault-item shape, strict
|
||||
- `MergeStrategySchema` — `"lww" | "append" | "atomic"`
|
||||
- `SyncOperationKindSchema` — `"create" | "update" | "delete"`
|
||||
- `SyncOperationSchema` — durable-queue entry shape with partial `fields`
|
||||
- `VaultSnapshotSchema` — `{ items; clock; snapshotAt }`, strict
|
||||
- `EncryptedSyncPayloadSchema` — version + 24-byte nonce + ciphertext
|
||||
- All schemas are `.strict()` — extra keys raise an error
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { VaultItemSchema } from "@suaveplan/vault-sync/schemas/schemas";
|
||||
|
||||
const parsed = VaultItemSchema.safeParse({
|
||||
id: "i1",
|
||||
title: "Email",
|
||||
password: "hunter2",
|
||||
folderId: null,
|
||||
history: [],
|
||||
nodeId: "n1",
|
||||
updatedAt: 0,
|
||||
clock: { n1: 1 },
|
||||
});
|
||||
if (!parsed.success) throw new Error("invalid item");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
EncryptedSyncPayloadSchema,
|
||||
SyncOperationSchema,
|
||||
VaultSnapshotSchema,
|
||||
} from "@suaveplan/vault-sync/schemas/schemas";
|
||||
import { VaultSyncError } from "@suaveplan/vault-sync";
|
||||
import { VAULT_SYNC_CODES } from "@suaveplan/vault-sync/errors";
|
||||
|
||||
function asPayload(input: unknown): import("@suaveplan/vault-sync/types").EncryptedSyncPayload {
|
||||
const parsed = EncryptedSyncPayloadSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new VaultSyncError(
|
||||
VAULT_SYNC_CODES["SYNC.PAYLOAD_INVALID_VERSION"],
|
||||
"error.vault-sync.sync.payload-invalid-version",
|
||||
{ context: { issues: parsed.error.issues.length } }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
declare const wireSnapshot: unknown;
|
||||
declare const wireOperation: unknown;
|
||||
const snapshot = VaultSnapshotSchema.parse(wireSnapshot);
|
||||
const operation = SyncOperationSchema.parse(wireOperation);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `VectorClockSchema: ZodSchema<Record<string, number>>` — keys must be
|
||||
non-empty strings; values must be non-negative integers.
|
||||
- `VaultHistoryEntrySchema: ZodSchema<VaultHistoryEntry>` — strict.
|
||||
- `VaultItemSchema: ZodSchema<VaultItem>` — strict; `folderId` is `string |
|
||||
null`; `history` is an array; `clock` validated by `VectorClockSchema`.
|
||||
- `MergeStrategySchema: ZodUnion<[lww, append, atomic]>`.
|
||||
- `SyncOperationKindSchema: ZodUnion<[create, update, delete]>`.
|
||||
- `SyncOperationSchema: ZodSchema<SyncOperation>` — strict; `fields` validated
|
||||
with `VaultItemSchema.partial()` so any subset of vault fields is acceptable.
|
||||
- `VaultSnapshotSchema: ZodSchema<VaultSnapshot>` — strict.
|
||||
- `EncryptedSyncPayloadSchema: ZodSchema<EncryptedSyncPayload>` — `version`
|
||||
must equal `"v1"`; `nonce` must be exactly 24 bytes (XChaCha20).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Strict mode (`.strict()`) is intentional — `vault-sync` is a wire
|
||||
boundary, and silently accepting unknown fields opens a downgrade attack
|
||||
where a malicious peer adds a future-version field that gets ignored. The
|
||||
`nonce` byte-length check is duplicated (also enforced by
|
||||
`crypto-cipher`'s XChaCha primitive) so callers parsing a payload without
|
||||
attempting to decrypt still see a clear schema-level failure. All schemas
|
||||
are pure values — they take no constructor arguments, they have no shared
|
||||
state, and they are safe to use in any execution context.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
124
packages/security-stack/security/vault-sync/src/types/types.md
Normal file
124
packages/security-stack/security/vault-sync/src/types/types.md
Normal 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.
|
||||
102
packages/security-stack/security/vault-sync/src/types/types.ts
Normal file
102
packages/security-stack/security/vault-sync/src/types/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
64
packages/security-stack/security/vault-sync/vite.config.ts
Normal file
64
packages/security-stack/security/vault-sync/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
Reference in New Issue
Block a user