1 Commits

Author SHA1 Message Date
xiaoju d83173107a 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
2026-06-01 13:33:44 +00:00
13 changed files with 12 additions and 244 deletions
-1
View File
@@ -46,7 +46,6 @@ ocas var delete <name> [--schema <hash>]
ocas var list [prefix] [--schema <hash>] [--tag ...]
ocas var tag <name> --schema <hash> <operations...>
ocas var history <name> [--schema <hash>]
ocas var rollback <name> <position> [--schema <hash>]
```
### [[Render System|Template & Render]]
-2
View File
@@ -62,11 +62,9 @@ Every variable tracks its last `MAX_HISTORY` (default 10) values with LRU rotati
- **set()** appends to history: if the new value already exists in history, it rotates to position 0; otherwise it's inserted at 0 and the oldest entry beyond MAX_HISTORY is evicted.
- **Idempotent**: setting the same value as the current (position 0) is a no-op — important for [[Bootstrap]] which calls set() repeatedly.
- **rollback(name, schema, position)** promotes a historical value back to current.
```bash
ocas var history <name> [--schema <hash-or-name>] # show history, [0] = current
ocas var rollback <name> <position> [--schema <hash-or-name>] # restore a previous value
```
## Role in Garbage Collection
-58
View File
@@ -765,60 +765,6 @@ async function cmdVarHistory(args: string[]): Promise<void> {
}
}
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;
@@ -1098,7 +1044,6 @@ Commands:
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)
@@ -1194,9 +1139,6 @@ switch (cmd) {
case "history":
await cmdVarHistory(subRest);
break;
case "rollback":
await cmdVarRollback(subRest);
break;
default:
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
}
@@ -32,7 +32,6 @@ Commands:
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)
@@ -258,13 +257,6 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
"tags": {},
"value": "EVZJS80TRFKE1",
},
{
"labels": [],
"name": "@ocas/output/var-rollback",
"schema": "CTS5P6RD8HMCS",
"tags": {},
"value": "3S1EZX570VBHF",
},
{
"labels": [],
"name": "@ocas/output/template-set",
@@ -131,46 +131,3 @@ describe("var history", () => {
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/");
});
});
+3 -4
View File
@@ -21,7 +21,6 @@ const OUTPUT_ALIASES = [
"@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",
@@ -34,11 +33,11 @@ const OUTPUT_ALIASES = [
// ──────────────────────────────────────────────────────────────────────────────
describe("bootstrap - Built-in Schemas", () => {
test("should return map of 31 built-in schema aliases to hashes", async () => {
test("should return map of 30 built-in schema aliases to hashes", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
// Should return object with 9 primitive + 22 output aliases = 31
// Should return object with 9 primitive + 21 output aliases = 30
expect(builtinSchemas).toHaveProperty("@ocas/schema");
expect(builtinSchemas).toHaveProperty("@ocas/string");
expect(builtinSchemas).toHaveProperty("@ocas/number");
@@ -53,7 +52,7 @@ describe("bootstrap - Built-in Schemas", () => {
expect(builtinSchemas).toHaveProperty(alias);
}
expect(Object.keys(builtinSchemas)).toHaveLength(31);
expect(Object.keys(builtinSchemas)).toHaveLength(30);
// All values should be valid hashes
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
-8
View File
@@ -244,14 +244,6 @@ const OUTPUT_SCHEMAS: ReadonlyArray<
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 31 built-in schema aliases", async () => {
test("returns a map with 30 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(31);
expect(Object.keys(builtinSchemas)).toHaveLength(30);
});
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 + 22 outputs)
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(30);
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs)
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
});
});
+1 -2
View File
@@ -24,7 +24,6 @@ const OUTPUT_ALIASES = [
"@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",
@@ -50,7 +49,7 @@ describe("registerOutputTemplates", () => {
const registered = await registerOutputTemplates(store, varStore);
expect(Object.keys(registered)).toHaveLength(20);
expect(Object.keys(registered)).toHaveLength(19);
for (const alias of OUTPUT_ALIASES) {
expect(registered).toHaveProperty(alias);
-4
View File
@@ -40,10 +40,6 @@ const DEFAULT_TEMPLATES: ReadonlyArray<
"@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 }}",
+3 -48
View File
@@ -1886,7 +1886,7 @@ describe("VariableStore - History (LRU)", () => {
varStore.close();
});
test("rollback() moves a historical value to position 0", async () => {
test("rollback semantics: re-setting an old value moves it to position 0", async () => {
store = createMemoryStore();
await bootstrap(store);
const schema = await putSchema(store, { type: "number" });
@@ -1899,8 +1899,8 @@ describe("VariableStore - History (LRU)", () => {
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);
// History: [v3, v2, v1]; rolling back is just calling set() with v1
const result = varStore.set("x", v1);
expect(result.value).toBe(v1);
expect(varStore.history("x", schema)).toEqual([v1, v3, v2]);
@@ -1908,51 +1908,6 @@ describe("VariableStore - History (LRU)", () => {
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);
-61
View File
@@ -843,67 +843,6 @@ export class VariableStore {
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(30);
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
});
});