Initial Commit
This commit is contained in:
57
packages/security-stack/security/vault/src/items/helpers.md
Normal file
57
packages/security-stack/security/vault/src/items/helpers.md
Normal 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.
|
||||
121
packages/security-stack/security/vault/src/items/helpers.test.ts
Normal file
121
packages/security-stack/security/vault/src/items/helpers.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
74
packages/security-stack/security/vault/src/items/helpers.ts
Normal file
74
packages/security-stack/security/vault/src/items/helpers.ts
Normal 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;
|
||||
}
|
||||
99
packages/security-stack/security/vault/src/items/items.md
Normal file
99
packages/security-stack/security/vault/src/items/items.md
Normal 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`.
|
||||
261
packages/security-stack/security/vault/src/items/items.test.ts
Normal file
261
packages/security-stack/security/vault/src/items/items.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
255
packages/security-stack/security/vault/src/items/items.ts
Normal file
255
packages/security-stack/security/vault/src/items/items.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user