@mcp-abap-adt/connection 2.0.0 → 4.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.
package/docs/USAGE.md CHANGED
@@ -219,10 +219,9 @@ implied. Four things follow from it.
219
219
 
220
220
  > **Availability.** `connect()` is on the shared `IAbapConnection` contract and
221
221
  > works on every connection. The rest of this section is on the contract too, but
222
- > as two **capability atoms** in `@mcp-abap-adt/interfaces` (11.5.0+) rather than
223
- > as methods on `IAbapConnection`: `ISessionLifecycleAware` (`disconnect()`,
224
- > `isConnected()`, `getSessionIdentity()`) and `ILockWindowAware`
225
- > (`beginWindow()`, `endWindow()`).
222
+ > as a **capability atom** in `@mcp-abap-adt/interfaces` rather than as methods on
223
+ > `IAbapConnection`: `ISessionLifecycleAware` — `disconnect()`, `isConnected()`,
224
+ > `getSessionIdentity()`.
226
225
  >
227
226
  > The split is the point. `IAbapConnection` is the minimum every transport can
228
227
  > honour, and `RfcAbapConnection` has none of these — on RFC the session *is* the
@@ -232,50 +231,51 @@ implied. Four things follow from it.
232
231
  > So ask for the atom you need, not for a concrete class:
233
232
  >
234
233
  > ```typescript
235
- > function withLock(conn: IAbapConnection & ILockWindowAware) { /* ... */ }
234
+ > function tearDownAfter(conn: IAbapConnection & ISessionLifecycleAware) { /* ... */ }
236
235
  > ```
237
236
  >
238
237
  > **How you satisfy that parameter decides whether you get a compile-time
239
238
  > guarantee, and there is only one way that does.**
240
239
  >
241
240
  > Construct the connection through a concrete HTTP class and the compiler knows
242
- > it implements the atoms, so passing an RFC connection is an error at the call
241
+ > it implements the atom, so passing an RFC connection is an error at the call
243
242
  > site:
244
243
  >
245
244
  > ```typescript
246
245
  > const conn = new BaseAbapConnection(config, logger);
247
- > withLock(conn); // ✅ checked
248
- > withLock(new RfcAbapConnection(cfg)); // ✅ compile error, as it should be
246
+ > tearDownAfter(conn); // ✅ checked
247
+ > tearDownAfter(new RfcAbapConnection(cfg)); // ✅ compile error, as it should be
249
248
  > ```
250
249
  >
251
250
  > `createAbapConnection()` cannot give you that. It returns `IAbapConnection` for
252
251
  > **every** config, RFC included, so the type carries no evidence either way and
253
252
  > the compiler rejects its result whatever the transport. Asserting past that —
254
- > `conn as IAbapConnection & ILockWindowAware` — silences the error for the HTTP
255
- > case *and* for RFC, which then fails at runtime on `beginWindow()`. An assertion
256
- > is not a check; it is a promise you make to the compiler on your own authority.
253
+ > `conn as IAbapConnection & ISessionLifecycleAware` — silences the error for the
254
+ > HTTP case *and* for RFC, which then fails at runtime. An assertion is not a
255
+ > check; it is a promise you make to the compiler on your own authority.
257
256
  >
258
257
  > When you only have an `IAbapConnection` — from the factory, or from a caller —
259
258
  > narrow it at runtime with a predicate:
260
259
  >
261
260
  > ```typescript
262
- > function supportsLockWindows(
261
+ > function ownsItsSession(
263
262
  > conn: IAbapConnection,
264
- > ): conn is IAbapConnection & ILockWindowAware {
265
- > const candidate = conn as Partial<ILockWindowAware>;
263
+ > ): conn is IAbapConnection & ISessionLifecycleAware {
264
+ > const candidate = conn as Partial<ISessionLifecycleAware>;
266
265
  > // EVERY method of the atom. A predicate narrows to the whole interface, so
267
- > // checking one method and promising two puts the failure back where this
268
- > // check was meant to remove it — inside the branch that looked safe.
266
+ > // checking one and promising three puts the failure back where this check
267
+ > // was meant to remove it — inside the branch that looked safe.
269
268
  > return (
270
- > typeof candidate.beginWindow === 'function' &&
271
- > typeof candidate.endWindow === 'function'
269
+ > typeof candidate.disconnect === 'function' &&
270
+ > typeof candidate.isConnected === 'function' &&
271
+ > typeof candidate.getSessionIdentity === 'function'
272
272
  > );
273
273
  > }
274
274
  >
275
- > if (supportsLockWindows(conn)) {
276
- > withLock(conn); // narrowed by evidence, not by assertion
275
+ > if (ownsItsSession(conn)) {
276
+ > tearDownAfter(conn); // narrowed by evidence, not by assertion
277
277
  > } else {
278
- > // No lock windows here. On RFC that is expected, not a failure.
278
+ > // No HTTP session here. On RFC that is expected, not a failure.
279
279
  > }
280
280
  > ```
281
281
  >
@@ -323,72 +323,68 @@ try {
323
323
  }
324
324
  ```
325
325
 
326
- `ITeardownReport`, `WindowToken` and `AdtSessionErrorCode` come from the same
327
- place, as do the two capability interfaces this connection implements —
328
- `ISessionLifecycleAware` and `ILockWindowAware`. Depend on those rather than on a
329
- concrete connection class where you can: an `RfcAbapConnection` is a valid
330
- `IAbapConnection` that implements neither, so the atom you require is also the
326
+ `AdtSessionErrorCode` comes from the same place, as does the capability interface
327
+ this connection implements — `ISessionLifecycleAware`. Depend on that rather than
328
+ on a concrete connection class where you can: an `RfcAbapConnection` is a valid
329
+ `IAbapConnection` that does not implement it, so the atom you require is also the
331
330
  documentation of what your code actually needs.
332
331
 
333
- ### disconnect() reports what it could not finish
332
+ ### disconnect() waits for nothing
334
333
 
335
334
  ```typescript
336
- const report = await connection.disconnect();
337
- // { abandonedWindows: string[], releasePending: boolean }
335
+ await connection.disconnect(); // Promise<void>
338
336
  ```
339
337
 
340
- It never throws — the report says what did not finish instead. It waits for
341
- in-flight requests to settle before clearing anything, so a teardown never
342
- pulls the session out from under a request already running.
338
+ It never throws, and it always settles. **It waits for nothing** — not for
339
+ requests in flight, not for anything you hold. Deciding when to disconnect is
340
+ yours, and so is preparing for it.
343
341
 
344
- **Over HTTP it does not release locks.** The ABAP session lives on until its
345
- timeout, along with whatever it held. Unlock first; disconnecting is not a way
346
- to clean up after yourself.
342
+ Requests already in flight run to completion untouched; nothing is aborted. What
343
+ the connection guarantees is that their results can no longer reach it: a
344
+ response arriving after the teardown is fenced, so it cannot write its cookies
345
+ over a session established since, and cannot be mistaken for a replacement.
347
346
 
348
- ### Lock windows
347
+ An earlier version waited for in-flight requests before clearing anything. That
348
+ turned out to be unbounded — a request whose caller chose no timeout could hold a
349
+ teardown open forever, and since lifecycle transitions are serialized, every
350
+ later `connect()` queued behind it.
349
351
 
350
- A lock outlives the request that takes it, which makes it the one thing a
351
- teardown has to know about:
352
+ **Over HTTP it does not release locks.** The ABAP session lives on until its
353
+ timeout, along with whatever it held. Unlock first; disconnecting is not a way to
354
+ clean up after yourself.
352
355
 
353
- ```typescript
354
- const token = connection.beginWindow('Class/ZCL_MY_CLASS');
356
+ ### Uninterruptible spans
355
357
 
356
- let lockHandle: string | undefined;
357
- try {
358
- lockHandle = await lock(connection, 'ZCL_MY_CLASS'); // your LOCK call
359
- } catch (error) {
360
- // The LOCK is confirmed to have failed, so nothing is held: close the window.
361
- connection.endWindow(token);
362
- throw error;
363
- }
358
+ The connection does **not** track locks — it does not know one exists, what
359
+ object it covers, or what would release it. Pairing every LOCK with its UNLOCK is
360
+ `@mcp-abap-adt/adt-clients`' job, and it does it per object in its lock registry.
361
+
362
+ What this layer owns is the timeout, because a timeout is the server taking too
363
+ long and nothing about it depends on the caller. Aborting a request mid-flight
364
+ leaves an operation whose outcome you cannot determine — a `LOCK` that may have
365
+ succeeded server-side, with a handle you never received:
364
366
 
367
+ ```typescript
368
+ connection.beginCriticalSection();
365
369
  try {
366
- await update(connection, 'ZCL_MY_CLASS', lockHandle);
367
- await unlock(connection, 'ZCL_MY_CLASS', lockHandle);
368
- // The UNLOCK is confirmed: the lock is released, so close the window.
369
- connection.endWindow(token);
370
- } catch (error) {
371
- // Deliberately NOT closed here. The unlock may have failed, never gone out,
372
- // or come back with an unknown outcome — in each of those the lock is most
373
- // likely still held, and a window left open is what makes a teardown wait for
374
- // it and report it by name instead of walking past it.
375
- throw error;
370
+ const handle = await lock(connection, 'ZCL_MY_CLASS');
371
+ await update(connection, 'ZCL_MY_CLASS', handle);
372
+ await unlock(connection, 'ZCL_MY_CLASS', handle);
373
+ } finally {
374
+ connection.endCriticalSection();
376
375
  }
377
376
  ```
378
377
 
379
- Note what the `catch` does **not** do. A `finally { endWindow(token) }` reads
380
- naturally and is wrong here: it closes the window exactly when the lock is most
381
- likely still there. The window tracks *"a lock may be held"*, so only proof that
382
- nothing is held closes it — a confirmed unlock, or a confirmed failure to lock.
378
+ While a section is active the effective timeout is raised to
379
+ `SAP_TIMEOUT_CRITICAL` (600s by default) — `Math.max(yours, the ceiling)`, so it
380
+ never shortens a timeout you chose. The pair is reference-counted, so nesting is
381
+ safe.
383
382
 
384
- The cost of forgetting `endWindow()` on the success path is not silence: every
385
- later teardown waits out `SAP_TIMEOUT_CRITICAL` and then reports the window as
386
- abandoned.
387
-
388
- While a window is open, a teardown waits for it rather than abandoning it, and
389
- a new window cannot be opened once a teardown has been requested. A window that
390
- never closes is given up on after `SAP_TIMEOUT_CRITICAL` and comes back **named**
391
- in `ITeardownReport.abandonedWindows`.
383
+ The raise is **connection-wide**: while any section is active, every request on
384
+ that connection gets the ceiling, including one that has nothing to do with the
385
+ locked object. Those requests share one ABAP session, and an abort leaves that
386
+ session in the same uncertain state during a span someone declared sensitive
387
+ precisely to avoid it.
392
388
 
393
389
  ### When the session is lost
394
390
 
@@ -583,7 +579,7 @@ interface AbapConnection {
583
579
 
584
580
  Anything beyond that is **not** on the contract. `reset()`, `getSessionMode()`
585
581
  and the session lifecycle (`disconnect()`, `isConnected()`,
586
- `getSessionIdentity()`, `beginWindow()`, `endWindow()`) live on the HTTP
582
+ `getSessionIdentity()`) live on the HTTP
587
583
  connection classes; `RfcAbapConnection` has some of them and not others, so
588
584
  reach for them through a concrete type rather than through what
589
585
  `createAbapConnection()` returns.
@@ -680,12 +676,33 @@ For SAP BTP cloud systems. Token refresh is handled by `@mcp-abap-adt/auth-broke
680
676
 
681
677
  ```typescript
682
678
  class JwtAbapConnection extends AbstractAbapConnection {
683
- constructor(config: SapConfig, logger?: ILogger | null);
679
+ constructor(
680
+ config: SapConfig,
681
+ logger?: ILogger | null,
682
+ sessionId?: string,
683
+ tokenRefresher?: ITokenRefresher,
684
+ );
684
685
  // Note: refreshToken() and canRefreshToken() methods removed in 0.2.0
685
- // Use @mcp-abap-adt/auth-broker for token refresh functionality
686
+ // Token acquisition itself belongs to @mcp-abap-adt/auth-broker
686
687
  }
687
688
  ```
688
689
 
690
+ **How failures are classified** (4.0.0 — see
691
+ [MIGRATION-4.0.md](./MIGRATION-4.0.md)):
692
+
693
+ | answer | what happens |
694
+ |---|---|
695
+ | **401** | with an injected `ITokenRefresher`, the token is refreshed, the SAP session re-established, and the request retried once. Without one, or when the retry is refused too, the server's error is rethrown |
696
+ | **403** | propagates untouched. The server authenticated the caller and refused the action anyway, so a new token is the same caller — usually the body names the authorization object |
697
+ | anything else | untouched |
698
+
699
+ The connection never replaces the server's error with one of its own: `error.response.status` and
700
+ `error.response.data` are always what SAP sent. Before 4.0.0 both 401 and 403 were reported as
701
+ `JWT token has expired. Please re-authenticate.` with the original error discarded.
702
+
703
+ Concurrent requests that meet the same expired token share **one** renewal — a single token fetch
704
+ and a single session re-establishment between them, not one each.
705
+
689
706
  ### `createAbapConnection()` Factory
690
707
 
691
708
  Recommended way to create connections:
@@ -43,8 +43,9 @@ node examples/jwt-with-token-refresh.js
43
43
 
44
44
  **What it demonstrates:**
45
45
  - Creating connection with token refresher injection
46
- - Automatic token refresh on 401/403 errors
46
+ - Automatic token refresh on 401 errors
47
47
  - Retry logic with refreshed token
48
+ - A 403 propagating untouched, with its status and body intact
48
49
 
49
50
  ### saml-connection.js
50
51
 
@@ -4,10 +4,16 @@
4
4
  * This example demonstrates how to create a JwtAbapConnection with
5
5
  * automatic token refresh using ITokenRefresher from auth-broker.
6
6
  *
7
- * When 401/403 errors occur, the connection automatically:
7
+ * When a **401** occurs, the connection automatically:
8
8
  * 1. Calls tokenRefresher.refreshToken() to get a new token
9
9
  * 2. Updates internal token state
10
- * 3. Retries the failed request with the new token
10
+ * 3. Re-establishes the SAP session, which the new credential cannot inherit
11
+ * 4. Retries the failed request
12
+ *
13
+ * A **403** is left alone. It means the server authenticated you and refused
14
+ * the action anyway — an authorization gap, not an expired credential — so it
15
+ * propagates with its status and the server's message, and a new token would
16
+ * change nothing.
11
17
  */
12
18
 
13
19
  const { JwtAbapConnection } = require('@mcp-abap-adt/connection');
@@ -68,7 +74,9 @@ async function main() {
68
74
  try {
69
75
  await connection.connect();
70
76
 
71
- // This request will automatically refresh token if 401/403 occurs. Note
77
+ // This request will automatically refresh the token if a 401 occurs. A
78
+ // 403 arrives as-is — read err.response.status and err.response.data to
79
+ // see which authorization object the server named. Note
72
80
  // that a refresh replaces the SAP session: with a lock window open the
73
81
  // request would fail with ADT_SESSION_REPLACED rather than continue on a
74
82
  // session your lock is not in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-abap-adt/connection",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "ABAP connection layer for MCP ABAP ADT server",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -50,7 +50,7 @@
50
50
  "node": ">=18.0.0"
51
51
  },
52
52
  "dependencies": {
53
- "@mcp-abap-adt/interfaces": "^11.5.0",
53
+ "@mcp-abap-adt/interfaces": "^12.0.0",
54
54
  "axios": "^1.16.0",
55
55
  "commander": "^14.0.3",
56
56
  "express": "^5.1.0",