diff --git a/.cards/cli.md b/.cards/cli.md index 88d8f46..e101aad 100644 --- a/.cards/cli.md +++ b/.cards/cli.md @@ -45,6 +45,7 @@ ocas var get --schema ocas var delete [--schema ] ocas var list [prefix] [--schema ] [--tag ...] ocas var tag --schema +ocas var history [--schema ] ``` ### [[Render System|Template & Render]] diff --git a/.cards/variable.md b/.cards/variable.md index 8e2ffe0..b915b67 100644 --- a/.cards/variable.md +++ b/.cards/variable.md @@ -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 [--schema ] # 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. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4d5651f..775a8df 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -720,6 +720,51 @@ async function cmdVarTag(args: string[]): Promise { } } +async function cmdVarHistory(args: string[]): Promise { + const name = args[0]; + const schemaInput = flags.schema as string | undefined; + + if (!name) { + die("Usage: ocas var history [--schema ]"); + } + + 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 { const namePrefix = args[0] ?? ""; const schemaInput = flags.schema as string | undefined; @@ -998,6 +1043,7 @@ Commands: var delete [--schema ] Delete variable(s) (@ocas/output/var-delete) var list [prefix] [--schema ] [--tag ...] List variables (@ocas/output/var-list) var tag --schema Modify tags/labels (@ocas/output/var-tag) + var history [--schema ] Show value history (LRU) (@ocas/output/var-history) template set | --inline Set template for schema (@ocas/output/template-set) template get 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)"}`); } diff --git a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap index e3fb0d4..df0ee99 100644 --- a/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap +++ b/packages/cli/tests/__snapshots__/edge-cases.test.ts.snap @@ -31,6 +31,7 @@ Commands: var delete [--schema ] Delete variable(s) (@ocas/output/var-delete) var list [prefix] [--schema ] [--tag ...] List variables (@ocas/output/var-list) var tag --schema Modify tags/labels (@ocas/output/var-tag) + var history [--schema ] Show value history (LRU) (@ocas/output/var-history) template set | --inline Set template for schema (@ocas/output/template-set) template get 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", diff --git a/packages/cli/tests/variable-history.test.ts b/packages/cli/tests/variable-history.test.ts new file mode 100644 index 0000000..c71b90d --- /dev/null +++ b/packages/cli/tests/variable-history.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/bootstrap.test.ts b/packages/core/src/bootstrap.test.ts index 0a0fcf0..648c9c1 100644 --- a/packages/core/src/bootstrap.test.ts +++ b/packages/core/src/bootstrap.test.ts @@ -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)) { diff --git a/packages/core/src/bootstrap.ts b/packages/core/src/bootstrap.ts index f8cd298..eead489 100644 --- a/packages/core/src/bootstrap.ts +++ b/packages/core/src/bootstrap.ts @@ -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", { diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index bfc39a7..b90222f 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -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); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 40f87b6..4fd066a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { createVariableStore, InvalidTagFormatError, InvalidVariableNameError, + MAX_HISTORY, SchemaMismatchError, TagLabelConflictError, VariableNotFoundError, diff --git a/packages/core/src/output-templates.test.ts b/packages/core/src/output-templates.test.ts index 70cfce2..267439a 100644 --- a/packages/core/src/output-templates.test.ts +++ b/packages/core/src/output-templates.test.ts @@ -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); diff --git a/packages/core/src/output-templates.ts b/packages/core/src/output-templates.ts index ed70de1..d721a62 100644 --- a/packages/core/src/output-templates.ts +++ b/packages/core/src/output-templates.ts @@ -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 }}", diff --git a/packages/core/src/variable-store.test.ts b/packages/core/src/variable-store.test.ts index 383c299..cc9ace9 100644 --- a/packages/core/src/variable-store.test.ts +++ b/packages/core/src/variable-store.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/variable-store.ts b/packages/core/src/variable-store.ts index 520bfd6..bd9df8c 100644 --- a/packages/core/src/variable-store.ts +++ b/packages/core/src/variable-store.ts @@ -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 */ diff --git a/packages/fs/src/store.test.ts b/packages/fs/src/store.test.ts index 08f93b9..f52e152 100644 --- a/packages/fs/src/store.test.ts +++ b/packages/fs/src/store.test.ts @@ -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); }); });