feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

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>
This commit is contained in:
Anthony Ettinger 2026-07-28 05:10:33 -07:00 committed by GitHub
parent 296775e003
commit da5f6f8381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 6939 additions and 23 deletions

View file

@ -0,0 +1,272 @@
import { describe, expect, it } from "vitest";
import {
csvAdapter,
githubAdapter,
httpApiAdapter,
jsonAdapter,
listAdapters,
markdownAdapter,
ndjsonAdapter,
parseCsv,
yamlAdapter,
type FetchLike,
type IngestContext
} from "./adapters.js";
import { createOntologyEngine } from "./engine.js";
import { loadPrdFixturePackage } from "./test-helpers.js";
import { proposerActor } from "./policy.js";
const ctx: IngestContext = {
prefix: "test",
actor: "agent:importer",
runId: "run_import_1",
now: "2026-07-26T00:00:00Z",
confidence: 0.6,
license: "CC-BY-4.0"
};
const MAPPING = {
entityType: "Person",
idField: "handle",
nameField: "name",
idSegment: "person",
aliasField: "aliases",
externalIds: { github: "github" },
properties: { role: "role" },
relationships: { worksOn: { field: "projects", targetSegment: "project" } }
};
describe("adapter capabilities", () => {
it("declares what every adapter can and cannot do (R118)", () => {
const adapters = listAdapters();
expect(adapters.map((a) => a.id).sort()).toEqual([
"csv",
"github",
"http-api",
"json",
"markdown",
"ndjson",
"yaml"
]);
for (const adapter of adapters) {
expect(adapter.capabilities).toHaveProperty("publicData");
expect(adapter.capabilities).toHaveProperty("deletions");
}
// None of the shipped adapters can see upstream deletions — say so.
expect(adapters.every((a) => a.capabilities.deletions === false)).toBe(true);
});
});
describe("CSV", () => {
const csv = `handle,name,role,github,projects
alice,Alice Reyes,Protocol engineer,areyes,"zk-prover,ledger-indexer"
bob,Bob Nakamura,Indexer lead,bnak,ledger-indexer
`;
it("parses quoted fields and embedded separators", () => {
const rows = parseCsv(csv);
expect(rows).toHaveLength(2);
expect(rows[0]!.projects).toBe("zk-prover,ledger-indexer");
});
it("handles escaped quotes", () => {
const rows = parseCsv('a,b\n"say ""hi""",2\n');
expect(rows[0]!.a).toBe('say "hi"');
});
it("maps rows to entities and claims", () => {
const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx);
expect(result.entities.map((e) => e.id)).toEqual(["test:person:alice", "test:person:bob"]);
expect(result.entities[0]!.externalIds).toEqual({ github: "areyes" });
const worksOn = result.claims.filter((c) => c.predicate === "worksOn");
expect(worksOn).toHaveLength(3);
expect(worksOn[0]!.object).toEqual({ entity: "test:project:zk-prover" });
});
it("proposes rather than asserts, and records the run (R113/R114)", () => {
const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx);
expect(result.claims.every((c) => c.status === "proposed")).toBe(true);
expect(result.claims.every((c) => c.runId === "run_import_1")).toBe(true);
expect(result.claims.every((c) => c.confidence === 0.6)).toBe(true);
});
it("attaches source and evidence with a line selector", () => {
const result = csvAdapter.ingest({ uri: "https://example.org/people.csv", content: csv, mapping: MAPPING }, ctx);
expect(result.sources[0]!.contentHash).toMatch(/^sha256:/);
expect(result.sources[0]!.license).toBe("CC-BY-4.0");
expect(result.evidence[0]!.selector).toEqual({ type: "line-range", start: 2, end: 2 });
expect(result.claims[0]!.sources).toEqual([result.sources[0]!.id]);
});
it("skips a row with no id and says so", () => {
const result = csvAdapter.ingest(
{ uri: "x.csv", content: "handle,name\n,Nobody\nalice,Alice\n", mapping: MAPPING },
ctx
);
expect(result.entities).toHaveLength(1);
expect(result.warnings[0]).toMatch(/no handle/);
});
});
describe("JSON, YAML, NDJSON", () => {
const records = [{ handle: "alice", name: "Alice Reyes" }];
it("reads a JSON array", () => {
const result = jsonAdapter.ingest(
{ uri: "x.json", content: JSON.stringify(records), mapping: MAPPING },
ctx
);
expect(result.entities[0]!.canonicalName).toBe("Alice Reyes");
});
it("finds the array inside a wrapper object", () => {
const result = jsonAdapter.ingest(
{ uri: "x.json", content: JSON.stringify({ data: records }), mapping: MAPPING },
ctx
);
expect(result.entities).toHaveLength(1);
});
it("reads a YAML sequence", () => {
const result = yamlAdapter.ingest(
{ uri: "x.yaml", content: "- handle: alice\n name: Alice Reyes\n", mapping: MAPPING },
ctx
);
expect(result.entities[0]!.id).toBe("test:person:alice");
});
it("reports a bad NDJSON line instead of aborting the file", () => {
const result = ndjsonAdapter.ingest(
{ uri: "x.ndjson", content: `${JSON.stringify(records[0])}\nnot json\n`, mapping: MAPPING },
ctx
);
expect(result.entities).toHaveLength(1);
expect(result.warnings[0]).toMatch(/line 2 is not valid JSON/);
});
});
describe("Markdown", () => {
const md = `# Title
## ZK Prover
See [the repo](https://example.org/zk) and [docs](https://example.org/docs).
## Ledger Indexer
No links here.
`;
it("turns headings into entities and links into claims", () => {
const result = markdownAdapter.ingest({ uri: "x.md", content: md, entityType: "Project" }, ctx);
expect(result.entities.map((e) => e.canonicalName)).toEqual(["ZK Prover", "Ledger Indexer"]);
expect(result.claims).toHaveLength(2);
expect(result.claims[0]!.object).toEqual({ value: "https://example.org/zk" });
});
it("warns when the document has no headings at the requested level", () => {
const result = markdownAdapter.ingest({ uri: "x.md", content: "just prose\n", entityType: "Project" }, ctx);
expect(result.warnings[0]).toMatch(/no level-2 headings/);
});
});
describe("HTTP and GitHub", () => {
const fetchOk = (bodies: Record<string, unknown>): FetchLike =>
async (url) => {
const key = Object.keys(bodies).find((k) => url.includes(k));
if (!key) return { ok: false, status: 404, text: async () => "{}" };
return { ok: true, status: 200, text: async () => JSON.stringify(bodies[key]) };
};
it("reads a JSON endpoint through an injected fetch", async () => {
const result = await httpApiAdapter.ingest(
{
url: "https://example.org/api/people",
mapping: MAPPING,
path: "items",
fetch: fetchOk({ "/api/people": { items: [{ handle: "alice", name: "Alice Reyes" }] } })
},
ctx
);
expect(result.entities[0]!.id).toBe("test:person:alice");
expect(result.evidence[0]!.selector).toMatchObject({ type: "api-field" });
});
it("throws on a non-OK response rather than proposing nothing silently", async () => {
const fetchFail: FetchLike = async () => ({ ok: false, status: 503, text: async () => "" });
await expect(
httpApiAdapter.ingest({ url: "https://example.org/x", mapping: MAPPING, fetch: fetchFail }, ctx)
).rejects.toThrow(/HTTP 503/);
});
it("maps a GitHub repo and its contributors", async () => {
const result = await githubAdapter.ingest(
{
repo: "example/zk-prover",
fetch: fetchOk({
"/repos/example/zk-prover/contributors": [{ login: "areyes", contributions: 42 }],
"/repos/example/zk-prover": {
full_name: "example/zk-prover",
name: "zk-prover",
language: "Rust",
license: { spdx_id: "Apache-2.0" },
html_url: "https://example.org/zk-prover"
}
})
},
ctx
);
expect(result.entities.map((e) => e.type)).toEqual(["Codebase", "Person"]);
expect(result.sources[0]!.license).toBe("Apache-2.0");
expect(result.claims.find((c) => c.predicate === "language")?.object).toEqual({ value: "Rust" });
expect(result.claims.find((c) => c.predicate === "contributesTo")?.object).toEqual({
entity: "test:code:zk-prover"
});
});
it("warns when a repository declares no licence", async () => {
const result = await githubAdapter.ingest(
{
repo: "example/unlicensed",
fetch: fetchOk({
"/repos/example/unlicensed/contributors": [],
"/repos/example/unlicensed": { name: "unlicensed", full_name: "example/unlicensed" }
})
},
ctx
);
expect(result.warnings.some((w) => /no SPDX licence/.test(w))).toBe(true);
expect(result.sources[0]!.license).toBe("CC-BY-4.0");
});
});
describe("ingestion feeds the governed path", () => {
it("produces operations a proposer agent can turn into a change set", () => {
const engine = createOntologyEngine({
package: loadPrdFixturePackage(),
actor: proposerActor("agent:importer"),
clock: () => ctx.now
});
const result = csvAdapter.ingest(
{
uri: "https://example.org/people.csv",
content: "handle,name\ndave,Dave Okonkwo\n",
mapping: { entityType: "Person", idField: "handle", nameField: "name", idSegment: "person" }
},
ctx
);
const changeSet = engine.createOntologyChangeSet({
title: "Import people.csv",
operations: result.operations,
runId: ctx.runId
});
expect(changeSet.status).toBe("proposed");
// An imported entity does not exist until a human applies the change set.
expect(() => engine.getEntity("test:person:dave")).toThrow(/Unknown entity/);
});
});

View file

@ -0,0 +1,752 @@
import { parse as parseYaml } from "yaml";
import { digest } from "./canonical.js";
import { OPENONTOLOGY_VERSION } from "./types.js";
import type { ChangeOperation, Claim, Entity, Evidence, EvidenceSelector, Source } from "./types.js";
/**
* Source adapters turn foreign data into **proposals**.
*
* Nothing here writes to a store. Every adapter returns entities, claims,
* sources, evidence, and the change-set operations that would introduce them
* a human or a policy decides whether they land (R113).
*
* Each adapter declares what it can and cannot do (R118), so a caller knows
* whether "no deletions" means "nothing was deleted" or "this adapter cannot
* see deletions."
*/
export interface AdapterCapabilities {
/** Can read publicly available data. */
publicData: boolean;
/** Can read private data given credentials. */
privateData: boolean;
/** Can fetch only what changed since a marker. */
incremental: boolean;
/** Can detect that a record disappeared upstream. */
deletions: boolean;
/** Reports the licence of what it ingested. */
license: boolean;
}
export interface IngestResult {
adapter: string;
sources: Source[];
entities: Entity[];
claims: Claim[];
evidence: Evidence[];
/** Ready for `createOntologyChangeSet({ operations })`. */
operations: ChangeOperation[];
warnings: string[];
capabilities: AdapterCapabilities;
}
export interface IngestContext {
/** Compact id prefix for generated ids. */
prefix: string;
/** Actor recorded on generated claims. */
actor: string;
/** Required when the actor is an agent. */
runId?: string;
now: string;
/** Confidence stamped on generated claims. */
confidence?: number;
license?: string;
}
/** How a flat record becomes an entity plus its claims. */
export interface RecordMapping {
entityType: string;
/** Field holding the stable local id. */
idField: string;
/** Field holding the display name. Defaults to `idField`. */
nameField?: string;
/** Segment used in generated ids: `<prefix>:<idSegment>:<value>`. */
idSegment?: string;
aliasField?: string;
/** External id namespace → field. */
externalIds?: Record<string, string>;
/** Property predicate → field. */
properties?: Record<string, string>;
/** Relationship predicate → field holding the target's local id. */
relationships?: Record<string, { field: string; targetSegment: string; separator?: string }>;
}
export interface SourceAdapter<Input> {
id: string;
description: string;
capabilities: AdapterCapabilities;
ingest(input: Input, ctx: IngestContext): Promise<IngestResult> | IngestResult;
}
const READ_ONLY_PUBLIC: AdapterCapabilities = {
publicData: true,
privateData: false,
incremental: false,
deletions: false,
license: true
};
/* ── shared record → proposal machinery ─────────────────────────────────── */
function slug(value: string): string {
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
interface BuildOptions {
records: Array<Record<string, unknown>>;
mapping: RecordMapping;
source: Source;
ctx: IngestContext;
adapter: string;
selectorFor?: (index: number) => EvidenceSelector;
}
function buildProposal(options: BuildOptions): IngestResult {
const { records, mapping, source, ctx, adapter } = options;
const entities: Entity[] = [];
const claims: Claim[] = [];
const evidence: Evidence[] = [];
const warnings: string[] = [];
const segment = mapping.idSegment ?? slug(mapping.entityType);
let claimSeq = 0;
let evidenceSeq = 0;
const nextClaimId = () => `${ctx.prefix}:claim:${adapter}-${String(++claimSeq).padStart(4, "0")}`;
records.forEach((record, index) => {
const rawId = record[mapping.idField];
if (rawId === undefined || rawId === null || String(rawId).trim() === "") {
warnings.push(`record ${index} has no ${mapping.idField}; skipped`);
return;
}
const entityId = `${ctx.prefix}:${segment}:${slug(String(rawId))}`;
const name = String(record[mapping.nameField ?? mapping.idField] ?? rawId);
const selector = options.selectorFor?.(index) ?? { type: "json-pointer", pointer: `/${index}` };
const evidenceId = `${ctx.prefix}:evidence:${adapter}-${String(++evidenceSeq).padStart(4, "0")}`;
evidence.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Evidence",
id: evidenceId,
source: source.id,
selector
});
const entity: Entity = {
openontology: OPENONTOLOGY_VERSION,
kind: "Entity",
id: entityId,
type: mapping.entityType,
canonicalName: name,
createdAt: ctx.now,
createdBy: ctx.actor
};
if (mapping.aliasField && record[mapping.aliasField]) {
const raw = record[mapping.aliasField];
entity.aliases = Array.isArray(raw)
? raw.map(String)
: String(raw)
.split(",")
.map((alias) => alias.trim())
.filter(Boolean);
}
if (mapping.externalIds) {
const externalIds: Record<string, string> = {};
for (const [namespace, field] of Object.entries(mapping.externalIds)) {
const value = record[field];
if (value !== undefined && value !== null && String(value) !== "") {
externalIds[namespace] = String(value);
}
}
if (Object.keys(externalIds).length > 0) entity.externalIds = externalIds;
}
entities.push(entity);
const base = {
openontology: OPENONTOLOGY_VERSION,
kind: "Claim" as const,
status: "proposed" as const,
assertedAt: ctx.now,
assertedBy: ctx.actor,
sources: [source.id],
evidence: [evidenceId],
...(ctx.runId ? { runId: ctx.runId } : {}),
...(ctx.confidence !== undefined ? { confidence: ctx.confidence } : {})
};
for (const [predicate, field] of Object.entries(mapping.properties ?? {})) {
const value = record[field];
if (value === undefined || value === null || value === "") continue;
claims.push({ ...base, id: nextClaimId(), subject: entityId, predicate, object: { value } });
}
for (const [predicate, config] of Object.entries(mapping.relationships ?? {})) {
const raw = record[config.field];
if (raw === undefined || raw === null || String(raw) === "") continue;
const targets = Array.isArray(raw)
? raw.map(String)
: String(raw)
.split(config.separator ?? ",")
.map((value) => value.trim())
.filter(Boolean);
for (const target of targets) {
claims.push({
...base,
id: nextClaimId(),
subject: entityId,
predicate,
object: { entity: `${ctx.prefix}:${config.targetSegment}:${slug(target)}` }
});
}
}
});
const operations: ChangeOperation[] = [
...entities.map((entity) => ({ op: "add-entity" as const, value: entity as unknown as Record<string, unknown> })),
...claims.map((claim) => ({ op: "assert-claim" as const, value: claim as unknown as Record<string, unknown> }))
];
return {
adapter,
sources: [source],
entities,
claims,
evidence,
operations,
warnings,
capabilities: READ_ONLY_PUBLIC
};
}
function makeSource(
id: string,
sourceType: string,
uri: string,
ctx: IngestContext,
content: string,
extra: Partial<Source> = {}
): Source {
return {
openontology: OPENONTOLOGY_VERSION,
kind: "Source",
id,
sourceType,
uri,
retrievedAt: ctx.now,
contentHash: digest(content),
license: ctx.license ?? "unknown",
...extra
};
}
/* ── file adapters ──────────────────────────────────────────────────────── */
export interface FileInput {
/** Where the content came from, recorded on the Source. */
uri: string;
content: string;
mapping: RecordMapping;
}
export const csvAdapter: SourceAdapter<FileInput> = {
id: "csv",
description: "Rows of a CSV file become entities and claims.",
capabilities: READ_ONLY_PUBLIC,
ingest(input, ctx) {
const rows = parseCsv(input.content);
return buildProposal({
adapter: "csv",
records: rows,
mapping: input.mapping,
ctx,
source: makeSource(`${ctx.prefix}:source:csv-${digest(input.uri).slice(7, 15)}`, "csv", input.uri, ctx, input.content, {
mediaType: "text/csv"
}),
// Row 1 is the header, so record 0 lives on line 2.
selectorFor: (index) => ({ type: "line-range", start: index + 2, end: index + 2 })
});
}
};
export const jsonAdapter: SourceAdapter<FileInput> = {
id: "json",
description: "A JSON array (or an object with an array field) becomes entities and claims.",
capabilities: READ_ONLY_PUBLIC,
ingest(input, ctx) {
const parsed = JSON.parse(input.content) as unknown;
const records = toRecords(parsed);
return buildProposal({
adapter: "json",
records,
mapping: input.mapping,
ctx,
source: makeSource(
`${ctx.prefix}:source:json-${digest(input.uri).slice(7, 15)}`,
"json",
input.uri,
ctx,
input.content,
{ mediaType: "application/json" }
)
});
}
};
export const yamlAdapter: SourceAdapter<FileInput> = {
id: "yaml",
description: "A YAML sequence becomes entities and claims.",
capabilities: READ_ONLY_PUBLIC,
ingest(input, ctx) {
const records = toRecords(parseYaml(input.content) as unknown);
return buildProposal({
adapter: "yaml",
records,
mapping: input.mapping,
ctx,
source: makeSource(
`${ctx.prefix}:source:yaml-${digest(input.uri).slice(7, 15)}`,
"yaml",
input.uri,
ctx,
input.content,
{ mediaType: "application/yaml" }
)
});
}
};
export const ndjsonAdapter: SourceAdapter<FileInput> = {
id: "ndjson",
description: "Newline-delimited JSON records become entities and claims.",
capabilities: READ_ONLY_PUBLIC,
ingest(input, ctx) {
const records: Array<Record<string, unknown>> = [];
input.content.split("\n").forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed) return;
try {
records.push(JSON.parse(trimmed) as Record<string, unknown>);
} catch {
// Reported below rather than aborting the whole file.
records.push({ __parseError: index + 1 });
}
});
const bad = records.filter((record) => "__parseError" in record);
const clean = records.filter((record) => !("__parseError" in record));
const result = buildProposal({
adapter: "ndjson",
records: clean,
mapping: input.mapping,
ctx,
source: makeSource(
`${ctx.prefix}:source:ndjson-${digest(input.uri).slice(7, 15)}`,
"ndjson",
input.uri,
ctx,
input.content,
{ mediaType: "application/x-ndjson" }
),
selectorFor: (index) => ({ type: "line-range", start: index + 1, end: index + 1 })
});
for (const record of bad) {
result.warnings.push(`line ${String(record.__parseError)} is not valid JSON; skipped`);
}
return result;
}
};
export interface MarkdownInput {
uri: string;
content: string;
entityType: string;
idSegment?: string;
/** Heading level that starts a new entity. Defaults to 2 (`##`). */
headingLevel?: number;
}
export const markdownAdapter: SourceAdapter<MarkdownInput> = {
id: "markdown",
description: "Each heading in a Markdown document becomes an entity; its links become claims.",
capabilities: READ_ONLY_PUBLIC,
ingest(input, ctx) {
const level = input.headingLevel ?? 2;
const marker = "#".repeat(level);
const source = makeSource(
`${ctx.prefix}:source:markdown-${digest(input.uri).slice(7, 15)}`,
"markdown",
input.uri,
ctx,
input.content,
{ mediaType: "text/markdown" }
);
const entities: Entity[] = [];
const claims: Claim[] = [];
const evidence: Evidence[] = [];
const segment = input.idSegment ?? slug(input.entityType);
let current: { id: string; line: number } | null = null;
let seq = 0;
input.content.split("\n").forEach((line, index) => {
const heading = new RegExp(`^${marker}\\s+(.+?)\\s*$`).exec(line);
if (heading && !line.startsWith(`${marker}#`)) {
const name = heading[1] as string;
const id = `${ctx.prefix}:${segment}:${slug(name)}`;
const evidenceId = `${ctx.prefix}:evidence:markdown-${String(++seq).padStart(4, "0")}`;
evidence.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Evidence",
id: evidenceId,
source: source.id,
selector: { type: "line-range", start: index + 1, end: index + 1 }
});
entities.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Entity",
id,
type: input.entityType,
canonicalName: name,
createdAt: ctx.now,
createdBy: ctx.actor
});
current = { id, line: index + 1 };
return;
}
if (!current) return;
for (const match of line.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) {
const evidenceId = `${ctx.prefix}:evidence:markdown-${String(++seq).padStart(4, "0")}`;
evidence.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Evidence",
id: evidenceId,
source: source.id,
selector: { type: "line-range", start: index + 1, end: index + 1 }
});
claims.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Claim",
id: `${ctx.prefix}:claim:markdown-${String(seq).padStart(4, "0")}`,
subject: current.id,
predicate: "references",
object: { value: match[2] as string },
status: "proposed",
assertedAt: ctx.now,
assertedBy: ctx.actor,
sources: [source.id],
evidence: [evidenceId],
...(ctx.runId ? { runId: ctx.runId } : {}),
...(ctx.confidence !== undefined ? { confidence: ctx.confidence } : {})
});
}
});
return {
adapter: "markdown",
sources: [source],
entities,
claims,
evidence,
operations: [
...entities.map((entity) => ({ op: "add-entity" as const, value: entity as unknown as Record<string, unknown> })),
...claims.map((claim) => ({ op: "assert-claim" as const, value: claim as unknown as Record<string, unknown> }))
],
warnings: entities.length === 0 ? [`no level-${level} headings found in ${input.uri}`] : [],
capabilities: READ_ONLY_PUBLIC
};
}
};
/* ── HTTP adapters ──────────────────────────────────────────────────────── */
export type FetchLike = (url: string, init?: { headers?: Record<string, string> }) => Promise<{
ok: boolean;
status: number;
text: () => Promise<string>;
}>;
export interface HttpInput {
url: string;
mapping: RecordMapping;
headers?: Record<string, string>;
/** JSON pointer-ish path to the array in the response, e.g. "data.items". */
path?: string;
fetch: FetchLike;
}
export const httpApiAdapter: SourceAdapter<HttpInput> = {
id: "http-api",
description: "A JSON HTTP endpoint becomes entities and claims.",
capabilities: { publicData: true, privateData: true, incremental: false, deletions: false, license: false },
async ingest(input, ctx) {
const response = await input.fetch(input.url, { headers: input.headers });
if (!response.ok) {
throw new Error(`${input.url} returned HTTP ${response.status}`);
}
const body = await response.text();
const parsed = JSON.parse(body) as unknown;
const target = input.path
? input.path.split(".").reduce<unknown>((value, key) => (value as Record<string, unknown>)?.[key], parsed)
: parsed;
const result = buildProposal({
adapter: "http-api",
records: toRecords(target),
mapping: input.mapping,
ctx,
source: makeSource(
`${ctx.prefix}:source:http-${digest(input.url).slice(7, 15)}`,
"api-response",
input.url,
ctx,
body,
{ mediaType: "application/json" }
),
selectorFor: (index) => ({ type: "api-field", field: `${input.path ?? "$"}[${index}]`, endpoint: input.url })
});
result.capabilities = httpApiAdapter.capabilities;
if (!ctx.license) {
result.warnings.push("no licence declared for this endpoint; source licence recorded as unknown");
}
return result;
}
};
export interface GithubInput {
/** `owner/name`. */
repo: string;
fetch: FetchLike;
token?: string;
apiBase?: string;
}
/**
* GitHub repository a codebase entity plus its contributors.
*
* Fetch is injected, so tests (and offline runs) never touch the network.
*/
export const githubAdapter: SourceAdapter<GithubInput> = {
id: "github",
description: "A GitHub repository and its contributors become a codebase and people.",
capabilities: { publicData: true, privateData: true, incremental: false, deletions: false, license: true },
async ingest(input, ctx) {
const base = input.apiBase ?? "https://api.github.com";
const headers = {
accept: "application/vnd.github+json",
...(input.token ? { authorization: `Bearer ${input.token}` } : {})
};
const repoResponse = await input.fetch(`${base}/repos/${input.repo}`, { headers });
if (!repoResponse.ok) throw new Error(`GitHub returned HTTP ${repoResponse.status} for ${input.repo}`);
const repoBody = await repoResponse.text();
const repo = JSON.parse(repoBody) as {
full_name?: string;
name?: string;
language?: string;
license?: { spdx_id?: string };
html_url?: string;
};
const contributorsResponse = await input.fetch(`${base}/repos/${input.repo}/contributors`, { headers });
const contributorsBody = contributorsResponse.ok ? await contributorsResponse.text() : "[]";
const contributors = JSON.parse(contributorsBody) as Array<{ login?: string; contributions?: number }>;
const source = makeSource(
`${ctx.prefix}:source:github-${slug(input.repo)}`,
"api-response",
repo.html_url ?? `${base}/repos/${input.repo}`,
ctx,
repoBody,
{ mediaType: "application/json", license: repo.license?.spdx_id ?? ctx.license ?? "unknown", publisher: "github" }
);
const codebaseId = `${ctx.prefix}:code:${slug(repo.name ?? input.repo)}`;
const entities: Entity[] = [
{
openontology: OPENONTOLOGY_VERSION,
kind: "Entity",
id: codebaseId,
type: "Codebase",
canonicalName: repo.name ?? input.repo,
externalIds: { github: repo.full_name ?? input.repo },
createdAt: ctx.now,
createdBy: ctx.actor
}
];
const claims: Claim[] = [];
let seq = 0;
const claimBase = {
openontology: OPENONTOLOGY_VERSION,
kind: "Claim" as const,
status: "proposed" as const,
assertedAt: ctx.now,
assertedBy: ctx.actor,
sources: [source.id],
...(ctx.runId ? { runId: ctx.runId } : {}),
...(ctx.confidence !== undefined ? { confidence: ctx.confidence } : {})
};
if (repo.language) {
claims.push({
...claimBase,
id: `${ctx.prefix}:claim:github-${String(++seq).padStart(4, "0")}`,
subject: codebaseId,
predicate: "language",
object: { value: repo.language }
});
}
if (repo.license?.spdx_id) {
claims.push({
...claimBase,
id: `${ctx.prefix}:claim:github-${String(++seq).padStart(4, "0")}`,
subject: codebaseId,
predicate: "license",
object: { value: repo.license.spdx_id }
});
}
for (const contributor of contributors) {
if (!contributor.login) continue;
const personId = `${ctx.prefix}:person:${slug(contributor.login)}`;
entities.push({
openontology: OPENONTOLOGY_VERSION,
kind: "Entity",
id: personId,
type: "Person",
canonicalName: contributor.login,
externalIds: { github: contributor.login },
createdAt: ctx.now,
createdBy: ctx.actor
});
claims.push({
...claimBase,
id: `${ctx.prefix}:claim:github-${String(++seq).padStart(4, "0")}`,
subject: personId,
predicate: "contributesTo",
object: { entity: codebaseId }
});
}
const warnings: string[] = [];
if (!contributorsResponse.ok) {
warnings.push(`contributors endpoint returned HTTP ${contributorsResponse.status}; only the codebase was mapped`);
}
if (!repo.license?.spdx_id) {
warnings.push("repository declares no SPDX licence; source licence recorded as unknown");
}
return {
adapter: "github",
sources: [source],
entities,
claims,
evidence: [],
operations: [
...entities.map((entity) => ({ op: "add-entity" as const, value: entity as unknown as Record<string, unknown> })),
...claims.map((claim) => ({ op: "assert-claim" as const, value: claim as unknown as Record<string, unknown> }))
],
warnings,
capabilities: githubAdapter.capabilities
};
}
};
export const ADAPTERS = {
csv: csvAdapter,
json: jsonAdapter,
yaml: yamlAdapter,
ndjson: ndjsonAdapter,
markdown: markdownAdapter,
"http-api": httpApiAdapter,
github: githubAdapter
} as const;
export type AdapterId = keyof typeof ADAPTERS;
export function listAdapters(): Array<{ id: string; description: string; capabilities: AdapterCapabilities }> {
return Object.values(ADAPTERS).map((adapter) => ({
id: adapter.id,
description: adapter.description,
capabilities: adapter.capabilities
}));
}
/* ── helpers ────────────────────────────────────────────────────────────── */
function toRecords(value: unknown): Array<Record<string, unknown>> {
if (Array.isArray(value)) return value as Array<Record<string, unknown>>;
if (value && typeof value === "object") {
const arrayField = Object.values(value as Record<string, unknown>).find((entry) => Array.isArray(entry));
if (arrayField) return arrayField as Array<Record<string, unknown>>;
return [value as Record<string, unknown>];
}
return [];
}
/** Minimal RFC-4180 CSV reader: quoted fields, escaped quotes, CRLF. */
export function parseCsv(text: string): Array<Record<string, string>> {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i] as string;
if (quoted) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 1;
} else {
quoted = false;
}
} else {
field += char;
}
continue;
}
if (char === '"') {
quoted = true;
} else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
} else if (char !== "\r") {
field += char;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
const [header, ...body] = rows.filter((entry) => entry.some((cell) => cell.trim() !== ""));
if (!header) return [];
return body.map((cells) =>
Object.fromEntries(header.map((name, index) => [name.trim(), (cells[index] ?? "").trim()]))
);
}

View file

@ -51,6 +51,15 @@ export {
type QueryLimits
} from "./query.js";
export {
createLibsqlStore,
migrate as migrateLibsql,
searchEntities,
MIGRATIONS,
type LibsqlStore,
type LibsqlStoreOptions
} from "./libsql.js";
export {
createMemoryStore,
type ClaimFilter,
@ -93,6 +102,30 @@ export {
type JsonLdExport
} from "./jsonld.js";
export {
ADAPTERS,
csvAdapter,
githubAdapter,
httpApiAdapter,
jsonAdapter,
listAdapters,
markdownAdapter,
ndjsonAdapter,
parseCsv,
yamlAdapter,
type AdapterCapabilities,
type AdapterId,
type FetchLike,
type IngestContext,
type IngestResult,
type RecordMapping,
type SourceAdapter
} from "./adapters.js";
export { exportTurtle, importTurtle, type TurtleExport } from "./rdf.js";
export { constraintsToShacl, type ShaclExport } from "./shacl.js";
export {
createEd25519Provider,
generateEd25519KeyPair,

View file

@ -0,0 +1,184 @@
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/);
});
});

View file

@ -0,0 +1,713 @@
import type { Client, InArgs } from "@libsql/client";
import { revisionId } from "./ids.js";
import { createMemoryStore, type OntologyStore } from "./store.js";
import type { LoadedPackage } from "./types.js";
/**
* SQLite / Turso storage adapter.
*
* The engine's store interface is synchronous by design a query evaluator
* that awaits per triple pattern is unusable. So this adapter:
*
* 1. hydrates the whole package into the in-memory read model at open,
* 2. serves reads from it synchronously,
* 3. records every mutation as a pending SQL statement, and
* 4. writes them in one transaction when you `flush()`.
*
* Callers that mutate MUST await `flush()` for durability; the REST layer does
* so after every applied change set. Everything persisted is append-only, so a
* crash before flush loses the last change set rather than corrupting history.
*
* At the reference target (~100k claims) hydration is a handful of queries.
* Beyond that, an adapter that pushes evaluation into SQL is the right answer
* which is exactly why the store is an interface.
*/
export const MIGRATIONS: Array<{ version: number; name: string; statements: string[] }> = [
{
version: 1,
name: "initial",
statements: [
`CREATE TABLE IF NOT EXISTS ontologies (
id TEXT PRIMARY KEY,
version TEXT NOT NULL,
manifest TEXT NOT NULL,
schema_json TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS entities (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
type TEXT NOT NULL,
canonical_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
superseded_by TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
created_at TEXT NOT NULL,
created_by TEXT NOT NULL,
document TEXT NOT NULL,
PRIMARY KEY (ontology, id)
)`,
`CREATE TABLE IF NOT EXISTS aliases (
ontology TEXT NOT NULL,
entity_id TEXT NOT NULL,
alias TEXT NOT NULL,
PRIMARY KEY (ontology, entity_id, alias)
)`,
`CREATE TABLE IF NOT EXISTS external_ids (
ontology TEXT NOT NULL,
entity_id TEXT NOT NULL,
namespace TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (ontology, entity_id, namespace)
)`,
`CREATE TABLE IF NOT EXISTS claims (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
subject TEXT NOT NULL,
predicate TEXT NOT NULL,
object_entity TEXT,
object_value TEXT,
status TEXT NOT NULL,
confidence REAL,
valid_from TEXT,
valid_to TEXT,
observed_at TEXT,
asserted_at TEXT NOT NULL,
asserted_by TEXT NOT NULL,
run_id TEXT,
change_set TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
document TEXT NOT NULL,
PRIMARY KEY (ontology, id)
)`,
`CREATE TABLE IF NOT EXISTS claim_sources (
ontology TEXT NOT NULL,
claim_id TEXT NOT NULL,
source_id TEXT NOT NULL,
PRIMARY KEY (ontology, claim_id, source_id)
)`,
`CREATE TABLE IF NOT EXISTS sources (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
source_type TEXT NOT NULL,
uri TEXT NOT NULL,
retrieved_at TEXT NOT NULL,
content_hash TEXT,
license TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
stale INTEGER NOT NULL DEFAULT 0,
document TEXT NOT NULL,
PRIMARY KEY (ontology, id)
)`,
`CREATE TABLE IF NOT EXISTS evidence (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
source_id TEXT NOT NULL,
document TEXT NOT NULL,
PRIMARY KEY (ontology, id)
)`,
// Append-only status log: claims and entities are never updated in place.
`CREATE TABLE IF NOT EXISTS status_log (
ontology TEXT NOT NULL,
kind TEXT NOT NULL,
object_id TEXT NOT NULL,
status TEXT NOT NULL,
at TEXT NOT NULL,
by TEXT NOT NULL,
change_set TEXT,
reason TEXT,
seq INTEGER PRIMARY KEY AUTOINCREMENT
)`,
`CREATE TABLE IF NOT EXISTS changesets (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT NOT NULL,
base_revision TEXT,
result_revision TEXT,
document TEXT NOT NULL,
PRIMARY KEY (ontology, id)
)`,
`CREATE TABLE IF NOT EXISTS reviews (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
change_set TEXT NOT NULL,
reviewer TEXT NOT NULL,
state TEXT NOT NULL,
created_at TEXT NOT NULL,
document TEXT NOT NULL,
PRIMARY KEY (ontology, id)
)`,
`CREATE TABLE IF NOT EXISTS approvals (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
change_set TEXT NOT NULL,
approver TEXT NOT NULL,
created_at TEXT NOT NULL,
document TEXT NOT NULL,
PRIMARY KEY (ontology, id)
)`,
`CREATE TABLE IF NOT EXISTS ontology_events (
ontology TEXT NOT NULL,
id TEXT NOT NULL,
type TEXT NOT NULL,
at TEXT NOT NULL,
actor TEXT NOT NULL,
change_set TEXT,
subject TEXT,
revision TEXT,
document TEXT NOT NULL,
seq INTEGER PRIMARY KEY AUTOINCREMENT
)`,
// R185: the indexes the query evaluator and lookups actually need.
"CREATE INDEX IF NOT EXISTS idx_entities_type ON entities (ontology, type)",
"CREATE INDEX IF NOT EXISTS idx_entities_status ON entities (ontology, status)",
"CREATE INDEX IF NOT EXISTS idx_entities_name ON entities (ontology, canonical_name)",
"CREATE INDEX IF NOT EXISTS idx_aliases_alias ON aliases (ontology, alias)",
"CREATE INDEX IF NOT EXISTS idx_external_ids ON external_ids (ontology, namespace, value)",
"CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (ontology, subject)",
"CREATE INDEX IF NOT EXISTS idx_claims_predicate ON claims (ontology, predicate)",
"CREATE INDEX IF NOT EXISTS idx_claims_object ON claims (ontology, object_entity)",
"CREATE INDEX IF NOT EXISTS idx_claims_sp ON claims (ontology, subject, predicate)",
"CREATE INDEX IF NOT EXISTS idx_claims_status ON claims (ontology, status)",
"CREATE INDEX IF NOT EXISTS idx_claims_valid ON claims (ontology, valid_from, valid_to)",
"CREATE INDEX IF NOT EXISTS idx_claims_asserted ON claims (ontology, asserted_at)",
"CREATE INDEX IF NOT EXISTS idx_claim_sources ON claim_sources (ontology, source_id)",
"CREATE INDEX IF NOT EXISTS idx_status_log_object ON status_log (ontology, kind, object_id)",
"CREATE INDEX IF NOT EXISTS idx_events_type ON ontology_events (ontology, type)",
"CREATE INDEX IF NOT EXISTS idx_events_changeset ON ontology_events (ontology, change_set)"
]
},
{
version: 2,
name: "full-text-search",
statements: [
// R186: label, alias, and description search.
`CREATE VIRTUAL TABLE IF NOT EXISTS entity_fts USING fts5(
entity_id UNINDEXED,
ontology UNINDEXED,
canonical_name,
aliases,
description,
tokenize = 'unicode61'
)`
]
}
];
export interface LibsqlStoreOptions {
client: Client;
/** Ontology id. Defaults to the package manifest id. */
ontology?: string;
/** Seed the database from this package when it holds no rows for the id. */
seed?: LoadedPackage;
}
export interface LibsqlStore extends OntologyStore {
/** Persist everything buffered since the last flush, in one transaction. */
flush(): Promise<{ statements: number }>;
/** Pending statement count — 0 means everything is durable. */
pending(): number;
close(): void;
}
export async function migrate(client: Client): Promise<number> {
await client.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)"
);
const applied = await client.execute("SELECT version FROM schema_migrations");
const have = new Set(applied.rows.map((row) => Number(row.version)));
let count = 0;
for (const migration of MIGRATIONS) {
if (have.has(migration.version)) continue;
for (const statement of migration.statements) {
await client.execute(statement);
}
await client.execute({
sql: "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
args: [migration.version, migration.name, new Date().toISOString()]
});
count += 1;
}
return count;
}
/** Open (and if needed seed) a libSQL-backed store. */
export async function createLibsqlStore(options: LibsqlStoreOptions): Promise<LibsqlStore> {
const { client, seed } = options;
await migrate(client);
const ontology = options.ontology ?? seed?.manifest.id;
if (!ontology) throw new Error("createLibsqlStore needs an ontology id or a seed package");
const existing = await client.execute({
sql: "SELECT manifest, schema_json, revision FROM ontologies WHERE id = ?",
args: [ontology]
});
if (existing.rows.length === 0) {
if (!seed) throw new Error(`Ontology ${ontology} is not in this database and no seed was given`);
await seedPackage(client, ontology, seed);
}
const hydrated = await hydrate(client, ontology);
const memory = createMemoryStore(hydrated.pkg);
// Replay the append-only status log so effective statuses match the database.
for (const transition of hydrated.transitions) {
if (transition.kind === "claim") {
try {
memory.setClaimStatus(transition.transition);
} catch {
// A transition for a claim that is no longer present is not fatal.
}
} else {
memory.setEntityStatus(transition.transition);
}
}
for (let i = 0; i < hydrated.revision; i += 1) memory.bumpRevision();
for (const changeSet of hydrated.changeSets) memory.putChangeSet(changeSet);
for (const review of hydrated.reviews) memory.putReview(review);
for (const approval of hydrated.approvals) memory.putApproval(approval);
for (const event of hydrated.events) memory.appendEvent(event);
const pendingStatements: Array<{ sql: string; args: InArgs }> = [];
const enqueue = (sql: string, args: InArgs) => pendingStatements.push({ sql, args });
const store: LibsqlStore = {
...memory,
addEntity(entity) {
memory.addEntity(entity);
enqueue(
`INSERT INTO entities (ontology, id, type, canonical_name, status, superseded_by, visibility, created_at, created_by, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
ontology,
entity.id,
entity.type,
entity.canonicalName,
entity.status ?? "active",
entity.supersededBy ?? null,
entity.visibility ?? "public",
entity.createdAt,
entity.createdBy,
JSON.stringify(entity)
]
);
for (const alias of entity.aliases ?? []) {
enqueue("INSERT OR IGNORE INTO aliases (ontology, entity_id, alias) VALUES (?, ?, ?)", [
ontology,
entity.id,
alias
]);
}
for (const [namespace, value] of Object.entries(entity.externalIds ?? {})) {
enqueue(
"INSERT OR REPLACE INTO external_ids (ontology, entity_id, namespace, value) VALUES (?, ?, ?, ?)",
[ontology, entity.id, namespace, value]
);
}
enqueue(
"INSERT INTO entity_fts (entity_id, ontology, canonical_name, aliases, description) VALUES (?, ?, ?, ?, ?)",
[entity.id, ontology, entity.canonicalName, (entity.aliases ?? []).join(" "), ""]
);
},
updateEntityMetadata(id, patch) {
const updated = memory.updateEntityMetadata(id, patch);
enqueue(
"UPDATE entities SET canonical_name = ?, status = ?, superseded_by = ?, document = ? WHERE ontology = ? AND id = ?",
[
updated.canonicalName,
updated.status ?? "active",
updated.supersededBy ?? null,
JSON.stringify(updated),
ontology,
updated.id
]
);
return updated;
},
appendClaim(claim) {
memory.appendClaim(claim);
const objectEntity = "entity" in claim.object ? claim.object.entity : null;
const objectValue = "entity" in claim.object ? null : JSON.stringify(claim.object.value);
enqueue(
`INSERT INTO claims (ontology, id, subject, predicate, object_entity, object_value, status, confidence,
valid_from, valid_to, observed_at, asserted_at, asserted_by, run_id, change_set, visibility, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
ontology,
claim.id,
claim.subject,
claim.predicate,
objectEntity,
objectValue,
claim.status,
claim.confidence ?? null,
claim.validTime?.from ?? null,
claim.validTime?.to ?? null,
claim.observedAt ?? null,
claim.assertedAt,
claim.assertedBy,
claim.runId ?? null,
claim.changeSet ?? null,
claim.visibility ?? "public",
JSON.stringify(claim)
]
);
for (const source of claim.sources ?? []) {
enqueue(
"INSERT OR IGNORE INTO claim_sources (ontology, claim_id, source_id) VALUES (?, ?, ?)",
[ontology, claim.id, source]
);
}
},
setClaimStatus(transition) {
memory.setClaimStatus(transition);
enqueue(
"INSERT INTO status_log (ontology, kind, object_id, status, at, by, change_set, reason) VALUES (?, 'claim', ?, ?, ?, ?, ?, ?)",
[
ontology,
transition.objectId,
String(transition.status),
transition.at,
transition.by,
transition.changeSet ?? null,
transition.reason ?? null
]
);
},
setEntityStatus(transition) {
memory.setEntityStatus(transition);
enqueue(
"INSERT INTO status_log (ontology, kind, object_id, status, at, by, change_set, reason) VALUES (?, 'entity', ?, ?, ?, ?, ?, ?)",
[
ontology,
transition.objectId,
String(transition.status),
transition.at,
transition.by,
transition.changeSet ?? null,
transition.reason ?? null
]
);
},
addSource(source) {
memory.addSource(source);
enqueue(
`INSERT OR REPLACE INTO sources (ontology, id, source_type, uri, retrieved_at, content_hash, license, visibility, stale, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
ontology,
source.id,
source.sourceType,
source.uri,
source.retrievedAt,
source.contentHash ?? null,
source.license ?? null,
source.visibility ?? "public",
source.stale ? 1 : 0,
JSON.stringify(source)
]
);
},
addEvidence(record) {
memory.addEvidence(record);
enqueue("INSERT OR REPLACE INTO evidence (ontology, id, source_id, document) VALUES (?, ?, ?, ?)", [
ontology,
record.id,
record.source,
JSON.stringify(record)
]);
},
putChangeSet(changeSet) {
memory.putChangeSet(changeSet);
enqueue(
`INSERT OR REPLACE INTO changesets (ontology, id, title, status, created_at, created_by, base_revision, result_revision, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
ontology,
changeSet.id,
changeSet.title,
changeSet.status,
changeSet.createdAt,
changeSet.createdBy,
changeSet.baseRevision ?? null,
changeSet.resultRevision ?? null,
JSON.stringify(changeSet)
]
);
},
putReview(review) {
memory.putReview(review);
enqueue(
`INSERT OR REPLACE INTO reviews (ontology, id, change_set, reviewer, state, created_at, document)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[ontology, review.id, review.changeSet, review.reviewer, review.state, review.createdAt, JSON.stringify(review)]
);
},
putApproval(approval) {
memory.putApproval(approval);
enqueue(
`INSERT OR REPLACE INTO approvals (ontology, id, change_set, approver, created_at, document)
VALUES (?, ?, ?, ?, ?, ?)`,
[ontology, approval.id, approval.changeSet, approval.approver, approval.createdAt, JSON.stringify(approval)]
);
},
appendEvent(event) {
memory.appendEvent(event);
enqueue(
`INSERT INTO ontology_events (ontology, id, type, at, actor, change_set, subject, revision, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
ontology,
event.id,
event.type,
event.at,
event.actor,
event.changeSet ?? null,
event.subject ?? null,
event.revision ?? null,
JSON.stringify(event)
]
);
},
bumpRevision() {
const revision = memory.bumpRevision();
enqueue("UPDATE ontologies SET revision = revision + 1, updated_at = ? WHERE id = ?", [
new Date().toISOString(),
ontology
]);
return revision;
},
pending: () => pendingStatements.length,
async flush() {
if (pendingStatements.length === 0) return { statements: 0 };
const batch = pendingStatements.splice(0, pendingStatements.length);
await client.batch(
batch.map((statement) => ({ sql: statement.sql, args: statement.args })),
"write"
);
return { statements: batch.length };
},
close() {
client.close();
}
};
return store;
}
async function seedPackage(client: Client, ontology: string, pkg: LoadedPackage): Promise<void> {
const statements: Array<{ sql: string; args: InArgs }> = [
{
sql: "INSERT INTO ontologies (id, version, manifest, schema_json, revision, updated_at) VALUES (?, ?, ?, ?, 0, ?)",
args: [
ontology,
pkg.manifest.version,
JSON.stringify(pkg.manifest),
JSON.stringify(pkg.schema),
new Date().toISOString()
]
}
];
for (const entity of pkg.data.entities) {
statements.push({
sql: `INSERT INTO entities (ontology, id, type, canonical_name, status, superseded_by, visibility, created_at, created_by, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
ontology,
entity.id,
entity.type,
entity.canonicalName,
entity.status ?? "active",
entity.supersededBy ?? null,
entity.visibility ?? "public",
entity.createdAt,
entity.createdBy,
JSON.stringify(entity)
]
});
statements.push({
sql: "INSERT INTO entity_fts (entity_id, ontology, canonical_name, aliases, description) VALUES (?, ?, ?, ?, ?)",
args: [entity.id, ontology, entity.canonicalName, (entity.aliases ?? []).join(" "), ""]
});
for (const alias of entity.aliases ?? []) {
statements.push({
sql: "INSERT OR IGNORE INTO aliases (ontology, entity_id, alias) VALUES (?, ?, ?)",
args: [ontology, entity.id, alias]
});
}
for (const [namespace, value] of Object.entries(entity.externalIds ?? {})) {
statements.push({
sql: "INSERT OR REPLACE INTO external_ids (ontology, entity_id, namespace, value) VALUES (?, ?, ?, ?)",
args: [ontology, entity.id, namespace, value]
});
}
}
for (const claim of pkg.data.claims) {
statements.push({
sql: `INSERT INTO claims (ontology, id, subject, predicate, object_entity, object_value, status, confidence,
valid_from, valid_to, observed_at, asserted_at, asserted_by, run_id, change_set, visibility, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
ontology,
claim.id,
claim.subject,
claim.predicate,
"entity" in claim.object ? claim.object.entity : null,
"entity" in claim.object ? null : JSON.stringify(claim.object.value),
claim.status,
claim.confidence ?? null,
claim.validTime?.from ?? null,
claim.validTime?.to ?? null,
claim.observedAt ?? null,
claim.assertedAt,
claim.assertedBy,
claim.runId ?? null,
claim.changeSet ?? null,
claim.visibility ?? "public",
JSON.stringify(claim)
]
});
for (const source of claim.sources ?? []) {
statements.push({
sql: "INSERT OR IGNORE INTO claim_sources (ontology, claim_id, source_id) VALUES (?, ?, ?)",
args: [ontology, claim.id, source]
});
}
}
for (const source of pkg.data.sources) {
statements.push({
sql: `INSERT INTO sources (ontology, id, source_type, uri, retrieved_at, content_hash, license, visibility, stale, document)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
ontology,
source.id,
source.sourceType,
source.uri,
source.retrievedAt,
source.contentHash ?? null,
source.license ?? null,
source.visibility ?? "public",
source.stale ? 1 : 0,
JSON.stringify(source)
]
});
}
for (const record of pkg.data.evidence) {
statements.push({
sql: "INSERT INTO evidence (ontology, id, source_id, document) VALUES (?, ?, ?, ?)",
args: [ontology, record.id, record.source, JSON.stringify(record)]
});
}
// Batched so a partial seed cannot leave a half-populated ontology behind.
for (let i = 0; i < statements.length; i += 200) {
await client.batch(statements.slice(i, i + 200), "write");
}
}
async function hydrate(client: Client, ontology: string) {
const [meta, entities, claims, sources, evidence, log, changeSets, reviews, approvals, events] =
await Promise.all([
client.execute({ sql: "SELECT manifest, schema_json, revision FROM ontologies WHERE id = ?", args: [ontology] }),
client.execute({ sql: "SELECT document FROM entities WHERE ontology = ?", args: [ontology] }),
client.execute({ sql: "SELECT document FROM claims WHERE ontology = ?", args: [ontology] }),
client.execute({ sql: "SELECT document FROM sources WHERE ontology = ?", args: [ontology] }),
client.execute({ sql: "SELECT document FROM evidence WHERE ontology = ?", args: [ontology] }),
client.execute({
sql: "SELECT kind, object_id, status, at, by, change_set, reason FROM status_log WHERE ontology = ? ORDER BY seq",
args: [ontology]
}),
client.execute({ sql: "SELECT document FROM changesets WHERE ontology = ?", args: [ontology] }),
client.execute({ sql: "SELECT document FROM reviews WHERE ontology = ?", args: [ontology] }),
client.execute({ sql: "SELECT document FROM approvals WHERE ontology = ?", args: [ontology] }),
client.execute({ sql: "SELECT document FROM ontology_events WHERE ontology = ? ORDER BY seq", args: [ontology] })
]);
const row = meta.rows[0];
if (!row) throw new Error(`Ontology ${ontology} disappeared while hydrating`);
const docs = <T>(result: { rows: Array<Record<string, unknown>> }): T[] =>
result.rows.map((r) => JSON.parse(String(r.document)) as T);
const pkg: LoadedPackage = {
manifest: JSON.parse(String(row.manifest)) as LoadedPackage["manifest"],
schema: JSON.parse(String(row.schema_json)) as LoadedPackage["schema"],
data: {
entities: docs(entities),
claims: docs(claims),
sources: docs(sources),
evidence: docs(evidence)
},
files: []
};
return {
pkg,
revision: Number(row.revision ?? 0),
transitions: log.rows.map((r) => ({
kind: String(r.kind) as "claim" | "entity",
transition: {
objectId: String(r.object_id),
status: String(r.status) as never,
at: String(r.at),
by: String(r.by),
changeSet: r.change_set ? String(r.change_set) : undefined,
reason: r.reason ? String(r.reason) : undefined
}
})),
changeSets: docs<LoadedPackage["data"]["entities"][number] & never>(changeSets) as never[],
reviews: docs(reviews) as never[],
approvals: docs(approvals) as never[],
events: docs(events) as never[]
};
}
/** Full-text entity search backed by FTS5 (R186). */
export async function searchEntities(
client: Client,
ontology: string,
query: string,
limit = 20
): Promise<Array<{ id: string; canonicalName: string; rank: number }>> {
const result = await client.execute({
sql: `SELECT entity_id, canonical_name, rank FROM entity_fts
WHERE ontology = ? AND entity_fts MATCH ?
ORDER BY rank LIMIT ?`,
args: [ontology, query, limit]
});
return result.rows.map((row) => ({
id: String(row.entity_id),
canonicalName: String(row.canonical_name),
rank: Number(row.rank ?? 0)
}));
}
export { revisionId };

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { exportTurtle, importTurtle } from "./rdf.js";
import { constraintsToShacl } from "./shacl.js";
import { packagePrefix } from "./jsonld.js";
import { loadPrdFixturePackage } from "./test-helpers.js";
const pkg = loadPrdFixturePackage();
describe("RDF/Turtle", () => {
it("declares the prefixes it uses", () => {
const { turtle } = exportTurtle(pkg);
for (const prefix of ["oo:", "prov:", "rdf:", "rdfs:", "xsd:"]) {
expect(turtle).toContain(`@prefix ${prefix}`);
}
});
it("reifies claims so provenance survives the crossing", () => {
const { turtle } = exportTurtle(pkg);
expect(turtle).toContain("a oo:Claim");
expect(turtle).toContain("rdf:subject");
expect(turtle).toContain("prov:wasDerivedFrom");
expect(turtle).toContain("prov:wasAttributedTo");
});
it("also emits the plain triple for asserted relationships", () => {
const { turtle } = exportTurtle(pkg);
const worksOn = turtle.split("\n").filter((line) => line.includes("worksOn"));
// Reified (rdf:predicate) plus at least one direct triple.
expect(worksOn.some((line) => line.includes("rdf:predicate"))).toBe(true);
expect(worksOn.some((line) => !line.includes("rdf:predicate"))).toBe(true);
});
it("round-trips entities and claims through Turtle", () => {
const { turtle } = exportTurtle(pkg);
const back = importTurtle(turtle, { ...pkg.manifest, prefix: packagePrefix(pkg) });
expect(back.entities.map((e) => e.id).sort()).toEqual(pkg.data.entities.map((e) => e.id).sort());
expect(back.claims.map((c) => c.id).sort()).toEqual(pkg.data.claims.map((c) => c.id).sort());
expect(back.unsupported).toEqual([]);
const original = pkg.data.claims[0]!;
const restored = back.claims.find((c) => c.id === original.id)!;
expect(restored.subject).toBe(original.subject);
expect(restored.predicate).toBe(original.predicate);
expect(restored.object).toEqual(original.object);
expect(restored.sources).toEqual(original.sources);
expect(restored.confidence).toBe(original.confidence);
expect(restored.validTime?.from).toBe(original.validTime?.from);
});
it("preserves a scalar property claim's value and type", () => {
const { turtle } = exportTurtle(pkg);
const back = importTurtle(turtle, { ...pkg.manifest, prefix: packagePrefix(pkg) });
const homepage = back.claims.find((c) => c.predicate === "homepage")!;
expect(homepage.object).toEqual({ value: "https://example.org/zk-prover" });
});
it("reports fields the profile cannot carry", () => {
const copy = structuredClone(pkg);
copy.data.claims[0]!.tags = ["zk"];
copy.data.claims[0]!.license = "CC-BY-4.0";
const { lossy } = exportTurtle(copy);
const entry = lossy.find((l) => l.objectId === copy.data.claims[0]!.id);
expect(entry?.fields).toEqual(expect.arrayContaining(["tags", "license"]));
});
it("counts what crossed the boundary", () => {
const { counts } = exportTurtle(pkg);
expect(counts.entities).toBe(pkg.data.entities.length);
expect(counts.claims).toBe(pkg.data.claims.length);
expect(counts.triples).toBeGreaterThan(counts.claims);
});
});
describe("SHACL", () => {
it("maps a required-predicate constraint to a node shape", () => {
const { turtle, mapped } = constraintsToShacl(pkg);
expect(mapped.map((m) => m.constraint)).toContain("project-has-homepage");
expect(turtle).toContain("sh:NodeShape");
expect(turtle).toContain("sh:targetClass ns:Project");
expect(turtle).toContain("sh:minCount 1");
});
it("carries the constraint severity across", () => {
const { turtle } = constraintsToShacl(pkg);
expect(turtle).toContain("sh:Warning");
});
it("reports uniqueness and query constraints as unmappable rather than faking them", () => {
const copy = structuredClone(pkg);
copy.schema.constraints.push(
{
openontology: "0.1",
kind: "Constraint",
id: "unique-homepage",
description: "Homepages are unique.",
rule: { type: "unique", predicate: "homepage" }
},
{
openontology: "0.1",
kind: "Constraint",
id: "needs-review",
description: "No claims awaiting review.",
rule: { type: "query", query: "contributors" }
}
);
const { unmapped, turtle } = constraintsToShacl(copy);
expect(unmapped.map((u) => u.constraint)).toEqual(["unique-homepage", "needs-review"]);
expect(unmapped[0]!.reason).toMatch(/SPARQLConstraint/);
// The gap is stated in the output itself, not just in a return value.
expect(turtle).toContain("# unmapped: unique-homepage");
});
});

View file

@ -0,0 +1,355 @@
import { toIri } from "./ids.js";
import { packagePrefix, OO, PROV } from "./jsonld.js";
import { OPENONTOLOGY_VERSION } from "./types.js";
import type { BuiltPackage, Claim, Entity, LoadedPackage } from "./types.js";
/**
* RDF/Turtle export and import for the losslessly mappable core subset.
*
* Claims are reified each is its own resource carrying subject, predicate,
* object, status, time, confidence, and provenance because a bare triple
* cannot say "asserted by this agent, from this commit, valid since April."
* Asserted relationship claims additionally emit the plain triple, so a
* consumer that only wants the current graph gets one.
*/
const PREFIXES: Array<[string, string]> = [
["oo", OO],
["prov", PROV],
["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"],
["rdfs", "http://www.w3.org/2000/01/rdf-schema#"],
["xsd", "http://www.w3.org/2001/XMLSchema#"]
];
/** Fields this profile carries. Everything else is reported as lossy. */
const LOSSLESS_CLAIM_FIELDS = new Set([
"openontology",
"kind",
"id",
"ontology",
"subject",
"predicate",
"object",
"status",
"confidence",
"validTime",
"observedAt",
"assertedAt",
"assertedBy",
"runId",
"sources",
"evidence",
"supersedes",
"disputes"
]);
const LOSSLESS_ENTITY_FIELDS = new Set([
"openontology",
"kind",
"id",
"type",
"canonicalName",
"aliases",
"externalIds",
"status",
"createdAt",
"createdBy",
"supersededBy"
]);
export interface TurtleExport {
turtle: string;
lossy: Array<{ objectId: string; fields: string[] }>;
/** Counts, so a caller can report what actually crossed the boundary. */
counts: { entities: number; claims: number; sources: number; triples: number };
}
export function exportTurtle(pkg: BuiltPackage | LoadedPackage): TurtleExport {
const manifest = pkg.manifest;
const prefix = packagePrefix(pkg);
const ns = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
const iri = (id: string) => `<${toIri(id, { defaultNamespace: manifest.namespace })}>`;
const vocab = (term: string) => `<${ns}${encodeURIComponent(term)}>`;
const lossy: TurtleExport["lossy"] = [];
const lines: string[] = [];
let triples = 0;
const emit = (subject: string, pairs: Array<[string, string]>) => {
if (pairs.length === 0) return;
lines.push(`${subject}`);
pairs.forEach(([predicate, object], index) => {
triples += 1;
lines.push(` ${predicate} ${object}${index === pairs.length - 1 ? " ." : " ;"}`);
});
lines.push("");
};
for (const [name, uri] of PREFIXES) lines.push(`@prefix ${name}: <${uri}> .`);
lines.push(`@prefix ns: <${ns}> .`);
lines.push(`@prefix pkg: <${ns}> .`);
lines.push("");
lines.push(`# LogicSRC OpenOntology ${OPENONTOLOGY_VERSION}${manifest.id}@${manifest.version}`);
lines.push(`# compact id prefix: ${prefix}`);
lines.push("");
for (const entity of pkg.data.entities) {
const pairs: Array<[string, string]> = [
["a", vocab(entity.type)],
["rdfs:label", literal(entity.canonicalName)],
["oo:status", literal(entity.status ?? "active")],
["prov:generatedAtTime", typed(entity.createdAt, "xsd:dateTime")],
["prov:wasAttributedTo", literal(entity.createdBy)]
];
for (const alias of entity.aliases ?? []) pairs.push(["oo:alias", literal(alias)]);
for (const [namespace, value] of Object.entries(entity.externalIds ?? {})) {
pairs.push(["oo:externalId", literal(`${namespace}:${value}`)]);
}
if (entity.supersededBy) pairs.push(["oo:supersededBy", iri(entity.supersededBy)]);
emit(iri(entity.id), pairs);
const extra = extraFields(entity as unknown as Record<string, unknown>, LOSSLESS_ENTITY_FIELDS);
if (extra.length) lossy.push({ objectId: entity.id, fields: extra });
}
for (const claim of pkg.data.claims) {
const objectTerm =
"entity" in claim.object ? iri(claim.object.entity) : valueTerm(claim.object);
const pairs: Array<[string, string]> = [
["a", "oo:Claim"],
["rdf:subject", iri(claim.subject)],
["rdf:predicate", vocab(claim.predicate)],
["rdf:object", objectTerm],
["oo:status", literal(claim.status)],
["prov:generatedAtTime", typed(claim.assertedAt, "xsd:dateTime")],
["prov:wasAttributedTo", literal(claim.assertedBy)]
];
if (claim.confidence !== undefined) pairs.push(["oo:confidence", typed(String(claim.confidence), "xsd:double")]);
if (claim.validTime?.from) pairs.push(["oo:validFrom", typed(claim.validTime.from, "xsd:dateTime")]);
if (claim.validTime?.to) pairs.push(["oo:validTo", typed(claim.validTime.to, "xsd:dateTime")]);
if (claim.observedAt) pairs.push(["oo:observedAt", typed(claim.observedAt, "xsd:dateTime")]);
if (claim.runId) pairs.push(["prov:wasGeneratedBy", literal(claim.runId)]);
for (const source of claim.sources ?? []) pairs.push(["prov:wasDerivedFrom", iri(source)]);
for (const record of claim.evidence ?? []) pairs.push(["oo:evidence", iri(record)]);
if (claim.supersedes) pairs.push(["oo:supersedes", iri(claim.supersedes)]);
if (claim.disputes) pairs.push(["oo:disputes", iri(claim.disputes)]);
emit(iri(claim.id), pairs);
// The plain triple, for consumers that only want the accepted graph.
if (claim.status === "asserted" && "entity" in claim.object) {
emit(iri(claim.subject), [[vocab(claim.predicate), iri(claim.object.entity)]]);
}
const extra = extraFields(claim as unknown as Record<string, unknown>, LOSSLESS_CLAIM_FIELDS);
if (extra.length) lossy.push({ objectId: claim.id, fields: extra });
}
for (const source of pkg.data.sources) {
emit(iri(source.id), [
["a", "prov:Entity"],
["oo:sourceType", literal(source.sourceType)],
["oo:uri", literal(source.uri)],
["prov:generatedAtTime", typed(source.retrievedAt, "xsd:dateTime")],
...(source.license ? ([["oo:license", literal(source.license)]] as Array<[string, string]>) : []),
...(source.contentHash ? ([["oo:contentHash", literal(source.contentHash)]] as Array<[string, string]>) : [])
]);
}
return {
turtle: `${lines.join("\n").trimEnd()}\n`,
lossy,
counts: {
entities: pkg.data.entities.length,
claims: pkg.data.claims.length,
sources: pkg.data.sources.length,
triples
}
};
}
/**
* Import the profile `exportTurtle` produces.
*
* This is deliberately a parser for that profile, not a general Turtle parser:
* it reads the reified claim shape and the entity shape, and ignores plain
* triples (which are redundant with the claims). Anything it cannot interpret
* is reported rather than silently dropped.
*/
export function importTurtle(
turtle: string,
manifest: { id: string; namespace: string; prefix?: string }
): { entities: Entity[]; claims: Claim[]; unsupported: string[] } {
const base = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
const prefix = manifest.prefix ?? manifest.id;
const compact = (value: string): string => {
const trimmed = value.replace(/^<|>$/g, "");
if (!trimmed.startsWith(base)) return trimmed;
return [prefix, ...trimmed.slice(base.length).split("/").map(decodeURIComponent)].join(":");
};
const term = (value: string): string => {
const trimmed = value.replace(/^<|>$/g, "");
if (!trimmed.startsWith(base)) return trimmed;
return decodeURIComponent(trimmed.slice(base.length));
};
const entities: Entity[] = [];
const claims: Claim[] = [];
const unsupported: string[] = [];
for (const block of splitBlocks(turtle)) {
const pairs = block.pairs;
const type = pairs.find(([p]) => p === "a" || p === "rdf:type")?.[1];
if (type === "oo:Claim") {
const objectTerm = pairs.find(([p]) => p === "rdf:object")?.[1] ?? "";
const claim: Claim = {
openontology: OPENONTOLOGY_VERSION,
kind: "Claim",
id: compact(block.subject),
subject: compact(pairs.find(([p]) => p === "rdf:subject")?.[1] ?? ""),
predicate: term(pairs.find(([p]) => p === "rdf:predicate")?.[1] ?? ""),
object: objectTerm.startsWith("<")
? { entity: compact(objectTerm) }
: { value: parseLiteral(objectTerm) },
status: (unquote(pairs.find(([p]) => p === "oo:status")?.[1] ?? '"asserted"') as Claim["status"]) ?? "asserted",
assertedAt: unquote(pairs.find(([p]) => p === "prov:generatedAtTime")?.[1] ?? '""'),
assertedBy: unquote(pairs.find(([p]) => p === "prov:wasAttributedTo")?.[1] ?? '""')
};
const confidence = pairs.find(([p]) => p === "oo:confidence")?.[1];
if (confidence) claim.confidence = Number(unquote(confidence));
const from = pairs.find(([p]) => p === "oo:validFrom")?.[1];
const to = pairs.find(([p]) => p === "oo:validTo")?.[1];
if (from || to) {
claim.validTime = {
...(from ? { from: unquote(from) } : {}),
...(to ? { to: unquote(to) } : {})
};
}
const observed = pairs.find(([p]) => p === "oo:observedAt")?.[1];
if (observed) claim.observedAt = unquote(observed);
const run = pairs.find(([p]) => p === "prov:wasGeneratedBy")?.[1];
if (run) claim.runId = unquote(run);
const sources = pairs.filter(([p]) => p === "prov:wasDerivedFrom").map(([, o]) => compact(o));
if (sources.length) claim.sources = sources;
const evidence = pairs.filter(([p]) => p === "oo:evidence").map(([, o]) => compact(o));
if (evidence.length) claim.evidence = evidence;
const supersedes = pairs.find(([p]) => p === "oo:supersedes")?.[1];
if (supersedes) claim.supersedes = compact(supersedes);
const disputes = pairs.find(([p]) => p === "oo:disputes")?.[1];
if (disputes) claim.disputes = compact(disputes);
claims.push(claim);
continue;
}
if (type === "prov:Entity" || !type) continue;
if (!type.startsWith("<")) {
unsupported.push(`${block.subject} has unrecognized type ${type}`);
continue;
}
const entity: Entity = {
openontology: OPENONTOLOGY_VERSION,
kind: "Entity",
id: compact(block.subject),
type: term(type),
canonicalName: unquote(pairs.find(([p]) => p === "rdfs:label")?.[1] ?? '""'),
createdAt: unquote(pairs.find(([p]) => p === "prov:generatedAtTime")?.[1] ?? '""'),
createdBy: unquote(pairs.find(([p]) => p === "prov:wasAttributedTo")?.[1] ?? '""')
};
const aliases = pairs.filter(([p]) => p === "oo:alias").map(([, o]) => unquote(o));
if (aliases.length) entity.aliases = aliases;
const externalIds = pairs.filter(([p]) => p === "oo:externalId").map(([, o]) => unquote(o));
if (externalIds.length) {
entity.externalIds = Object.fromEntries(
externalIds.map((pair) => {
const at = pair.indexOf(":");
return [pair.slice(0, at), pair.slice(at + 1)];
})
);
}
const status = pairs.find(([p]) => p === "oo:status")?.[1];
if (status && unquote(status) !== "active") entity.status = unquote(status) as Entity["status"];
const supersededBy = pairs.find(([p]) => p === "oo:supersededBy")?.[1];
if (supersededBy) entity.supersededBy = compact(supersededBy);
entities.push(entity);
}
return { entities, claims, unsupported };
}
interface Block {
subject: string;
pairs: Array<[string, string]>;
}
/** Split the profile's `subject\n predicate object ;\n … .` blocks. */
function splitBlocks(turtle: string): Block[] {
const blocks: Block[] = [];
let current: Block | null = null;
for (const raw of turtle.split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#") || line.startsWith("@prefix")) continue;
if (!raw.startsWith(" ")) {
if (current) blocks.push(current);
current = { subject: line.replace(/\s*[;.]$/, ""), pairs: [] };
continue;
}
if (!current) continue;
const body = line.replace(/\s*[;.]$/, "");
const space = body.indexOf(" ");
if (space < 0) continue;
current.pairs.push([body.slice(0, space), body.slice(space + 1).trim()]);
}
if (current) blocks.push(current);
// Plain triples re-state an asserted claim, so drop those single-pair blocks.
return blocks.filter((block) => block.pairs.some(([p]) => p === "a" || p === "rdf:type"));
}
function literal(value: string): string {
return JSON.stringify(String(value));
}
function typed(value: string, datatype: string): string {
return `${JSON.stringify(value)}^^${datatype}`;
}
function valueTerm(object: { value: unknown; language?: string }): string {
if (object.language) return `${JSON.stringify(String(object.value))}@${object.language}`;
if (typeof object.value === "number") return `${JSON.stringify(String(object.value))}^^xsd:double`;
if (typeof object.value === "boolean") return `${JSON.stringify(String(object.value))}^^xsd:boolean`;
return literal(String(object.value));
}
function unquote(term: string): string {
const match = /^"((?:[^"\\]|\\.)*)"/.exec(term.trim());
if (!match) return term.trim();
return JSON.parse(`"${match[1]}"`) as string;
}
function parseLiteral(term: string): unknown {
const text = unquote(term);
if (term.includes("^^xsd:double") || term.includes("^^xsd:integer")) return Number(text);
if (term.includes("^^xsd:boolean")) return text === "true";
return text;
}
function extraFields(object: Record<string, unknown>, lossless: Set<string>): string[] {
return Object.keys(object).filter((key) => !lossless.has(key) && object[key] !== undefined);
}

View file

@ -0,0 +1,209 @@
import { packagePrefix, OO } from "./jsonld.js";
import type { BuiltPackage, Constraint, LoadedPackage } from "./types.js";
/**
* SHACL mapping for the constraint kinds whose semantics genuinely match.
*
* Four of the seven map cleanly onto node/property shapes. `unique` and the
* query-based checks do not SHACL has no portable "this value appears once
* across the graph" without SPARQL constraints, and an OpenOntology saved
* query is not a SPARQL query. Those are reported as unmapped rather than
* approximated, because a shape that silently means something else is worse
* than no shape at all.
*/
export interface ShaclExport {
turtle: string;
mapped: Array<{ constraint: string; shape: string }>;
unmapped: Array<{ constraint: string; rule: string; reason: string }>;
}
export function constraintsToShacl(pkg: BuiltPackage | LoadedPackage): ShaclExport {
const manifest = pkg.manifest;
const ns = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
const prefix = packagePrefix(pkg);
const mapped: ShaclExport["mapped"] = [];
const unmapped: ShaclExport["unmapped"] = [];
const body: string[] = [];
const shapeName = (constraint: Constraint) =>
`ns:${toPascal(constraint.id)}Shape`;
for (const constraint of pkg.schema.constraints) {
const severity = shaclSeverity(constraint.severity ?? "error");
const shape = shapeName(constraint);
const rule = constraint.rule;
switch (rule.type) {
case "required-predicate": {
body.push(
`${shape}`,
` a sh:NodeShape ;`,
` sh:targetClass ns:${rule.entityType} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
` sh:minCount 1 ;`,
` sh:severity ${severity} ;`,
` sh:message ${JSON.stringify(constraint.description)} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "cardinality": {
const counts = [
rule.min !== undefined ? ` sh:minCount ${rule.min} ;` : null,
rule.max !== undefined ? ` sh:maxCount ${rule.max} ;` : null
].filter(Boolean) as string[];
body.push(
`${shape}`,
` a sh:NodeShape ;`,
rule.entityType ? ` sh:targetClass ns:${rule.entityType} ;` : ` sh:targetSubjectsOf ns:${rule.predicate} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
...counts,
` sh:severity ${severity} ;`,
` sh:message ${JSON.stringify(constraint.description)} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "allowed-values": {
body.push(
`${shape}`,
` a sh:NodeShape ;`,
` sh:targetSubjectsOf ns:${rule.predicate} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
` sh:in (${rule.values.map((value) => JSON.stringify(String(value))).join(" ")}) ;`,
` sh:severity ${severity} ;`,
` sh:message ${JSON.stringify(constraint.description)} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "domain-range": {
const lines = [`${shape}`, ` a sh:NodeShape ;`];
lines.push(` sh:targetSubjectsOf ns:${rule.predicate} ;`);
lines.push(` rdfs:comment ${JSON.stringify(constraint.description)} ;`);
if (rule.from?.length) {
lines.push(
` sh:or (${rule.from.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;`
);
}
if (rule.to?.length) {
lines.push(
` sh:property [`,
` sh:path ns:${rule.predicate} ;`,
` sh:or (${rule.to.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;`,
` sh:severity ${severity} ;`,
` ] ;`
);
}
lines.push(` sh:severity ${severity} .`, "");
body.push(...lines);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "temporal-bounds": {
const props: string[] = [];
if (rule.notBefore) props.push(` sh:minInclusive ${JSON.stringify(rule.notBefore)} ;`);
if (rule.notAfter) props.push(` sh:maxInclusive ${JSON.stringify(rule.notAfter)} ;`);
if (rule.requireValidFrom) props.push(` sh:minCount 1 ;`);
if (props.length === 0) {
unmapped.push({
constraint: constraint.id,
rule: rule.type,
reason: "temporal-bounds with no bounds has nothing to express"
});
break;
}
body.push(
`${shape}`,
` a sh:NodeShape ;`,
` sh:targetObjectsOf ns:${rule.predicate} ;`,
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
` sh:property [`,
` sh:path oo:validFrom ;`,
...props,
` sh:severity ${severity} ;`,
` ] .`,
""
);
mapped.push({ constraint: constraint.id, shape });
break;
}
case "unique":
unmapped.push({
constraint: constraint.id,
rule: rule.type,
reason:
"graph-wide uniqueness has no portable SHACL Core equivalent; it needs a sh:SPARQLConstraint"
});
break;
case "query":
unmapped.push({
constraint: constraint.id,
rule: rule.type,
reason: "an OpenOntology saved query is a triple-pattern AST, not SPARQL"
});
break;
default:
unmapped.push({ constraint: (constraint as Constraint).id, rule: "unknown", reason: "unrecognized rule type" });
}
}
const header = [
"@prefix sh: <http://www.w3.org/ns/shacl#> .",
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .",
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
`@prefix oo: <${OO}> .`,
`@prefix ns: <${ns}> .`,
"",
`# SHACL shapes generated from ${manifest.id}@${manifest.version} (compact prefix: ${prefix})`,
`# ${mapped.length} constraint(s) mapped, ${unmapped.length} not mappable to SHACL Core.`,
...unmapped.map((entry) => `# unmapped: ${entry.constraint} (${entry.rule}) — ${entry.reason}`),
""
];
return {
turtle: `${[...header, ...body].join("\n").trimEnd()}\n`,
mapped,
unmapped
};
}
function shaclSeverity(severity: string): string {
switch (severity) {
case "warning":
return "sh:Warning";
case "info":
case "policy":
return "sh:Info";
default:
return "sh:Violation";
}
}
function toPascal(id: string): string {
return id
.split(/[-_\s]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}

View file

@ -0,0 +1,16 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadOntologyPackage } from "./package.js";
import { initOntologyPackage } from "./scaffold.js";
import type { LoadedPackage } from "./types.js";
/**
* A scaffolded package with a pinned timestamp, for tests that need real data
* without depending on the Ethereum example (which is removable by design).
*/
export function loadPrdFixturePackage(id = "test-ecosystem"): LoadedPackage {
const dir = mkdtempSync(join(tmpdir(), "openontology-fixture-"));
initOntologyPackage(dir, { id, now: "2026-07-26T00:00:00Z" });
return loadOntologyPackage(dir);
}