mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-15 07:17:30 +00:00
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
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:
parent
296775e003
commit
da5f6f8381
53 changed files with 6939 additions and 23 deletions
272
packages/openontology/src/adapters.test.ts
Normal file
272
packages/openontology/src/adapters.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
752
packages/openontology/src/adapters.ts
Normal file
752
packages/openontology/src/adapters.ts
Normal 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()]))
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
184
packages/openontology/src/libsql.test.ts
Normal file
184
packages/openontology/src/libsql.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
713
packages/openontology/src/libsql.ts
Normal file
713
packages/openontology/src/libsql.ts
Normal 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 };
|
||||
114
packages/openontology/src/rdf.test.ts
Normal file
114
packages/openontology/src/rdf.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
355
packages/openontology/src/rdf.ts
Normal file
355
packages/openontology/src/rdf.ts
Normal 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);
|
||||
}
|
||||
209
packages/openontology/src/shacl.ts
Normal file
209
packages/openontology/src/shacl.ts
Normal 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("");
|
||||
}
|
||||
16
packages/openontology/src/test-helpers.ts
Normal file
16
packages/openontology/src/test-helpers.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue