mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 04:43:58 +00:00
Everything the two shipped PRD phases deferred, minus what is called out below.
Storage (Phase 2)
@logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
model at open, serves reads synchronously — a query evaluator that awaits per
triple pattern is unusable — and buffers mutations as SQL that flush() writes
in one transaction. Versioned idempotent migrations; indexes over subject,
predicate, entity-valued object, status, both time axes, aliases, and external
ids; FTS5 for label/alias search. The append-only status log is replayed on
open, so retractions, supersessions, and merge redirects survive a reopen.
REST + SSE + OpenAPI (Phase 2)
16 paths under /api/ontologies in logicsrc-web, described at
/api/ontologies/openapi and referencing the published JSON Schemas rather
than restating them. No token is read-only; a curator token can apply; an
agent token can propose and cannot apply. Idempotency-Key on mutations,
revision ETags, 409 on a stale base revision, and an SSE stream that emits
the same event objects as the JSON endpoint.
MCP (Phase 2)
OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
proposals, never applies — the denial is the shared policy layer, not a
second rule that could drift.
Interoperability (Phase 3)
RDF/Turtle export and import of the reified profile, plus the plain triple
for asserted relationships so a consumer wanting only the accepted graph gets
one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
unmapped in both the return value and the generated Turtle, because a shape
that quietly means something narrower is worse than no shape.
Source adapters (Phase 3)
CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
PROPOSED change-set operations with source, evidence selector, run id, and
confidence attached; fetch is injected so ingestion is offline and testable.
Each declares its capabilities, so "nothing was deleted upstream" is never
confused with "this adapter cannot see deletions" — none of the seven can.
TUI + explorer
Keyboard-first panels (types, entities, claims, sources, queries, change
sets, validation, audit) as plain strings that survive SSH and 60 columns;
status is a glyph and a word, never colour alone; the key bar wraps rather
than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
at /openontology/explore with entity and claim views showing status, both
clocks, confidence, sources, evidence, and append-only history — plus an
/openprd page for the companion standard.
Bugs found and fixed while testing
- the API built a new engine per request, so `explain` could never find a
resultId from a prior request; engines are now cached per role
- the TUI status bar called engine.validateOntologyPackage(), appending a
package.validated event on every repaint; it now uses the pure validator
Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.
Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".
Refs: prd/0001-add-logicsrc-openontology-spec.md
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
6.7 KiB
TypeScript
184 lines
6.7 KiB
TypeScript
import { createClient } from "@libsql/client";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import { createOntologyEngine } from "./engine.js";
|
|
import { createLibsqlStore, migrate, searchEntities } from "./libsql.js";
|
|
import { loadPrdFixturePackage } from "./test-helpers.js";
|
|
import { localActor } from "./policy.js";
|
|
|
|
const dirs: string[] = [];
|
|
afterAll(() => {
|
|
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function dbUrl(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "openontology-libsql-"));
|
|
dirs.push(dir);
|
|
return `file:${join(dir, "ontology.db")}`;
|
|
}
|
|
|
|
const NOW = "2026-07-26T00:00:00Z";
|
|
|
|
describe("libSQL adapter", () => {
|
|
it("applies migrations once and is idempotent", async () => {
|
|
const client = createClient({ url: dbUrl() });
|
|
expect(await migrate(client)).toBeGreaterThan(0);
|
|
expect(await migrate(client)).toBe(0);
|
|
const tables = await client.execute(
|
|
"SELECT name FROM sqlite_master WHERE type IN ('table','index') ORDER BY name"
|
|
);
|
|
const names = tables.rows.map((row) => String(row.name));
|
|
expect(names).toEqual(expect.arrayContaining(["claims", "entities", "status_log", "ontology_events"]));
|
|
expect(names.filter((n) => n.startsWith("idx_claims")).length).toBeGreaterThanOrEqual(5);
|
|
client.close();
|
|
});
|
|
|
|
it("seeds a package and hydrates it back identically", async () => {
|
|
const pkg = loadPrdFixturePackage();
|
|
const url = dbUrl();
|
|
|
|
const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg });
|
|
expect(store.listEntities()).toHaveLength(pkg.data.entities.length);
|
|
expect(store.listClaims({ status: ["asserted"] }).length).toBeGreaterThan(0);
|
|
store.close();
|
|
|
|
// Re-open against the same file: no seed, everything comes from SQL.
|
|
const reopened = await createLibsqlStore({
|
|
client: createClient({ url }),
|
|
ontology: pkg.manifest.id
|
|
});
|
|
expect(reopened.listEntities().map((e) => e.id).sort()).toEqual(
|
|
pkg.data.entities.map((e) => e.id).sort()
|
|
);
|
|
expect(reopened.getManifest().id).toBe(pkg.manifest.id);
|
|
reopened.close();
|
|
});
|
|
|
|
it("persists an applied change set across a reopen", async () => {
|
|
const pkg = loadPrdFixturePackage();
|
|
const url = dbUrl();
|
|
|
|
const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg });
|
|
let n = 0;
|
|
const engine = createOntologyEngine({
|
|
store,
|
|
actor: localActor("curator@example.org"),
|
|
clock: () => NOW,
|
|
idFactory: (kind) => `${kind}:${String(++n).padStart(4, "0")}`
|
|
});
|
|
|
|
const changeSet = engine.createOntologyChangeSet({
|
|
title: "persisted",
|
|
operations: [
|
|
{
|
|
op: "assert-claim",
|
|
value: {
|
|
subject: "test:person:alice",
|
|
predicate: "worksOn",
|
|
object: { entity: "test:project:ledger-indexer" },
|
|
sources: ["test:source:repo"]
|
|
}
|
|
}
|
|
]
|
|
});
|
|
engine.approveOntologyChangeSet(changeSet.id);
|
|
const applied = engine.applyOntologyChangeSet(changeSet.id);
|
|
|
|
expect(store.pending()).toBeGreaterThan(0);
|
|
const flushed = await store.flush();
|
|
expect(flushed.statements).toBeGreaterThan(0);
|
|
expect(store.pending()).toBe(0);
|
|
store.close();
|
|
|
|
const reopened = await createLibsqlStore({
|
|
client: createClient({ url }),
|
|
ontology: pkg.manifest.id
|
|
});
|
|
const reread = createOntologyEngine({ store: reopened, actor: localActor() });
|
|
const rows = reread.queryOntology({
|
|
match: [{ subject: "test:person:alice", predicate: "worksOn", object: "?p" }]
|
|
}).rows;
|
|
|
|
expect(rows).toHaveLength(2);
|
|
expect(reopened.getClaim(applied.addedClaims[0]!)).toBeDefined();
|
|
expect(reopened.listChangeSets()).toHaveLength(1);
|
|
expect(reopened.listEvents().length).toBeGreaterThan(0);
|
|
expect(reopened.revision()).toBe("data-000001");
|
|
reopened.close();
|
|
});
|
|
|
|
it("replays the append-only status log so retractions survive a reopen", async () => {
|
|
const pkg = loadPrdFixturePackage();
|
|
const url = dbUrl();
|
|
|
|
const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg });
|
|
const engine = createOntologyEngine({ store, actor: localActor(), clock: () => NOW });
|
|
|
|
const target = engine.queryOntology({
|
|
match: [{ subject: "test:person:carol", predicate: "worksOn", object: "?p" }]
|
|
}).rows[0]!.claims[0]!;
|
|
|
|
const changeSet = engine.createOntologyChangeSet({
|
|
title: "retract",
|
|
operations: [{ op: "retract-claim", target, reason: "left the project" }]
|
|
});
|
|
engine.approveOntologyChangeSet(changeSet.id);
|
|
engine.applyOntologyChangeSet(changeSet.id);
|
|
await store.flush();
|
|
store.close();
|
|
|
|
const reopened = await createLibsqlStore({
|
|
client: createClient({ url }),
|
|
ontology: pkg.manifest.id
|
|
});
|
|
// The claim row is still there; its effective status came from the log.
|
|
expect(reopened.getClaim(target)?.status).toBe("retracted");
|
|
expect(reopened.claimHistory(target).map((h) => h.status)).toEqual(["asserted", "retracted"]);
|
|
reopened.close();
|
|
});
|
|
|
|
it("keeps merge redirects resolvable after a reopen", async () => {
|
|
const pkg = loadPrdFixturePackage();
|
|
const url = dbUrl();
|
|
|
|
const store = await createLibsqlStore({ client: createClient({ url }), seed: pkg });
|
|
const engine = createOntologyEngine({ store, actor: localActor(), clock: () => NOW });
|
|
const changeSet = engine.createOntologyChangeSet({
|
|
title: "merge",
|
|
operations: [{ op: "merge-entity", source: "test:person:carol", target: "test:person:bob" }]
|
|
});
|
|
engine.approveOntologyChangeSet(changeSet.id);
|
|
engine.applyOntologyChangeSet(changeSet.id);
|
|
await store.flush();
|
|
store.close();
|
|
|
|
const reopened = await createLibsqlStore({
|
|
client: createClient({ url }),
|
|
ontology: pkg.manifest.id
|
|
});
|
|
expect(reopened.getEntity("test:person:carol")?.id).toBe("test:person:bob");
|
|
reopened.close();
|
|
});
|
|
|
|
it("indexes entities for full-text search", async () => {
|
|
const pkg = loadPrdFixturePackage();
|
|
const url = dbUrl();
|
|
const client = createClient({ url });
|
|
const store = await createLibsqlStore({ client, seed: pkg });
|
|
|
|
const hits = await searchEntities(client, pkg.manifest.id, "Alice");
|
|
expect(hits.map((hit) => hit.id)).toContain("test:person:alice");
|
|
|
|
const none = await searchEntities(client, pkg.manifest.id, "nonexistentterm");
|
|
expect(none).toHaveLength(0);
|
|
store.close();
|
|
});
|
|
|
|
it("refuses to open an unknown ontology with no seed", async () => {
|
|
await expect(
|
|
createLibsqlStore({ client: createClient({ url: dbUrl() }), ontology: "not-there" })
|
|
).rejects.toThrow(/not in this database/);
|
|
});
|
|
});
|