Merge pull request 'feat: variable value history with LRU rotation' (#26) from feat/25-variable-history into main
This commit was merged in pull request #26.
This commit is contained in:
@@ -45,6 +45,7 @@ ocas var get <name> --schema <hash>
|
||||
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>]
|
||||
```
|
||||
|
||||
### [[Render System|Template & Render]]
|
||||
|
||||
@@ -56,6 +56,17 @@ Variable names starting with `@ocas/` are reserved for internal use. [[Bootstrap
|
||||
|
||||
Every CLI command that takes a hash argument also accepts a variable name. The `resolveHash()` helper checks whether the input matches the 13-char hash format; if not, it queries the variable store by exact name and returns the first match's value. This unifies builtin schema names and user-defined variables under a single resolution path — there is no separate "alias" concept.
|
||||
|
||||
## Value History
|
||||
|
||||
Every variable tracks its last `MAX_HISTORY` (default 10) values with LRU rotation:
|
||||
|
||||
- **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.
|
||||
|
||||
```bash
|
||||
ocas var history <name> [--schema <hash-or-name>] # show history, [0] = current
|
||||
```
|
||||
|
||||
## Role in Garbage Collection
|
||||
|
||||
Variables are the **roots** of [[Garbage Collection]]. Any node reachable from a variable's value hash (via [[Schema|ocas_ref]] edges) is kept alive; unreachable nodes are swept.
|
||||
|
||||
@@ -720,6 +720,51 @@ 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 cmdVarList(args: string[]): Promise<void> {
|
||||
const namePrefix = args[0] ?? "";
|
||||
const schemaInput = flags.schema as string | undefined;
|
||||
@@ -998,6 +1043,7 @@ 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)
|
||||
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 +1136,9 @@ switch (cmd) {
|
||||
case "list":
|
||||
await cmdVarList(subRest);
|
||||
break;
|
||||
case "history":
|
||||
await cmdVarHistory(subRest);
|
||||
break;
|
||||
default:
|
||||
die(`Unknown var subcommand: ${sub ?? "(none)"}`);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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)
|
||||
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 +250,13 @@ 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/template-set",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const OUTPUT_ALIASES = [
|
||||
"@ocas/output/var-delete",
|
||||
"@ocas/output/var-tag",
|
||||
"@ocas/output/var-list",
|
||||
"@ocas/output/var-history",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
@@ -32,11 +33,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 30 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 + 21 output aliases = 30
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/schema");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/string");
|
||||
expect(builtinSchemas).toHaveProperty("@ocas/number");
|
||||
@@ -51,7 +52,7 @@ describe("bootstrap - Built-in Schemas", () => {
|
||||
expect(builtinSchemas).toHaveProperty(alias);
|
||||
}
|
||||
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(29);
|
||||
expect(Object.keys(builtinSchemas)).toHaveLength(30);
|
||||
|
||||
// All values should be valid hashes
|
||||
for (const [_alias, hash] of Object.entries(builtinSchemas)) {
|
||||
|
||||
@@ -229,6 +229,21 @@ 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/template-set",
|
||||
{
|
||||
|
||||
@@ -264,7 +264,7 @@ describe("bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a map with 26 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(29);
|
||||
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 + 20 outputs)
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(28);
|
||||
// All built-in schemas typed by the meta-schema (1 self + 7 unique primitives + 21 outputs)
|
||||
expect(store.listByType(h1["@ocas/schema"] ?? "")).toHaveLength(29);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
createVariableStore,
|
||||
InvalidTagFormatError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
|
||||
@@ -23,6 +23,7 @@ const OUTPUT_ALIASES = [
|
||||
"@ocas/output/var-delete",
|
||||
"@ocas/output/var-tag",
|
||||
"@ocas/output/var-list",
|
||||
"@ocas/output/var-history",
|
||||
"@ocas/output/template-set",
|
||||
"@ocas/output/template-get",
|
||||
"@ocas/output/template-list",
|
||||
@@ -48,7 +49,7 @@ describe("registerOutputTemplates", () => {
|
||||
|
||||
const registered = await registerOutputTemplates(store, varStore);
|
||||
|
||||
expect(Object.keys(registered)).toHaveLength(18);
|
||||
expect(Object.keys(registered)).toHaveLength(19);
|
||||
|
||||
for (const alias of OUTPUT_ALIASES) {
|
||||
expect(registered).toHaveProperty(alias);
|
||||
|
||||
@@ -36,6 +36,10 @@ 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/template-set",
|
||||
"schemaHash: {{ payload.schemaHash }}\ncontentHash: {{ payload.contentHash }}",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Variable } from "./variable.js";
|
||||
import {
|
||||
CasNodeNotFoundError,
|
||||
InvalidVariableNameError,
|
||||
MAX_HISTORY,
|
||||
SchemaMismatchError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
@@ -1776,3 +1777,156 @@ 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 semantics: re-setting an old value moves it 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]; 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]);
|
||||
expect((varStore.get("x", schema) as Variable).value).toBe(v1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,20 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
|
||||
@@ -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(29);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user