feat: append-only version history + bliss CLI ergonomics
Snapshot the code-bearing fields of routes (verb/path/handler), templates (name/content/test_object), and db libraries (name/library) on every content-changing save, so nothing edited is ever lost. Recorded in a new `versions` table via recordVersion() hooked into the create/update functions in db.js (the single write choke point), deduped against the latest snapshot so no-op saves, error-flag-only route writes, and server-restart WS re-bootstraps don't pile up. The live row stays current; old snapshots are the undo trail. Read-only, no revert UI yet. - migrations/004_add_versions.sql: versions table + indexes - db.js: recordVersion/getVersions/getVersion + create/update hooks - plumbing.js: /versions/:id and per-entity .../versions list endpoints - bliss: `versions <type> <sid> <id>` and `version <id>` reads; `update-settings` (edit route_prefix/head_injection, preserving the untouched field); `use <url>` sticky target persisted to ~/.bliss/target so BLISS_URL needn't be re-exported each call - client.js: target-file precedence (BLISS_URL > ~/.bliss/target > localhost) - SKILL.md: document the above Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
03a9d2143d
commit
42f4aacf7c
6 changed files with 211 additions and 7 deletions
|
|
@ -23,6 +23,15 @@ It logs in once and caches the session cookie under `~/.bliss/`, re-logging-in
|
||||||
automatically when it expires. Run it as `node bliss-cli/bliss <command>` (or
|
automatically when it expires. Run it as `node bliss-cli/bliss <command>` (or
|
||||||
`./bliss-cli/bliss` if on PATH).
|
`./bliss-cli/bliss` if on PATH).
|
||||||
|
|
||||||
|
Instead of re-exporting `BLISS_URL` every time, set a **sticky target** once —
|
||||||
|
it persists to `~/.bliss/target` and is used until you change it (a `BLISS_URL`
|
||||||
|
env var still overrides it when set):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bliss use https://your-instance.example.com # set the target instance
|
||||||
|
bliss use # print the current target
|
||||||
|
```
|
||||||
|
|
||||||
## The model (what you're editing)
|
## The model (what you're editing)
|
||||||
|
|
||||||
A **Structure** is a mini web app. It has **routes** (a VERB + path + a JS
|
A **Structure** is a mini web app. It has **routes** (a VERB + path + a JS
|
||||||
|
|
@ -42,14 +51,34 @@ bliss template <sid> <tid> # one template incl. content + test_objec
|
||||||
bliss db <sid> <dbid> # one db incl. its library source
|
bliss db <sid> <dbid> # one db incl. its library source
|
||||||
bliss files <sid> # files attached to a structure
|
bliss files <sid> # files attached to a structure
|
||||||
bliss logs <sid> <rid> [--since <id>] # route logs (console.log output + errors)
|
bliss logs <sid> <rid> [--since <id>] # route logs (console.log output + errors)
|
||||||
|
|
||||||
|
bliss versions <route|template|db> <sid> <id> # save history (newest first)
|
||||||
|
bliss version <versionId> # one version incl. full snapshot
|
||||||
```
|
```
|
||||||
|
|
||||||
All emit JSON, so: `bliss structure 3 | jq '.routes[].path'`.
|
All emit JSON, so: `bliss structure 3 | jq '.routes[].path'`.
|
||||||
|
|
||||||
|
## Version history (undo trail)
|
||||||
|
|
||||||
|
Every content-changing save of a route handler, template, or db library records
|
||||||
|
an append-only snapshot. The live row is always the current version; old ones
|
||||||
|
are kept so nothing edited is ever lost. `versions` lists the summaries (id,
|
||||||
|
`created_at`, `bytes`) newest-first; `version <id>` returns the full snapshot
|
||||||
|
(the versioned fields). No-op re-saves are deduped, so identical content doesn't
|
||||||
|
pile up. There's no revert command yet — to roll back, fetch an old snapshot and
|
||||||
|
re-`update-*` its content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bliss versions route 3 12 # what saves exist
|
||||||
|
bliss version 24 | jq -r .snapshot.handler > /tmp/old.js
|
||||||
|
bliss update-route 3 12 --handler-file /tmp/old.js # restore it as a new save
|
||||||
|
```
|
||||||
|
|
||||||
## Writes
|
## Writes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bliss create-structure "<name>" # -> {location, id}
|
bliss create-structure "<name>" # -> {location, id}
|
||||||
|
bliss update-settings <sid> [--route-prefix <p>] [--head-injection <s>|--head-injection-file <f>]
|
||||||
bliss clone <sid> --name "<n>" --prefix /p [--clone-dbs]
|
bliss clone <sid> --name "<n>" --prefix /p [--clone-dbs]
|
||||||
|
|
||||||
bliss create-route <sid> <VERB> <path> # VERB: GET|POST|PUT|DELETE|WS ; -> {id}
|
bliss create-route <sid> <VERB> <path> # VERB: GET|POST|PUT|DELETE|WS ; -> {id}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,16 @@ function need(val, msg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands = {
|
const commands = {
|
||||||
|
// Set (or, with no arg, show) the sticky target instance. Persisted to
|
||||||
|
// ~/.bliss/target so it survives across invocations without re-exporting
|
||||||
|
// BLISS_URL. BLISS_URL in the env still overrides this when set.
|
||||||
|
async use({ pos }) {
|
||||||
|
if (pos[0]) {
|
||||||
|
out("target set: " + c.setTarget(pos[0]));
|
||||||
|
} else {
|
||||||
|
out(c.BASE);
|
||||||
|
}
|
||||||
|
},
|
||||||
// ---- reads (JSON via /plumbing) ----
|
// ---- reads (JSON via /plumbing) ----
|
||||||
async structures() {
|
async structures() {
|
||||||
out(await c.getJSON("/plumbing/structures"));
|
out(await c.getJSON("/plumbing/structures"));
|
||||||
|
|
@ -94,12 +104,52 @@ const commands = {
|
||||||
const q = flags.since ? `?since=${encodeURIComponent(flags.since)}` : "";
|
const q = flags.since ? `?since=${encodeURIComponent(flags.since)}` : "";
|
||||||
out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}/logs${q}`));
|
out(await c.getJSON(`/plumbing/structures/${sid}/routes/${rid}/logs${q}`));
|
||||||
},
|
},
|
||||||
|
// List the saved versions (newest first) of a route handler, template, or db
|
||||||
|
// library. Each row is a summary (id, created_at, bytes) — `bliss version
|
||||||
|
// <id>` fetches the full snapshot.
|
||||||
|
async versions({ pos }) {
|
||||||
|
const [type, sid, id] = pos;
|
||||||
|
const paths = { route: "routes", template: "templates", db: "dbs" };
|
||||||
|
need(
|
||||||
|
paths[type] && sid && id,
|
||||||
|
"usage: bliss versions <route|template|db> <structureId> <id>",
|
||||||
|
);
|
||||||
|
out(
|
||||||
|
await c.getJSON(
|
||||||
|
`/plumbing/structures/${sid}/${paths[type]}/${id}/versions`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// One version including its full snapshot (the versioned fields).
|
||||||
|
async version({ pos }) {
|
||||||
|
const vid = need(pos[0], "usage: bliss version <versionId>");
|
||||||
|
out(await c.getJSON(`/plumbing/versions/${vid}`));
|
||||||
|
},
|
||||||
|
|
||||||
// ---- writes (via /workshop) ----
|
// ---- writes (via /workshop) ----
|
||||||
async "create-structure"({ pos }) {
|
async "create-structure"({ pos }) {
|
||||||
const name = need(pos[0], "usage: bliss create-structure <name>");
|
const name = need(pos[0], "usage: bliss create-structure <name>");
|
||||||
out(await c.writeAndGetId("POST", "/workshop", { name }));
|
out(await c.writeAndGetId("POST", "/workshop", { name }));
|
||||||
},
|
},
|
||||||
|
// Edit a structure's settings (route prefix, head injection). The settings
|
||||||
|
// endpoint wants both fields, so we fetch the current structure and only
|
||||||
|
// override what was passed — leaving the other untouched.
|
||||||
|
async "update-settings"({ pos, flags }) {
|
||||||
|
const sid = need(
|
||||||
|
pos[0],
|
||||||
|
"usage: bliss update-settings <structureId> [--route-prefix <p>] [--head-injection <s> | --head-injection-file <f>]",
|
||||||
|
);
|
||||||
|
const { structure } = await c.getJSON(`/plumbing/structures/${sid}`);
|
||||||
|
const head =
|
||||||
|
flags["head-injection-file"] !== undefined
|
||||||
|
? readSource(flags["head-injection-file"])
|
||||||
|
: flags["head-injection"];
|
||||||
|
const body = {
|
||||||
|
route_prefix: flags["route-prefix"] ?? structure.route_prefix ?? "",
|
||||||
|
head_injection: head ?? structure.head_injection ?? "",
|
||||||
|
};
|
||||||
|
out(await c.writeText("PUT", `/workshop/${sid}/settings`, body));
|
||||||
|
},
|
||||||
async clone({ pos, flags }) {
|
async clone({ pos, flags }) {
|
||||||
const sid = need(
|
const sid = need(
|
||||||
pos[0],
|
pos[0],
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,37 @@ const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const BASE = (process.env.BLISS_URL || "http://localhost:3000").replace(
|
const JAR_DIR = path.join(os.homedir(), ".bliss");
|
||||||
/\/$/,
|
// Sticky target so callers don't have to re-set BLISS_URL on every invocation
|
||||||
"",
|
// (shell env doesn't persist between separate CLI runs). Precedence:
|
||||||
);
|
// BLISS_URL env > ~/.bliss/target > localhost default
|
||||||
|
const TARGET_FILE = path.join(JAR_DIR, "target");
|
||||||
|
|
||||||
|
function readTarget() {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(TARGET_FILE, "utf8").trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTarget(url) {
|
||||||
|
const clean = url.replace(/\/$/, "");
|
||||||
|
fs.mkdirSync(JAR_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(TARGET_FILE, clean + "\n");
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = (
|
||||||
|
process.env.BLISS_URL ||
|
||||||
|
readTarget() ||
|
||||||
|
"http://localhost:3000"
|
||||||
|
).replace(/\/$/, "");
|
||||||
const USER = process.env.BLISS_USER;
|
const USER = process.env.BLISS_USER;
|
||||||
const PASS = process.env.BLISS_PASS;
|
const PASS = process.env.BLISS_PASS;
|
||||||
|
|
||||||
// Persist the session cookie so we don't log in on every CLI invocation. Keyed
|
// Persist the session cookie so we don't log in on every CLI invocation. Keyed
|
||||||
// by base URL so pointing at a different instance uses a different jar.
|
// by base URL so pointing at a different instance uses a different jar.
|
||||||
const JAR_DIR = path.join(os.homedir(), ".bliss");
|
|
||||||
const JAR_FILE = path.join(
|
const JAR_FILE = path.join(
|
||||||
JAR_DIR,
|
JAR_DIR,
|
||||||
"session-" + Buffer.from(BASE).toString("hex").slice(0, 24) + ".txt",
|
"session-" + Buffer.from(BASE).toString("hex").slice(0, 24) + ".txt",
|
||||||
|
|
@ -146,6 +167,7 @@ async function getText(urlPath) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BASE,
|
BASE,
|
||||||
|
setTarget,
|
||||||
request,
|
request,
|
||||||
getJSON,
|
getJSON,
|
||||||
getText,
|
getText,
|
||||||
|
|
|
||||||
71
db.js
71
db.js
|
|
@ -137,6 +137,8 @@ function createRoute(verb, path, structureId, handler) {
|
||||||
createScaffoldPage(routeId);
|
createScaffoldPage(routeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordVersion("route", routeId, structureId, { verb, path, handler });
|
||||||
|
|
||||||
return routeId; // Returns the route_id of the newly created route
|
return routeId; // Returns the route_id of the newly created route
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,12 +177,66 @@ function update(table, fields, obj) {
|
||||||
return db.prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`).run(...values);
|
return db.prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`).run(...values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- version history (append-only) --------------------------------------
|
||||||
|
//
|
||||||
|
// Snapshot the code-bearing fields of a route/template/db every time they
|
||||||
|
// change, so nothing a user (or the plumber) edits is ever truly lost. Deduped
|
||||||
|
// against the latest snapshot for that entity, so no-op saves, error-flag-only
|
||||||
|
// route writes, and server-restart WS re-bootstraps don't pile up noise.
|
||||||
|
// snapshotFor() defines exactly which fields are versioned per type; keep the
|
||||||
|
// key order stable so the dedup string compare is reliable.
|
||||||
|
function snapshotFor(entityType, obj) {
|
||||||
|
switch (entityType) {
|
||||||
|
case "route":
|
||||||
|
return { verb: obj.verb, path: obj.path, handler: obj.handler };
|
||||||
|
case "template":
|
||||||
|
return { name: obj.name, content: obj.content, test_object: obj.test_object };
|
||||||
|
case "db":
|
||||||
|
return { name: obj.name, library: obj.library };
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown version entity type: ${entityType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordVersion(entityType, entityId, structureId, obj) {
|
||||||
|
const json = JSON.stringify(snapshotFor(entityType, obj));
|
||||||
|
const last = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT snapshot FROM versions WHERE entity_type = ? AND entity_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.get(entityType, entityId);
|
||||||
|
if (last && last.snapshot === json) return; // content unchanged — skip
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO versions (entity_type, entity_id, structure_id, snapshot) VALUES (?, ?, ?, ?)",
|
||||||
|
).run(entityType, entityId, structureId, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version list for one entity (newest first). Omits the snapshot body to stay
|
||||||
|
// scannable; fetch a single version to get its full content.
|
||||||
|
function getVersions(entityType, entityId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, entity_type, entity_id, structure_id, created_at, length(snapshot) AS bytes
|
||||||
|
FROM versions WHERE entity_type = ? AND entity_id = ? ORDER BY id DESC`,
|
||||||
|
)
|
||||||
|
.all(entityType, entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One version with its full snapshot parsed back into fields.
|
||||||
|
function getVersion(versionId) {
|
||||||
|
const row = db.prepare("SELECT * FROM versions WHERE id = ?").get(versionId);
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...row, snapshot: JSON.parse(row.snapshot) };
|
||||||
|
}
|
||||||
|
|
||||||
function updateRoute(route) {
|
function updateRoute(route) {
|
||||||
update("routes", ["verb", "path", "structure_id", "handler", "updated_at", "error"], route);
|
update("routes", ["verb", "path", "structure_id", "handler", "updated_at", "error"], route);
|
||||||
|
recordVersion("route", route.id, route.structure_id, route);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDb(appDb) {
|
function updateDb(appDb) {
|
||||||
update("dbs", ["name", "library"], appDb);
|
update("dbs", ["name", "library"], appDb);
|
||||||
|
recordVersion("db", appDb.id, appDb.structure_id, appDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStruct(struct) {
|
function updateStruct(struct) {
|
||||||
|
|
@ -189,6 +245,7 @@ function updateStruct(struct) {
|
||||||
|
|
||||||
function updateTemplate(template) {
|
function updateTemplate(template) {
|
||||||
update("templates", ["content", "name", "test_object"], template);
|
update("templates", ["content", "name", "test_object"], template);
|
||||||
|
recordVersion("template", template.id, template.structure_id, template);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTemplates(structureId) {
|
function getTemplates(structureId) {
|
||||||
|
|
@ -210,11 +267,17 @@ function getTemplateContentByName(structId, name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTemplate(structureId, name, content, testObjectString) {
|
function createTemplate(structureId, name, content, testObjectString) {
|
||||||
return db
|
const id = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO templates (structure_id, name, content, test_object) VALUES (?, ?, ?, ?)",
|
"INSERT INTO templates (structure_id, name, content, test_object) VALUES (?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.run(structureId, name, content, testObjectString).lastInsertRowid;
|
.run(structureId, name, content, testObjectString).lastInsertRowid;
|
||||||
|
recordVersion("template", id, structureId, {
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
test_object: testObjectString,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDbsForStructure(structureId) {
|
function getDbsForStructure(structureId) {
|
||||||
|
|
@ -282,7 +345,9 @@ function createDb(structId, name) {
|
||||||
return dbId;
|
return dbId;
|
||||||
});
|
});
|
||||||
|
|
||||||
return transaction();
|
const dbId = transaction();
|
||||||
|
recordVersion("db", dbId, structId, { name, library: "" });
|
||||||
|
return dbId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachDb(structId, dbId, alias) {
|
function attachDb(structId, dbId, alias) {
|
||||||
|
|
@ -615,6 +680,8 @@ module.exports = {
|
||||||
updateDb,
|
updateDb,
|
||||||
updateStruct,
|
updateStruct,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
|
getVersions,
|
||||||
|
getVersion,
|
||||||
getTemplater,
|
getTemplater,
|
||||||
getTemplates,
|
getTemplates,
|
||||||
getTemplate,
|
getTemplate,
|
||||||
|
|
|
||||||
15
migrations/004_add_versions.sql
Normal file
15
migrations/004_add_versions.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Append-only version history for the code-bearing entities: route handlers,
|
||||||
|
-- template content, and db libraries. Every content-changing save records a
|
||||||
|
-- snapshot here (see recordVersion in db.js). The live rows in routes/templates/
|
||||||
|
-- dbs remain the current version; this table is the "plumber's" undo trail.
|
||||||
|
CREATE TABLE versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('route', 'template', 'db')),
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
structure_id INTEGER,
|
||||||
|
snapshot TEXT NOT NULL, -- JSON of the versioned fields
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_versions_entity ON versions (entity_type, entity_id, id);
|
||||||
|
CREATE INDEX idx_versions_structure ON versions (structure_id, id);
|
||||||
21
plumbing.js
21
plumbing.js
|
|
@ -72,6 +72,27 @@ router.get(
|
||||||
json((req) => model.getFilesForStruct(req.params.id)),
|
json((req) => model.getFilesForStruct(req.params.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Version history (newest first) for a route handler, template, or db library.
|
||||||
|
// Each row is a summary; fetch /versions/:versionId for the full snapshot.
|
||||||
|
router.get(
|
||||||
|
"/structures/:id/routes/:routeId/versions",
|
||||||
|
json((req) => model.getVersions("route", req.params.routeId)),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/structures/:id/templates/:templateId/versions",
|
||||||
|
json((req) => model.getVersions("template", req.params.templateId)),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/structures/:id/dbs/:dbId/versions",
|
||||||
|
json((req) => model.getVersions("db", req.params.dbId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One version, including its full snapshot (the versioned fields).
|
||||||
|
router.get(
|
||||||
|
"/versions/:versionId",
|
||||||
|
json((req) => model.getVersion(req.params.versionId)),
|
||||||
|
);
|
||||||
|
|
||||||
// Logs for a route. ?since=<id> returns only newer rows (for polling).
|
// Logs for a route. ?since=<id> returns only newer rows (for polling).
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/routes/:routeId/logs",
|
"/structures/:id/routes/:routeId/logs",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue