@sigloch/graph-api-core 4.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,12 @@ export declare class FormatECodec {
23
23
  private parseNodeLine;
24
24
  private parseEdgeLine;
25
25
  private parseMerge;
26
+ /**
27
+ * CR-SM-251: derselbe Hydrations-Ort wie beim `@key value`-Pfad. Vorher war dies der einzige
28
+ * Pfad OHNE Hydration — hart auf `Record<string, string>` typisiert, womit `[concept:true]`
29
+ * als String "true" ankam und jede `=== true`-Ausnahme wirkungslos blieb (CR-GC-334 hatte
30
+ * genau diese Doppelung schon einmal fuer Objekte aufgeloest, nur eine Zeile weiter oben).
31
+ */
26
32
  private parseInlineAttrs;
27
33
  /**
28
34
  * CR-SM-215: fan-out serialization — edges sharing `(sourceId, edgeType)` collapse
@@ -7,7 +7,7 @@
7
7
  * family (`TYPE-slug`, `Name.TypeAbbr.Counter`, `cand_<hex>`); typing by spelling made
8
8
  * every foreign convention fail silently instead of loudly.
9
9
  */
10
- import { hydrateAttrValue } from '@sigloch/contracts/se';
10
+ import { hydrateAttrValue, attributeTypeOf } from '@sigloch/contracts/se';
11
11
  import { isValidTrace, tracePatternsOf } from './types.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Regex patterns
@@ -78,7 +78,7 @@ export class FormatECodec {
78
78
  // them back as objects. Kept as a string, `realRef`/`testRef` fail their schema and
79
79
  // the element reads as UNBOUND — R-19/R-20 fired on every node authored through this
80
80
  // path. Same rule as the contracts parser, imported, not re-implemented.
81
- lastOp.attributes[attrMatch[1]] = hydrateAttrValue(attrMatch[2].trim());
81
+ lastOp.attributes[attrMatch[1]] = hydrateAttrValue(attrMatch[2].trim(), attributeTypeOf(lastOp.elementType, attrMatch[1]));
82
82
  }
83
83
  else {
84
84
  errors.push(`@attribute line without preceding node: "${line}"`);
@@ -197,7 +197,7 @@ export class FormatECodec {
197
197
  const attrMatch = INLINE_ATTRS_RE.exec(rest);
198
198
  if (attrMatch) {
199
199
  mainPart = rest.slice(0, attrMatch.index).trim();
200
- inlineAttrs = this.parseInlineAttrs(attrMatch[1]);
200
+ inlineAttrs = this.parseInlineAttrs(attrMatch[1], nodeType);
201
201
  }
202
202
  // Split uid|description
203
203
  const pipeIdx = mainPart.indexOf('|');
@@ -237,6 +237,9 @@ export class FormatECodec {
237
237
  const action = OP_PREFIX[opChar] ?? 'add';
238
238
  // Extract inline attributes from the line
239
239
  let mainPart = rest;
240
+ // CR-SM-251: Kanten-Attribute (cardinality/constraint/notes) sind Strings und bleiben es —
241
+ // `attributeTypeOf` kennt nur Element-Attribute, ohne Typ faellt die Hydrierung auf String
242
+ // zurueck. Der Typ ist trotzdem `unknown`, damit hier kein zweiter Hydrations-Pfad entsteht.
240
243
  let inlineAttrs;
241
244
  const attrMatch = INLINE_ATTRS_RE.exec(rest);
242
245
  if (attrMatch) {
@@ -308,12 +311,19 @@ export class FormatECodec {
308
311
  sourceIds: parts,
309
312
  });
310
313
  }
311
- parseInlineAttrs(raw) {
314
+ /**
315
+ * CR-SM-251: derselbe Hydrations-Ort wie beim `@key value`-Pfad. Vorher war dies der einzige
316
+ * Pfad OHNE Hydration — hart auf `Record<string, string>` typisiert, womit `[concept:true]`
317
+ * als String "true" ankam und jede `=== true`-Ausnahme wirkungslos blieb (CR-GC-334 hatte
318
+ * genau diese Doppelung schon einmal fuer Objekte aufgeloest, nur eine Zeile weiter oben).
319
+ */
320
+ parseInlineAttrs(raw, nodeType) {
312
321
  const attrs = {};
313
322
  for (const pair of raw.split(',')) {
314
323
  const colonIdx = pair.indexOf(':');
315
324
  if (colonIdx > 0) {
316
- attrs[pair.slice(0, colonIdx).trim()] = pair.slice(colonIdx + 1).trim();
325
+ const key = pair.slice(0, colonIdx).trim();
326
+ attrs[key] = hydrateAttrValue(pair.slice(colonIdx + 1).trim(), attributeTypeOf(nodeType, key));
317
327
  }
318
328
  }
319
329
  return attrs;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * GraphCypherEngine — thin adapter over kuzu-wasm.
3
+ *
4
+ * Lifecycle:
5
+ * const e = new GraphCypherEngine();
6
+ * await e.init(ontology);
7
+ * await e.loadGraph(graph); // idempotent DROP + CREATE
8
+ * const r = await e.query(cypher);
9
+ * await e.destroy();
10
+ *
11
+ * Engine choice is isolated here so a future swap (kuzu fork, server-side
12
+ * Cypher) does not break consumers.
13
+ */
14
+ import type { Graph, OntologyDescriptor } from '../types.js';
15
+ import { type GeneratedSchema } from './schema-generator.js';
16
+ import type { GraphCypherEngineOptions, QueryResultRows } from './types.js';
17
+ export declare class GraphCypherEngine {
18
+ private readonly options;
19
+ private kuzu;
20
+ private db;
21
+ private connection;
22
+ private schema;
23
+ private ontology;
24
+ constructor(options?: GraphCypherEngineOptions);
25
+ /**
26
+ * Initialize kuzu, declare schema. Idempotent for the same OntologyDescriptor.
27
+ */
28
+ init(ontology: OntologyDescriptor): Promise<void>;
29
+ /**
30
+ * Full DROP + CREATE of all graph data. Cheap (<50 ms) at workshop scale
31
+ * (<500 nodes) and removes any need for incremental sync logic.
32
+ */
33
+ loadGraph(graph: Graph): Promise<void>;
34
+ /**
35
+ * Run an ad-hoc Cypher query and return rows as plain JS objects.
36
+ * BigInt values (from kuzu aggregates like count) are coerced to Number.
37
+ */
38
+ query(cypher: string): Promise<QueryResultRows>;
39
+ /**
40
+ * Run a write/DDL statement (no rows expected). Throws on Cypher error.
41
+ * Used by the KuzuAdapter for incremental MERGE/SET/DELETE mutations.
42
+ */
43
+ exec(cypher: string): Promise<void>;
44
+ /** Generated schema (label/table maps) — needed by adapters for type↔label mapping. */
45
+ get generatedSchema(): GeneratedSchema;
46
+ destroy(): Promise<void>;
47
+ private execRaw;
48
+ private ensureReady;
49
+ }
50
+ export declare function stringifyProps(props: Record<string, string>): string;
51
+ export declare function escapeString(v: string): string;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * GraphCypherEngine — thin adapter over kuzu-wasm.
3
+ *
4
+ * Lifecycle:
5
+ * const e = new GraphCypherEngine();
6
+ * await e.init(ontology);
7
+ * await e.loadGraph(graph); // idempotent DROP + CREATE
8
+ * const r = await e.query(cypher);
9
+ * await e.destroy();
10
+ *
11
+ * Engine choice is isolated here so a future swap (kuzu fork, server-side
12
+ * Cypher) does not break consumers.
13
+ */
14
+ import { generateSchema } from './schema-generator.js';
15
+ async function loadKuzu() {
16
+ // Node path: sync nodejs variant. Detect Node by checking for `process.versions.node`.
17
+ const isNode = typeof process !== 'undefined' && !!process.versions?.node;
18
+ if (isNode) {
19
+ // CommonJS require keeps vitest happy and avoids ESM-export issues
20
+ // with the kuzu-wasm `./nodejs/sync` subpath.
21
+ const { createRequire } = await import('node:module');
22
+ const req = createRequire(import.meta.url);
23
+ const kuzu = req('kuzu-wasm/nodejs/sync');
24
+ if (kuzu.init)
25
+ await kuzu.init();
26
+ return kuzu;
27
+ }
28
+ // Browser path: default async ESM build.
29
+ const mod = await import('kuzu-wasm');
30
+ const kuzu = (mod.default ?? mod);
31
+ if (kuzu.init)
32
+ await kuzu.init();
33
+ return kuzu;
34
+ }
35
+ export class GraphCypherEngine {
36
+ options;
37
+ kuzu = null;
38
+ db = null;
39
+ connection = null;
40
+ schema = null;
41
+ ontology = null;
42
+ constructor(options = {}) {
43
+ this.options = options;
44
+ }
45
+ /**
46
+ * Initialize kuzu, declare schema. Idempotent for the same OntologyDescriptor.
47
+ */
48
+ async init(ontology) {
49
+ if (this.connection && this.ontology?.version === ontology.version
50
+ && this.ontology.name === ontology.name) {
51
+ return;
52
+ }
53
+ await this.destroy();
54
+ this.kuzu = await loadKuzu();
55
+ this.db = new this.kuzu.Database(this.options.path ?? ':memory:');
56
+ this.connection = new this.kuzu.Connection(this.db);
57
+ this.schema = generateSchema(ontology, this.options);
58
+ this.ontology = ontology;
59
+ for (const ddl of this.schema.ddl) {
60
+ // Idempotent so a persistent DB can be re-opened (tables already exist).
61
+ await this.execRaw(idempotentDdl(ddl));
62
+ }
63
+ }
64
+ /**
65
+ * Full DROP + CREATE of all graph data. Cheap (<50 ms) at workshop scale
66
+ * (<500 nodes) and removes any need for incremental sync logic.
67
+ */
68
+ async loadGraph(graph) {
69
+ this.ensureReady();
70
+ const schema = this.schema;
71
+ // Wipe: kuzu has no TRUNCATE in 0.11.x, but `MATCH (n) DETACH DELETE n`
72
+ // clears everything in one statement. (Tested in the empirical probe.)
73
+ await this.execRaw('MATCH (n) DETACH DELETE n');
74
+ // Insert nodes — grouped by label so we issue one CREATE per node (kuzu
75
+ // accepts batched CREATE in 0.11.x but parameter binding for STRING is the
76
+ // cleaner path; we'll prepare per label later if perf demands).
77
+ for (const node of graph.nodes) {
78
+ const label = schema.nodeLabelByKey.get(node.type);
79
+ if (!label) {
80
+ console.warn(`[graph-api-core/kuzu] Unknown node type "${node.type}" for uid ${node.uid} — skipped`);
81
+ continue;
82
+ }
83
+ const cols = schema.nodePropsByLabel.get(label);
84
+ const props = {
85
+ uid: node.uid,
86
+ name: node.name ?? '',
87
+ description: node.description ?? '',
88
+ };
89
+ for (const key of cols.slice(3)) {
90
+ const v = node.attributes?.[key];
91
+ if (v !== undefined && v !== null)
92
+ props[key] = String(v);
93
+ }
94
+ await this.execRaw(`CREATE (:${label} ${stringifyProps(props)})`);
95
+ }
96
+ // Insert edges — kuzu requires labeled endpoints in CREATE patterns,
97
+ // so we resolve each endpoint's label from the loaded node set first.
98
+ const uidToLabel = new Map();
99
+ for (const node of graph.nodes) {
100
+ const label = schema.nodeLabelByKey.get(node.type);
101
+ if (label)
102
+ uidToLabel.set(node.uid, label);
103
+ }
104
+ for (const edge of graph.edges) {
105
+ const table = schema.edgeTableByKey.get(edge.edgeType);
106
+ if (!table) {
107
+ console.warn(`[graph-api-core/kuzu] Unknown edge type "${edge.edgeType}" — skipped`);
108
+ continue;
109
+ }
110
+ const fromLabel = uidToLabel.get(edge.sourceId);
111
+ const toLabel = uidToLabel.get(edge.targetId);
112
+ if (!fromLabel || !toLabel) {
113
+ console.warn(`[graph-api-core/kuzu] Edge "${edge.sourceId} -${edge.edgeType}-> ${edge.targetId}" ` +
114
+ `references unknown node(s) — skipped`);
115
+ continue;
116
+ }
117
+ const edgeProps = {};
118
+ for (const key of schema.edgePropsByTable.get(table) ?? []) {
119
+ const v = edge.attributes?.[key];
120
+ if (v !== undefined && v !== null)
121
+ edgeProps[key] = String(v);
122
+ }
123
+ const propsClause = Object.keys(edgeProps).length > 0
124
+ ? ` ${stringifyProps(edgeProps)}`
125
+ : '';
126
+ await this.execRaw(`MATCH (s:${fromLabel} {uid: ${escapeString(edge.sourceId)}}), ` +
127
+ `(t:${toLabel} {uid: ${escapeString(edge.targetId)}}) ` +
128
+ `CREATE (s)-[:${table}${propsClause}]->(t)`);
129
+ }
130
+ }
131
+ /**
132
+ * Run an ad-hoc Cypher query and return rows as plain JS objects.
133
+ * BigInt values (from kuzu aggregates like count) are coerced to Number.
134
+ */
135
+ async query(cypher) {
136
+ this.ensureReady();
137
+ const t0 = performance.now();
138
+ const result = await Promise.resolve(this.connection.query(cypher));
139
+ if (!result.isSuccess()) {
140
+ const msg = result.getErrorMessage();
141
+ try {
142
+ result.close?.();
143
+ }
144
+ catch { /* noop */ }
145
+ throw new Error(`Cypher error: ${msg}`);
146
+ }
147
+ const columns = await Promise.resolve(result.getColumnNames());
148
+ const rawRows = await Promise.resolve(result.getAllObjects());
149
+ const rows = rawRows.map(coerceRow);
150
+ try {
151
+ result.close?.();
152
+ }
153
+ catch { /* noop */ }
154
+ const ms = Math.round((performance.now() - t0) * 10) / 10;
155
+ return { rows, columns, ms };
156
+ }
157
+ /**
158
+ * Run a write/DDL statement (no rows expected). Throws on Cypher error.
159
+ * Used by the KuzuAdapter for incremental MERGE/SET/DELETE mutations.
160
+ */
161
+ async exec(cypher) {
162
+ this.ensureReady();
163
+ await this.execRaw(cypher);
164
+ }
165
+ /** Generated schema (label/table maps) — needed by adapters for type↔label mapping. */
166
+ get generatedSchema() {
167
+ this.ensureReady();
168
+ return this.schema;
169
+ }
170
+ async destroy() {
171
+ try {
172
+ this.db?.close?.();
173
+ }
174
+ catch { /* noop */ }
175
+ this.connection = null;
176
+ this.db = null;
177
+ this.kuzu = null;
178
+ this.schema = null;
179
+ this.ontology = null;
180
+ }
181
+ // -- internals --
182
+ async execRaw(cypher) {
183
+ const result = await Promise.resolve(this.connection.query(cypher));
184
+ if (!result.isSuccess()) {
185
+ const msg = result.getErrorMessage();
186
+ try {
187
+ result.close?.();
188
+ }
189
+ catch { /* noop */ }
190
+ throw new Error(`Cypher error in "${cypher.slice(0, 80)}": ${msg}`);
191
+ }
192
+ try {
193
+ result.close?.();
194
+ }
195
+ catch { /* noop */ }
196
+ }
197
+ ensureReady() {
198
+ if (!this.connection || !this.schema) {
199
+ throw new Error('GraphCypherEngine: call init(ontology) before use');
200
+ }
201
+ }
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // helpers
205
+ // ---------------------------------------------------------------------------
206
+ function coerceRow(row) {
207
+ const out = {};
208
+ for (const [k, v] of Object.entries(row)) {
209
+ out[k] = coerceValue(v);
210
+ }
211
+ return out;
212
+ }
213
+ function coerceValue(v) {
214
+ if (typeof v === 'bigint')
215
+ return Number(v);
216
+ if (Array.isArray(v))
217
+ return v.map(coerceValue);
218
+ if (v && typeof v === 'object') {
219
+ // kuzu wraps numeric results from aggregates as boxed Number objects.
220
+ // Unbox them so consumers can do `r.rows[0].c === 2` not `Number(r.rows[0].c) === 2`.
221
+ if (v instanceof Number)
222
+ return Number(v);
223
+ if (v instanceof String)
224
+ return String(v);
225
+ if (v instanceof Boolean)
226
+ return Boolean(v);
227
+ return coerceRow(v);
228
+ }
229
+ return v;
230
+ }
231
+ export function stringifyProps(props) {
232
+ const parts = Object.entries(props).map(([k, v]) => `${k}: ${escapeString(v)}`);
233
+ return `{${parts.join(', ')}}`;
234
+ }
235
+ export function escapeString(v) {
236
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
237
+ }
238
+ /**
239
+ * Insert `IF NOT EXISTS` into a `CREATE NODE/REL TABLE` statement so DDL can be
240
+ * replayed against a persistent DB whose tables already exist (no-op then).
241
+ */
242
+ function idempotentDdl(ddl) {
243
+ return ddl.replace(/^CREATE (NODE|REL) TABLE (?!IF NOT EXISTS)/, 'CREATE $1 TABLE IF NOT EXISTS ');
244
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @sigloch/graph-api-core/kuzu
3
+ * In-process Cypher engine and store over kuzu-wasm.
4
+ *
5
+ * Deliberately NOT re-exported from the package root: only this subpath pulls
6
+ * kuzu-wasm, so a consumer that needs the ontology types alone (./browser) never
7
+ * loads the WASM binary. kuzu-wasm is an optional peerDependency (CR-SM-248).
8
+ */
9
+ export { GraphCypherEngine } from './graph-cypher-engine.js';
10
+ export { KuzuAdapter } from './kuzu-adapter.js';
11
+ export type { KuzuAdapterOptions } from './kuzu-adapter.js';
12
+ export { generateSchema } from './schema-generator.js';
13
+ export type { GeneratedSchema } from './schema-generator.js';
14
+ export type { GraphCypherEngineOptions, QueryResultRows } from './types.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @sigloch/graph-api-core/kuzu
3
+ * In-process Cypher engine and store over kuzu-wasm.
4
+ *
5
+ * Deliberately NOT re-exported from the package root: only this subpath pulls
6
+ * kuzu-wasm, so a consumer that needs the ontology types alone (./browser) never
7
+ * loads the WASM binary. kuzu-wasm is an optional peerDependency (CR-SM-248).
8
+ */
9
+ export { GraphCypherEngine } from './graph-cypher-engine.js';
10
+ export { KuzuAdapter } from './kuzu-adapter.js';
11
+ export { generateSchema } from './schema-generator.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * KuzuAdapter — StorageAdapter over Kuzu.
3
+ *
4
+ * Persistent and incremental: mutations are MERGE/SET/DELETE statements against
5
+ * a live Kuzu DB — no DROP+CREATE per write (unlike GraphCypherEngine.loadGraph,
6
+ * which is kept for full-reload consumers). Pass an on-disk `path` to survive a
7
+ * process restart; the default `:memory:` is for tests/short-lived sessions.
8
+ *
9
+ * Kuzu is "just another adapter" behind the existing storage-adapter.ts contract
10
+ * (bok §6, 2yR-35 C7). Schema (node tables / rel tables) is derived from the
11
+ * OntologyDescriptor via the shared schema-generator.
12
+ */
13
+ import type { StorageAdapter } from '../storage-adapter.js';
14
+ import type { GraphNode, GraphEdge, Graph, GraphScope, OntologyDescriptor } from '../types.js';
15
+ import type { GraphCypherEngineOptions } from './types.js';
16
+ export interface KuzuAdapterOptions extends GraphCypherEngineOptions {
17
+ /** Ontology whose node/edge types define the Kuzu schema (node + rel tables). */
18
+ ontology: OntologyDescriptor;
19
+ }
20
+ export declare class KuzuAdapter implements StorageAdapter {
21
+ readonly name = "kuzu";
22
+ private readonly engine;
23
+ private readonly ontology;
24
+ /** kuzu label → ontology node-type key (inverse of schema.nodeLabelByKey). */
25
+ private readonly labelToType;
26
+ /** kuzu rel table → ontology edge-type key (inverse of schema.edgeTableByKey). */
27
+ private readonly tableToEdgeType;
28
+ constructor(options: KuzuAdapterOptions);
29
+ initialize(): Promise<void>;
30
+ shutdown(): Promise<void>;
31
+ loadGraph(_scope: GraphScope): Promise<Graph>;
32
+ saveNodes(nodes: GraphNode[]): Promise<void>;
33
+ deleteNodes(uids: string[]): Promise<void>;
34
+ saveEdges(edges: GraphEdge[]): Promise<void>;
35
+ /** uid → kuzu label for the given uids (one query). Missing uids are absent. */
36
+ private resolveLabels;
37
+ deleteEdges(keys: Array<{
38
+ sourceId: string;
39
+ targetId: string;
40
+ edgeType: string;
41
+ }>): Promise<void>;
42
+ getNode(uid: string): Promise<GraphNode | null>;
43
+ getSubgraph(root: string, depth: number, direction?: 'out' | 'in' | 'both'): Promise<Graph>;
44
+ stats(): Promise<{
45
+ nodeCount: number;
46
+ edgeCount: number;
47
+ }>;
48
+ isHealthy(): Promise<boolean>;
49
+ private toNode;
50
+ private toEdge;
51
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * KuzuAdapter — StorageAdapter over Kuzu.
3
+ *
4
+ * Persistent and incremental: mutations are MERGE/SET/DELETE statements against
5
+ * a live Kuzu DB — no DROP+CREATE per write (unlike GraphCypherEngine.loadGraph,
6
+ * which is kept for full-reload consumers). Pass an on-disk `path` to survive a
7
+ * process restart; the default `:memory:` is for tests/short-lived sessions.
8
+ *
9
+ * Kuzu is "just another adapter" behind the existing storage-adapter.ts contract
10
+ * (bok §6, 2yR-35 C7). Schema (node tables / rel tables) is derived from the
11
+ * OntologyDescriptor via the shared schema-generator.
12
+ */
13
+ import { GraphCypherEngine, escapeString } from './graph-cypher-engine.js';
14
+ /** Parse the lossless `attrs_json` catch-all column back into an attributes object. */
15
+ function parseAttrsJson(raw) {
16
+ if (typeof raw !== 'string' || raw === '')
17
+ return {};
18
+ try {
19
+ const parsed = JSON.parse(raw);
20
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
21
+ ? parsed
22
+ : {};
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ }
28
+ export class KuzuAdapter {
29
+ name = 'kuzu';
30
+ engine;
31
+ ontology;
32
+ /** kuzu label → ontology node-type key (inverse of schema.nodeLabelByKey). */
33
+ labelToType = new Map();
34
+ /** kuzu rel table → ontology edge-type key (inverse of schema.edgeTableByKey). */
35
+ tableToEdgeType = new Map();
36
+ constructor(options) {
37
+ this.ontology = options.ontology;
38
+ this.engine = new GraphCypherEngine({
39
+ path: options.path ?? ':memory:',
40
+ extraNodeProps: options.extraNodeProps,
41
+ extraEdgeProps: options.extraEdgeProps,
42
+ });
43
+ }
44
+ async initialize() {
45
+ await this.engine.init(this.ontology);
46
+ const schema = this.engine.generatedSchema;
47
+ this.labelToType.clear();
48
+ for (const [key, label] of schema.nodeLabelByKey)
49
+ this.labelToType.set(label, key);
50
+ this.tableToEdgeType.clear();
51
+ for (const [key, table] of schema.edgeTableByKey)
52
+ this.tableToEdgeType.set(table, key);
53
+ }
54
+ async shutdown() {
55
+ await this.engine.destroy();
56
+ }
57
+ // -- CRUD ------------------------------------------------------------------
58
+ async loadGraph(_scope) {
59
+ const nodeRows = await this.engine.query('MATCH (n) RETURN n');
60
+ const nodes = nodeRows.rows.map(r => this.toNode(r.n));
61
+ const edgeRows = await this.engine.query('MATCH (s)-[r]->(t) RETURN s.uid AS src, t.uid AS tgt, r AS rel');
62
+ const edges = edgeRows.rows.map(r => this.toEdge(r));
63
+ return { nodes, edges };
64
+ }
65
+ async saveNodes(nodes) {
66
+ const schema = this.engine.generatedSchema;
67
+ // Group by kuzu label: one UNWIND-batched MERGE per label instead of one
68
+ // engine round-trip per node (CR-GC-120). Each MERGE pattern needs a concrete
69
+ // label, so the grouping is by label.
70
+ const byLabel = new Map();
71
+ for (const node of nodes) {
72
+ const label = schema.nodeLabelByKey.get(node.type);
73
+ if (!label) {
74
+ console.warn(`[kuzu-adapter] Unknown node type "${node.type}" for uid ${node.uid} — skipped`);
75
+ continue;
76
+ }
77
+ (byLabel.get(label) ?? byLabel.set(label, []).get(label)).push(node);
78
+ }
79
+ for (const [label, group] of byLabel) {
80
+ // Extra (declared) props are queryable columns. Old per-row path SET only
81
+ // the props that were present, leaving absent ones untouched. To keep that
82
+ // idempotent semantic in a single batched statement, every row carries the
83
+ // SAME key set (homogeneous map list — kuzu requires it) using NULL for
84
+ // absent props, and the SET uses coalesce(row.k, n.k) so NULL preserves the
85
+ // existing column value (exact equivalent of the old omit-if-absent path).
86
+ const declaredExtra = (schema.nodePropsByLabel.get(label) ?? [])
87
+ .filter(p => p !== 'uid' && p !== 'name' && p !== 'description');
88
+ // Only project extra columns that ANY row in the group actually sets. A prop
89
+ // absent across the whole group is never referenced (identical to the old
90
+ // per-row path that omitted absent props) — and skipping it avoids a needless
91
+ // per-row coalesce read/write, the dominant cost at scale (CR-GC-120).
92
+ const extraProps = declaredExtra.filter(key => group.some(n => n.attributes?.[key] !== undefined && n.attributes?.[key] !== null));
93
+ const rows = group.map(node => {
94
+ const cells = [
95
+ `uid: ${escapeString(node.uid)}`,
96
+ `name: ${escapeString(node.name ?? '')}`,
97
+ `description: ${escapeString(node.description ?? '')}`,
98
+ ];
99
+ for (const key of extraProps) {
100
+ const v = node.attributes?.[key];
101
+ cells.push(`${key}: ${v === undefined || v === null ? 'NULL' : escapeString(String(v))}`);
102
+ }
103
+ cells.push(`attrs_json: ${escapeString(JSON.stringify(node.attributes ?? {}))}`);
104
+ return `{${cells.join(', ')}}`;
105
+ });
106
+ const sets = [
107
+ 'n.name = row.name',
108
+ 'n.description = row.description',
109
+ // coalesce: a row that lacks this prop (NULL) preserves the existing column,
110
+ // matching the old omit-if-absent idempotency for partial updates.
111
+ ...extraProps.map(k => `n.${k} = coalesce(row.${k}, n.${k})`),
112
+ 'n.attrs_json = row.attrs_json',
113
+ ];
114
+ await this.engine.exec(`UNWIND [${rows.join(', ')}] AS row ` +
115
+ `MERGE (n:${label} {uid: row.uid}) SET ${sets.join(', ')}`);
116
+ }
117
+ }
118
+ async deleteNodes(uids) {
119
+ for (const uid of uids) {
120
+ await this.engine.exec(`MATCH (n {uid: ${escapeString(uid)}}) DETACH DELETE n`);
121
+ }
122
+ }
123
+ async saveEdges(edges) {
124
+ if (edges.length === 0)
125
+ return;
126
+ const schema = this.engine.generatedSchema;
127
+ // Multi-pair rel tables (e.g. compose: PR→C, C→C) need labeled endpoints in
128
+ // CREATE/MERGE — kuzu rejects rels bound by multiple node labels otherwise.
129
+ const endpointUids = new Set();
130
+ for (const e of edges) {
131
+ endpointUids.add(e.sourceId);
132
+ endpointUids.add(e.targetId);
133
+ }
134
+ const labelOf = await this.resolveLabels([...endpointUids]);
135
+ // Group by (table, sLabel, tLabel): the MERGE pattern needs concrete endpoint
136
+ // labels, so each distinct triple is one UNWIND-batched statement instead of
137
+ // one MATCH+MERGE round-trip per edge (CR-GC-120).
138
+ const groups = new Map();
139
+ for (const edge of edges) {
140
+ const table = schema.edgeTableByKey.get(edge.edgeType);
141
+ if (!table) {
142
+ console.warn(`[kuzu-adapter] Unknown edge type "${edge.edgeType}" — skipped`);
143
+ continue;
144
+ }
145
+ const sLabel = labelOf.get(edge.sourceId);
146
+ const tLabel = labelOf.get(edge.targetId);
147
+ if (!sLabel || !tLabel) {
148
+ console.warn(`[kuzu-adapter] Edge "${edge.sourceId} -${edge.edgeType}-> ${edge.targetId}" ` +
149
+ 'references unknown node(s) — skipped');
150
+ continue;
151
+ }
152
+ const gkey = `${table}\u0000${sLabel}\u0000${tLabel}`;
153
+ let g = groups.get(gkey);
154
+ if (!g) {
155
+ g = { table, sLabel, tLabel, edges: [] };
156
+ groups.set(gkey, g);
157
+ }
158
+ g.edges.push(edge);
159
+ }
160
+ for (const { table, sLabel, tLabel, edges: group } of groups.values()) {
161
+ // attrs_json always present; declared extra props use NULL + coalesce so an
162
+ // absent prop preserves the existing column (identical to the old per-edge
163
+ // omit-if-absent SET). Homogeneous map list is required by kuzu.
164
+ const declaredExtra = schema.edgePropsByTable.get(table) ?? [];
165
+ // Project only extra columns set by some edge in the group (see saveNodes).
166
+ const extraProps = declaredExtra.filter(key => group.some(e => e.attributes?.[key] !== undefined && e.attributes?.[key] !== null));
167
+ const rows = group.map(edge => {
168
+ const cells = [
169
+ `s: ${escapeString(edge.sourceId)}`,
170
+ `t: ${escapeString(edge.targetId)}`,
171
+ ];
172
+ for (const key of extraProps) {
173
+ const v = edge.attributes?.[key];
174
+ cells.push(`${key}: ${v === undefined || v === null ? 'NULL' : escapeString(String(v))}`);
175
+ }
176
+ cells.push(`attrs_json: ${escapeString(JSON.stringify(edge.attributes ?? {}))}`);
177
+ return `{${cells.join(', ')}}`;
178
+ });
179
+ const sets = [
180
+ ...extraProps.map(k => `r.${k} = coalesce(row.${k}, r.${k})`),
181
+ 'r.attrs_json = row.attrs_json',
182
+ ];
183
+ await this.engine.exec(`UNWIND [${rows.join(', ')}] AS row ` +
184
+ `MATCH (s:${sLabel} {uid: row.s}), (t:${tLabel} {uid: row.t}) ` +
185
+ `MERGE (s)-[r:${table}]->(t) SET ${sets.join(', ')}`);
186
+ }
187
+ }
188
+ /** uid → kuzu label for the given uids (one query). Missing uids are absent. */
189
+ async resolveLabels(uids) {
190
+ const map = new Map();
191
+ if (uids.length === 0)
192
+ return map;
193
+ const inList = uids.map(escapeString).join(', ');
194
+ const r = await this.engine.query(`MATCH (n) WHERE n.uid IN [${inList}] RETURN n.uid AS uid, label(n) AS lbl`);
195
+ for (const row of r.rows)
196
+ map.set(String(row.uid), String(row.lbl));
197
+ return map;
198
+ }
199
+ async deleteEdges(keys) {
200
+ const schema = this.engine.generatedSchema;
201
+ for (const key of keys) {
202
+ const table = schema.edgeTableByKey.get(key.edgeType);
203
+ if (!table)
204
+ continue;
205
+ await this.engine.exec(`MATCH (s {uid: ${escapeString(key.sourceId)}})-[r:${table}]->` +
206
+ `(t {uid: ${escapeString(key.targetId)}}) DELETE r`);
207
+ }
208
+ }
209
+ // -- Query -----------------------------------------------------------------
210
+ async getNode(uid) {
211
+ const r = await this.engine.query(`MATCH (n {uid: ${escapeString(uid)}}) RETURN n`);
212
+ if (r.rows.length === 0)
213
+ return null;
214
+ return this.toNode(r.rows[0].n);
215
+ }
216
+ async getSubgraph(root, depth, direction = 'out') {
217
+ const rootEsc = escapeString(root);
218
+ const nodeMap = new Map();
219
+ const rootRow = await this.engine.query(`MATCH (n {uid: ${rootEsc}}) RETURN n`);
220
+ for (const row of rootRow.rows) {
221
+ const n = this.toNode(row.n);
222
+ nodeMap.set(n.uid, n);
223
+ }
224
+ if (depth > 0) {
225
+ // Direction defines what "reachable" means:
226
+ // out → root's dependencies : (root)-[*1..d]->(m)
227
+ // in → root's dependents / impact : (m)-[*1..d]->(root)
228
+ // both → union of in + out
229
+ const patterns = [];
230
+ if (direction === 'out' || direction === 'both') {
231
+ patterns.push(`MATCH (root {uid: ${rootEsc}})-[*1..${depth}]->(m) RETURN DISTINCT m`);
232
+ }
233
+ if (direction === 'in' || direction === 'both') {
234
+ patterns.push(`MATCH (m)-[*1..${depth}]->(root {uid: ${rootEsc}}) RETURN DISTINCT m`);
235
+ }
236
+ for (const cypher of patterns) {
237
+ const reach = await this.engine.query(cypher);
238
+ for (const row of reach.rows) {
239
+ const n = this.toNode(row.m);
240
+ nodeMap.set(n.uid, n);
241
+ }
242
+ }
243
+ }
244
+ // Induced edges: keep edges whose endpoints are both in the subgraph node set.
245
+ const edges = [];
246
+ if (nodeMap.size > 0) {
247
+ const inList = [...nodeMap.keys()].map(escapeString).join(', ');
248
+ const edgeRows = await this.engine.query(`MATCH (s)-[r]->(t) WHERE s.uid IN [${inList}] AND t.uid IN [${inList}] ` +
249
+ 'RETURN s.uid AS src, t.uid AS tgt, r AS rel');
250
+ for (const row of edgeRows.rows)
251
+ edges.push(this.toEdge(row));
252
+ }
253
+ return { nodes: [...nodeMap.values()], edges };
254
+ }
255
+ // -- Meta ------------------------------------------------------------------
256
+ async stats() {
257
+ const n = await this.engine.query('MATCH (n) RETURN count(n) AS c');
258
+ const e = await this.engine.query('MATCH ()-[r]->() RETURN count(r) AS c');
259
+ return {
260
+ nodeCount: Number(n.rows[0]?.c ?? 0),
261
+ edgeCount: Number(e.rows[0]?.c ?? 0),
262
+ };
263
+ }
264
+ async isHealthy() {
265
+ try {
266
+ await this.engine.query('RETURN 1 AS ok');
267
+ return true;
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ }
273
+ // -- internals -------------------------------------------------------------
274
+ toNode(n) {
275
+ const label = String(n._label);
276
+ // attrs_json is the lossless source of truth; declared columns are a fallback
277
+ // (legacy rows) and never override a value already present in the blob.
278
+ const attributes = parseAttrsJson(n.attrs_json);
279
+ for (const [k, v] of Object.entries(n)) {
280
+ if (k.startsWith('_') || k === 'uid' || k === 'name' || k === 'description' || k === 'attrs_json')
281
+ continue;
282
+ if (v !== null && v !== undefined && v !== '' && attributes[k] === undefined)
283
+ attributes[k] = v;
284
+ }
285
+ const node = {
286
+ uid: String(n.uid),
287
+ type: this.labelToType.get(label) ?? label,
288
+ name: n.name == null ? '' : String(n.name),
289
+ attributes,
290
+ };
291
+ if (n.description != null && n.description !== '')
292
+ node.description = String(n.description);
293
+ return node;
294
+ }
295
+ toEdge(row) {
296
+ const rel = row.rel;
297
+ const table = String(rel._label);
298
+ const attributes = parseAttrsJson(rel.attrs_json);
299
+ for (const [k, v] of Object.entries(rel)) {
300
+ if (k.startsWith('_') || k === 'attrs_json')
301
+ continue;
302
+ if (v !== null && v !== undefined && v !== '' && attributes[k] === undefined)
303
+ attributes[k] = v;
304
+ }
305
+ return {
306
+ sourceId: String(row.src),
307
+ targetId: String(row.tgt),
308
+ edgeType: this.tableToEdgeType.get(table) ?? table,
309
+ attributes,
310
+ };
311
+ }
312
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Generate kuzu DDL from a sigloch OntologyDescriptor.
3
+ *
4
+ * Each NodeTypeDescriptor becomes one `CREATE NODE TABLE`. Each
5
+ * EdgeTypeDescriptor becomes one `CREATE REL TABLE` with all valid
6
+ * (sourceType, targetType) pairs declared via kuzu's multi-pair syntax.
7
+ *
8
+ * Property model:
9
+ * - Every node table has: `uid STRING PRIMARY KEY`, `name STRING`, `description STRING`
10
+ * - Plus a configurable set of extra props (also STRING — pragmatic; kuzu has
11
+ * no schemaless properties) — these stay individually queryable in Cypher.
12
+ * - Plus `attrs_json STRING`: a catch-all on every node AND rel table holding the
13
+ * FULL attribute set as serialized JSON. The adapter writes/reads it so NO
14
+ * attribute is lost on a store round-trip (lossless SSOT — created_at, kinds,
15
+ * arrays/objects, edge labels). Declared extra props remain the queryable
16
+ * projection; `attrs_json` is the source of truth on read.
17
+ */
18
+ import type { OntologyDescriptor } from '@sigloch/graph-api-core';
19
+ import type { GraphCypherEngineOptions } from './types.js';
20
+ export interface GeneratedSchema {
21
+ /** Ordered list of `CREATE NODE TABLE` / `CREATE REL TABLE` statements. */
22
+ ddl: string[];
23
+ /** Label-by-typeKey lookup for the loader (e.g. `OU → OrgUnit`). */
24
+ nodeLabelByKey: Map<string, string>;
25
+ /** Edge-table-name by edgeType key (kebab keys map 1:1 to table names). */
26
+ edgeTableByKey: Map<string, string>;
27
+ /** Allowed property keys per node label. */
28
+ nodePropsByLabel: Map<string, string[]>;
29
+ /** Allowed property keys per edge table. */
30
+ edgePropsByTable: Map<string, string[]>;
31
+ }
32
+ export declare function generateSchema(ontology: OntologyDescriptor, options?: GraphCypherEngineOptions): GeneratedSchema;
Binary file
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Public types of @sigloch/graph-api-core/kuzu.
3
+ * Intentionally engine-agnostic so kuzu-wasm can be swapped later.
4
+ */
5
+ export interface GraphCypherEngineOptions {
6
+ /**
7
+ * Kuzu database path. `:memory:` (default) or empty → ephemeral in-memory DB.
8
+ * A real on-disk directory makes the graph persistent across process restarts
9
+ * (the kuzu-wasm nodejs build uses NODERAWFS — the path maps to the real FS).
10
+ */
11
+ path?: string;
12
+ /**
13
+ * Property keys on nodes that should be pre-declared as queryable columns
14
+ * (STRING type). Anything not in this list lands in `attrs_json` and is not
15
+ * directly query-able with `n.foo`. Defaults to the keys commonly referenced
16
+ * by ontoagent workshop CQs.
17
+ */
18
+ extraNodeProps?: string[];
19
+ /**
20
+ * Property keys on edges that should be queryable. Defaults to the
21
+ * CR-007-reserved keys (`cardinality`, `constraint`, `notes`).
22
+ */
23
+ extraEdgeProps?: string[];
24
+ }
25
+ export interface QueryResultRows {
26
+ rows: Record<string, unknown>[];
27
+ columns: string[];
28
+ /** Wall-clock execution time in milliseconds (rounded to 0.1). */
29
+ ms: number;
30
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Public types of @sigloch/graph-api-core/kuzu.
3
+ * Intentionally engine-agnostic so kuzu-wasm can be swapped later.
4
+ */
5
+ export {};
@@ -1,5 +1,5 @@
1
1
  /**
2
- * StorageAdapter interface — implemented by KuzuAdapter (@sigloch/graph-cypher-wasm), the one production store (Kuzu-only, 2yR-SSOT §Verriegelte Entscheidungen #3).
2
+ * StorageAdapter interface — implemented by KuzuAdapter (./kuzu), the one production store (Kuzu-only, 2yR-SSOT §Verriegelte Entscheidungen #3).
3
3
  */
4
4
  import type { GraphNode, GraphEdge, Graph, GraphScope } from './types.js';
5
5
  export interface StorageAdapter {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Format-E test fixtures shipped with @sigloch/graph-api-core.
3
3
  *
4
- * Purpose: every consumer package (graph-renderer, graph-cypher-wasm,
4
+ * Purpose: every consumer package (graph-renderer, kuzu,
5
5
  * ontoagent-app, aise, flowground, …) loads these same fixtures in its test
6
6
  * setup. Round-trip equality across consumers fences off Format-E drift sigloch-wide.
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Format-E test fixtures shipped with @sigloch/graph-api-core.
3
3
  *
4
- * Purpose: every consumer package (graph-renderer, graph-cypher-wasm,
4
+ * Purpose: every consumer package (graph-renderer, kuzu,
5
5
  * ontoagent-app, aise, flowground, …) loads these same fixtures in its test
6
6
  * setup. Round-trip equality across consumers fences off Format-E drift sigloch-wide.
7
7
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/graph-api-core",
3
- "version": "4.0.0",
3
+ "version": "5.1.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,7 +13,8 @@
13
13
  ".": "./dist/index.js",
14
14
  "./browser": "./dist/browser.js",
15
15
  "./testing": "./dist/testing/index.js",
16
- "./test-fixtures": "./dist/test-fixtures.js"
16
+ "./test-fixtures": "./dist/test-fixtures.js",
17
+ "./kuzu": "./dist/kuzu/index.js"
17
18
  },
18
19
  "scripts": {
19
20
  "build": "rm -rf dist && tsc",
@@ -21,11 +22,10 @@
21
22
  "prepublishOnly": "npm run build && npm run test"
22
23
  },
23
24
  "dependencies": {
24
- "@sigloch/contracts": "^5.0.0",
25
25
  "zod": "^4.3.6"
26
26
  },
27
27
  "license": "MIT",
28
- "description": "Framework-agnostic graph engine core — ontology-typed nodes/traces, rule evaluation, views",
28
+ "description": "Framework-agnostic graph engine core — ontology-typed nodes/traces, rule evaluation, views, and the embedded Kuzu store (./kuzu)",
29
29
  "author": "sigloch-consulting",
30
30
  "repository": {
31
31
  "type": "git",
@@ -41,5 +41,18 @@
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"
44
+ },
45
+ "peerDependencies": {
46
+ "@sigloch/contracts": ">=5 <7",
47
+ "kuzu-wasm": "^0.11.3"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "kuzu-wasm": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@sigloch/contracts": "^6.0.0",
56
+ "kuzu-wasm": "^0.11.3"
44
57
  }
45
58
  }