Initial Commit

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

View File

@@ -0,0 +1,57 @@
# vault items helpers
## Purpose
`items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with on-the-fly concealed-field decryption), `applyPatch` to merge a partial `ItemFields` into an existing row, and `generateItemId` to produce a hex-encoded random 16-byte id. Keeping these helpers in their own file lets the main `items.ts` stay under the 300-line cap while preserving readability.
The helpers are **pure** with respect to the vault state — they take inputs (rows / vault key / rng) and return new values without touching the container's `Map<string, VaultItemRow>`. The `VaultItems` class wires them together, applies them inside the `state.items.set(...)` mutation, and emits the OTel outcome counters.
## Features
- `rowToItem(row, vaultKey)` — project a row back to a `VaultItem`, lazily decrypting concealed envelopes.
- `applyPatch(row, patch, vaultKey, rng)` — merge a `Partial<ItemFields>` into an existing row; re-encrypts concealed-field updates under the per-item key.
- `generateItemId(rng)` — 32-char hex string from 16 random bytes; falls back to `crypto.getRandomValues` when no `rng` is injected.
- All helpers are stateless and side-effect-free except for the per-call key derivation done inside `encryptConcealedFields` / `decryptConcealedFields`.
## Usage: Basic Example
```ts
import { rowToItem } from "@suaveplan/vault/items/items";
const row = state.items.get(id);
if (row !== undefined) {
const item = rowToItem(row, vaultKey);
console.log(item.concealedFields.password);
}
```
## Usage: Advanced Example
```ts
import { applyPatch, generateItemId } from "@suaveplan/vault/items/items";
const row = state.items.get(id);
if (row === undefined) throw new Error("missing");
const patched = applyPatch(
row,
{ title: "Renamed", concealedFields: { password: "new" } },
vaultKey,
rng
);
state.items.set(id, { ...patched, version: row.version + 1 });
const fresh = generateItemId(rng); // hex-encoded id
```
## API Reference
- `rowToItem(row: VaultItemRow, vaultKey: Uint8Array): VaultItem`.
- `applyPatch(row: VaultItemRow, patch: Partial<ItemFields>, vaultKey: Uint8Array, rng: Rng | undefined): VaultItemRow`.
- `generateItemId(rng: Rng | undefined): string`.
## Implementation Notes
- `rowToItem` re-decrypts concealed envelopes on every call. Callers that read the same row repeatedly are responsible for caching the result; the items repository chooses lazy decryption to keep the typical-read path cheap when only metadata is touched.
- `applyPatch` returns a fresh row object. Callers must not mutate the returned row before storing it; the items repository takes ownership.
- `generateItemId` uses 128 bits of entropy — well above the birthday-collision bound for any vault size. The hex encoding is chosen for stable JSON serialisation across runtimes.
- The injected `rng` flows through `encryptConcealedFields` so per-field nonces are sourced from a deterministic stream in tests.

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encryptConcealedFields } from "../encryption/field-encryption.js";
import { applyPatch, generateItemId, rowToItem } from "./helpers.js";
import type { VaultItemRow } from "./migrations/migrations.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
function makeRow(over: Partial<VaultItemRow> = {}): VaultItemRow {
const key = vaultKey();
const id = "i1";
return {
id,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
title: "GitHub",
category: "login",
fieldsJson: JSON.stringify({ username: "u" }),
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, id, { password: "p" })
),
tagsJson: JSON.stringify(["work"]),
favorite: false,
...over,
};
}
describe("items helpers", () => {
it("rowToItem decrypts concealed fields", { timeout: 5000 }, () => {
const key = vaultKey();
const row = makeRow({
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, "i1", { password: "p" })
),
});
const item = rowToItem(row, key);
expect(item.title).toBe("GitHub");
expect(item.concealedFields.password).toBe("p");
expect(item.fields.username).toBe("u");
expect(item.tags).toEqual(["work"]);
});
it("applyPatch updates each field independently", { timeout: 5000 }, () => {
const key = vaultKey();
const row = makeRow({
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, "i1", { password: "p" })
),
});
const patched = applyPatch(
row,
{
title: "Renamed",
favorite: true,
fields: { url: "x" },
tags: ["new"],
},
key,
undefined
);
expect(patched.title).toBe("Renamed");
expect(patched.favorite).toBe(true);
expect(JSON.parse(patched.fieldsJson)).toEqual({ url: "x" });
expect(JSON.parse(patched.tagsJson)).toEqual(["new"]);
});
it("applyPatch re-encrypts concealed fields on update", {
timeout: 5000,
}, () => {
const key = vaultKey();
const row = makeRow({
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(key, "i1", { password: "old" })
),
});
const patched = applyPatch(
row,
{ concealedFields: { password: "new" } },
key,
undefined
);
const recovered = rowToItem(patched, key);
expect(recovered.concealedFields.password).toBe("new");
});
it("applyPatch updates category when provided", { timeout: 5000 }, () => {
const key = vaultKey();
const row = makeRow();
const patched = applyPatch(
row,
{ category: "secure-note" },
key,
undefined
);
expect(patched.category).toBe("secure-note");
});
it("generateItemId produces 32-character hex strings", {
timeout: 5000,
}, () => {
const id = generateItemId(undefined);
expect(id).toMatch(/^[0-9a-f]{32}$/);
});
it("generateItemId uses the injected RNG when provided", {
timeout: 5000,
}, () => {
const rng = {
nextUint32: () => 1,
nextFloat: () => 0,
nextBytes: (len: number) => new Uint8Array(len).fill(0xab),
};
const id = generateItemId(rng);
expect(id).toBe("ab".repeat(16));
});
});

View File

@@ -0,0 +1,74 @@
import type { Rng } from "@suaveplan/types";
import {
decryptConcealedFields,
type EncryptedField,
encryptConcealedFields,
} from "../encryption/field-encryption.js";
import type { ItemFields, VaultItem } from "../types/types.js";
import type { VaultItemRow } from "./migrations/migrations.js";
/**
* Project a persisted `VaultItemRow` back into a `VaultItem`, lazily
* decrypting the concealed-field envelopes under the vault key.
*/
export function rowToItem(row: VaultItemRow, vaultKey: Uint8Array): VaultItem {
const fields = JSON.parse(row.fieldsJson) as Record<string, string>;
const tags = JSON.parse(row.tagsJson) as string[];
const envelopes = JSON.parse(row.concealedFieldsJson) as Record<
string,
EncryptedField
>;
const concealed = decryptConcealedFields(vaultKey, row.id, envelopes);
return {
id: row.id,
title: row.title,
category: row.category,
fields,
concealedFields: concealed,
tags,
favorite: row.favorite,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
version: row.version,
deleted: row.deleted,
};
}
/**
* Apply a partial-`ItemFields` patch to an existing row. Concealed-field
* patches are re-encrypted under the per-item key.
*/
export function applyPatch(
row: VaultItemRow,
patch: Partial<ItemFields>,
vaultKey: Uint8Array,
rng: Rng | undefined
): VaultItemRow {
const next: VaultItemRow = { ...row };
if (patch.title !== undefined) next.title = patch.title;
if (patch.category !== undefined) next.category = patch.category;
if (patch.favorite !== undefined) next.favorite = patch.favorite;
if (patch.fields !== undefined)
next.fieldsJson = JSON.stringify(patch.fields);
if (patch.tags !== undefined) next.tagsJson = JSON.stringify(patch.tags);
if (patch.concealedFields !== undefined) {
next.concealedFieldsJson = JSON.stringify(
encryptConcealedFields(vaultKey, row.id, patch.concealedFields, rng)
);
}
return next;
}
/** Generate a hex-encoded 16-byte random id, sourced from `rng` if provided. */
export function generateItemId(rng: Rng | undefined): string {
const bytes = rng?.nextBytes?.(16) ?? defaultRandomBytes(16);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function defaultRandomBytes(len: number): Uint8Array {
const out = new Uint8Array(len);
crypto.getRandomValues(out);
return out;
}

View File

@@ -0,0 +1,99 @@
# vault items
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodule…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodul…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`items.ts` exposes the `VaultItems` class, the in-memory CRUD repository over the vault container's item map. It is the boundary between the public `vault.items` surface and the underlying state — every public method validates input via Zod, mutates the container `Map<string, VaultItemRow>`, appends an audit entry to the container history, encrypts concealed-field values per item, and notifies the orchestrator that a flush is due.
The repository operates on the in-memory state loaded from the encrypted container blob. It does NOT touch drive directly; persistence is the orchestrator's job (debounced flush + on lock). Concealed-field values (passwords / PINs / secure notes) are encrypted with per-item keys derived from the vault key, so even a leaked container snapshot keeps concealed values as ciphertext until decrypted on demand by `rowToItem`.
## Features
- `create(fields) → VaultItem` — Zod-validated insert with concealed-field encryption.
- `get(id) → VaultItem | null` — lazy decryption; returns null for missing or tombstoned ids.
- `update(id, patch, expectedVersion?) → VaultItem` — optimistic concurrency via `version`; throws `VAULT.ITEM.VERSION_CONFLICT` on stale writes.
- `delete(id)` — tombstone (not physical delete) so sync can propagate the deletion.
- `list(options?) → VaultItem[]` — filters by category / favorite, with offset / limit pagination.
- `allRows()` — snapshot of non-deleted rows for the search index.
- Every operation records a histogram sample and an outcome counter; OTel attributes are limited to `{ op, status }` — no item ids, no field names, no plaintext.
- Mutations call `onMutation()` so the orchestrator can debounce flushes.
## Usage: Basic Example
```ts
import { VaultItems } from "@suaveplan/vault/items/items";
const items = new VaultItems({
state,
vaultKey,
clock: Date.now,
rng: undefined,
onMutation: () => scheduleFlush(),
});
const created = items.create({
title: "GitHub",
category: "login",
fields: { username: "alice", url: "https://github.com" },
concealedFields: { password: "p@ssw0rd" },
tags: ["work"],
favorite: true,
});
```
## Usage: Advanced Example
```ts
import { VaultItems } from "@suaveplan/vault/items/items";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
// Optimistic concurrency: capture version, retry on conflict.
function updateWithRetry(items: VaultItems, id: string, patch: Partial<ItemFields>) {
for (let attempt = 0; attempt < 3; attempt++) {
const current = items.get(id);
if (current === null) throw new Error("missing");
try {
return items.update(id, patch, current.version);
} catch (err) {
if (
VaultError.is(err) &&
err.code === VAULT_CODES["ITEM.VERSION_CONFLICT"] &&
attempt < 2
) continue;
throw err;
}
}
}
```
## API Reference
- `class VaultItems` — public surface for `vault.items`.
- `constructor(options: ItemsOptions)`.
- `create(fields: ItemFields): VaultItem` — throws `ITEM.VALIDATION_FAILED`.
- `get(id: string): VaultItem | null`.
- `update(id: string, patch: Partial<ItemFields>, expectedVersion?: number): VaultItem` — throws `ITEM.NOT_FOUND` / `ITEM.VERSION_CONFLICT` / `ITEM.VALIDATION_FAILED`.
- `delete(id: string): void` — throws `ITEM.NOT_FOUND`.
- `list(options?: ListOptions): VaultItem[]` — throws `ITEM.VALIDATION_FAILED`.
- `allRows(): VaultItemRow[]` — read-only snapshot for search.
- `interface ItemsOptions` — `{ state, vaultKey, clock, rng, onMutation }`.
## Implementation Notes
- The repository is **stateful** — it holds references to the container state map, the vault key, and the orchestrator's mutation callback. The vault layer creates a new `VaultItems` on every unlock; the state map is replaced when the container reloads from drive.
- `update` always increments `version` by 1 per successful call. Concurrent writers using `expectedVersion` will see exactly one winner; the others receive `VAULT.ITEM.VERSION_CONFLICT`.
- `delete` is a tombstone — the row stays in the map with `deleted: true` so the sync layer can propagate the deletion. A future garbage-collection pass (out of scope for this version) would prune tombstones older than a retention window.
- The history log is append-only: every successful `create` / `update` / `delete` appends one row with `(itemId, version, timestamp, operation)`. This feeds the audit-trail and CRDT merge paths.
- `list` walks the map insertion order — the same order in which rows were created or last updated. Callers that need stable cross-process ordering should sort by `createdAt` or `updatedAt` after fetch.
- The OTel `outcome` counter uses a fixed allowlist (`success | not_found | conflict | rejected`) — adding a new value requires an OpenSpec change in lockstep with `getVaultInstruments`.

View File

@@ -0,0 +1,261 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { VaultItems } from "./items.js";
import {
emptyContainerState,
runMigrations,
type VaultContainerState,
} from "./migrations/migrations.js";
function vaultKey(): Uint8Array {
const key = new Uint8Array(32);
crypto.getRandomValues(key);
return key;
}
function freshState(): VaultContainerState {
const state = emptyContainerState();
runMigrations(state, undefined, () => 0);
return state;
}
function makeItems(): {
items: VaultItems;
state: VaultContainerState;
flushes: number;
} {
const state = freshState();
let now = 1_000;
let flushes = 0;
const items = new VaultItems({
state,
vaultKey: vaultKey(),
clock: () => now++,
rng: undefined,
onMutation: () => {
flushes += 1;
},
});
return {
items,
state,
get flushes() {
return flushes;
},
} as { items: VaultItems; state: VaultContainerState; flushes: number };
}
const sample = {
title: "GitHub",
category: "login",
fields: { username: "u", url: "https://github.com" },
concealedFields: { password: "secret" },
tags: ["work"],
favorite: false,
};
describe("VaultItems CRUD", () => {
it("create assigns id, version=1, and timestamps", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
expect(typeof created.id).toBe("string");
expect(created.version).toBe(1);
expect(created.deleted).toBe(false);
expect(created.concealedFields.password).toBe("secret");
});
it("create rejects malformed inputs with ITEM.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
const { items } = makeItems();
try {
items.create({ ...sample, title: "" });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("get returns the created item by id", { timeout: 5000 }, () => {
const { items } = makeItems();
const created = items.create(sample);
const fetched = items.get(created.id);
expect(fetched).not.toBeNull();
expect(fetched?.title).toBe("GitHub");
expect(fetched?.concealedFields.password).toBe("secret");
});
it("get returns null for missing ids", { timeout: 5000 }, () => {
const { items } = makeItems();
expect(items.get("missing")).toBeNull();
});
it("update increments version and applies the patch", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
const updated = items.update(created.id, { title: "Renamed" });
expect(updated.version).toBe(2);
expect(updated.title).toBe("Renamed");
});
it("update with stale expectedVersion throws VERSION_CONFLICT", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
items.update(created.id, { title: "v2" });
try {
items.update(created.id, { title: "v3" }, created.version);
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VERSION_CONFLICT"]
);
}
});
it("update rejects malformed patches with ITEM.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
try {
items.update(created.id, { title: "" });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("update on missing id throws ITEM.NOT_FOUND", { timeout: 5000 }, () => {
const { items } = makeItems();
try {
items.update("missing", { title: "x" });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.NOT_FOUND"]
);
}
});
it("delete tombstones the item", { timeout: 5000 }, () => {
const { items } = makeItems();
const created = items.create(sample);
items.delete(created.id);
expect(items.get(created.id)).toBeNull();
});
it("delete on missing id throws ITEM.NOT_FOUND", { timeout: 5000 }, () => {
const { items } = makeItems();
try {
items.delete("missing");
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.NOT_FOUND"]
);
}
});
it("list returns non-deleted items with pagination and filters", {
timeout: 5000,
}, () => {
const { items } = makeItems();
items.create({ ...sample, title: "a" });
items.create({ ...sample, title: "b", category: "note" });
const c = items.create({ ...sample, title: "c", favorite: true });
items.delete(c.id);
const all = items.list();
expect(all).toHaveLength(2);
const logins = items.list({ category: "login" });
expect(logins).toHaveLength(1);
const favorites = items.list({ favorite: true });
expect(favorites).toHaveLength(0);
});
it("list respects limit and offset", { timeout: 5000 }, () => {
const { items } = makeItems();
for (let i = 0; i < 5; i++) items.create({ ...sample, title: `t${i}` });
const page = items.list({ limit: 2, offset: 1 });
expect(page).toHaveLength(2);
});
it("list rejects negative offset", { timeout: 5000 }, () => {
const { items } = makeItems();
try {
items.list({ offset: -1 });
throw new Error("should have thrown");
} catch (err) {
expect(VaultError.is(err)).toBe(true);
expect((err as VaultError).code).toBe(
VAULT_CODES["ITEM.VALIDATION_FAILED"]
);
}
});
it("allRows omits tombstoned rows", { timeout: 5000 }, () => {
const { items } = makeItems();
const a = items.create({ ...sample, title: "a" });
const b = items.create({ ...sample, title: "b" });
items.delete(a.id);
const rows = items.allRows();
expect(rows.length).toBe(1);
expect(rows[0]?.id).toBe(b.id);
});
it("create + update + delete each notify the orchestrator", {
timeout: 5000,
}, () => {
const state = freshState();
let notifications = 0;
const items = new VaultItems({
state,
vaultKey: vaultKey(),
clock: () => 0,
rng: undefined,
onMutation: () => {
notifications += 1;
},
});
const created = items.create(sample);
items.update(created.id, { title: "x" });
items.delete(created.id);
expect(notifications).toBe(3);
});
it("get on tombstoned id returns null but does not throw", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
items.delete(created.id);
expect(items.get(created.id)).toBeNull();
});
it("update with matching expectedVersion succeeds", {
timeout: 5000,
}, () => {
const { items } = makeItems();
const created = items.create(sample);
const ok = items.update(created.id, { title: "x" }, created.version);
expect(ok.title).toBe("x");
});
});

View File

@@ -0,0 +1,255 @@
import type { Clock, Rng } from "@suaveplan/types";
import { encryptConcealedFields } from "../encryption/field-encryption.js";
import { VAULT_CODES } from "../errors/codes.js";
import { VaultError } from "../errors/vault-error.js";
import { getVaultInstruments } from "../otel/instruments.js";
import {
ItemFieldsSchema,
ListOptionsSchema,
PartialItemFieldsSchema,
} from "../schemas/schemas.js";
import type { ItemFields, ListOptions, VaultItem } from "../types/types.js";
import { applyPatch, generateItemId, rowToItem } from "./helpers.js";
import type {
VaultContainerState,
VaultItemRow,
} from "./migrations/migrations.js";
/** Construction options for the items repository. */
export interface ItemsOptions {
readonly state: VaultContainerState;
readonly vaultKey: Uint8Array;
readonly clock: Clock;
readonly rng: Rng | undefined;
/** Called whenever a mutation happens — used by the orchestrator to
* schedule a debounced flush. */
readonly onMutation: () => void;
}
type ItemOp = "create" | "get" | "update" | "delete" | "list";
type ItemStatus = "success" | "not_found" | "conflict" | "rejected";
/**
* In-memory items repository. Stores plaintext (`fields`) alongside
* encrypted concealed-field envelopes inside `VaultItemRow`s. Reads
* lazily decrypt concealed fields on demand via `rowToItem`.
*/
export class VaultItems {
private readonly state: VaultContainerState;
private readonly vaultKey: Uint8Array;
private readonly clock: Clock;
private readonly rng: Rng | undefined;
private readonly notifyMutation: () => void;
public constructor(options: ItemsOptions) {
this.state = options.state;
this.vaultKey = options.vaultKey;
this.clock = options.clock;
this.rng = options.rng;
this.notifyMutation = options.onMutation;
}
/** Create a new vault item with encrypted concealed fields. */
public create(fields: ItemFields): VaultItem {
const start = this.clock();
const parsed = ItemFieldsSchema.safeParse(fields);
if (!parsed.success) {
this.outcome("create", "rejected", start);
throw this.validationError(parsed.error.issues.length);
}
const id = generateItemId(this.rng);
const now = this.clock();
const row: VaultItemRow = {
id,
createdAt: now,
updatedAt: now,
version: 1,
deleted: false,
title: parsed.data.title,
category: parsed.data.category,
fieldsJson: JSON.stringify(parsed.data.fields),
concealedFieldsJson: JSON.stringify(
encryptConcealedFields(
this.vaultKey,
id,
parsed.data.concealedFields,
this.rng
)
),
tagsJson: JSON.stringify(parsed.data.tags),
favorite: parsed.data.favorite,
};
this.state.items.set(id, row);
this.state.history.push({
itemId: id,
version: 1,
timestamp: now,
operation: "create",
});
this.notifyMutation();
this.outcome("create", "success", start);
return rowToItem(row, this.vaultKey);
}
/** Read a single item by id. Returns null when missing or tombstoned. */
public get(id: string): VaultItem | null {
const start = this.clock();
const row = this.state.items.get(id);
if (row === undefined || row.deleted) {
this.outcome(
"get",
row === undefined ? "not_found" : "success",
start
);
return null;
}
const item = rowToItem(row, this.vaultKey);
this.outcome("get", "success", start);
return item;
}
/**
* Update an item. `expectedVersion` enables optimistic concurrency —
* pass the version returned by the previous read; the call rejects
* with `VAULT.ITEM.VERSION_CONFLICT` if the row has advanced.
*/
public update(
id: string,
patch: Partial<ItemFields>,
expectedVersion?: number
): VaultItem {
const start = this.clock();
const row = this.requireRow(id, "update", start);
if (expectedVersion !== undefined && expectedVersion !== row.version) {
this.outcome("update", "conflict", start);
throw new VaultError(
VAULT_CODES["ITEM.VERSION_CONFLICT"],
"error.vault.item.version-conflict",
{
context: {
expected: expectedVersion,
actual: row.version,
},
}
);
}
const parsed = PartialItemFieldsSchema.safeParse(patch);
if (!parsed.success) {
this.outcome("update", "rejected", start);
throw this.validationError(parsed.error.issues.length);
}
const merged = applyPatch(
row,
parsed.data as Partial<ItemFields>,
this.vaultKey,
this.rng
);
const now = this.clock();
const updatedRow: VaultItemRow = {
...merged,
updatedAt: now,
version: row.version + 1,
};
this.state.items.set(id, updatedRow);
this.state.history.push({
itemId: id,
version: updatedRow.version,
timestamp: now,
operation: "update",
});
this.notifyMutation();
this.outcome("update", "success", start);
return rowToItem(updatedRow, this.vaultKey);
}
/**
* Tombstone an item. The row remains in the container so sync can
* propagate the deletion; subsequent `get` returns null.
*/
public delete(id: string): void {
const start = this.clock();
const row = this.requireRow(id, "delete", start);
const now = this.clock();
this.state.items.set(id, {
...row,
deleted: true,
updatedAt: now,
version: row.version + 1,
});
this.state.history.push({
itemId: id,
version: row.version + 1,
timestamp: now,
operation: "delete",
});
this.notifyMutation();
this.outcome("delete", "success", start);
}
/** List items in insertion order with optional filters and pagination. */
public list(options?: ListOptions): VaultItem[] {
const start = this.clock();
const parsed = ListOptionsSchema.safeParse(options ?? {});
if (!parsed.success) {
this.outcome("list", "rejected", start);
throw this.validationError(parsed.error.issues.length);
}
const opts = parsed.data;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
const out: VaultItem[] = [];
let skipped = 0;
for (const row of this.state.items.values()) {
if (row.deleted) continue;
if (opts.category !== undefined && row.category !== opts.category)
continue;
if (opts.favorite !== undefined && row.favorite !== opts.favorite)
continue;
if (skipped < offset) {
skipped += 1;
continue;
}
out.push(rowToItem(row, this.vaultKey));
if (out.length >= limit) break;
}
this.outcome("list", "success", start);
return out;
}
/**
* Snapshot of every non-deleted row, exposed for the search module so
* it can build an index without re-decrypting concealed fields.
*/
public allRows(): VaultItemRow[] {
return Array.from(this.state.items.values()).filter(
(row) => !row.deleted
);
}
private requireRow(id: string, op: ItemOp, start: number): VaultItemRow {
const row = this.state.items.get(id);
if (row === undefined || row.deleted) {
this.outcome(op, "not_found", start);
throw new VaultError(
VAULT_CODES["ITEM.NOT_FOUND"],
"error.vault.item.not-found",
{ context: { id } }
);
}
return row;
}
private validationError(issues: number): VaultError {
return new VaultError(
VAULT_CODES["ITEM.VALIDATION_FAILED"],
"error.vault.item.validation-failed",
{ context: { issues } }
);
}
private outcome(op: ItemOp, status: ItemStatus, startedAt: number): void {
const i = getVaultInstruments();
i.itemOpDurationMs.record(this.clock() - startedAt, { op });
i.itemOutcomeTotal.add(1, { op, status });
}
}

View File

@@ -0,0 +1,80 @@
# vault item migrations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`migrations.ts` is the forward-only schema-migration ledger for the in-memory vault container. The container holds a `schemaVersion`, an item map, a history log, and a ledger of applied migrations. Migrations are described by `{ version, name, up(state) }`; running them advances the container state from one schema version to the next while recording the step in the ledger.
The model is deliberately schema-light: the container state is plain JS data structures, not a SQL database. This keeps the package vitest-runnable (no `bun:sqlite` dependency in test) while preserving the migration semantics consumers expect — strictly-increasing versions, append-only ledger, deterministic forward replay. Future schema changes append a new entry to `VAULT_MIGRATIONS` with the next version number; the manifest's `schemaVersion` field carries the current head so older clients can detect they need to upgrade.
## Features
- `VaultMigration` interface — `{ version, name, up(state) }`.
- `VAULT_MIGRATIONS` — frozen array of every migration shipped with the current package version.
- `emptyContainerState()` — produce a pristine state at version 0.
- `runMigrations(state, migrations?, now?)` — apply every step whose version exceeds the state's, recording a `MigrationLedgerEntry` per step.
- `headSchemaVersion(migrations?)` — return the highest version in the migration set.
- Strictly-monotonic ledger — once applied, an entry never changes; later migrations are appended.
## Usage: Basic Example
```ts
import {
emptyContainerState,
runMigrations,
VAULT_MIGRATIONS,
} from "@suaveplan/vault/items/migrations/migrations";
const state = emptyContainerState();
const result = runMigrations(state);
// result.fromVersion === 0
// result.toVersion === headSchemaVersion()
```
## Usage: Advanced Example
```ts
import {
runMigrations,
type VaultMigration,
type VaultContainerState,
} from "@suaveplan/vault/items/migrations/migrations";
// In-place migration: rename `tagsJson` to `tagListJson` (illustrative only).
const customMigration: VaultMigration = {
version: 2,
name: "rename-tagsJson-to-tagListJson",
up(state) {
for (const row of state.items.values()) {
// Mutate state in place — migrations are forward-only.
(row as unknown as Record<string, unknown>).tagListJson =
row.tagsJson;
}
},
};
const fakeClock = () => 1_700_000_000_000;
runMigrations(state, [...VAULT_MIGRATIONS, customMigration], fakeClock);
```
## API Reference
- `interface VaultMigration` — `{ version: number, name: string, up(state): void }`.
- `interface MigrationLedgerEntry` — `{ version, name, appliedAt }`.
- `interface VaultContainerState` — `{ schemaVersion, items: Map, history: [], ledger: [] }`.
- `interface VaultItemRow` — persisted row shape (see source for full field list).
- `interface VaultHistoryRow` — `{ itemId, version, timestamp, operation }`.
- `VAULT_MIGRATIONS: readonly VaultMigration[]` — frozen migration set.
- `emptyContainerState(): VaultContainerState`.
- `runMigrations(state, migrations?, now?): MigrationResult`.
- `headSchemaVersion(migrations?): number`.
## Implementation Notes
- Migrations sort by `version` before application so callers can declare them in any order; the runtime ensures strictly-increasing application.
- The `up` callback mutates state in place. Callers that want transactional semantics across multiple migrations should snapshot state before invoking `runMigrations` and roll back on failure.
- The ledger is append-only — every successfully-applied migration adds one entry. Duplicate applications are prevented by the `m.version <= state.schemaVersion` check.
- The shipped `VAULT_MIGRATIONS` set is intentionally sparse: the suaveplan "schema" is plain JS structures, so the v1 migration is a no-op marker. Future versions add real transformations (e.g. adding a new field to every row, splitting a JSON blob into two columns).
- `runMigrations` accepts an optional injectable `now` to keep ledger timestamps deterministic in tests; production passes `Date.now`.

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
emptyContainerState,
headSchemaVersion,
runMigrations,
VAULT_MIGRATIONS,
type VaultMigration,
} from "./migrations.js";
describe("vault migrations", () => {
it("emptyContainerState produces a pristine state", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
expect(state.schemaVersion).toBe(0);
expect(state.items.size).toBe(0);
expect(state.history).toEqual([]);
expect(state.ledger).toEqual([]);
});
it("runMigrations applies every step and updates the ledger", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
const result = runMigrations(state);
expect(result.fromVersion).toBe(0);
expect(result.toVersion).toBe(headSchemaVersion());
expect(result.appliedCount).toBe(VAULT_MIGRATIONS.length);
expect(state.schemaVersion).toBe(headSchemaVersion());
expect(state.ledger.length).toBe(VAULT_MIGRATIONS.length);
});
it("runMigrations is idempotent at head version", { timeout: 5000 }, () => {
const state = emptyContainerState();
runMigrations(state);
const second = runMigrations(state);
expect(second.appliedCount).toBe(0);
expect(second.fromVersion).toBe(headSchemaVersion());
});
it("runMigrations advances state with custom migration sets", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
const custom: VaultMigration[] = [
...VAULT_MIGRATIONS,
{
version: 99,
name: "custom",
up(s) {
s.history.push({
itemId: "marker",
version: 99,
timestamp: 0,
operation: "create",
});
},
},
];
runMigrations(state, custom, () => 1);
expect(state.schemaVersion).toBe(99);
const marker = state.history.find((h) => h.itemId === "marker");
expect(marker).toBeDefined();
});
it("runMigrations sorts migrations before applying", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
const trace: number[] = [];
const out: VaultMigration[] = [
{ version: 3, name: "c", up: () => trace.push(3) },
{ version: 1, name: "a", up: () => trace.push(1) },
{ version: 2, name: "b", up: () => trace.push(2) },
];
runMigrations(state, out, () => 1);
expect(trace).toEqual([1, 2, 3]);
});
it("headSchemaVersion returns 0 for an empty migration set", {
timeout: 5000,
}, () => {
expect(headSchemaVersion([])).toBe(0);
});
it("runMigrations uses the injected clock for ledger timestamps", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
runMigrations(state, undefined, () => 1234);
for (const entry of state.ledger) {
expect(entry.appliedAt).toBe(1234);
}
});
it("init migration recovers when items / history are missing", {
timeout: 5000,
}, () => {
const state = emptyContainerState();
(state as unknown as { items: unknown }).items = null;
(state as unknown as { history: unknown }).history = null;
runMigrations(state);
expect(state.items instanceof Map).toBe(true);
expect(Array.isArray(state.history)).toBe(true);
});
it("headSchemaVersion takes the maximum across out-of-order migrations", {
timeout: 5000,
}, () => {
const out = [
{ version: 5, name: "five", up: () => undefined },
{ version: 2, name: "two", up: () => undefined },
{ version: 7, name: "seven", up: () => undefined },
{ version: 1, name: "one", up: () => undefined },
];
expect(headSchemaVersion(out)).toBe(7);
});
});

View File

@@ -0,0 +1,143 @@
/**
* Forward-only schema migration ledger for the in-memory vault container.
*
* The container holds a versioned schema state plus an in-memory item table
* (rows + history rows). Each migration is described by `{ version, up }`
* where `up(state)` mutates the container state to advance from
* `version - 1` to `version`. Migrations run on `unlock` after the container
* blob has been decrypted; the resulting `schemaVersion` is written back
* into the manifest on the next flush.
*
* The shape mirrors `@suaveplan/db-sqlite`'s `MigrationRunner`: a sorted
* list of migrations with strictly-increasing versions, applied in order,
* with the highest applied version persisted in the container state.
*/
/** A single migration step. */
export interface VaultMigration {
/** Strictly-increasing version number (≥ 1). */
readonly version: number;
/** Human-readable migration name; recorded in the ledger for debug. */
readonly name: string;
/** Apply the migration to the in-memory state. */
up(state: VaultContainerState): void;
}
/** Bookkeeping entry written to the ledger after each migration. */
export interface MigrationLedgerEntry {
readonly version: number;
readonly name: string;
readonly appliedAt: number;
}
/** In-memory state managed by the container. */
export interface VaultContainerState {
schemaVersion: number;
items: Map<string, VaultItemRow>;
history: VaultHistoryRow[];
ledger: MigrationLedgerEntry[];
}
/** Row shape persisted inside the container. */
export interface VaultItemRow {
id: string;
createdAt: number;
updatedAt: number;
version: number;
deleted: boolean;
title: string;
category: string;
fieldsJson: string;
concealedFieldsJson: string;
tagsJson: string;
favorite: boolean;
}
/** History row recording a single mutation for audit / sync. */
export interface VaultHistoryRow {
itemId: string;
version: number;
timestamp: number;
operation: "create" | "update" | "delete";
}
/**
* The genesis migration set. Future schema bumps append entries here with
* monotonically-increasing `version` numbers and never modify earlier
* entries.
*/
export const VAULT_MIGRATIONS: readonly VaultMigration[] = [
{
version: 1,
name: "init-vault-items",
up(state) {
// The base schema is the in-memory shape itself — there is no
// SQL DDL to run. We seed the state with empty maps if not
// already present and record the version.
if (!(state.items instanceof Map)) state.items = new Map();
if (!Array.isArray(state.history)) state.history = [];
},
},
] as const;
/**
* Build a fresh, empty container state at schema version 0 (pre-migration).
* Call `runMigrations(state, ...)` to advance to the head version.
*/
export function emptyContainerState(): VaultContainerState {
return {
schemaVersion: 0,
items: new Map(),
history: [],
ledger: [],
};
}
/** Outcome of `runMigrations`. */
export interface MigrationResult {
readonly appliedCount: number;
readonly fromVersion: number;
readonly toVersion: number;
}
/**
* Run every migration whose `version` is strictly greater than the state's
* `schemaVersion`. Migrations are applied in order; the ledger and
* `schemaVersion` are updated in lockstep with each successful step.
*/
export function runMigrations(
state: VaultContainerState,
migrations: readonly VaultMigration[] = VAULT_MIGRATIONS,
now: () => number = () => Date.now()
): MigrationResult {
const fromVersion = state.schemaVersion;
const sorted = [...migrations].sort((a, b) => a.version - b.version);
let applied = 0;
for (const m of sorted) {
if (m.version <= state.schemaVersion) continue;
m.up(state);
state.schemaVersion = m.version;
state.ledger.push({
version: m.version,
name: m.name,
appliedAt: now(),
});
applied += 1;
}
return {
appliedCount: applied,
fromVersion,
toVersion: state.schemaVersion,
};
}
/** The head schema version this build of the package targets. */
export function headSchemaVersion(
migrations: readonly VaultMigration[] = VAULT_MIGRATIONS
): number {
if (migrations.length === 0) return 0;
return migrations.reduce(
(max, m) => (m.version > max ? m.version : max),
0
);
}