tina4-nodejs 3.13.110 → 3.13.112
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.
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +951 -153
- package/packages/core/dist/index.js +951 -153
- package/packages/core/src/devAdmin.ts +181 -145
- package/packages/core/src/server.ts +6 -0
- package/packages/orm/dist/index.js +958 -150
- package/packages/orm/src/graph/adapters/arango.ts +256 -0
- package/packages/orm/src/graph/adapters/bolt.ts +259 -0
- package/packages/orm/src/graph/adapters/ultipa.ts +271 -0
- package/packages/orm/src/graph/connectTimeout.ts +39 -0
- package/packages/orm/src/graph/errors.ts +29 -0
- package/packages/orm/src/graph/graphAdapter.ts +53 -0
- package/packages/orm/src/graph/graphDatabase.ts +114 -0
- package/packages/orm/src/graph/graphUrl.ts +98 -0
- package/packages/orm/src/graph/shapes.ts +95 -0
- package/packages/orm/src/index.ts +137 -115
- package/types/core/src/devAdmin.d.ts +1 -0
- package/types/orm/src/graph/adapters/arango.d.ts +42 -0
- package/types/orm/src/graph/adapters/bolt.d.ts +43 -0
- package/types/orm/src/graph/adapters/ultipa.d.ts +39 -0
- package/types/orm/src/graph/connectTimeout.d.ts +12 -0
- package/types/orm/src/graph/errors.d.ts +21 -0
- package/types/orm/src/graph/graphAdapter.d.ts +38 -0
- package/types/orm/src/graph/graphDatabase.d.ts +44 -0
- package/types/orm/src/graph/graphUrl.d.ts +28 -0
- package/types/orm/src/graph/shapes.d.ts +37 -0
- package/types/orm/src/index.d.ts +8 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultipa graph adapter — the portable core built in GQL over the tina4-ultipa driver.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the standalone `tina4-ultipa` driver (an OPTIONAL dependency — imported at
|
|
5
|
+
* the top of THIS module only, so `import "@tina4/orm"` stays driver-free; the
|
|
6
|
+
* GraphDatabase factory dynamically imports this module and turns a missing driver
|
|
7
|
+
* into the actionable install error). The portable node/edge/traverse surface is
|
|
8
|
+
* expressed in Ultipa GQL on top of the driver's query()/execute(); raw
|
|
9
|
+
* query()/execute() send GQL straight through.
|
|
10
|
+
*
|
|
11
|
+
* GQL note: the exact statements are verified against the live Ultipa community
|
|
12
|
+
* edition on the lab (no mocks). Ultipa node ids are the engine's own UUID strings;
|
|
13
|
+
* we echo back whatever id(n)/id(e) returns without assuming a type. Reads go via
|
|
14
|
+
* query() (readOnly=true); writes via execute() (readOnly=false) — a write through
|
|
15
|
+
* query() is rejected by Ultipa.
|
|
16
|
+
*/
|
|
17
|
+
import { GraphNode, GraphEdge, GraphResult } from "../shapes.js";
|
|
18
|
+
import { GraphError, GraphConnectTimeout } from "../errors.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveGraphConnectTimeout,
|
|
21
|
+
GRAPH_CONNECT_TIMEOUT_VARIABLE,
|
|
22
|
+
} from "../connectTimeout.js";
|
|
23
|
+
import type { GraphUrl } from "../graphUrl.js";
|
|
24
|
+
import type { GraphCredentials } from "../graphDatabase.js";
|
|
25
|
+
import type {
|
|
26
|
+
GraphAdapter,
|
|
27
|
+
NeighborOptions,
|
|
28
|
+
TraverseOptions,
|
|
29
|
+
} from "../graphAdapter.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The driver package name as a VARIABLE, so TypeScript does not try to resolve a
|
|
33
|
+
* package that is only installed when the Ultipa engine is actually used. This
|
|
34
|
+
* top-level await is what makes an absent driver surface as the factory's
|
|
35
|
+
* actionable install error (the dynamic import of this module rejects).
|
|
36
|
+
*/
|
|
37
|
+
const DRIVER_PACKAGE = "tina4-ultipa";
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
const driver: any = await import(DRIVER_PACKAGE);
|
|
40
|
+
const UltipaClient = driver.UltipaClient;
|
|
41
|
+
const UltipaConnectError = driver.UltipaConnectError;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Seconds handed to the driver when the bound is disabled (resolver returns null).
|
|
45
|
+
* The driver arms a real gRPC deadline (now + seconds*1000), so "unbounded" is a
|
|
46
|
+
* far-future finite deadline rather than 0 — which the driver would read as an
|
|
47
|
+
* instant, already-past deadline.
|
|
48
|
+
*/
|
|
49
|
+
const UNBOUNDED_CONNECT_SECONDS = 315_360_000; // ~10 years
|
|
50
|
+
|
|
51
|
+
interface DriverRow {
|
|
52
|
+
id?: unknown;
|
|
53
|
+
labels?: unknown;
|
|
54
|
+
props?: unknown;
|
|
55
|
+
type?: unknown;
|
|
56
|
+
f?: unknown;
|
|
57
|
+
t?: unknown;
|
|
58
|
+
[key: string]: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a GQL property map `{k1: $p_k1, ...}` plus the param dict for it.
|
|
63
|
+
*
|
|
64
|
+
* Params are BOUND (never interpolated), matching the relational ?-placeholder
|
|
65
|
+
* rule. Keys are namespaced (`p_`) so they never collide with an id param.
|
|
66
|
+
*/
|
|
67
|
+
function propClause(
|
|
68
|
+
properties: Record<string, unknown> | null | undefined,
|
|
69
|
+
): { clause: string; params: Record<string, unknown> } {
|
|
70
|
+
const props = properties ?? {};
|
|
71
|
+
const keys = Object.keys(props);
|
|
72
|
+
if (keys.length === 0) return { clause: "{}", params: {} };
|
|
73
|
+
const pairs = keys.map((key) => `${key}: $p_${key}`).join(", ");
|
|
74
|
+
const params: Record<string, unknown> = {};
|
|
75
|
+
for (const key of keys) params[`p_${key}`] = props[key];
|
|
76
|
+
return { clause: `{${pairs}}`, params };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function errorMessage(exc: unknown): string {
|
|
80
|
+
if (exc instanceof Error) return exc.message;
|
|
81
|
+
return String(exc);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class UltipaGraphAdapter implements GraphAdapter {
|
|
85
|
+
private readonly url: GraphUrl;
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
private readonly client: any;
|
|
88
|
+
private lastError: string | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(graphUrl: GraphUrl, credentials: GraphCredentials = {}) {
|
|
91
|
+
this.url = graphUrl;
|
|
92
|
+
const timeout = resolveGraphConnectTimeout();
|
|
93
|
+
this.client = new UltipaClient({
|
|
94
|
+
host: graphUrl.host,
|
|
95
|
+
port: graphUrl.port,
|
|
96
|
+
username: graphUrl.username || credentials.username || null,
|
|
97
|
+
password: graphUrl.password || credentials.password || null,
|
|
98
|
+
graph: graphUrl.graph,
|
|
99
|
+
connectTimeout: timeout ?? UNBOUNDED_CONNECT_SECONDS,
|
|
100
|
+
useTls: graphUrl.useTls,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -- connection + raw pass-through -------------------------------------
|
|
105
|
+
private async run(
|
|
106
|
+
gql: string,
|
|
107
|
+
params: Record<string, unknown> | null = null,
|
|
108
|
+
readOnly = true,
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
110
|
+
): Promise<any> {
|
|
111
|
+
try {
|
|
112
|
+
await this.client.connect();
|
|
113
|
+
} catch (exc) {
|
|
114
|
+
this.lastError = errorMessage(exc);
|
|
115
|
+
if (exc instanceof UltipaConnectError || (exc as { name?: string })?.name === "UltipaConnectError") {
|
|
116
|
+
const elapsed = typeof (exc as { elapsed?: number })?.elapsed === "number"
|
|
117
|
+
? (exc as { elapsed: number }).elapsed
|
|
118
|
+
: 0;
|
|
119
|
+
throw new GraphConnectTimeout(
|
|
120
|
+
`Graph connect to ${this.url.host}:${this.url.port} timed out after ${elapsed.toFixed(1)}s `
|
|
121
|
+
+ `(${GRAPH_CONNECT_TIMEOUT_VARIABLE}). Raise ${GRAPH_CONNECT_TIMEOUT_VARIABLE} if the server `
|
|
122
|
+
+ `is simply slow, or set it to 0 to wait indefinitely.`,
|
|
123
|
+
exc,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// A non-timeout connect failure (auth, protocol) still fails loud.
|
|
127
|
+
throw new GraphError(this.lastError, exc);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return await this.client.query(gql, { params: params ?? null, readOnly });
|
|
131
|
+
} catch (exc) {
|
|
132
|
+
this.lastError = errorMessage(exc);
|
|
133
|
+
throw new GraphError(this.lastError, exc);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async query(text: string, params: Record<string, unknown> | null = null): Promise<GraphResult> {
|
|
138
|
+
const result = await this.run(text, params, true);
|
|
139
|
+
return new GraphResult(result.dicts(), result.columns);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async execute(text: string, params: Record<string, unknown> | null = null): Promise<GraphResult> {
|
|
143
|
+
const result = await this.run(text, params, false);
|
|
144
|
+
return new GraphResult(result.dicts(), result.columns);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -- portable node/edge/traverse core (GQL) ----------------------------
|
|
148
|
+
private nodeFromRow(row: DriverRow | undefined | null): GraphNode | null {
|
|
149
|
+
if (row === null || row === undefined) return null;
|
|
150
|
+
return new GraphNode(
|
|
151
|
+
String(row.id),
|
|
152
|
+
(row.labels as string[]) ?? [],
|
|
153
|
+
(row.props as Record<string, unknown>) ?? {},
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async addNode(
|
|
158
|
+
label: string,
|
|
159
|
+
properties: Record<string, unknown> | null = null,
|
|
160
|
+
): Promise<GraphNode | null> {
|
|
161
|
+
const { clause, params } = propClause(properties);
|
|
162
|
+
const gql =
|
|
163
|
+
`INSERT (n:\`${label}\` ${clause}) `
|
|
164
|
+
+ `RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props`;
|
|
165
|
+
const rows = (await this.run(gql, params, false)).dicts() as DriverRow[];
|
|
166
|
+
return rows.length ? this.nodeFromRow(rows[0]) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async addEdge(
|
|
170
|
+
fromId: string,
|
|
171
|
+
toId: string,
|
|
172
|
+
type: string,
|
|
173
|
+
properties: Record<string, unknown> | null = null,
|
|
174
|
+
): Promise<GraphEdge | null> {
|
|
175
|
+
// id(e) requires EDGE_ID enabled on the Ultipa graph
|
|
176
|
+
// (`ALTER GRAPH <g> SET EDGE_ID ENABLED`), a one-time per-graph setting.
|
|
177
|
+
const { clause, params } = propClause(properties);
|
|
178
|
+
params.from_id = fromId;
|
|
179
|
+
params.to_id = toId;
|
|
180
|
+
const gql =
|
|
181
|
+
`MATCH (a), (b) WHERE id(a) = $from_id AND id(b) = $to_id `
|
|
182
|
+
+ `INSERT (a)-[e:\`${type}\` ${clause}]->(b) `
|
|
183
|
+
+ `RETURN id(e) AS id, type(e) AS type, id(a) AS f, id(b) AS t, properties(e) AS props`;
|
|
184
|
+
const rows = (await this.run(gql, params, false)).dicts() as DriverRow[];
|
|
185
|
+
if (rows.length === 0) return null;
|
|
186
|
+
const row = rows[0];
|
|
187
|
+
return new GraphEdge(
|
|
188
|
+
String(row.id),
|
|
189
|
+
String(row.type),
|
|
190
|
+
String(row.f),
|
|
191
|
+
String(row.t),
|
|
192
|
+
(row.props as Record<string, unknown>) ?? {},
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async getNode(nodeId: string): Promise<GraphNode | null> {
|
|
197
|
+
const gql =
|
|
198
|
+
`MATCH (n) WHERE id(n) = $id `
|
|
199
|
+
+ `RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props`;
|
|
200
|
+
const rows = (await this.run(gql, { id: nodeId }, true)).dicts() as DriverRow[];
|
|
201
|
+
return rows.length ? this.nodeFromRow(rows[0]) : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async updateNode(
|
|
205
|
+
nodeId: string,
|
|
206
|
+
properties: Record<string, unknown>,
|
|
207
|
+
): Promise<GraphNode | null> {
|
|
208
|
+
const props = properties ?? {};
|
|
209
|
+
const keys = Object.keys(props);
|
|
210
|
+
const sets = keys.map((key) => `n.${key} = $p_${key}`).join(", ");
|
|
211
|
+
const params: Record<string, unknown> = { id: nodeId };
|
|
212
|
+
for (const key of keys) params[`p_${key}`] = props[key];
|
|
213
|
+
const gql =
|
|
214
|
+
`MATCH (n) WHERE id(n) = $id SET ${sets} `
|
|
215
|
+
+ `RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props`;
|
|
216
|
+
const rows = (await this.run(gql, params, false)).dicts() as DriverRow[];
|
|
217
|
+
return rows.length ? this.nodeFromRow(rows[0]) : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async deleteNode(nodeId: string): Promise<boolean> {
|
|
221
|
+
const gql = "MATCH (n) WHERE id(n) = $id DETACH DELETE n";
|
|
222
|
+
await this.run(gql, { id: nodeId }, false);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async neighbors(nodeId: string, options: NeighborOptions = {}): Promise<GraphNode[]> {
|
|
227
|
+
const direction = options.direction ?? "both";
|
|
228
|
+
const limit = options.limit ?? 100;
|
|
229
|
+
const edge = options.edgeType ? `:\`${options.edgeType}\`` : "";
|
|
230
|
+
const pattern = {
|
|
231
|
+
out: `(n)-[${edge}]->(m)`,
|
|
232
|
+
in: `(n)<-[${edge}]-(m)`,
|
|
233
|
+
both: `(n)-[${edge}]-(m)`,
|
|
234
|
+
}[direction];
|
|
235
|
+
const gql =
|
|
236
|
+
`MATCH ${pattern} WHERE id(n) = $id `
|
|
237
|
+
+ `RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props `
|
|
238
|
+
+ `LIMIT ${Math.trunc(limit)}`;
|
|
239
|
+
const rows = (await this.run(gql, { id: nodeId }, true)).dicts() as DriverRow[];
|
|
240
|
+
return rows.map((row) => this.nodeFromRow(row)!).filter((node) => node !== null);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async traverse(startId: string, options: TraverseOptions = {}): Promise<GraphNode[]> {
|
|
244
|
+
// Ultipa GQL uses the ISO quantified-path form `-[]->{1,N}`, NOT Cypher's
|
|
245
|
+
// `-[*1..N]->` (which is a parse error on gqldb).
|
|
246
|
+
const depth = options.depth ?? 1;
|
|
247
|
+
const direction = options.direction ?? "both";
|
|
248
|
+
const limit = options.limit ?? 1000;
|
|
249
|
+
const edge = options.edgeType ? `:\`${options.edgeType}\`` : "";
|
|
250
|
+
const quant = `{1,${Math.trunc(depth)}}`;
|
|
251
|
+
const pattern = {
|
|
252
|
+
out: `(n)-[${edge}]->${quant}(m)`,
|
|
253
|
+
in: `(n)<-[${edge}]-${quant}(m)`,
|
|
254
|
+
both: `(n)-[${edge}]-${quant}(m)`,
|
|
255
|
+
}[direction];
|
|
256
|
+
const gql =
|
|
257
|
+
`MATCH ${pattern} WHERE id(n) = $start `
|
|
258
|
+
+ `RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props `
|
|
259
|
+
+ `LIMIT ${Math.trunc(limit)}`;
|
|
260
|
+
const rows = (await this.run(gql, { start: startId }, true)).dicts() as DriverRow[];
|
|
261
|
+
return rows.map((row) => this.nodeFromRow(row)!).filter((node) => node !== null);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
close(): void {
|
|
265
|
+
this.client.close();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getError(): string | null {
|
|
269
|
+
return this.lastError;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TINA4_GRAPH_CONNECT_TIMEOUT — the graph connect bound.
|
|
3
|
+
*
|
|
4
|
+
* The sibling of TINA4_DATABASE_CONNECT_TIMEOUT: seconds a graph connect may
|
|
5
|
+
* block. A value <= 0 disables the bound (unbounded); a non-number warns and
|
|
6
|
+
* falls back to the default rather than waiting forever. Same contract in all
|
|
7
|
+
* four frameworks.
|
|
8
|
+
*/
|
|
9
|
+
import { Log } from "../../../core/src/index.js";
|
|
10
|
+
|
|
11
|
+
/** The env var name — one spelling, so grepping finds every reader. */
|
|
12
|
+
export const GRAPH_CONNECT_TIMEOUT_VARIABLE = "TINA4_GRAPH_CONNECT_TIMEOUT";
|
|
13
|
+
|
|
14
|
+
/** Seconds. Long enough for a cold connect, short enough to page. */
|
|
15
|
+
export const DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS = 10;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Seconds a graph connect may block, or `null` when the bound is disabled (<= 0).
|
|
19
|
+
*
|
|
20
|
+
* Mirrors resolveConnectTimeout: a value <= 0 disables the bound, a non-number
|
|
21
|
+
* warns and uses the default. Seconds is the operator-facing unit (the Ultipa
|
|
22
|
+
* driver takes seconds), so no ms conversion happens here.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveGraphConnectTimeout(): number | null {
|
|
25
|
+
const raw = (process.env[GRAPH_CONNECT_TIMEOUT_VARIABLE] ?? "").trim();
|
|
26
|
+
if (raw === "") {
|
|
27
|
+
return DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS;
|
|
28
|
+
}
|
|
29
|
+
const seconds = Number(raw);
|
|
30
|
+
if (!Number.isFinite(seconds)) {
|
|
31
|
+
Log.warning(
|
|
32
|
+
`${GRAPH_CONNECT_TIMEOUT_VARIABLE}="${raw}" is not a number of seconds — `
|
|
33
|
+
+ `bounding graph connects at the ${DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS}s default instead`,
|
|
34
|
+
);
|
|
35
|
+
return DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS;
|
|
36
|
+
}
|
|
37
|
+
// <= 0 is a deliberate opt-out, not a typo: unbounded, wait indefinitely.
|
|
38
|
+
return seconds <= 0 ? null : seconds;
|
|
39
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph layer errors — the graph siblings of the relational fail-loud contract.
|
|
3
|
+
*
|
|
4
|
+
* A bad graph statement RAISES (never a falsy return); the cause is readable via
|
|
5
|
+
* the adapter's getError() after the throw. An unreachable host throws
|
|
6
|
+
* GraphConnectTimeout within TINA4_GRAPH_CONNECT_TIMEOUT, naming host and port.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A graph operation failed (bad statement, engine error). */
|
|
10
|
+
export class GraphError extends Error {
|
|
11
|
+
constructor(message: string, cause?: unknown) {
|
|
12
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
13
|
+
this.name = "GraphError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A graph connect exceeded TINA4_GRAPH_CONNECT_TIMEOUT.
|
|
19
|
+
*
|
|
20
|
+
* The message names the host, the port and the elapsed seconds — the mirror of
|
|
21
|
+
* the relational DatabaseConnectTimeout, so an operator can tell "my bound
|
|
22
|
+
* fired" apart from "the engine rejected me".
|
|
23
|
+
*/
|
|
24
|
+
export class GraphConnectTimeout extends GraphError {
|
|
25
|
+
constructor(message: string, cause?: unknown) {
|
|
26
|
+
super(message, cause);
|
|
27
|
+
this.name = "GraphConnectTimeout";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one surface every graph engine implements.
|
|
3
|
+
*
|
|
4
|
+
* The portable node/edge/traverse core PLUS a raw query()/execute() pass-through
|
|
5
|
+
* in the engine's native dialect, and lifecycle. Every method is async — the
|
|
6
|
+
* Node graph drivers are async (gRPC/Bolt/HTTP), so the surface is async
|
|
7
|
+
* everywhere, matching the relational Database wrapper. See ADR-0059.
|
|
8
|
+
*/
|
|
9
|
+
import type { GraphNode, GraphEdge, GraphResult } from "./shapes.js";
|
|
10
|
+
|
|
11
|
+
/** Direction of an edge relative to the anchor node. */
|
|
12
|
+
export type GraphDirection = "out" | "in" | "both";
|
|
13
|
+
|
|
14
|
+
/** Options for the one-hop neighbours read. */
|
|
15
|
+
export interface NeighborOptions {
|
|
16
|
+
direction?: GraphDirection;
|
|
17
|
+
edgeType?: string;
|
|
18
|
+
limit?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Options for the bounded multi-hop traversal. */
|
|
22
|
+
export interface TraverseOptions {
|
|
23
|
+
depth?: number;
|
|
24
|
+
direction?: GraphDirection;
|
|
25
|
+
edgeType?: string;
|
|
26
|
+
limit?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GraphAdapter {
|
|
30
|
+
// -- portable node/edge/traverse core ----------------------------------
|
|
31
|
+
addNode(label: string, properties?: Record<string, unknown> | null): Promise<GraphNode | null>;
|
|
32
|
+
addEdge(
|
|
33
|
+
fromId: string,
|
|
34
|
+
toId: string,
|
|
35
|
+
type: string,
|
|
36
|
+
properties?: Record<string, unknown> | null,
|
|
37
|
+
): Promise<GraphEdge | null>;
|
|
38
|
+
getNode(nodeId: string): Promise<GraphNode | null>;
|
|
39
|
+
updateNode(nodeId: string, properties: Record<string, unknown>): Promise<GraphNode | null>;
|
|
40
|
+
deleteNode(nodeId: string): Promise<boolean>;
|
|
41
|
+
neighbors(nodeId: string, options?: NeighborOptions): Promise<GraphNode[]>;
|
|
42
|
+
traverse(startId: string, options?: TraverseOptions): Promise<GraphNode[]>;
|
|
43
|
+
|
|
44
|
+
// -- raw pass-through (engine-native dialect) --------------------------
|
|
45
|
+
query(text: string, params?: Record<string, unknown> | null): Promise<GraphResult>;
|
|
46
|
+
execute(text: string, params?: Record<string, unknown> | null): Promise<GraphResult>;
|
|
47
|
+
|
|
48
|
+
// -- lifecycle ---------------------------------------------------------
|
|
49
|
+
close(): Promise<void> | void;
|
|
50
|
+
|
|
51
|
+
/** Cause of the last failed operation, or null. */
|
|
52
|
+
getError(): string | null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GraphAdapter URL-selected factory — the GraphDatabase sibling of Database.
|
|
3
|
+
*
|
|
4
|
+
* GraphDatabase.create picks an adapter by URL scheme and imports its driver
|
|
5
|
+
* lazily (dynamic import): a missing driver raises an actionable install error,
|
|
6
|
+
* never a bare module-not-found, and the graph CORE imports with no driver
|
|
7
|
+
* present (the zero-dependency-core rule). The barrel deliberately does NOT
|
|
8
|
+
* re-export any engine adapter, so importing @tina4/orm pulls in no graph driver.
|
|
9
|
+
*/
|
|
10
|
+
import { GraphUrl, type GraphEngine } from "./graphUrl.js";
|
|
11
|
+
import { GraphError } from "./errors.js";
|
|
12
|
+
import type { GraphAdapter } from "./graphAdapter.js";
|
|
13
|
+
|
|
14
|
+
/** Credentials passed alongside the URL when it carries none. */
|
|
15
|
+
export interface GraphCredentials {
|
|
16
|
+
username?: string;
|
|
17
|
+
password?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AdapterRegistration {
|
|
21
|
+
/** Dynamic import of the adapter module — the ONLY place a driver is pulled. */
|
|
22
|
+
load: () => Promise<Record<string, unknown>>;
|
|
23
|
+
className: string;
|
|
24
|
+
/** npm package that provides the engine driver. */
|
|
25
|
+
package: string;
|
|
26
|
+
/** The command that installs it. */
|
|
27
|
+
installCommand: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* engine -> adapter registration. Selected lazily so importing this module pulls
|
|
32
|
+
* in NO engine driver. bolt (Neo4j/Memgraph) and arango land later — declared so
|
|
33
|
+
* the factory gives an actionable message rather than a bare KeyError.
|
|
34
|
+
*/
|
|
35
|
+
export const ENGINE_ADAPTERS: Partial<Record<GraphEngine, AdapterRegistration>> = {
|
|
36
|
+
ultipa: {
|
|
37
|
+
load: () => import("./adapters/ultipa.js") as Promise<Record<string, unknown>>,
|
|
38
|
+
className: "UltipaGraphAdapter",
|
|
39
|
+
package: "tina4-ultipa",
|
|
40
|
+
installCommand: "npm install tina4-ultipa",
|
|
41
|
+
},
|
|
42
|
+
bolt: {
|
|
43
|
+
// Neo4j AND Memgraph — both speak Bolt/Cypher over the neo4j-driver package.
|
|
44
|
+
load: () => import("./adapters/bolt.js") as Promise<Record<string, unknown>>,
|
|
45
|
+
className: "BoltGraphAdapter",
|
|
46
|
+
package: "neo4j-driver",
|
|
47
|
+
installCommand: "npm install neo4j-driver",
|
|
48
|
+
},
|
|
49
|
+
arango: {
|
|
50
|
+
load: () => import("./adapters/arango.js") as Promise<Record<string, unknown>>,
|
|
51
|
+
className: "ArangoGraphAdapter",
|
|
52
|
+
package: "arangojs",
|
|
53
|
+
installCommand: "npm install arangojs",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type AdapterConstructor = new (
|
|
58
|
+
graphUrl: GraphUrl,
|
|
59
|
+
credentials: GraphCredentials,
|
|
60
|
+
) => GraphAdapter;
|
|
61
|
+
|
|
62
|
+
export class GraphDatabase {
|
|
63
|
+
/**
|
|
64
|
+
* Parse the URL, pick the engine adapter, connect lazily.
|
|
65
|
+
*
|
|
66
|
+
* The engine driver is imported only here (first use of that engine); if it is
|
|
67
|
+
* absent the error names the package and the install command. Async because the
|
|
68
|
+
* driver import is dynamic — the connect itself still happens lazily on first
|
|
69
|
+
* operation (mirroring the relational adapters).
|
|
70
|
+
*/
|
|
71
|
+
static async create(
|
|
72
|
+
url: string,
|
|
73
|
+
credentials: GraphCredentials = {},
|
|
74
|
+
): Promise<GraphAdapter> {
|
|
75
|
+
const graphUrl = new GraphUrl(url);
|
|
76
|
+
const registration = ENGINE_ADAPTERS[graphUrl.engine];
|
|
77
|
+
if (registration === undefined) {
|
|
78
|
+
throw new GraphError(
|
|
79
|
+
`No graph adapter for engine '${graphUrl.engine}' yet (scheme '${graphUrl.scheme}'). `
|
|
80
|
+
+ `Available: ${Object.keys(ENGINE_ADAPTERS).sort().join(", ")}.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
let module: Record<string, unknown>;
|
|
84
|
+
try {
|
|
85
|
+
// The adapter module imports its driver at the top; a missing driver
|
|
86
|
+
// surfaces here as an actionable install error.
|
|
87
|
+
module = await registration.load();
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
throw new GraphError(
|
|
90
|
+
`The graph driver for '${graphUrl.engine}' is not installed (${registration.package}). `
|
|
91
|
+
+ `Install it with:\n ${registration.installCommand}`,
|
|
92
|
+
cause,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const AdapterClass = module[registration.className] as AdapterConstructor | undefined;
|
|
96
|
+
if (AdapterClass === undefined) {
|
|
97
|
+
throw new GraphError(
|
|
98
|
+
`The graph adapter '${registration.className}' is missing from its module `
|
|
99
|
+
+ `for engine '${graphUrl.engine}'.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return new AdapterClass(graphUrl, credentials);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build from TINA4_GRAPH_URL (+ TINA4_GRAPH_USERNAME/_PASSWORD). */
|
|
106
|
+
static async fromEnv(envKey = "TINA4_GRAPH_URL"): Promise<GraphAdapter | null> {
|
|
107
|
+
const url = (process.env[envKey] ?? "").trim();
|
|
108
|
+
if (url === "") return null;
|
|
109
|
+
return GraphDatabase.create(url, {
|
|
110
|
+
username: process.env.TINA4_GRAPH_USERNAME,
|
|
111
|
+
password: process.env.TINA4_GRAPH_PASSWORD,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph connection URL parser — the DatabaseUrl sibling for graph engines.
|
|
3
|
+
*
|
|
4
|
+
* Parse a graph connection URL into its parts, the same way DatabaseUrl does for
|
|
5
|
+
* SQL. `engine` is the CANONICAL name the factory selects an adapter by; a scheme
|
|
6
|
+
* alias (bolt/neo4j/memgraph all speak Bolt/Cypher and share ONE adapter) resolves
|
|
7
|
+
* to its engine here. See tina4-documentation/plan/v3/features/139-graph-databases.md.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The canonical graph engine names. */
|
|
11
|
+
export type GraphEngine = "ultipa" | "bolt" | "arango";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* URL scheme to canonical engine. bolt/neo4j/memgraph share ONE adapter ("bolt");
|
|
15
|
+
* the engine label only tunes per-engine defaults. The `…s` schemes are the TLS
|
|
16
|
+
* variants.
|
|
17
|
+
*/
|
|
18
|
+
const SCHEME_ENGINE: Record<string, GraphEngine> = {
|
|
19
|
+
ultipa: "ultipa",
|
|
20
|
+
ultipas: "ultipa", // TLS variant
|
|
21
|
+
neo4j: "bolt",
|
|
22
|
+
"neo4j+s": "bolt",
|
|
23
|
+
bolt: "bolt",
|
|
24
|
+
"bolt+s": "bolt",
|
|
25
|
+
memgraph: "bolt",
|
|
26
|
+
arango: "arango",
|
|
27
|
+
arangodb: "arango",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Default port per engine when the URL omits one. */
|
|
31
|
+
const ENGINE_DEFAULT_PORT: Record<GraphEngine, number> = {
|
|
32
|
+
ultipa: 60061,
|
|
33
|
+
bolt: 7687,
|
|
34
|
+
arango: 8529,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A parsed graph URL: engine, host, port, graph, credentials, params. */
|
|
38
|
+
export class GraphUrl {
|
|
39
|
+
readonly raw: string;
|
|
40
|
+
readonly scheme: string;
|
|
41
|
+
readonly engine: GraphEngine;
|
|
42
|
+
readonly host: string;
|
|
43
|
+
readonly port: number;
|
|
44
|
+
/** The graph/database name (leading slash stripped), or null when absent. */
|
|
45
|
+
readonly graph: string | null;
|
|
46
|
+
readonly username: string | null;
|
|
47
|
+
readonly password: string | null;
|
|
48
|
+
readonly params: Record<string, string>;
|
|
49
|
+
readonly useTls: boolean;
|
|
50
|
+
|
|
51
|
+
constructor(url: string) {
|
|
52
|
+
this.raw = url;
|
|
53
|
+
let parsed: URL;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(url);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Unsupported graph URL '${url}' — expected scheme://[user[:password]@]host[:port]/graph `
|
|
59
|
+
+ `(e.g. ultipa://host:60061/mygraph).`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
63
|
+
const engine = SCHEME_ENGINE[scheme];
|
|
64
|
+
if (engine === undefined) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Unsupported graph URL scheme '${scheme}'. Supported: `
|
|
67
|
+
+ `${Object.keys(SCHEME_ENGINE).sort().join(", ")} `
|
|
68
|
+
+ `(e.g. ultipa://host:60061/mygraph).`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
this.scheme = scheme;
|
|
72
|
+
this.engine = engine;
|
|
73
|
+
this.host = parsed.hostname || "localhost";
|
|
74
|
+
this.port = parsed.port ? parseInt(parsed.port, 10) : ENGINE_DEFAULT_PORT[engine];
|
|
75
|
+
const path = (parsed.pathname || "").replace(/^\//, "");
|
|
76
|
+
this.graph = path === "" ? null : path;
|
|
77
|
+
this.username = parsed.username ? decodeURIComponent(parsed.username) : null;
|
|
78
|
+
this.password = parsed.password ? decodeURIComponent(parsed.password) : null;
|
|
79
|
+
this.params = {};
|
|
80
|
+
for (const [key, value] of parsed.searchParams) {
|
|
81
|
+
// First value wins, matching Python's parse_qs[0].
|
|
82
|
+
if (!(key in this.params)) this.params[key] = value;
|
|
83
|
+
}
|
|
84
|
+
// TLS if the scheme says so (…s) or ?tls=1|true.
|
|
85
|
+
this.useTls = scheme.endsWith("s") || this.params.tls === "1" || this.params.tls === "true";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** host:port/graph — for messages, never carrying credentials. */
|
|
89
|
+
getDsn(): string {
|
|
90
|
+
const target = this.port ? `${this.host}:${this.port}` : this.host;
|
|
91
|
+
return this.graph ? `${target}/${this.graph}` : target;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static fromEnv(envKey = "TINA4_GRAPH_URL"): GraphUrl | null {
|
|
95
|
+
const url = (process.env[envKey] ?? "").trim();
|
|
96
|
+
return url === "" ? null : new GraphUrl(url);
|
|
97
|
+
}
|
|
98
|
+
}
|