schema stamps: lazy v1 for chats + the graph family, honest refusal
Chat records gain version (CURRENT_CHAT_VERSION), stamped by a single writeChat choke point in the store; absence reads as v1 — existing files migrate on their next write, no mass rewrite. The graph family (four JSONL ledgers, sources.json, render-settings.json) shares one lazy sidecar, graph/format.json; positions stays unstamped (rebuildable) and jobs stay unstamped (transient). Downgrade scenario ruled honest-refusal: records stamped newer than the running build read as absent (chats, logged) or throw (graph family) — a read-modify-write store never rewrites what it doesn't understand. Golden fixtures stay the unstamped legacy shape on purpose (lazy v1 is current-valid by design); the stamped write path is pinned by tests/schema-stamps.test.ts. Reads never write — the golden byte-identical pin holds.
This commit is contained in:
@@ -86,6 +86,13 @@ export const POSITIONS_FILE = path.join(PATHS.graph, "positions.json");
|
|||||||
*/
|
*/
|
||||||
export const RENDER_SETTINGS_FILE = path.join(PATHS.graph, "render-settings.json");
|
export const RENDER_SETTINGS_FILE = path.join(PATHS.graph, "render-settings.json");
|
||||||
|
|
||||||
|
/** The graph-family FORMAT STAMP sidecar (schema-versioning wave,
|
||||||
|
* 2026-10-10): one version for the whole family — the four JSONL ledgers,
|
||||||
|
* sources.json, render-settings.json. LAZY v1: absent = v1; the first
|
||||||
|
* graph-family write creates it. positions.json is REBUILDABLE and rides
|
||||||
|
* along unversioned by design (owner ruling). */
|
||||||
|
export const GRAPH_FORMAT_FILE = path.join(PATHS.graph, "format.json");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE USER SETTINGS RECORD (settings-storage wave, 2026-09-19): one
|
* THE USER SETTINGS RECORD (settings-storage wave, 2026-09-19): one
|
||||||
* versioned, DELTA-shaped JSON blob at the data-dir seam holding every
|
* versioned, DELTA-shaped JSON blob at the data-dir seam holding every
|
||||||
|
|||||||
+3
-1
@@ -178,4 +178,6 @@ The teaching methodology comes from an Obsidian-based POC (its `knowledge graph
|
|||||||
|
|
||||||
## Data
|
## Data
|
||||||
|
|
||||||
Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`.
|
Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`.
|
||||||
|
|
||||||
|
**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
|
||||||
@@ -52,8 +52,17 @@ export interface ChatRecord {
|
|||||||
extractions?: ExtractionRunUsage[];
|
extractions?: ExtractionRunUsage[];
|
||||||
/** Per-chat model/thinking overrides for the conversation agents. */
|
/** Per-chat model/thinking overrides for the conversation agents. */
|
||||||
settings?: ChatSettings;
|
settings?: ChatSettings;
|
||||||
|
/** SCHEMA STAMP (schema-versioning wave, owner 2026-10-10): LAZY v1 —
|
||||||
|
* existing records stay unstamped (absence reads as v1); every write
|
||||||
|
* stamps the current version. A record stamped NEWER than this build
|
||||||
|
* (the downgrade scenario) is REFUSED on read: a read-modify-write
|
||||||
|
* store must never rewrite what it doesn't understand. */
|
||||||
|
version?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chat record format this build writes and understands. */
|
||||||
|
export const CURRENT_CHAT_VERSION = 1;
|
||||||
|
|
||||||
/** The conversation agents' runtime model choice (model-control wave,
|
/** The conversation agents' runtime model choice (model-control wave,
|
||||||
* 2026-09-12): a per-chat override of config.ts's pinned prober/teacher
|
* 2026-09-12): a per-chat override of config.ts's pinned prober/teacher
|
||||||
* spec. Absent fields fall back to config; written by the model picker
|
* spec. Absent fields fall back to config; written by the model picker
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export interface NamingRunStats {
|
|||||||
appended: number;
|
appended: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The graph-family format version this build writes and understands
|
||||||
|
* (the format.json sidecar — src/store/graph-format.ts). */
|
||||||
|
export const CURRENT_GRAPH_FORMAT_VERSION = 1;
|
||||||
|
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
||||||
|
|||||||
+28
-9
@@ -13,6 +13,7 @@ import { PATHS } from "../../config.ts";
|
|||||||
import { emit } from "../core/events.ts";
|
import { emit } from "../core/events.ts";
|
||||||
import type { ChatStore } from "../core/ports.ts";
|
import type { ChatStore } from "../core/ports.ts";
|
||||||
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||||
|
import { CURRENT_CHAT_VERSION } from "../core/contracts.ts";
|
||||||
import type { LhMessage } from "../core/native-messages.ts";
|
import type { LhMessage } from "../core/native-messages.ts";
|
||||||
import { activeProblemOf, danglingQuestionOf } from "../core/native-messages.ts";
|
import { activeProblemOf, danglingQuestionOf } from "../core/native-messages.ts";
|
||||||
import { phaseAt3Lh } from "../core/phases.ts";
|
import { phaseAt3Lh } from "../core/phases.ts";
|
||||||
@@ -44,17 +45,35 @@ function createChat(title: string, kind?: "practice"): ChatRecord {
|
|||||||
...(kind === "practice" ? { kind: "practice" as const } : {}),
|
...(kind === "practice" ? { kind: "practice" as const } : {}),
|
||||||
};
|
};
|
||||||
fs.mkdirSync(PATHS.chats, { recursive: true });
|
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||||
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
writeChat(record);
|
||||||
emit("chats-changed", {});
|
emit("chats-changed", {});
|
||||||
return record;
|
return { ...record, version: CURRENT_CHAT_VERSION }; // the in-memory twin of what landed on disk
|
||||||
|
}
|
||||||
|
|
||||||
|
/** THE WRITE CHOKE POINT (schema-versioning wave, owner 2026-10-10): every
|
||||||
|
* record persist goes through here and carries the CURRENT version stamp —
|
||||||
|
* lazy migration means existing files only get stamped on their next write.
|
||||||
|
* A future-version guard lives on the read side (getChat). */
|
||||||
|
function writeChat(record: ChatRecord): void {
|
||||||
|
fs.writeFileSync(chatFile(record.id), JSON.stringify({ ...record, version: CURRENT_CHAT_VERSION }, null, "\t"), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChat(id: string): ChatRecord | null {
|
function getChat(id: string): ChatRecord | null {
|
||||||
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(chatFile(id), "utf-8")) as ChatRecord;
|
parsed = JSON.parse(fs.readFileSync(chatFile(id), "utf-8"));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Honest refusal (owner ruling 2026-10-10): a record stamped NEWER than
|
||||||
|
// this build (the downgrade scenario) reads as absent, never misparsed —
|
||||||
|
// the next read-modify-write must not rewrite what it doesn't understand.
|
||||||
|
const v = (parsed as { version?: unknown }).version;
|
||||||
|
if (typeof v === "number" && v > CURRENT_CHAT_VERSION) {
|
||||||
|
console.error(`[chat ${id}] record stamped with schema version ${v} > ${CURRENT_CHAT_VERSION} — this app build is older; refusing to read it`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed as ChatRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listChats(): ChatSummary[] {
|
function listChats(): ChatSummary[] {
|
||||||
@@ -109,7 +128,7 @@ function saveMessages(id: string, messages: LhMessage[]): void {
|
|||||||
if (!record) throw new Error(`Chat not found: ${id}`);
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||||
record.messages = messages;
|
record.messages = messages;
|
||||||
record.updatedAt = new Date().toISOString();
|
record.updatedAt = new Date().toISOString();
|
||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
writeChat(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append one usage entry to the record's turn log — idempotent per
|
/** Append one usage entry to the record's turn log — idempotent per
|
||||||
@@ -122,7 +141,7 @@ function appendTurnUsage(id: string, entry: TurnUsage): void {
|
|||||||
record.turns = record.turns?.some((t) => t.id === entry.id)
|
record.turns = record.turns?.some((t) => t.id === entry.id)
|
||||||
? record.turns
|
? record.turns
|
||||||
: [...(record.turns ?? []), entry];
|
: [...(record.turns ?? []), entry];
|
||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
writeChat(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append one extraction-run usage entry to the record's extractions
|
/** Append one extraction-run usage entry to the record's extractions
|
||||||
@@ -134,7 +153,7 @@ function appendExtractionUsage(id: string, entry: ExtractionRunUsage): void {
|
|||||||
record.extractions = record.extractions?.some((r) => r.startedAt === entry.startedAt)
|
record.extractions = record.extractions?.some((r) => r.startedAt === entry.startedAt)
|
||||||
? record.extractions
|
? record.extractions
|
||||||
: [...(record.extractions ?? []), entry];
|
: [...(record.extractions ?? []), entry];
|
||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
writeChat(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the per-chat model/thinking settings — replace-whole (the
|
/** Persist the per-chat model/thinking settings — replace-whole (the
|
||||||
@@ -145,7 +164,7 @@ function saveSettings(id: string, settings: ChatSettings): void {
|
|||||||
// Like saveTree: a settings tweak is not conversation activity, so it
|
// Like saveTree: a settings tweak is not conversation activity, so it
|
||||||
// deliberately does NOT bump updatedAt (sidebar order must not churn).
|
// deliberately does NOT bump updatedAt (sidebar order must not churn).
|
||||||
record.settings = settings;
|
record.settings = settings;
|
||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
writeChat(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rename a session (the header's click-to-edit title, 2026-09-17). Like
|
/** Rename a session (the header's click-to-edit title, 2026-09-17). Like
|
||||||
@@ -157,7 +176,7 @@ function renameChat(id: string, title: string): ChatRecord {
|
|||||||
const record = getChat(id);
|
const record = getChat(id);
|
||||||
if (!record) throw new Error(`Chat not found: ${id}`);
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||||
record.title = title.trim() || "Untitled session";
|
record.title = title.trim() || "Untitled session";
|
||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
writeChat(record);
|
||||||
emit("chats-changed", {});
|
emit("chats-changed", {});
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
@@ -183,7 +202,7 @@ function saveTree(id: string, tree: PlanTree): void {
|
|||||||
record.tree = tree;
|
record.tree = tree;
|
||||||
// Deliberately does NOT bump updatedAt: tree mutations piggyback on the
|
// Deliberately does NOT bump updatedAt: tree mutations piggyback on the
|
||||||
// turn's saveMessages, and sidebar ordering shouldn't churn on tree edits.
|
// turn's saveMessages, and sidebar ordering shouldn't churn on tree edits.
|
||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
writeChat(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatStore: ChatStore = {
|
export const chatStore: ChatStore = {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* The graph-family FORMAT STAMP (schema-versioning wave, owner 2026-10-10):
|
||||||
|
* data/graph/format.json — one sidecar versioning the whole graph dir
|
||||||
|
* family (the four JSONL ledgers, sources.json, render-settings.json).
|
||||||
|
* JSONL ledgers can't carry inline stamps without per-line bloat, and the
|
||||||
|
* JSON files share the family's fate, so the stamp lives beside them.
|
||||||
|
* positions.json is rebuildable (owner ruling) and needs no protection.
|
||||||
|
*
|
||||||
|
* The contract:
|
||||||
|
* - LAZY v1: NO sidecar = v1 (the pre-stamp world stays valid forever);
|
||||||
|
* the first graph-family WRITE creates it. Reads never write.
|
||||||
|
* - Honest refusal (owner: refuse-honestly ruling): a sidecar stamped
|
||||||
|
* NEWER than this build (the downgrade scenario — user reverted the
|
||||||
|
* app) makes graph-family reads THROW. Read-modify-write stores must
|
||||||
|
* never rewrite what they don't understand; a ledger store must never
|
||||||
|
* silently skip lines it can't interpret as current-format data.
|
||||||
|
*
|
||||||
|
* Version constants: contracts/graph.ts CURRENT_GRAPH_FORMAT_VERSION.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { GRAPH_FORMAT_FILE } from "../../config.ts";
|
||||||
|
import { CURRENT_GRAPH_FORMAT_VERSION } from "../core/contracts/graph.ts";
|
||||||
|
|
||||||
|
function stampedVersion(): number | null {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(GRAPH_FORMAT_FILE, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return null; // no sidecar — the lazy-v1 pre-stamp world
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { version?: unknown };
|
||||||
|
return typeof parsed.version === "number" && Number.isInteger(parsed.version) ? parsed.version : null;
|
||||||
|
} catch {
|
||||||
|
return null; // corrupt sidecar reads as unstamped (v1), not fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** READ-SIDE GUARD: throws when the data dir was written by a NEWER build.
|
||||||
|
* Called by every graph-family read — never writes (the golden fixtures
|
||||||
|
* pin reads as byte-identical). */
|
||||||
|
export function assertGraphFormat(): void {
|
||||||
|
const v = stampedVersion();
|
||||||
|
if (v !== null && v > CURRENT_GRAPH_FORMAT_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
`Graph data is stamped with format version ${v}, but this build understands only ${CURRENT_GRAPH_FORMAT_VERSION}. ` +
|
||||||
|
"The app is older than its data — upgrade the app before touching the knowledge map.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WRITE-SIDE STAMP: creates the sidecar on the first graph-family write
|
||||||
|
* (lazy); afterwards a no-op existence-wise. Callers keep their own mkdir. */
|
||||||
|
export function ensureGraphFormat(): void {
|
||||||
|
if (stampedVersion() !== null) return;
|
||||||
|
fs.mkdirSync(path.dirname(GRAPH_FORMAT_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(GRAPH_FORMAT_FILE, JSON.stringify({ version: CURRENT_GRAPH_FORMAT_VERSION }, null, "\t") + "\n", "utf-8");
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
|
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
|
||||||
|
import { assertGraphFormat, ensureGraphFormat } from "./graph-format.ts";
|
||||||
import type {
|
import type {
|
||||||
GraphChain,
|
GraphChain,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
@@ -51,6 +52,7 @@ import type { GraphStore } from "../core/ports.ts";
|
|||||||
|
|
||||||
function appendLine<T>(file: string, record: T): void {
|
function appendLine<T>(file: string, record: T): void {
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
ensureGraphFormat(); // the lazy format stamp (graph-format.ts)
|
||||||
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf-8");
|
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ const isString = (v: unknown): v is string => typeof v === "string";
|
|||||||
|
|
||||||
/** Strict shape guards — anything not matching a W4 ledger shape is skipped. */
|
/** Strict shape guards — anything not matching a W4 ledger shape is skipped. */
|
||||||
function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
|
function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
|
||||||
|
assertGraphFormat(); // honest refusal on a newer-stamped data dir
|
||||||
return readRawLines(file).filter(isValid);
|
return readRawLines(file).filter(isValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +283,7 @@ const isPosition = (v: unknown): v is Position =>
|
|||||||
/** sources.json — { id: NotesSource }. Read-modify-write, NOT append-only:
|
/** sources.json — { id: NotesSource }. Read-modify-write, NOT append-only:
|
||||||
* the usage log lives inside the record (like a chat's JSON file). */
|
* the usage log lives inside the record (like a chat's JSON file). */
|
||||||
function readSourceMap(): Record<string, NotesSource> {
|
function readSourceMap(): Record<string, NotesSource> {
|
||||||
|
assertGraphFormat(); // honest refusal on a newer-stamped data dir
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(SOURCES_FILE, "utf-8");
|
raw = fs.readFileSync(SOURCES_FILE, "utf-8");
|
||||||
@@ -301,6 +305,7 @@ function readSourceMap(): Record<string, NotesSource> {
|
|||||||
|
|
||||||
function writeSourceMap(map: Record<string, NotesSource>): void {
|
function writeSourceMap(map: Record<string, NotesSource>): void {
|
||||||
fs.mkdirSync(path.dirname(SOURCES_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(SOURCES_FILE), { recursive: true });
|
||||||
|
ensureGraphFormat(); // the lazy format stamp (graph-format.ts)
|
||||||
fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8");
|
fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { RENDER_SETTINGS_FILE } from "../../config.ts";
|
import { RENDER_SETTINGS_FILE } from "../../config.ts";
|
||||||
import { DEFAULT_RENDER_SETTINGS, sanitizeRenderSettings, type GraphRenderSettings } from "../core/render-settings.ts";
|
import { DEFAULT_RENDER_SETTINGS, sanitizeRenderSettings, type GraphRenderSettings } from "../core/render-settings.ts";
|
||||||
|
import { assertGraphFormat, ensureGraphFormat } from "./graph-format.ts";
|
||||||
|
|
||||||
/** The persisted settings, or null when no file exists yet (first boot). */
|
/** The persisted settings, or null when no file exists yet (first boot). */
|
||||||
export function readRenderSettings(): GraphRenderSettings | null {
|
export function readRenderSettings(): GraphRenderSettings | null {
|
||||||
|
assertGraphFormat(); // honest refusal on a newer-stamped data dir
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(RENDER_SETTINGS_FILE, "utf-8");
|
raw = fs.readFileSync(RENDER_SETTINGS_FILE, "utf-8");
|
||||||
@@ -33,6 +35,7 @@ export function readRenderSettings(): GraphRenderSettings | null {
|
|||||||
export function writeRenderSettings(raw: unknown): GraphRenderSettings {
|
export function writeRenderSettings(raw: unknown): GraphRenderSettings {
|
||||||
const settings = sanitizeRenderSettings(raw);
|
const settings = sanitizeRenderSettings(raw);
|
||||||
fs.mkdirSync(path.dirname(RENDER_SETTINGS_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(RENDER_SETTINGS_FILE), { recursive: true });
|
||||||
|
ensureGraphFormat(); // the lazy format stamp (graph-format.ts)
|
||||||
fs.writeFileSync(RENDER_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8");
|
fs.writeFileSync(RENDER_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8");
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+9
@@ -36,3 +36,12 @@ core tool surface must appear in real data here.
|
|||||||
Run the app (`npm run dev`), exercise the feature against `data/`, then copy
|
Run the app (`npm run dev`), exercise the feature against `data/`, then copy
|
||||||
the produced record in verbatim. Do not hand-edit content; if a record needs
|
the produced record in verbatim. Do not hand-edit content; if a record needs
|
||||||
pruning, re-run the flow instead.
|
pruning, re-run the flow instead.
|
||||||
|
|
||||||
|
## Stamps
|
||||||
|
|
||||||
|
These files are deliberately the UNSTAMPED legacy shape (schema-stamp wave,
|
||||||
|
2026-10-10): lazy v1 means absence reads as current-valid, so this set pins
|
||||||
|
the pre-stamp read path forever. Stamped writes (chat `version: 1`, the
|
||||||
|
`graph/format.json` sidecar) are pinned by `tests/schema-stamps.test.ts`.
|
||||||
|
New records copied in after their first post-stamp write will carry the
|
||||||
|
stamps — that shape is equally current-valid.
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* SCHEMA STAMPS (schema-versioning wave, owner 2026-10-10): LAZY v1 for
|
||||||
|
* chats (record.version, stamped at the write choke point) and the graph
|
||||||
|
* family (data/graph/format.json sidecar — ledgers, sources,
|
||||||
|
* render-settings; positions deliberately unversioned, rebuildable).
|
||||||
|
* Honest refusal: records stamped NEWER than this build are refused on
|
||||||
|
* read (the downgrade scenario) — never misparsed, never rewritten.
|
||||||
|
*
|
||||||
|
* Runs under an isolated LH_DATA_DIR (the chat-birth-stamp discipline:
|
||||||
|
* the seam is set before any config-loading import, so every app-module
|
||||||
|
* import is dynamic).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
// ── the seam, BEFORE any config-loading import ──────────────────────────────
|
||||||
|
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-stamps-"));
|
||||||
|
process.env.LH_DATA_DIR = TMP_DATA;
|
||||||
|
|
||||||
|
const { chatStore } = await import("../src/store/chats.ts");
|
||||||
|
const { graphStore } = await import("../src/store/graph.ts");
|
||||||
|
const { readRenderSettings, writeRenderSettings } = await import("../src/store/render-settings.ts");
|
||||||
|
const { GRAPH_FORMAT_FILE } = await import("../config.ts");
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
fs.rmSync(TMP_DATA, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── chats: lazy stamp at the write choke point ──────────────────────────────
|
||||||
|
|
||||||
|
test("chats: createChat stamps version 1 on disk", () => {
|
||||||
|
const r = chatStore.createChat("stamp test");
|
||||||
|
assert.equal(r.version, 1, "the in-memory twin is stamped");
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(path.join(TMP_DATA, "chats", `${r.id}.json`), "utf8"));
|
||||||
|
assert.equal(onDisk.version, 1, "the record carries the stamp");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chats: an UNSTAMPED legacy record reads fine (absence = v1) and is stamped by its next write", () => {
|
||||||
|
const id = "chat_legacy000_stamp0";
|
||||||
|
fs.mkdirSync(path.join(TMP_DATA, "chats"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(TMP_DATA, "chats", `${id}.json`),
|
||||||
|
JSON.stringify({ id, title: "legacy", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messages: [] }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const r = chatStore.getChat(id);
|
||||||
|
assert.ok(r, "unstamped record reads (lazy v1)");
|
||||||
|
chatStore.saveMessages(id, []);
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(path.join(TMP_DATA, "chats", `${id}.json`), "utf8"));
|
||||||
|
assert.equal(onDisk.version, 1, "the write choke point stamped it");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chats: a NEWER-stamped record is refused honestly (the downgrade scenario)", () => {
|
||||||
|
const id = "chat_future000_stamp0";
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(TMP_DATA, "chats", `${id}.json`),
|
||||||
|
JSON.stringify({ id, title: "from the future", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messages: [], version: 99 }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
assert.equal(chatStore.getChat(id), null, "getChat refuses: reads as absent, never misparsed");
|
||||||
|
assert.ok(!chatStore.listChats().some((s) => s.id === id), "listChats skips it too");
|
||||||
|
// and the refused record is NOT rewritten or downgraded by the visit:
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(path.join(TMP_DATA, "chats", `${id}.json`), "utf8"));
|
||||||
|
assert.equal(onDisk.version, 99, "the future record survives untouched");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── the graph family: the lazy format.json sidecar ──────────────────────────
|
||||||
|
|
||||||
|
test("graph: reads never write — no sidecar appears from reading alone", () => {
|
||||||
|
assert.ok(!fs.existsSync(GRAPH_FORMAT_FILE), "no sidecar before any write");
|
||||||
|
assert.deepEqual(graphStore.readNodes(), [], "ledger reads fine without a sidecar");
|
||||||
|
assert.equal(readRenderSettings(), null, "render-settings reads fine without a sidecar");
|
||||||
|
assert.ok(!fs.existsSync(GRAPH_FORMAT_FILE), "reads did NOT create the sidecar (lazy = write-side only)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("graph: the first ledger append creates the sidecar at version 1", () => {
|
||||||
|
graphStore.appendNode({
|
||||||
|
id: "n_stamp0",
|
||||||
|
name: "Stamps",
|
||||||
|
statement: "The graph family carries one format sidecar.",
|
||||||
|
aliases: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
provenance: [],
|
||||||
|
});
|
||||||
|
assert.ok(fs.existsSync(GRAPH_FORMAT_FILE), "the first write stamped the family");
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(GRAPH_FORMAT_FILE, "utf8")).version, 1);
|
||||||
|
assert.equal(graphStore.readNodes().length, 1, "the ledger reads back with the sidecar present");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("graph: a NEWER-stamped sidecar is refused honestly", () => {
|
||||||
|
fs.mkdirSync(path.dirname(GRAPH_FORMAT_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(GRAPH_FORMAT_FILE, JSON.stringify({ version: 99 }), "utf-8");
|
||||||
|
assert.throws(() => graphStore.readNodes(), /older than its data|format version 99/);
|
||||||
|
assert.throws(() => graphStore.readSources(), /older than its data|format version 99/);
|
||||||
|
assert.throws(() => readRenderSettings(), /older than its data|format version 99/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("render-settings: its write stamps the family sidecar too", () => {
|
||||||
|
fs.rmSync(path.dirname(GRAPH_FORMAT_FILE), { recursive: true, force: true });
|
||||||
|
assert.ok(!fs.existsSync(GRAPH_FORMAT_FILE));
|
||||||
|
const written = writeRenderSettings({ sizeUnits: "world", nodeSize: 3 });
|
||||||
|
assert.equal(written.nodeSize, 3);
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(GRAPH_FORMAT_FILE, "utf8")).version, 1, "the render-settings write stamped the family");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── the fixtures stay the legacy pin: golden data reads unstamped ───────────
|
||||||
|
|
||||||
|
test("golden fixtures are deliberately the unstamped legacy shape (lazy v1 = current-valid)", () => {
|
||||||
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
const teach = JSON.parse(fs.readFileSync(path.join(here, "fixtures", "golden-data", "chats", "chat_mv27rrra_feff23b6.json"), "utf8"));
|
||||||
|
assert.equal(teach.version, undefined, "fixture records pin the unstamped read path");
|
||||||
|
assert.ok(!fs.existsSync(path.join(here, "fixtures", "golden-data", "graph", "format.json")), "no sidecar in the fixture graph dir");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user