neptune-lambda-client 2.0.0 → 3.0.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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm test *)",
5
+ "Bash(TESTCONTAINERS_RYUK_DISABLED=true npm test)"
6
+ ]
7
+ }
8
+ }
package/README.md CHANGED
@@ -6,6 +6,15 @@ reestablish a connection to the database if the web socket connection closes and
6
6
  retry (up to 5 times, with exponential backoff and jitter) when it encounters `ConcurrentModificationException`
7
7
  and `ReadOnlyViolationException` errors.
8
8
 
9
+ ## Installation
10
+
11
+ `gremlin` is a peer dependency — install it alongside the client so your application controls the
12
+ exact version that talks to your Neptune cluster:
13
+
14
+ ```sh
15
+ npm install neptune-lambda-client gremlin
16
+ ```
17
+
9
18
  ## Usage
10
19
 
11
20
  This client is instantiated with a factory function and exposes a `query` function that accepts a single
@@ -21,6 +30,27 @@ async function getNode(id) {
21
30
  }
22
31
  ```
23
32
 
33
+ ### Partition strategy
34
+
35
+ To scope every query through the client to a Gremlin
36
+ [`PartitionStrategy`](https://tinkerpop.apache.org/docs/current/reference/#partitionstrategy)
37
+ — useful for multi-tenant graphs and per-suite test isolation — pass a `partition`
38
+ option. Its fields are forwarded directly to `PartitionStrategy`:
39
+
40
+ ```js
41
+ const { query } = create('neptune-db-url', '8182', {
42
+ useIam: true,
43
+ partition: {
44
+ partitionKey: '_partition',
45
+ writePartition: 'tenant-a',
46
+ readPartitions: ['tenant-a', 'shared'],
47
+ },
48
+ });
49
+ ```
50
+
51
+ The strategy is reapplied automatically when the client reconnects after a dropped
52
+ WebSocket, so partition isolation holds across the full Lambda lifetime.
53
+
24
54
  ## Known limitations
25
55
 
26
56
  Per the [AWS Neptune Lambda guidance](https://docs.aws.amazon.com/neptune/latest/userguide/lambda-functions-examples.html#lambda-functions-examples-javascript),
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import aws4 from 'aws4';
2
2
  import gremlin from 'gremlin';
3
+ import { PartitionStrategy } from 'gremlin/lib/process/traversal-strategy.js';
3
4
  import retry from 'async-retry';
4
5
 
5
6
  const traversal = gremlin.process.AnonymousTraversalSource.traversal;
@@ -35,7 +36,7 @@ function createHeaders(host, port, path, options) {
35
36
  }).headers;
36
37
  }
37
38
 
38
- export function create(host, port, {useIam = true, protocol = 'wss'} = {}) {
39
+ export function create(host, port, {useIam = true, protocol = 'wss', partition} = {}) {
39
40
  let conn = null;
40
41
  let g = null;
41
42
 
@@ -74,7 +75,8 @@ export function create(host, port, {useIam = true, protocol = 'wss'} = {}) {
74
75
  };
75
76
 
76
77
  const createGraphTraversalSource = conn => {
77
- return traversal().withRemote(conn);
78
+ const g = traversal().withRemote(conn);
79
+ return partition ? g.withStrategies(new PartitionStrategy(partition)) : g;
78
80
  };
79
81
 
80
82
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neptune-lambda-client",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Gremlin client to robustly query AWS Neptune from AWS Lambda",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,10 +29,14 @@
29
29
  "homepage": "https://github.com/svozza/neptune-lambda-client#readme",
30
30
  "dependencies": {
31
31
  "async-retry": "1.3.3",
32
- "aws4": "1.11.0",
33
- "gremlin": "3.5.2"
32
+ "aws4": "1.11.0"
33
+ },
34
+ "peerDependencies": {
35
+ "gremlin": "^3.5.0"
34
36
  },
35
37
  "devDependencies": {
38
+ "gremlin": "3.5.2",
39
+ "testcontainers": "^12.0.0",
36
40
  "vitest": "^2.1.9",
37
41
  "ws": "^8.18.0"
38
42
  }
@@ -0,0 +1,14 @@
1
+ import { GenericContainer, Wait } from 'testcontainers';
2
+
3
+ export async function startGremlinServer() {
4
+ const container = await new GenericContainer('tinkerpop/gremlin-server:3.7.3')
5
+ .withExposedPorts(8182)
6
+ .withWaitStrategy(Wait.forLogMessage(/Channel started at port 8182/))
7
+ .start();
8
+
9
+ return {
10
+ host: container.getHost(),
11
+ port: container.getMappedPort(8182),
12
+ stop: () => container.stop()
13
+ };
14
+ }
@@ -0,0 +1,155 @@
1
+ import { beforeAll, afterAll, describe, it, expect, onTestFinished } from 'vitest';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { create } from '../index.js';
4
+ import { startGremlinServer } from './helpers/gremlin-container.js';
5
+
6
+ let server;
7
+
8
+ beforeAll(async () => {
9
+ server = await startGremlinServer();
10
+ }, 120_000);
11
+
12
+ afterAll(async () => {
13
+ await server?.stop();
14
+ });
15
+
16
+ const KEY = '_partition';
17
+
18
+ function mkClient(write, reads) {
19
+ const client = create(server.host, String(server.port), {
20
+ useIam: false,
21
+ protocol: 'ws',
22
+ partition: { partitionKey: KEY, writePartition: write, readPartitions: reads }
23
+ });
24
+ onTestFinished(() => client.close());
25
+ return client;
26
+ }
27
+
28
+ describe('partition strategy', () => {
29
+ it('isolates writes to writePartition and gates reads on readPartitions', async () => {
30
+ const a = `a-${randomUUID()}`;
31
+ const b = `b-${randomUUID()}`;
32
+ const name = `iso-${randomUUID()}`;
33
+
34
+ const writerA = mkClient(a, [a]);
35
+ const readerA = mkClient(a, [a]);
36
+ const readerB = mkClient(b, [b]);
37
+
38
+ await writerA.query(g => g.addV('Widget').property('name', name).next());
39
+
40
+ const fromB = await readerB.query(g => g.V().has('Widget', 'name', name).toList());
41
+ expect(fromB).toEqual([]);
42
+
43
+ const fromA = await readerA.query(g => g.V().has('Widget', 'name', name).toList());
44
+ expect(fromA.length).toBe(1);
45
+ });
46
+
47
+ it('lets readPartitions span multiple partitions', async () => {
48
+ const a = `a-${randomUUID()}`;
49
+ const shared = `shared-${randomUUID()}`;
50
+ const nameA = `multi-a-${randomUUID()}`;
51
+ const nameShared = `multi-s-${randomUUID()}`;
52
+
53
+ const writerA = mkClient(a, [a]);
54
+ const writerShared = mkClient(shared, [shared]);
55
+ const readerBoth = mkClient(a, [a, shared]);
56
+
57
+ await writerA.query(g => g.addV('Widget').property('name', nameA).next());
58
+ await writerShared.query(g => g.addV('Widget').property('name', nameShared).next());
59
+
60
+ const fromA = await readerBoth.query(g => g.V().has('Widget', 'name', nameA).toList());
61
+ const fromShared = await readerBoth.query(g => g.V().has('Widget', 'name', nameShared).toList());
62
+ expect(fromA.length).toBe(1);
63
+ expect(fromShared.length).toBe(1);
64
+ });
65
+
66
+ it('preserves the partition strategy across a reconnect', async () => {
67
+ const a = `a-${randomUUID()}`;
68
+ const b = `b-${randomUUID()}`;
69
+ const name = `reconnect-${randomUUID()}`;
70
+
71
+ const writerA = mkClient(a, [a]);
72
+ const readerA = mkClient(a, [a]);
73
+ const readerB = mkClient(b, [b]);
74
+
75
+ let calls = 0;
76
+ await writerA.query(g => {
77
+ calls++;
78
+ if (calls === 1) {
79
+ // Triggers index.js's reconnect path: close + rebuild g via
80
+ // createGraphTraversalSource. If the strategy is dropped on
81
+ // rebuild, the write below leaks into other partitions.
82
+ throw new Error('WebSocket is not open: readyState 3 (CLOSED)');
83
+ }
84
+ return g.addV('Widget').property('name', name).next();
85
+ });
86
+ expect(calls).toBe(2);
87
+
88
+ const fromB = await readerB.query(g => g.V().has('Widget', 'name', name).toList());
89
+ expect(fromB).toEqual([]);
90
+
91
+ const fromA = await readerA.query(g => g.V().has('Widget', 'name', name).toList());
92
+ expect(fromA.length).toBe(1);
93
+ });
94
+ });
95
+
96
+ describe('end-to-end query', () => {
97
+ function plainClient() {
98
+ const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
99
+ onTestFinished(() => client.close());
100
+ return client;
101
+ }
102
+
103
+ it('round-trips a vertex through bytecode + GraphSON', async () => {
104
+ const client = plainClient();
105
+ const name = `e2e-${randomUUID()}`;
106
+
107
+ await client.query(g => g.addV('Widget').property('name', name).property('count', 7).next());
108
+
109
+ const fetched = await client.query(g =>
110
+ g.V().has('Widget', 'name', name).valueMap(true).toList()
111
+ );
112
+
113
+ expect(fetched.length).toBe(1);
114
+ const v = fetched[0];
115
+ expect(v.get('name')).toEqual([name]);
116
+ expect(v.get('count')).toEqual([7]);
117
+ });
118
+ });
119
+
120
+ describe('connection lifecycle', () => {
121
+ it('reuses one connection across multiple queries, then rebuilds after close()', async () => {
122
+ const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
123
+ onTestFinished(() => client.close());
124
+
125
+ for (let i = 0; i < 3; i++) {
126
+ const out = await client.query(g => g.inject(i).next());
127
+ expect(out.value).toBe(i);
128
+ }
129
+
130
+ await client.close();
131
+ await client.close();
132
+
133
+ const out = await client.query(g => g.inject('after-reopen').next());
134
+ expect(out.value).toBe('after-reopen');
135
+ });
136
+ });
137
+
138
+ describe('server-side errors', () => {
139
+ it('bails immediately on a malformed traversal (no retry)', async () => {
140
+ const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
141
+ onTestFinished(() => client.close());
142
+
143
+ let calls = 0;
144
+ await expect(
145
+ client.query(g => {
146
+ calls++;
147
+ // addE() without from/to context is invalid at execution time
148
+ // on the server; the resulting error doesn't match any
149
+ // RETRYABLE_ERRORS entry, so we expect a single attempt.
150
+ return g.addE('orphan').next();
151
+ })
152
+ ).rejects.toThrow();
153
+ expect(calls).toBe(1);
154
+ });
155
+ });