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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * ArangoDB graph adapter — the document/AQL engine behind the same surface.
3
+ *
4
+ * Wraps the community `arangojs` npm package (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). Arango is a document store, not a labelled-
8
+ * property graph, so the portable core maps onto ONE vertex collection + ONE edge
9
+ * collection: a node's `labels` and an edge's `type` are stored as document fields,
10
+ * ids are Arango `_id` handles (e.g. `tina4_nodes/123`), and traversal uses AQL
11
+ * `FOR v IN 1..N OUTBOUND ...`. Raw query()/execute() take AQL directly.
12
+ *
13
+ * The two collections are ensured lazily on the first query (the driver's
14
+ * constructor is synchronous and opens no connection), mirroring the Ultipa
15
+ * adapter's lazy connect.
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 Arango 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 = "arangojs";
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const driverModule: any = await import(DRIVER_PACKAGE);
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ const ArangoDatabase: any = driverModule.Database ?? driverModule.default?.Database;
42
+
43
+ const VERTEX_COLLECTION = "tina4_nodes";
44
+ const EDGE_COLLECTION = "tina4_edges";
45
+ const RESERVED = new Set(["_id", "_key", "_rev", "_from", "_to", "_labels", "_type"]);
46
+
47
+ interface ArangoDoc {
48
+ _id?: unknown;
49
+ _labels?: unknown;
50
+ _from?: unknown;
51
+ _to?: unknown;
52
+ _type?: unknown;
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ function cleanProps(doc: ArangoDoc): Record<string, unknown> {
57
+ const props: Record<string, unknown> = {};
58
+ for (const [key, value] of Object.entries(doc)) {
59
+ if (!RESERVED.has(key)) props[key] = value;
60
+ }
61
+ return props;
62
+ }
63
+
64
+ function errorMessage(exc: unknown): string {
65
+ if (exc instanceof Error) return exc.message;
66
+ return String(exc);
67
+ }
68
+
69
+ export class ArangoGraphAdapter implements GraphAdapter {
70
+ private readonly url: GraphUrl;
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ private readonly db: any;
73
+ private ensured = false;
74
+ private lastError: string | null = null;
75
+
76
+ constructor(graphUrl: GraphUrl, credentials: GraphCredentials = {}) {
77
+ this.url = graphUrl;
78
+ const scheme = graphUrl.useTls ? "https" : "http";
79
+ const user = graphUrl.username || credentials.username || "root";
80
+ const pwd = graphUrl.password || credentials.password || "";
81
+ const database = graphUrl.graph || "_system";
82
+ const timeout = resolveGraphConnectTimeout();
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ const config: Record<string, any> = {
85
+ url: `${scheme}://${graphUrl.host}:${graphUrl.port}`,
86
+ databaseName: database,
87
+ auth: { username: user, password: pwd },
88
+ };
89
+ if (timeout !== null) config.timeout = Math.max(1, Math.ceil(timeout * 1000));
90
+ this.db = new ArangoDatabase(config);
91
+ }
92
+
93
+ private connectOrError(exc: unknown): GraphError {
94
+ const text = errorMessage(exc).toLowerCase();
95
+ if (
96
+ text.includes("timed out")
97
+ || text.includes("timeout")
98
+ || text.includes("connection")
99
+ || text.includes("econnrefused")
100
+ || text.includes("etimedout")
101
+ || text.includes("max retries")
102
+ ) {
103
+ return new GraphConnectTimeout(
104
+ `Graph connect to ${this.url.host}:${this.url.port} timed out `
105
+ + `(${GRAPH_CONNECT_TIMEOUT_VARIABLE}). Raise ${GRAPH_CONNECT_TIMEOUT_VARIABLE} if the server `
106
+ + `is simply slow, or set it to 0 to wait indefinitely.`,
107
+ exc,
108
+ );
109
+ }
110
+ return new GraphError(errorMessage(exc), exc);
111
+ }
112
+
113
+ private async ensureCollections(): Promise<void> {
114
+ if (this.ensured) return;
115
+ try {
116
+ const nodes = this.db.collection(VERTEX_COLLECTION);
117
+ if (!(await nodes.exists())) await this.db.createCollection(VERTEX_COLLECTION);
118
+ const edges = this.db.collection(EDGE_COLLECTION);
119
+ if (!(await edges.exists())) await this.db.createEdgeCollection(EDGE_COLLECTION);
120
+ this.ensured = true;
121
+ } catch (exc) {
122
+ this.lastError = errorMessage(exc);
123
+ throw this.connectOrError(exc);
124
+ }
125
+ }
126
+
127
+ private async aql(
128
+ query: string,
129
+ bind: Record<string, unknown> | null = null,
130
+ ): Promise<ArangoDoc[]> {
131
+ await this.ensureCollections();
132
+ try {
133
+ const cursor = await this.db.query({ query, bindVars: bind ?? {} });
134
+ return (await cursor.all()) as ArangoDoc[];
135
+ } catch (exc) {
136
+ this.lastError = errorMessage(exc);
137
+ throw this.connectOrError(exc);
138
+ }
139
+ }
140
+
141
+ async query(text: string, params: Record<string, unknown> | null = null): Promise<GraphResult> {
142
+ const rows = await this.aql(text, params);
143
+ const first = rows[0];
144
+ const columns = rows.length && first && typeof first === "object" ? Object.keys(first) : [];
145
+ return new GraphResult(rows as Record<string, unknown>[], columns);
146
+ }
147
+
148
+ async execute(text: string, params: Record<string, unknown> | null = null): Promise<GraphResult> {
149
+ return this.query(text, params);
150
+ }
151
+
152
+ // -- portable node/edge/traverse core (AQL) ----------------------------
153
+ private nodeFromDoc(doc: ArangoDoc | undefined | null): GraphNode | null {
154
+ if (doc === null || doc === undefined) return null;
155
+ return new GraphNode(
156
+ String(doc._id),
157
+ (doc._labels as string[]) ?? [],
158
+ cleanProps(doc),
159
+ );
160
+ }
161
+
162
+ async addNode(
163
+ label: string,
164
+ properties: Record<string, unknown> | null = null,
165
+ ): Promise<GraphNode | null> {
166
+ const doc: ArangoDoc = { ...(properties ?? {}), _labels: [label] };
167
+ const rows = await this.aql(`INSERT @doc INTO ${VERTEX_COLLECTION} RETURN NEW`, { doc });
168
+ return rows.length ? this.nodeFromDoc(rows[0]) : null;
169
+ }
170
+
171
+ async addEdge(
172
+ fromId: string,
173
+ toId: string,
174
+ type: string,
175
+ properties: Record<string, unknown> | null = null,
176
+ ): Promise<GraphEdge | null> {
177
+ const doc: ArangoDoc = { ...(properties ?? {}), _from: fromId, _to: toId, _type: type };
178
+ const rows = await this.aql(`INSERT @doc INTO ${EDGE_COLLECTION} RETURN NEW`, { doc });
179
+ if (rows.length === 0) return null;
180
+ const row = rows[0];
181
+ return new GraphEdge(
182
+ String(row._id),
183
+ String(row._type),
184
+ String(row._from),
185
+ String(row._to),
186
+ cleanProps(row),
187
+ );
188
+ }
189
+
190
+ async getNode(nodeId: string): Promise<GraphNode | null> {
191
+ const rows = await this.aql("RETURN DOCUMENT(@id)", { id: nodeId });
192
+ return rows.length && rows[0] ? this.nodeFromDoc(rows[0]) : null;
193
+ }
194
+
195
+ async updateNode(
196
+ nodeId: string,
197
+ properties: Record<string, unknown>,
198
+ ): Promise<GraphNode | null> {
199
+ const rows = await this.aql(
200
+ `UPDATE PARSE_IDENTIFIER(@id).key WITH @props IN ${VERTEX_COLLECTION} RETURN NEW`,
201
+ { id: nodeId, props: properties ?? {} },
202
+ );
203
+ return rows.length ? this.nodeFromDoc(rows[0]) : null;
204
+ }
205
+
206
+ async deleteNode(nodeId: string): Promise<boolean> {
207
+ // Remove the node and any edges touching it, so a re-read is a clean miss.
208
+ await this.aql(
209
+ `FOR e IN ${EDGE_COLLECTION} FILTER e._from == @id OR e._to == @id REMOVE e IN ${EDGE_COLLECTION}`,
210
+ { id: nodeId },
211
+ );
212
+ await this.aql(
213
+ `REMOVE PARSE_IDENTIFIER(@id).key IN ${VERTEX_COLLECTION}`,
214
+ { id: nodeId },
215
+ );
216
+ return true;
217
+ }
218
+
219
+ async neighbors(nodeId: string, options: NeighborOptions = {}): Promise<GraphNode[]> {
220
+ const direction = options.direction ?? "both";
221
+ const limit = options.limit ?? 100;
222
+ const arangoDir = { out: "OUTBOUND", in: "INBOUND", both: "ANY" }[direction];
223
+ const typeFilter = options.edgeType ? "FILTER e._type == @etype " : "";
224
+ const bind: Record<string, unknown> = { start: nodeId, limit: Math.trunc(limit) };
225
+ if (options.edgeType) bind.etype = options.edgeType;
226
+ const rows = await this.aql(
227
+ `FOR v, e IN 1..1 ${arangoDir} @start ${EDGE_COLLECTION} ${typeFilter}LIMIT @limit RETURN DISTINCT v`,
228
+ bind,
229
+ );
230
+ return rows.map((doc) => this.nodeFromDoc(doc)!).filter((node) => node !== null);
231
+ }
232
+
233
+ async traverse(startId: string, options: TraverseOptions = {}): Promise<GraphNode[]> {
234
+ const depth = options.depth ?? 1;
235
+ const direction = options.direction ?? "both";
236
+ const limit = options.limit ?? 1000;
237
+ const arangoDir = { out: "OUTBOUND", in: "INBOUND", both: "ANY" }[direction];
238
+ const typeFilter = options.edgeType ? "FILTER e._type == @etype " : "";
239
+ const bind: Record<string, unknown> = { start: startId, limit: Math.trunc(limit) };
240
+ if (options.edgeType) bind.etype = options.edgeType;
241
+ const rows = await this.aql(
242
+ `FOR v, e IN 1..${Math.trunc(depth)} ${arangoDir} @start ${EDGE_COLLECTION} ${typeFilter}LIMIT @limit RETURN DISTINCT v`,
243
+ bind,
244
+ );
245
+ return rows.map((doc) => this.nodeFromDoc(doc)!).filter((node) => node !== null);
246
+ }
247
+
248
+ async close(): Promise<void> {
249
+ // arangojs closes its connection pool synchronously; wrap defensively.
250
+ if (typeof this.db.close === "function") this.db.close();
251
+ }
252
+
253
+ getError(): string | null {
254
+ return this.lastError;
255
+ }
256
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Bolt graph adapter — Neo4j AND Memgraph (both speak Bolt + Cypher).
3
+ *
4
+ * Wraps the community `neo4j-driver` npm package (an OPTIONAL dependency — imported
5
+ * at 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). Neo4j and Memgraph share this ONE adapter;
8
+ * the URL scheme only picks the engine label and the default port.
9
+ *
10
+ * Cypher note (verified against live Neo4j + Memgraph on the lab, no mocks):
11
+ * `id(n)` is the portable node/edge id (an INTEGER on both — Neo4j's `elementId`
12
+ * is Neo4j-only, Memgraph has none); variable-length traversal is Cypher's
13
+ * `[*1..N]` (the OPPOSITE of Ultipa's GQL `{1,N}`); `SET n += $props` merges.
14
+ *
15
+ * The driver is configured with `disableLosslessIntegers: true`, so `id(n)` and
16
+ * integer properties come back as native JS numbers instead of the driver's
17
+ * `Integer` wrapper. Node ids are engine integers; the neutral GraphNode carries
18
+ * them as strings, so a Cypher `WHERE id(n) = $id` is fed `Number(id)` back.
19
+ */
20
+ import { GraphNode, GraphEdge, GraphResult } from "../shapes.js";
21
+ import { GraphError, GraphConnectTimeout } from "../errors.js";
22
+ import {
23
+ resolveGraphConnectTimeout,
24
+ GRAPH_CONNECT_TIMEOUT_VARIABLE,
25
+ } from "../connectTimeout.js";
26
+ import type { GraphUrl } from "../graphUrl.js";
27
+ import type { GraphCredentials } from "../graphDatabase.js";
28
+ import type {
29
+ GraphAdapter,
30
+ NeighborOptions,
31
+ TraverseOptions,
32
+ } from "../graphAdapter.js";
33
+
34
+ /**
35
+ * The driver package name as a VARIABLE, so TypeScript does not try to resolve a
36
+ * package that is only installed when the Bolt engine is actually used. This
37
+ * top-level await is what makes an absent driver surface as the factory's
38
+ * actionable install error (the dynamic import of this module rejects).
39
+ */
40
+ const DRIVER_PACKAGE = "neo4j-driver";
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const driverModule: any = await import(DRIVER_PACKAGE);
43
+ // neo4j-driver is CJS with a default export carrying driver()/auth/error.
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ const neo4j: any = driverModule.default ?? driverModule;
46
+
47
+ interface DriverRow {
48
+ id?: unknown;
49
+ labels?: unknown;
50
+ props?: unknown;
51
+ type?: unknown;
52
+ f?: unknown;
53
+ t?: unknown;
54
+ [key: string]: unknown;
55
+ }
56
+
57
+ function errorMessage(exc: unknown): string {
58
+ if (exc instanceof Error) return exc.message;
59
+ return String(exc);
60
+ }
61
+
62
+ /**
63
+ * A neutral GraphNode id back to the engine's integer form. Non-numeric ids
64
+ * (e.g. a deliberate miss lookup) become -1, which matches nothing — an empty
65
+ * result, never a driver NaN-parameter error.
66
+ */
67
+ function boltId(id: string): number {
68
+ const value = Number(id);
69
+ return Number.isNaN(value) ? -1 : value;
70
+ }
71
+
72
+ export class BoltGraphAdapter implements GraphAdapter {
73
+ private readonly url: GraphUrl;
74
+ private readonly database: string | null;
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ private readonly driver: any;
77
+ private lastError: string | null = null;
78
+
79
+ constructor(graphUrl: GraphUrl, credentials: GraphCredentials = {}) {
80
+ this.url = graphUrl;
81
+ this.database = graphUrl.graph || null;
82
+ const user = graphUrl.username || credentials.username || "neo4j";
83
+ const pwd = graphUrl.password || credentials.password || "";
84
+ const scheme = graphUrl.useTls ? "bolt+s" : "bolt";
85
+ const uri = `${scheme}://${graphUrl.host}:${graphUrl.port}`;
86
+ const timeout = resolveGraphConnectTimeout();
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ const config: Record<string, any> = { disableLosslessIntegers: true };
89
+ if (timeout !== null) {
90
+ const ms = Math.max(1, Math.ceil(timeout * 1000));
91
+ config.connectionTimeout = ms;
92
+ config.connectionAcquisitionTimeout = ms;
93
+ config.maxTransactionRetryTime = ms;
94
+ }
95
+ this.driver = neo4j.driver(uri, neo4j.auth.basic(user, pwd), config);
96
+ }
97
+
98
+ // -- connection + raw pass-through -------------------------------------
99
+ private async run(
100
+ cypher: string,
101
+ params: Record<string, unknown> | null = null,
102
+ ): Promise<DriverRow[]> {
103
+ const session = this.database
104
+ ? this.driver.session({ database: this.database })
105
+ : this.driver.session();
106
+ try {
107
+ const result = await session.run(cypher, params ?? {});
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ return result.records.map((record: any) => record.toObject() as DriverRow);
110
+ } catch (exc) {
111
+ this.lastError = errorMessage(exc);
112
+ const code = (exc as { code?: string })?.code ?? "";
113
+ const message = this.lastError.toLowerCase();
114
+ // An unreachable host surfaces here as a service-unavailable / timeout.
115
+ if (
116
+ code === "ServiceUnavailable"
117
+ || code === neo4j.error?.SERVICE_UNAVAILABLE
118
+ || message.includes("timed out")
119
+ || message.includes("timeout")
120
+ ) {
121
+ throw new GraphConnectTimeout(
122
+ `Graph connect to ${this.url.host}:${this.url.port} timed out `
123
+ + `(${GRAPH_CONNECT_TIMEOUT_VARIABLE}). Raise ${GRAPH_CONNECT_TIMEOUT_VARIABLE} if the server `
124
+ + `is simply slow, or set it to 0 to wait indefinitely.`,
125
+ exc,
126
+ );
127
+ }
128
+ throw new GraphError(this.lastError, exc);
129
+ } finally {
130
+ await session.close();
131
+ }
132
+ }
133
+
134
+ async query(text: string, params: Record<string, unknown> | null = null): Promise<GraphResult> {
135
+ const rows = await this.run(text, params);
136
+ const columns = rows.length ? Object.keys(rows[0]) : [];
137
+ return new GraphResult(rows as Record<string, unknown>[], columns);
138
+ }
139
+
140
+ async execute(text: string, params: Record<string, unknown> | null = null): Promise<GraphResult> {
141
+ return this.query(text, params);
142
+ }
143
+
144
+ // -- portable node/edge/traverse core (Cypher) -------------------------
145
+ private nodeFromRow(row: DriverRow | undefined | null): GraphNode | null {
146
+ if (row === null || row === undefined) return null;
147
+ return new GraphNode(
148
+ String(row.id),
149
+ (row.labels as string[]) ?? [],
150
+ (row.props as Record<string, unknown>) ?? {},
151
+ );
152
+ }
153
+
154
+ async addNode(
155
+ label: string,
156
+ properties: Record<string, unknown> | null = null,
157
+ ): Promise<GraphNode | null> {
158
+ const cypher =
159
+ `CREATE (n:\`${label}\` $props) `
160
+ + `RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props`;
161
+ const rows = await this.run(cypher, { props: properties ?? {} });
162
+ return rows.length ? this.nodeFromRow(rows[0]) : null;
163
+ }
164
+
165
+ async addEdge(
166
+ fromId: string,
167
+ toId: string,
168
+ type: string,
169
+ properties: Record<string, unknown> | null = null,
170
+ ): Promise<GraphEdge | null> {
171
+ const cypher =
172
+ `MATCH (a), (b) WHERE id(a) = $from_id AND id(b) = $to_id `
173
+ + `CREATE (a)-[e:\`${type}\` $props]->(b) `
174
+ + `RETURN id(e) AS id, type(e) AS type, id(a) AS f, id(b) AS t, properties(e) AS props`;
175
+ const rows = await this.run(cypher, {
176
+ from_id: boltId(fromId),
177
+ to_id: boltId(toId),
178
+ props: properties ?? {},
179
+ });
180
+ if (rows.length === 0) return null;
181
+ const row = rows[0];
182
+ return new GraphEdge(
183
+ String(row.id),
184
+ String(row.type),
185
+ String(row.f),
186
+ String(row.t),
187
+ (row.props as Record<string, unknown>) ?? {},
188
+ );
189
+ }
190
+
191
+ async getNode(nodeId: string): Promise<GraphNode | null> {
192
+ const cypher =
193
+ `MATCH (n) WHERE id(n) = $id `
194
+ + `RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props`;
195
+ const rows = await this.run(cypher, { id: boltId(nodeId) });
196
+ return rows.length ? this.nodeFromRow(rows[0]) : null;
197
+ }
198
+
199
+ async updateNode(
200
+ nodeId: string,
201
+ properties: Record<string, unknown>,
202
+ ): Promise<GraphNode | null> {
203
+ const cypher =
204
+ `MATCH (n) WHERE id(n) = $id SET n += $props `
205
+ + `RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props`;
206
+ const rows = await this.run(cypher, { id: boltId(nodeId), props: properties ?? {} });
207
+ return rows.length ? this.nodeFromRow(rows[0]) : null;
208
+ }
209
+
210
+ async deleteNode(nodeId: string): Promise<boolean> {
211
+ await this.run("MATCH (n) WHERE id(n) = $id DETACH DELETE n", { id: boltId(nodeId) });
212
+ return true;
213
+ }
214
+
215
+ async neighbors(nodeId: string, options: NeighborOptions = {}): Promise<GraphNode[]> {
216
+ const direction = options.direction ?? "both";
217
+ const limit = options.limit ?? 100;
218
+ const edge = options.edgeType ? `:\`${options.edgeType}\`` : "";
219
+ const pattern = {
220
+ out: `(n)-[${edge}]->(m)`,
221
+ in: `(n)<-[${edge}]-(m)`,
222
+ both: `(n)-[${edge}]-(m)`,
223
+ }[direction];
224
+ const cypher =
225
+ `MATCH ${pattern} WHERE id(n) = $id `
226
+ + `RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props `
227
+ + `LIMIT ${Math.trunc(limit)}`;
228
+ const rows = await this.run(cypher, { id: boltId(nodeId) });
229
+ return rows.map((row) => this.nodeFromRow(row)!).filter((node) => node !== null);
230
+ }
231
+
232
+ async traverse(startId: string, options: TraverseOptions = {}): Promise<GraphNode[]> {
233
+ // Cypher variable-length path `[*1..N]` — Neo4j AND Memgraph.
234
+ const depth = options.depth ?? 1;
235
+ const direction = options.direction ?? "both";
236
+ const limit = options.limit ?? 1000;
237
+ const edge = options.edgeType ? `:\`${options.edgeType}\`` : "";
238
+ const range = `*1..${Math.trunc(depth)}`;
239
+ const arrow = {
240
+ out: `-[${edge}${range}]->`,
241
+ in: `<-[${edge}${range}]-`,
242
+ both: `-[${edge}${range}]-`,
243
+ }[direction];
244
+ const cypher =
245
+ `MATCH (n)${arrow}(m) WHERE id(n) = $start `
246
+ + `RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props `
247
+ + `LIMIT ${Math.trunc(limit)}`;
248
+ const rows = await this.run(cypher, { start: boltId(startId) });
249
+ return rows.map((row) => this.nodeFromRow(row)!).filter((node) => node !== null);
250
+ }
251
+
252
+ async close(): Promise<void> {
253
+ await this.driver.close();
254
+ }
255
+
256
+ getError(): string | null {
257
+ return this.lastError;
258
+ }
259
+ }