feat: variable value history with LRU rotation

- Add variable_history table, MAX_HISTORY=10
- set() tracks history with LRU rotation, idempotent for bootstrap
- New methods: history(), rollback()
- CLI: var history, var rollback commands
- 19 new tests (564 total)

Fixes #25
This commit is contained in:
2026-06-01 13:24:39 +00:00
parent 2feaff581d
commit 538770aa07
12 changed files with 748 additions and 17 deletions
+107
View File
@@ -720,6 +720,105 @@ async function cmdVarTag(args: string[]): Promise<void> {
}
}
async function cmdVarHistory(args: string[]): Promise<void> {
const name = args[0];
const schemaInput = flags.schema as string | undefined;
if (!name) {
die("Usage: ocas var history <name> [--schema <hash-or-name>]");
}
const { store, varStore } = await openStoreAndVarStore();
try {
let schema: Hash;
if (schemaInput !== undefined) {
schema = resolveHash(schemaInput, varStore);
} else {
const variants = varStore.list({ exactName: name });
if (variants.length === 0) {
die(`Error: Variable not found: ${name}`);
}
if (variants.length > 1) {
die(
`Error: Multiple schema variants for "${name}"; use --schema to disambiguate`,
);
}
schema = (variants[0] as { schema: string }).schema as Hash;
}
const values = varStore.history(name, schema);
if (values.length === 0) {
die(`Error: Variable not found: name=${name}, schema=${schema}`);
}
await out(
await wrapEnvelope(store, "@ocas/output/var-history", {
name,
schema,
values,
}),
store,
);
} finally {
varStore.close();
}
}
async function cmdVarRollback(args: string[]): Promise<void> {
const name = args[0];
const positionStr = args[1];
const schemaInput = flags.schema as string | undefined;
if (!name || positionStr === undefined) {
die("Usage: ocas var rollback <name> <position> [--schema <hash-or-name>]");
}
if (name.startsWith("@ocas/")) {
die("The @ocas/ namespace is reserved and cannot be modified directly.");
}
const position = Number.parseInt(positionStr, 10);
if (!Number.isInteger(position) || position < 0) {
die(
`Error: Invalid position: ${positionStr} (must be a non-negative integer)`,
);
}
const { store, varStore } = await openStoreAndVarStore();
try {
let schema: Hash;
if (schemaInput !== undefined) {
schema = resolveHash(schemaInput, varStore);
} else {
const variants = varStore.list({ exactName: name });
if (variants.length === 0) {
die(`Error: Variable not found: ${name}`);
}
if (variants.length > 1) {
die(
`Error: Multiple schema variants for "${name}"; use --schema to disambiguate`,
);
}
schema = (variants[0] as { schema: string }).schema as Hash;
}
const variable = varStore.rollback(name, schema, position);
await out(
await wrapEnvelope(store, "@ocas/output/var-rollback", variable),
store,
);
} catch (e) {
if (e instanceof VariableNotFoundError) {
die(`Error: ${e.message}`);
}
throw e;
} finally {
varStore.close();
}
}
async function cmdVarList(args: string[]): Promise<void> {
const namePrefix = args[0] ?? "";
const schemaInput = flags.schema as string | undefined;
@@ -998,6 +1097,8 @@ Commands:
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@ocas/output/var-tag)
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
var rollback <name> <position> [--schema <hash>] Rollback to historical value (@ocas/output/var-rollback)
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
template list List all templates (@ocas/output/template-list)
@@ -1090,6 +1191,12 @@ switch (cmd) {
case "list":
await cmdVarList(subRest);
break;
case "history":
await cmdVarHistory(subRest);
break;
case "rollback":
await cmdVarRollback(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
@@ -31,6 +31,8 @@ Commands:
var delete <name> [--schema <hash>] Delete variable(s) (@ocas/output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@ocas/output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@ocas/output/var-tag)
var history <name> [--schema <hash>] Show value history (LRU) (@ocas/output/var-history)
var rollback <name> <position> [--schema <hash>] Rollback to historical value (@ocas/output/var-rollback)
template set <schema-hash> <file> | --inline <text> Set template for schema (@ocas/output/template-set)
template get <schema-hash> Get template content (value=string) (@ocas/output/template-get)
template list List all templates (@ocas/output/template-list)
@@ -249,6 +251,20 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "AF0XACGXHPMC1",
},
{
"labels": [],
"name": "@ocas/output/var-history",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "EVZJS80TRFKE1",
},
{
"labels": [],
"name": "@ocas/output/var-rollback",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "3S1EZX570VBHF",
},
{
"labels": [],
"name": "@ocas/output/template-set",
+176
View File
@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@ocas/core";
import { bootstrap } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
let testDir: string;
let storePath: string;
let varDbPath: string;
let cliPath: string;
beforeEach(() => {
testDir = join(
tmpdir(),
`ocas-history-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storePath = join(testDir, "store");
varDbPath = join(testDir, "variables.db");
cliPath = join(import.meta.dir, "../src/index.ts");
mkdirSync(testDir, { recursive: true });
mkdirSync(storePath, { recursive: true });
});
afterEach(() => {
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore
}
});
async function runCli(...args: string[]): Promise<{
stdout: string;
stderr: string;
exitCode: number;
}> {
const proc = Bun.spawn(
[
"bun",
"run",
cliPath,
"--home",
storePath,
"--var-db",
varDbPath,
...args,
],
{
stdout: "pipe",
stderr: "pipe",
},
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: proc.exitCode ?? 0,
};
}
async function setupSchemaAndValues(): Promise<{
schema: Hash;
values: Hash[];
}> {
const store: Store = createFsStore(storePath);
const aliases = await bootstrap(store);
const numberHash = aliases["@ocas/number"] as Hash;
const values: Hash[] = [];
for (let i = 0; i < 4; i++) {
values.push((await store.put(numberHash, i)) as Hash);
}
return { schema: numberHash, values };
}
describe("var history", () => {
test("returns single entry after first set", async () => {
const { schema, values } = await setupSchemaAndValues();
const v1 = values[0] as Hash;
let r = await runCli("var", "set", "x", v1);
expect(r.exitCode).toBe(0);
r = await runCli("var", "history", "x", "--schema", schema);
expect(r.exitCode).toBe(0);
const envelope = JSON.parse(r.stdout);
expect(envelope.value.name).toBe("x");
expect(envelope.value.schema).toBe(schema);
expect(envelope.value.values).toEqual([v1]);
});
test("history grows with new sets, current is index 0", async () => {
const { schema, values } = await setupSchemaAndValues();
const [v1, v2, v3] = values as [Hash, Hash, Hash, Hash];
await runCli("var", "set", "x", v1);
await runCli("var", "set", "x", v2);
await runCli("var", "set", "x", v3);
const r = await runCli("var", "history", "x", "--schema", schema);
expect(r.exitCode).toBe(0);
const envelope = JSON.parse(r.stdout);
expect(envelope.value.values).toEqual([v3, v2, v1]);
});
test("history works without --schema when only one variant", async () => {
const { schema: _schema, values } = await setupSchemaAndValues();
const [v1, v2] = values as [Hash, Hash, Hash, Hash];
await runCli("var", "set", "x", v1);
await runCli("var", "set", "x", v2);
const r = await runCli("var", "history", "x");
expect(r.exitCode).toBe(0);
const envelope = JSON.parse(r.stdout);
expect(envelope.value.values).toEqual([v2, v1]);
});
test("history fails for non-existent variable", async () => {
const r = await runCli("var", "history", "missing");
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("Variable not found");
});
});
describe("var rollback", () => {
test("rolls back to a previous value", async () => {
const { schema, values } = await setupSchemaAndValues();
const [v1, v2, v3] = values as [Hash, Hash, Hash, Hash];
await runCli("var", "set", "x", v1);
await runCli("var", "set", "x", v2);
await runCli("var", "set", "x", v3);
// History: [v3, v2, v1] — rollback to position 2 (v1)
const r = await runCli("var", "rollback", "x", "2", "--schema", schema);
expect(r.exitCode).toBe(0);
const envelope = JSON.parse(r.stdout);
expect(envelope.value.value).toBe(v1);
// After rollback, history is [v1, v3, v2]
const h = await runCli("var", "history", "x", "--schema", schema);
expect(JSON.parse(h.stdout).value.values).toEqual([v1, v3, v2]);
});
test("rollback fails for invalid position", async () => {
const { schema: _schema, values } = await setupSchemaAndValues();
const v1 = values[0] as Hash;
await runCli("var", "set", "x", v1);
const r = await runCli("var", "rollback", "x", "9");
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("not found");
});
test("rollback fails for negative position", async () => {
const r = await runCli("var", "rollback", "x", "-1");
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("Invalid position");
});
test("rollback rejects @ocas/ namespace", async () => {
const r = await runCli("var", "rollback", "@ocas/string", "1");
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("@ocas/");
});
});
+5 -3
View File
@@ -20,6 +20,8 @@ const OUTPUT_ALIASES = [
"@ocas/output/var-delete",
"@ocas/output/var-tag",
"@ocas/output/var-list",
"@ocas/output/var-history",
"@ocas/output/var-rollback",
"@ocas/output/template-set",
"@ocas/output/template-get",
"@ocas/output/template-list",
@@ -32,11 +34,11 @@ const OUTPUT_ALIASES = [
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 29 built-in schema aliases to hashes", async () => {
test("should return map of 31 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 9 primitive + 20 output aliases = 29
// Should return object with 9 primitive + 22 output aliases = 31
expect(builtinSchemas).toHaveProperty("@ocas/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string");
expect(builtinSchemas).toHaveProperty("@ocas/number");
@@ -51,7 +53,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(29);
expect(Object.keys(builtinSchemas)).toHaveLength(31);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
+23
View File
@@ -229,6 +229,29 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
title: "ocas var list result",
},
],
[
"@ocas/output/var-history",
{
type: "object",
properties: {
name: { type: "string" },
schema: { type: "string", format: "ocas_ref" },
values: {
type: "array",
items: { type: "string", format: "ocas_ref" },
},
},
title: "ocas var history result",
},
],
[
"@ocas/output/var-rollback",
{
type: "object",
properties: { ...VARIABLE_PROPERTIES },
title: "ocas var rollback result",
},
],
[
"@ocas/output/template-set",
{
+4 -4
View File
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
);
});
test("returns a map with 26 built-in schema aliases", async () => {
test("returns a map with 31 built-in schema aliases", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
@@ -284,7 +284,7 @@ describe("bootstrap", () => {
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(Object.keys(builtinSchemas)).toHaveLength(29);
expect(Object.keys(builtinSchemas)).toHaveLength(31);
});
test("meta-schema node is stored and retrievable", async () => {
@@ -321,7 +321,7 @@ describe("bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 20 outputs)
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(28);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 22 outputs)
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
});
});
+1
View File
@@ -29,6 +29,7 @@ export {
createVariableStore,
InvalidTagFormatError,
InvalidVariableNameError,
MAX_HISTORY,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
+3 -1
View File
@@ -23,6 +23,8 @@ const OUTPUT_ALIASES = [
"@ocas/output/var-delete",
"@ocas/output/var-tag",
"@ocas/output/var-list",
"@ocas/output/var-history",
"@ocas/output/var-rollback",
"@ocas/output/template-set",
"@ocas/output/template-get",
"@ocas/output/template-list",
@@ -48,7 +50,7 @@ describe("registerOutputTemplates", () => {
const registered = await registerOutputTemplates(store, varStore);
expect(Object.keys(registered)).toHaveLength(18);
expect(Object.keys(registered)).toHaveLength(20);
for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias);
+8
View File
@@ -36,6 +36,14 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
"@ocas/output/var-list",
"{% for v in payload %}name: {{ v.name }}\nschema: {{ v.schema }}\nvalue: {{ v.value }}\n{% endfor %}",
],
[
"@ocas/output/var-history",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\n{% for v in payload.values %}{{ forloop.index0 }}: {{ v }}\n{% endfor %}",
],
[
"@ocas/output/var-rollback",
"name: {{ payload.name }}\nschema: {{ payload.schema }}\nvalue: {{ payload.value }}",
],
[
"@ocas/output/template-set",
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
+199
View File
@@ -10,6 +10,7 @@ import type { Variable } from "./variable.js";
import {
CasNodeNotFoundError,
InvalidVariableNameError,
MAX_HISTORY,
SchemaMismatchError,
TagLabelConflictError,
VariableNotFoundError,
@@ -1776,3 +1777,201 @@ describe("VariableStore - @ Prefix Variable Names", () => {
varStore.close();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// Variable Value History (LRU)
// ──────────────────────────────────────────────────────────────────────────────
describe("VariableStore - History (LRU)", () => {
let store: Store;
let dbPath: string;
afterEach(() => {
try {
unlinkSync(dbPath);
} catch {
// ignore
}
});
test("history() initializes with single entry on create", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("x", v1);
const hist = varStore.history("x", schema);
expect(hist).toEqual([v1]);
varStore.close();
});
test("history() pushes new values to position 0", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
const v2 = await store.put(schema, 2);
const v3 = await store.put(schema, 3);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("x", v1);
varStore.set("x", v2);
varStore.set("x", v3);
expect(varStore.history("x", schema)).toEqual([v3, v2, v1]);
varStore.close();
});
test("set() with same value as current is idempotent (no history change)", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
const created = varStore.set("x", v1);
const updatedTime = created.updated;
await new Promise((r) => setTimeout(r, 5));
const second = varStore.set("x", v1);
expect(second.updated).toBe(updatedTime);
expect(varStore.history("x", schema)).toEqual([v1]);
varStore.close();
});
test("setting an existing-history value moves it to position 0 (no duplicates)", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
const v2 = await store.put(schema, 2);
const v3 = await store.put(schema, 3);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("x", v1);
varStore.set("x", v2);
varStore.set("x", v3);
// History: [v3, v2, v1]; setting v1 should yield [v1, v3, v2]
varStore.set("x", v1);
expect(varStore.history("x", schema)).toEqual([v1, v3, v2]);
varStore.close();
});
test("history is bounded by MAX_HISTORY=10", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const values: string[] = [];
for (let i = 0; i < 15; i++) {
values.push(await store.put(schema, i));
}
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
for (const v of values) {
varStore.set("x", v);
}
const hist = varStore.history("x", schema);
expect(hist).toHaveLength(MAX_HISTORY);
expect(hist).toEqual(values.slice(-MAX_HISTORY).reverse());
varStore.close();
});
test("rollback() moves a historical value to position 0", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
const v2 = await store.put(schema, 2);
const v3 = await store.put(schema, 3);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("x", v1);
varStore.set("x", v2);
varStore.set("x", v3);
// History: [v3, v2, v1]; rollback to position 2 (v1)
const result = varStore.rollback("x", schema, 2);
expect(result.value).toBe(v1);
expect(varStore.history("x", schema)).toEqual([v1, v3, v2]);
expect((varStore.get("x", schema) as Variable).value).toBe(v1);
varStore.close();
});
test("rollback() with position 0 is a no-op", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("x", v1);
const r = varStore.rollback("x", schema, 0);
expect(r.value).toBe(v1);
expect(varStore.history("x", schema)).toEqual([v1]);
varStore.close();
});
test("rollback() throws for non-existent position", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("x", v1);
expect(() => varStore.rollback("x", schema, 5)).toThrow(
VariableNotFoundError,
);
varStore.close();
});
test("rollback() throws for non-existent variable", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
expect(() => varStore.rollback("nope", schema, 0)).toThrow(
VariableNotFoundError,
);
varStore.close();
});
test("history is cascade-deleted with the variable", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
const v1 = await store.put(schema, 1);
const v2 = await store.put(schema, 2);
dbPath = tmpDbPath();
const varStore = new VariableStore(dbPath, store);
varStore.set("x", v1);
varStore.set("x", v2);
expect(varStore.history("x", schema)).toHaveLength(2);
varStore.remove("x", schema);
expect(varStore.history("x", schema)).toEqual([]);
varStore.close();
});
test("MAX_HISTORY equals 10", () => {
expect(MAX_HISTORY).toBe(10);
});
});
+205 -8
View File
@@ -2,6 +2,12 @@ import { Database } from "bun:sqlite";
import type { Hash, Store } from "./types.js";
import type { Variable } from "./variable.js";
/**
* Maximum number of historical values retained per (variable_name, variable_schema).
* Position 0 is current; positions 1..MAX_HISTORY-1 are previous values (LRU).
*/
export const MAX_HISTORY = 10;
/**
* Custom error types for variable operations
*/
@@ -114,6 +120,18 @@ export class VariableStore {
CREATE INDEX IF NOT EXISTS idx_var_tag_key ON variable_tags(key);
CREATE INDEX IF NOT EXISTS idx_var_tag_key_value ON variable_tags(key, value);
CREATE INDEX IF NOT EXISTS idx_var_label_name ON variable_labels(name);
CREATE TABLE IF NOT EXISTS variable_history (
variable_name TEXT NOT NULL,
variable_schema TEXT NOT NULL,
value TEXT NOT NULL,
position INTEGER NOT NULL,
set_at INTEGER NOT NULL,
PRIMARY KEY (variable_name, variable_schema, position),
FOREIGN KEY (variable_name, variable_schema) REFERENCES variables(name, schema) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_var_history_value ON variable_history(value);
`);
}
@@ -213,6 +231,97 @@ export class VariableStore {
return rows.map((row) => row.name);
}
/**
* Manage history for a variable on set().
*
* Rules:
* - If new value equals current (position 0), no-op (idempotent).
* - If new value already exists in history at position N, remove it; entries
* with position < N shift +1; insert new value at position 0.
* - Otherwise shift all entries +1, insert new at position 0, prune any
* entries at position >= MAX_HISTORY.
*
* Caller must invoke inside a transaction.
* Returns true if history changed (i.e. value differs from current),
* false if it was a no-op.
*/
private recordHistory(
name: string,
schema: Hash,
value: Hash,
now: number,
): boolean {
// Check current value at position 0
const currentRow = this.db
.prepare(
`SELECT value FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = 0`,
)
.get(name, schema) as { value: string } | undefined | null;
if (currentRow && currentRow.value === value) {
// Idempotent: same value as current; do nothing
return false;
}
// Find existing position of this value (if any)
const existingRow = this.db
.prepare(
`SELECT position FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND value = ?`,
)
.get(name, schema, value) as { position: number } | undefined | null;
if (existingRow) {
const existingPos = existingRow.position;
// Delete the existing entry first to free its position
this.db
.prepare(
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = ?`,
)
.run(name, schema, existingPos);
// Shift positions [0, existingPos) up by 1.
// Use a temporary offset to avoid PK conflicts during the shift.
this.db
.prepare(
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ? AND position < ?`,
)
.run(name, schema, existingPos);
this.db
.prepare(
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
)
.run(name, schema);
} else {
// New value: shift everything +1 (using temp offset to avoid PK conflicts)
this.db
.prepare(
`UPDATE variable_history SET position = position + 1000000 WHERE variable_name = ? AND variable_schema = ?`,
)
.run(name, schema);
this.db
.prepare(
`UPDATE variable_history SET position = position - 1000000 + 1 WHERE variable_name = ? AND variable_schema = ? AND position >= 1000000`,
)
.run(name, schema);
// Prune any entries that ended up at position >= MAX_HISTORY
this.db
.prepare(
`DELETE FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position >= ?`,
)
.run(name, schema, MAX_HISTORY);
}
// Insert new value at position 0
this.db
.prepare(
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
)
.run(name, schema, value, now);
return true;
}
/**
* Set a variable (upsert: create or update)
*/
@@ -252,14 +361,20 @@ export class VariableStore {
this.db.exec("BEGIN TRANSACTION");
let changed = false;
try {
// Update value and timestamp
const updateStmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE name = ? AND schema = ?
`);
updateStmt.run(value, now, name, schema);
// Manage history (also detects idempotent same-value sets)
changed = this.recordHistory(name, schema, value, now);
// Update value and timestamp only if value changed
if (changed) {
const updateStmt = this.db.prepare(`
UPDATE variables
SET value = ?, updated = ?
WHERE name = ? AND schema = ?
`);
updateStmt.run(value, now, name, schema);
}
// If options provided, update tags/labels
if (options !== undefined) {
@@ -311,7 +426,7 @@ export class VariableStore {
schema,
value,
created: existing.created,
updated: now,
updated: changed ? now : existing.updated,
tags,
labels: [...labels],
};
@@ -341,6 +456,13 @@ export class VariableStore {
stmt.run(name, schema, value, now, now);
// Initialise history with this value at position 0
this.db
.prepare(
`INSERT INTO variable_history (variable_name, variable_schema, value, position, set_at) VALUES (?, ?, ?, 0, ?)`,
)
.run(name, schema, value, now);
// Insert tags
if (tagKeys.length > 0) {
const tagStmt = this.db.prepare(`
@@ -707,6 +829,81 @@ export class VariableStore {
return updated;
}
/**
* Get the value history for a variable, ordered by position.
* Index 0 is the current value; subsequent entries are older.
* Returns an empty array if the variable does not exist.
*/
history(name: string, schema: Hash): Hash[] {
const rows = this.db
.prepare(
`SELECT value, position FROM variable_history WHERE variable_name = ? AND variable_schema = ? ORDER BY position ASC`,
)
.all(name, schema) as Array<{ value: string; position: number }>;
return rows.map((r) => r.value as Hash);
}
/**
* Roll back a variable to the value currently at the given history position.
* The value at `position` is moved to position 0 (current); positions in
* [0, position) are shifted +1.
*
* Throws VariableNotFoundError if the variable does not exist or there is
* no history entry at the given position.
*/
rollback(name: string, schema: Hash, position: number): Variable {
if (!Number.isInteger(position) || position < 0) {
throw new Error(
`Invalid history position: ${position} (must be a non-negative integer)`,
);
}
const existing = this.get(name, schema);
if (existing === null) {
throw new VariableNotFoundError(name, schema);
}
if (position === 0) {
// No-op rollback to current
return existing;
}
const targetRow = this.db
.prepare(
`SELECT value FROM variable_history WHERE variable_name = ? AND variable_schema = ? AND position = ?`,
)
.get(name, schema, position) as { value: string } | undefined | null;
if (!targetRow) {
throw new VariableNotFoundError(name, schema);
}
const targetValue = targetRow.value as Hash;
const now = Date.now();
this.db.exec("BEGIN TRANSACTION");
try {
const changed = this.recordHistory(name, schema, targetValue, now);
if (changed) {
this.db
.prepare(
`UPDATE variables SET value = ?, updated = ? WHERE name = ? AND schema = ?`,
)
.run(targetValue, now, name, schema);
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
const updated = this.get(name, schema);
if (updated === null) {
throw new VariableNotFoundError(name, schema);
}
return updated;
}
/**
* Close the database connection
*/
+1 -1
View File
@@ -67,7 +67,7 @@ describe("createFsStore – init and bootstrap", () => {
const h2 = await bootstrap(store);
expect(h1).toEqual(h2);
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(28);
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
});
});