@abapify/adt-locks 0.4.0 → 0.4.3

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/README.md CHANGED
@@ -52,6 +52,21 @@ const acquired = await session.acquireAll();
52
52
  await session.releaseAll();
53
53
  ```
54
54
 
55
+ ## Recovery after a crash
56
+
57
+ SAP ADT requires the exact `lockHandle` returned by `LOCK` for `UNLOCK`.
58
+ `FileLockStore` persists that handle so a later process can release it:
59
+
60
+ ```ts
61
+ await locks.forceUnlock('/sap/bc/adt/oo/classes/zcl_test');
62
+ ```
63
+
64
+ Despite its historical name, `forceUnlock()` is **not** a server-side force
65
+ operation: it sends only `UNLOCK` with a persisted handle and never attempts a
66
+ second `LOCK`. If the handle was never persisted (for example, a server-side
67
+ CTS lock created by an interrupted object `POST`), SAP cannot disclose it by
68
+ URI. The caller must wait for expiry or ask the SAP administrator to clear it.
69
+
55
70
  ## Role in the monorepo
56
71
 
57
72
  - Single source of truth for ADT locks. `@abapify/adk` (save flow),
@@ -0,0 +1,199 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * ADT Lock Types
4
+ *
5
+ * Shared type definitions for lock handles and persisted lock entries.
6
+ */
7
+ /**
8
+ * Lock handle returned by SAP `_action=LOCK` operations.
9
+ *
10
+ * The handle is required for subsequent PUT (save) and `_action=UNLOCK` calls.
11
+ */
12
+ interface LockHandle {
13
+ /** Opaque lock token from SAP (LOCK_HANDLE) */
14
+ handle: string;
15
+ /** Transport request assigned to this object (CORRNR) */
16
+ correlationNumber?: string;
17
+ /** User who owns the transport (CORRUSER) */
18
+ correlationUser?: string;
19
+ }
20
+ /**
21
+ * A persisted lock entry — written to the lock store so handles survive process crashes.
22
+ */
23
+ interface LockEntry {
24
+ /** ADT object URI (e.g., /sap/bc/adt/oo/classes/zcl_my_class) */
25
+ objectUri: string;
26
+ /** Human-readable object name */
27
+ objectName: string;
28
+ /** ADT object type (e.g., CLAS/OC, INTF/OI, DTEL) */
29
+ objectType?: string;
30
+ /** The lock handle returned by SAP */
31
+ lockHandle: string;
32
+ /** Transport request from lock response */
33
+ transport?: string;
34
+ /** SAP system host */
35
+ host?: string;
36
+ /** ISO timestamp when lock was acquired */
37
+ lockedAt: string;
38
+ }
39
+ //#endregion
40
+ //#region src/store.d.ts
41
+ /**
42
+ * Lock store interface — opt-in persistence for ADT lock handles.
43
+ *
44
+ * Implementations can be file-based, in-memory, or backed by any storage.
45
+ */
46
+ interface LockStore {
47
+ /** Persist a lock entry after successful LOCK */
48
+ register(entry: LockEntry): void;
49
+ /** Remove a lock entry after successful UNLOCK */
50
+ deregister(objectUri: string): void;
51
+ /** List all persisted lock entries */
52
+ list(): LockEntry[];
53
+ /** Remove all entries */
54
+ clear(): void;
55
+ }
56
+ /**
57
+ * File-based lock store.
58
+ *
59
+ * Persists lock entries to a JSON file (default: `~/.adt/locks.json`).
60
+ * Each entry is keyed by objectUri — re-locking the same URI overwrites.
61
+ * File permissions are 0o600 (owner read/write only).
62
+ */
63
+ declare class FileLockStore implements LockStore {
64
+ private readonly filePath;
65
+ constructor(baseDir?: string);
66
+ register(entry: LockEntry): void;
67
+ deregister(objectUri: string): void;
68
+ list(): LockEntry[];
69
+ clear(): void;
70
+ private load;
71
+ private save;
72
+ }
73
+ //#endregion
74
+ //#region src/service.d.ts
75
+ /** Minimal client interface — anything with an authenticated fetch() */
76
+ interface LockClient {
77
+ fetch(url: string, options?: {
78
+ method?: string;
79
+ headers?: Record<string, string>;
80
+ body?: string;
81
+ }): Promise<unknown>;
82
+ }
83
+ interface LockServiceOptions {
84
+ /** Optional lock store for persistence */
85
+ store?: LockStore;
86
+ }
87
+ interface LockOptions {
88
+ /** Transport request */
89
+ transport?: string;
90
+ /** Object name (for store metadata) */
91
+ objectName?: string;
92
+ /** Object type (for store metadata) */
93
+ objectType?: string;
94
+ }
95
+ interface UnlockOptions {
96
+ /** Lock handle — REQUIRED. SAP always requires an explicit handle for unlock. */
97
+ lockHandle: string;
98
+ }
99
+ /**
100
+ * Resolve the CTS correlation for a write performed under a SAP editor lock.
101
+ * SAP's LOCK response is authoritative because it may normalize a child task
102
+ * to the request that actually owns the object lock.
103
+ */
104
+ declare function resolveLockCorrelation(handle: Pick<LockHandle, 'correlationNumber'>, requestedTransport?: string): string | undefined;
105
+ /**
106
+ * SAP does not expose an ADT operation that returns the handle of an
107
+ * existing CTS/editor lock. A caller can only unlock a handle it persisted
108
+ * when it acquired that lock.
109
+ */
110
+ declare class AdtLockHandleUnavailableError extends Error {
111
+ readonly objectUri: string;
112
+ readonly code: "ADT_LOCK_HANDLE_UNAVAILABLE";
113
+ readonly name = "AdtLockHandleUnavailableError";
114
+ constructor(objectUri: string);
115
+ }
116
+ /**
117
+ * Parse SAP lock response XML to extract lock handle and correlation info.
118
+ *
119
+ * Response format:
120
+ * ```xml
121
+ * <asx:abap>...<DATA>
122
+ * <LOCK_HANDLE>xxx</LOCK_HANDLE>
123
+ * <CORRNR>yyy</CORRNR>
124
+ * <CORRUSER>zzz</CORRUSER>
125
+ * </DATA>...</asx:abap>
126
+ * ```
127
+ */
128
+ declare function parseLockResponse(xml: string): LockHandle;
129
+ interface LockService {
130
+ /** Acquire a lock on an ADT object */
131
+ lock(objectUri: string, options?: LockOptions): Promise<LockHandle>;
132
+ /** Release a lock on an ADT object (lock handle required) */
133
+ unlock(objectUri: string, options: UnlockOptions): Promise<void>;
134
+ /**
135
+ * Unlock an object using the handle persisted when this client previously
136
+ * acquired the lock. The historical name is retained for compatibility;
137
+ * SAP has no server-side force-unlock operation and this never issues LOCK.
138
+ */
139
+ forceUnlock(objectUri: string): Promise<void>;
140
+ /** List all persisted lock entries (from store) */
141
+ list(): LockEntry[];
142
+ /** Try to UNLOCK every persisted entry, removing successful ones from store */
143
+ cleanup(): Promise<{
144
+ ok: number;
145
+ failed: number;
146
+ }>;
147
+ /** Clear the store without sending any UNLOCK requests */
148
+ clear(): void;
149
+ }
150
+ declare function createLockService(client: LockClient, serviceOptions?: LockServiceOptions): LockService;
151
+ //#endregion
152
+ //#region src/batch/batch-lock-session.d.ts
153
+ interface BatchLockTarget {
154
+ /** ADT object URI — e.g. `/sap/bc/adt/oo/classes/zcl_foo` */
155
+ objectUri: string;
156
+ /** Per-target lock options (transport, object name/type for the store). */
157
+ options?: LockOptions;
158
+ }
159
+ interface BatchLockAcquisition {
160
+ target: BatchLockTarget;
161
+ handle: LockHandle;
162
+ }
163
+ interface BatchReleaseResult {
164
+ released: number;
165
+ failed: Array<{
166
+ objectUri: string;
167
+ error: string;
168
+ }>;
169
+ }
170
+ interface BatchLockSessionOptions {
171
+ /**
172
+ * When true, `begin()` stops on the first failure and rolls back locks
173
+ * already acquired in this session. When false (rare), errors are
174
+ * collected and returned but already-acquired locks are kept — use only
175
+ * for diagnostic tooling. Default: true.
176
+ */
177
+ rollbackOnError?: boolean;
178
+ }
179
+ interface BatchLockSession {
180
+ /** Acquire all configured locks. Rollback on failure (by default). */
181
+ begin(): Promise<BatchLockAcquisition[]>;
182
+ /** Release every currently held handle. Safe to call multiple times. */
183
+ release(): Promise<BatchReleaseResult>;
184
+ /** Snapshot of currently held handles. */
185
+ handles(): ReadonlyArray<BatchLockAcquisition>;
186
+ /** Has `begin()` completed successfully? */
187
+ readonly active: boolean;
188
+ }
189
+ /**
190
+ * Create a batch lock session over a set of targets.
191
+ *
192
+ * The session does NOT auto-begin; callers drive the lifecycle explicitly so
193
+ * error handling stays visible at the call-site (`try { begin(); … } finally
194
+ * { release(); }`).
195
+ */
196
+ declare function createBatchLockSession(lockService: Pick<LockService, 'lock' | 'unlock'>, targets: BatchLockTarget[], options?: BatchLockSessionOptions): BatchLockSession;
197
+ //#endregion
198
+ export { AdtLockHandleUnavailableError, type BatchLockAcquisition, type BatchLockSession, type BatchLockSessionOptions, type BatchLockTarget, type BatchReleaseResult, FileLockStore, type LockClient, type LockEntry, type LockHandle, type LockOptions, type LockService, type LockStore, type UnlockOptions, createBatchLockSession, createLockService, parseLockResponse, resolveLockCorrelation };
199
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/store.ts","../src/service.ts","../src/batch/batch-lock-session.ts"],"mappings":";;;;;;;;;;;UAWiB;;EAEf;;EAEA;;EAEA;;;;;UAMe;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;UCpBe;;EAEf,SAAS,OAAO;;EAEhB,WAAW;;EAEX,QAAQ;;EAER;;;;;;;;;cAUW,yBAAyB;mBACnB;EAEL,YAAA;EAKZ,SAAS,OAAO;EAQhB,WAAW;EAQX,QAAQ;EAIR;UAIQ;UASA;;;;;UC1DO;EACf,MACE,aACA;IACE;IACA,UAAU;IACV;MAED;;UAGY;;EAEf,QAAQ;;UAGO;;EAEf;;EAEA;;EAEA;;UAGe;;EAEf;;;;;;;iBAQc,uBACd,QAAQ,KAAK,kCACb;;;;;;cAUW,sCAAsC;WAI5B;WAHZ;WACS;EAEG,YAAA;;;;;;;;;;;;;;iBAoBP,kBAAkB,cAAc;UAiB/B;;EAEf,KAAK,mBAAmB,UAAU,cAAc,QAAQ;;EAExD,OAAO,mBAAmB,SAAS,gBAAgB;;;;;;EAMnD,YAAY,oBAAoB;;EAEhC,QAAQ;;EAER,WAAW;IAAU;IAAY;;;EAEjC;;iBAGc,kBACd,QAAQ,YACR,iBAAiB,qBAChB;;;UCjGc;;EAEf;;EAEA,UAAU;;UAGK;EACf,QAAQ;EACR,QAAQ;;UAGO;EACf;EACA,QAAQ;IAAQ;IAAmB;;;UAGpB;;;;;;;EAOf;;UAGe;;EAEf,SAAS,QAAQ;;EAEjB,WAAW,QAAQ;;EAEnB,WAAW,cAAc;;WAEhB;;;;;;;;;iBAUK,uBACd,aAAa,KAAK,iCAClB,SAAS,mBACT,UAAU,0BACT"}
package/dist/index.mjs ADDED
@@ -0,0 +1,250 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ //#region src/store.ts
6
+ /**
7
+ * Lock Store — persistence layer for ADT lock handles
8
+ *
9
+ * Provides an interface for registering/deregistering lock entries
10
+ * and a file-based implementation that persists to `~/.adt/locks.json`.
11
+ */
12
+ /**
13
+ * File-based lock store.
14
+ *
15
+ * Persists lock entries to a JSON file (default: `~/.adt/locks.json`).
16
+ * Each entry is keyed by objectUri — re-locking the same URI overwrites.
17
+ * File permissions are 0o600 (owner read/write only).
18
+ */
19
+ var FileLockStore = class {
20
+ filePath;
21
+ constructor(baseDir) {
22
+ const dir = baseDir ?? join(homedir(), ".adt");
23
+ this.filePath = join(dir, "locks.json");
24
+ }
25
+ register(entry) {
26
+ const entries = this.load();
27
+ const idx = entries.findIndex((e) => e.objectUri === entry.objectUri);
28
+ if (idx >= 0) entries[idx] = entry;
29
+ else entries.push(entry);
30
+ this.save(entries);
31
+ }
32
+ deregister(objectUri) {
33
+ const entries = this.load();
34
+ const filtered = entries.filter((e) => e.objectUri !== objectUri);
35
+ if (filtered.length !== entries.length) this.save(filtered);
36
+ }
37
+ list() {
38
+ return this.load();
39
+ }
40
+ clear() {
41
+ this.save([]);
42
+ }
43
+ load() {
44
+ if (!existsSync(this.filePath)) return [];
45
+ try {
46
+ return JSON.parse(readFileSync(this.filePath, "utf8"));
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+ save(entries) {
52
+ const dir = dirname(this.filePath);
53
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
+ writeFileSync(this.filePath, JSON.stringify(entries, null, 2), { mode: 384 });
55
+ }
56
+ };
57
+
58
+ //#endregion
59
+ //#region src/service.ts
60
+ /**
61
+ * Resolve the CTS correlation for a write performed under a SAP editor lock.
62
+ * SAP's LOCK response is authoritative because it may normalize a child task
63
+ * to the request that actually owns the object lock.
64
+ */
65
+ function resolveLockCorrelation(handle, requestedTransport) {
66
+ return handle.correlationNumber || requestedTransport;
67
+ }
68
+ /**
69
+ * SAP does not expose an ADT operation that returns the handle of an
70
+ * existing CTS/editor lock. A caller can only unlock a handle it persisted
71
+ * when it acquired that lock.
72
+ */
73
+ var AdtLockHandleUnavailableError = class extends Error {
74
+ objectUri;
75
+ code = "ADT_LOCK_HANDLE_UNAVAILABLE";
76
+ name = "AdtLockHandleUnavailableError";
77
+ constructor(objectUri) {
78
+ super(`No persisted lock handle is available for ${objectUri}. SAP cannot force-unlock this object by URI; wait for the server lock to expire or ask the SAP administrator to clear it.`);
79
+ this.objectUri = objectUri;
80
+ }
81
+ };
82
+ /**
83
+ * Parse SAP lock response XML to extract lock handle and correlation info.
84
+ *
85
+ * Response format:
86
+ * ```xml
87
+ * <asx:abap>...<DATA>
88
+ * <LOCK_HANDLE>xxx</LOCK_HANDLE>
89
+ * <CORRNR>yyy</CORRNR>
90
+ * <CORRUSER>zzz</CORRUSER>
91
+ * </DATA>...</asx:abap>
92
+ * ```
93
+ */
94
+ function parseLockResponse(xml) {
95
+ const handleMatch = xml.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/);
96
+ if (!handleMatch) throw new Error("Failed to parse lock handle from SAP response. The response format may have changed.");
97
+ const corrNrMatch = xml.match(/<CORRNR>([^<]+)<\/CORRNR>/);
98
+ const corrUserMatch = xml.match(/<CORRUSER>([^<]+)<\/CORRUSER>/);
99
+ return {
100
+ handle: handleMatch[1],
101
+ correlationNumber: corrNrMatch?.[1],
102
+ correlationUser: corrUserMatch?.[1]
103
+ };
104
+ }
105
+ function createLockService(client, serviceOptions) {
106
+ const store = serviceOptions?.store;
107
+ /** Headers for lock operations — stateful session with security session reuse */
108
+ const lockHeaders = {
109
+ "X-sap-adt-sessiontype": "stateful",
110
+ "x-sap-security-session": "use",
111
+ Accept: "application/*,application/vnd.sap.as+xml;charset=UTF-8;dataname=com.sap.adt.lock.result"
112
+ };
113
+ /** Headers for unlock operations */
114
+ const unlockHeaders = {
115
+ "X-sap-adt-sessiontype": "stateful",
116
+ "x-sap-security-session": "use"
117
+ };
118
+ return {
119
+ async lock(objectUri, options) {
120
+ const query = options?.transport ? `?_action=LOCK&accessMode=MODIFY&corrNr=${options.transport}` : "?_action=LOCK&accessMode=MODIFY";
121
+ const response = await client.fetch(`${objectUri}${query}`, {
122
+ method: "POST",
123
+ headers: lockHeaders
124
+ });
125
+ const handle = parseLockResponse(typeof response === "string" ? response : response?.text ? await response.text() : JSON.stringify(response));
126
+ store?.register({
127
+ objectUri,
128
+ objectName: options?.objectName ?? objectUri.split("/").pop() ?? objectUri,
129
+ objectType: options?.objectType,
130
+ lockHandle: handle.handle,
131
+ transport: handle.correlationNumber,
132
+ lockedAt: (/* @__PURE__ */ new Date()).toISOString()
133
+ });
134
+ return handle;
135
+ },
136
+ async unlock(objectUri, options) {
137
+ const query = `?_action=UNLOCK&accessMode=MODIFY&lockHandle=${encodeURIComponent(options.lockHandle)}`;
138
+ await client.fetch(`${objectUri}${query}`, {
139
+ method: "POST",
140
+ headers: unlockHeaders
141
+ });
142
+ store?.deregister(objectUri);
143
+ },
144
+ async forceUnlock(objectUri) {
145
+ const entry = store?.list()?.find((item) => item.objectUri === objectUri);
146
+ if (!entry) throw new AdtLockHandleUnavailableError(objectUri);
147
+ await this.unlock(objectUri, { lockHandle: entry.lockHandle });
148
+ },
149
+ list() {
150
+ return store?.list() ?? [];
151
+ },
152
+ async cleanup() {
153
+ const entries = store?.list() ?? [];
154
+ let ok = 0;
155
+ let failed = 0;
156
+ for (const entry of entries) try {
157
+ const query = `?_action=UNLOCK&accessMode=MODIFY&lockHandle=${encodeURIComponent(entry.lockHandle)}`;
158
+ await client.fetch(`${entry.objectUri}${query}`, {
159
+ method: "POST",
160
+ headers: unlockHeaders
161
+ });
162
+ store?.deregister(entry.objectUri);
163
+ ok++;
164
+ } catch (err) {
165
+ const msg = err instanceof Error ? err.message : String(err);
166
+ if (msg.includes("not locked") || msg.includes("not enqueued")) {
167
+ store?.deregister(entry.objectUri);
168
+ ok++;
169
+ } else failed++;
170
+ }
171
+ return {
172
+ ok,
173
+ failed
174
+ };
175
+ },
176
+ clear() {
177
+ store?.clear();
178
+ }
179
+ };
180
+ }
181
+
182
+ //#endregion
183
+ //#region src/batch/batch-lock-session.ts
184
+ /**
185
+ * Create a batch lock session over a set of targets.
186
+ *
187
+ * The session does NOT auto-begin; callers drive the lifecycle explicitly so
188
+ * error handling stays visible at the call-site (`try { begin(); … } finally
189
+ * { release(); }`).
190
+ */
191
+ function createBatchLockSession(lockService, targets, options) {
192
+ const rollback = options?.rollbackOnError ?? true;
193
+ const acquired = [];
194
+ let beganOnce = false;
195
+ let released = false;
196
+ return {
197
+ async begin() {
198
+ if (beganOnce) throw new Error("BatchLockSession.begin() called more than once");
199
+ beganOnce = true;
200
+ for (const target of targets) try {
201
+ const handle = await lockService.lock(target.objectUri, target.options);
202
+ acquired.push({
203
+ target,
204
+ handle
205
+ });
206
+ } catch (err) {
207
+ if (rollback) {
208
+ for (const a of acquired.splice(0).reverse()) try {
209
+ await lockService.unlock(a.target.objectUri, { lockHandle: a.handle.handle });
210
+ } catch {}
211
+ released = true;
212
+ }
213
+ throw err instanceof Error ? err : /* @__PURE__ */ new Error(`Batch lock failed: ${String(err)}`);
214
+ }
215
+ return [...acquired];
216
+ },
217
+ async release() {
218
+ if (released) return {
219
+ released: 0,
220
+ failed: []
221
+ };
222
+ released = true;
223
+ const failed = [];
224
+ let ok = 0;
225
+ for (const a of acquired.splice(0).reverse()) try {
226
+ await lockService.unlock(a.target.objectUri, { lockHandle: a.handle.handle });
227
+ ok++;
228
+ } catch (err) {
229
+ failed.push({
230
+ objectUri: a.target.objectUri,
231
+ error: err instanceof Error ? err.message : String(err)
232
+ });
233
+ }
234
+ return {
235
+ released: ok,
236
+ failed
237
+ };
238
+ },
239
+ handles() {
240
+ return [...acquired];
241
+ },
242
+ get active() {
243
+ return beganOnce && !released;
244
+ }
245
+ };
246
+ }
247
+
248
+ //#endregion
249
+ export { AdtLockHandleUnavailableError, FileLockStore, createBatchLockSession, createLockService, parseLockResponse, resolveLockCorrelation };
250
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/store.ts","../src/service.ts","../src/batch/batch-lock-session.ts"],"sourcesContent":["/**\n * Lock Store — persistence layer for ADT lock handles\n *\n * Provides an interface for registering/deregistering lock entries\n * and a file-based implementation that persists to `~/.adt/locks.json`.\n */\n\nimport { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { homedir } from 'node:os';\nimport type { LockEntry } from './types';\n\n/**\n * Lock store interface — opt-in persistence for ADT lock handles.\n *\n * Implementations can be file-based, in-memory, or backed by any storage.\n */\nexport interface LockStore {\n /** Persist a lock entry after successful LOCK */\n register(entry: LockEntry): void;\n /** Remove a lock entry after successful UNLOCK */\n deregister(objectUri: string): void;\n /** List all persisted lock entries */\n list(): LockEntry[];\n /** Remove all entries */\n clear(): void;\n}\n\n/**\n * File-based lock store.\n *\n * Persists lock entries to a JSON file (default: `~/.adt/locks.json`).\n * Each entry is keyed by objectUri — re-locking the same URI overwrites.\n * File permissions are 0o600 (owner read/write only).\n */\nexport class FileLockStore implements LockStore {\n private readonly filePath: string;\n\n constructor(baseDir?: string) {\n const dir = baseDir ?? join(homedir(), '.adt');\n this.filePath = join(dir, 'locks.json');\n }\n\n register(entry: LockEntry): void {\n const entries = this.load();\n const idx = entries.findIndex((e) => e.objectUri === entry.objectUri);\n if (idx >= 0) entries[idx] = entry;\n else entries.push(entry);\n this.save(entries);\n }\n\n deregister(objectUri: string): void {\n const entries = this.load();\n const filtered = entries.filter((e) => e.objectUri !== objectUri);\n if (filtered.length !== entries.length) {\n this.save(filtered);\n }\n }\n\n list(): LockEntry[] {\n return this.load();\n }\n\n clear(): void {\n this.save([]);\n }\n\n private load(): LockEntry[] {\n if (!existsSync(this.filePath)) return [];\n try {\n return JSON.parse(readFileSync(this.filePath, 'utf8')) as LockEntry[];\n } catch {\n return [];\n }\n }\n\n private save(entries: LockEntry[]): void {\n const dir = dirname(this.filePath);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n writeFileSync(this.filePath, JSON.stringify(entries, null, 2), {\n mode: 0o600,\n });\n }\n}\n","/**\n * Lock Service — lock/unlock ADT objects via REST API\n *\n * Provides standalone lock/unlock operations that work with any\n * object URI. Optionally integrates with a LockStore for persistence.\n *\n * Usage:\n * import { createLockService, FileLockStore } from '@abapify/adt-locks';\n *\n * const locks = createLockService(client, { store: new FileLockStore() });\n * const handle = await locks.lock('/sap/bc/adt/oo/classes/zcl_test');\n * await locks.unlock('/sap/bc/adt/oo/classes/zcl_test', handle);\n */\n\nimport type { LockHandle, LockEntry } from './types';\nimport type { LockStore } from './store';\n\n/** Minimal client interface — anything with an authenticated fetch() */\nexport interface LockClient {\n fetch(\n url: string,\n options?: {\n method?: string;\n headers?: Record<string, string>;\n body?: string;\n },\n ): Promise<unknown>;\n}\n\nexport interface LockServiceOptions {\n /** Optional lock store for persistence */\n store?: LockStore;\n}\n\nexport interface LockOptions {\n /** Transport request */\n transport?: string;\n /** Object name (for store metadata) */\n objectName?: string;\n /** Object type (for store metadata) */\n objectType?: string;\n}\n\nexport interface UnlockOptions {\n /** Lock handle — REQUIRED. SAP always requires an explicit handle for unlock. */\n lockHandle: string;\n}\n\n/**\n * Resolve the CTS correlation for a write performed under a SAP editor lock.\n * SAP's LOCK response is authoritative because it may normalize a child task\n * to the request that actually owns the object lock.\n */\nexport function resolveLockCorrelation(\n handle: Pick<LockHandle, 'correlationNumber'>,\n requestedTransport?: string,\n): string | undefined {\n return handle.correlationNumber || requestedTransport;\n}\n\n/**\n * SAP does not expose an ADT operation that returns the handle of an\n * existing CTS/editor lock. A caller can only unlock a handle it persisted\n * when it acquired that lock.\n */\nexport class AdtLockHandleUnavailableError extends Error {\n readonly code = 'ADT_LOCK_HANDLE_UNAVAILABLE' as const;\n override readonly name = 'AdtLockHandleUnavailableError';\n\n constructor(readonly objectUri: string) {\n super(\n `No persisted lock handle is available for ${objectUri}. ` +\n 'SAP cannot force-unlock this object by URI; wait for the server lock to expire or ask the SAP administrator to clear it.',\n );\n }\n}\n\n/**\n * Parse SAP lock response XML to extract lock handle and correlation info.\n *\n * Response format:\n * ```xml\n * <asx:abap>...<DATA>\n * <LOCK_HANDLE>xxx</LOCK_HANDLE>\n * <CORRNR>yyy</CORRNR>\n * <CORRUSER>zzz</CORRUSER>\n * </DATA>...</asx:abap>\n * ```\n */\nexport function parseLockResponse(xml: string): LockHandle {\n const handleMatch = xml.match(/<LOCK_HANDLE>([^<]+)<\\/LOCK_HANDLE>/);\n if (!handleMatch) {\n throw new Error(\n 'Failed to parse lock handle from SAP response. The response format may have changed.',\n );\n }\n const corrNrMatch = xml.match(/<CORRNR>([^<]+)<\\/CORRNR>/);\n const corrUserMatch = xml.match(/<CORRUSER>([^<]+)<\\/CORRUSER>/);\n\n return {\n handle: handleMatch[1],\n correlationNumber: corrNrMatch?.[1],\n correlationUser: corrUserMatch?.[1],\n };\n}\n\nexport interface LockService {\n /** Acquire a lock on an ADT object */\n lock(objectUri: string, options?: LockOptions): Promise<LockHandle>;\n /** Release a lock on an ADT object (lock handle required) */\n unlock(objectUri: string, options: UnlockOptions): Promise<void>;\n /**\n * Unlock an object using the handle persisted when this client previously\n * acquired the lock. The historical name is retained for compatibility;\n * SAP has no server-side force-unlock operation and this never issues LOCK.\n */\n forceUnlock(objectUri: string): Promise<void>;\n /** List all persisted lock entries (from store) */\n list(): LockEntry[];\n /** Try to UNLOCK every persisted entry, removing successful ones from store */\n cleanup(): Promise<{ ok: number; failed: number }>;\n /** Clear the store without sending any UNLOCK requests */\n clear(): void;\n}\n\nexport function createLockService(\n client: LockClient,\n serviceOptions?: LockServiceOptions,\n): LockService {\n const store = serviceOptions?.store;\n\n /** Headers for lock operations — stateful session with security session reuse */\n const lockHeaders = {\n 'X-sap-adt-sessiontype': 'stateful',\n 'x-sap-security-session': 'use',\n Accept:\n 'application/*,application/vnd.sap.as+xml;charset=UTF-8;dataname=com.sap.adt.lock.result',\n };\n\n /** Headers for unlock operations */\n const unlockHeaders = {\n 'X-sap-adt-sessiontype': 'stateful',\n 'x-sap-security-session': 'use',\n };\n\n return {\n async lock(objectUri, options) {\n const query = options?.transport\n ? `?_action=LOCK&accessMode=MODIFY&corrNr=${options.transport}`\n : '?_action=LOCK&accessMode=MODIFY';\n\n const response = await client.fetch(`${objectUri}${query}`, {\n method: 'POST',\n headers: lockHeaders,\n });\n\n // The adapter may return a string (text/xml) or a parsed object;\n // parseLockResponse needs the raw XML string.\n const body =\n typeof response === 'string'\n ? response\n : (response as Response)?.text\n ? await (response as Response).text()\n : JSON.stringify(response);\n const handle = parseLockResponse(body);\n\n store?.register({\n objectUri,\n objectName:\n options?.objectName ?? objectUri.split('/').pop() ?? objectUri,\n objectType: options?.objectType,\n lockHandle: handle.handle,\n transport: handle.correlationNumber,\n lockedAt: new Date().toISOString(),\n });\n\n return handle;\n },\n\n async unlock(objectUri, options) {\n const query = `?_action=UNLOCK&accessMode=MODIFY&lockHandle=${encodeURIComponent(options.lockHandle)}`;\n\n await client.fetch(`${objectUri}${query}`, {\n method: 'POST',\n headers: unlockHeaders,\n });\n\n store?.deregister(objectUri);\n },\n\n async forceUnlock(objectUri) {\n // A second LOCK cannot recover a lost handle: real SAP systems such as\n // TRL reject it with an \"already locked in request\" conflict. Re-locking\n // also risks creating a new CTS lock on systems that permit it. Only a\n // handle registered by a previous successful LOCK is safe to use.\n const entry = store?.list()?.find((item) => item.objectUri === objectUri);\n if (!entry) {\n throw new AdtLockHandleUnavailableError(objectUri);\n }\n await this.unlock(objectUri, { lockHandle: entry.lockHandle });\n },\n\n list() {\n return store?.list() ?? [];\n },\n\n async cleanup() {\n const entries = store?.list() ?? [];\n let ok = 0;\n let failed = 0;\n\n for (const entry of entries) {\n try {\n const query = `?_action=UNLOCK&accessMode=MODIFY&lockHandle=${encodeURIComponent(entry.lockHandle)}`;\n await client.fetch(`${entry.objectUri}${query}`, {\n method: 'POST',\n headers: unlockHeaders,\n });\n store?.deregister(entry.objectUri);\n ok++;\n } catch (err: unknown) {\n const msg = err instanceof Error ? err.message : String(err);\n if (msg.includes('not locked') || msg.includes('not enqueued')) {\n store?.deregister(entry.objectUri);\n ok++;\n } else {\n failed++;\n }\n }\n }\n\n return { ok, failed };\n },\n\n clear() {\n store?.clear();\n },\n };\n}\n","/**\n * BatchLockSession — acquire and release N locks with best-effort rollback.\n *\n * SAP ADT has no transactional multi-object save. A \"batch lock\" is therefore\n * a **best-effort** construct:\n *\n * - `begin()` sequentially acquires a lock on every target. If any lock\n * acquisition fails, every previously acquired lock in this session is\n * released before the error is rethrown — leaving the backend in the\n * same state it was before `begin()` was called.\n *\n * - `release()` unlocks every successfully acquired handle, swallowing\n * per-object unlock failures (they are surfaced through the returned\n * result for the caller to inspect).\n *\n * - `handles()` exposes the acquired handles so callers can thread them\n * into downstream save/PUT flows without re-locking.\n *\n * The session is **single-use**: once released (explicitly or via rollback),\n * it cannot be reused. Tests exercise acquire/release/partial-failure paths.\n *\n * Consumer rationale: ADK's per-object `save({mode:'update'})` already owns\n * its own lock/unlock cycle. `BatchLockSession` is therefore positioned as an\n * outer pre-flight primitive — acquire across a whole ChangePlan to catch\n * conflicts BEFORE we start mutating SAP, release before handing off to ADK.\n * When ADK's save cycle later re-locks per object it simply re-uses the same\n * security session (the CSRF token survives), so the double lock is cheap.\n */\nimport type { LockHandle } from '../types';\nimport type { LockService, LockOptions } from '../service';\n\nexport interface BatchLockTarget {\n /** ADT object URI — e.g. `/sap/bc/adt/oo/classes/zcl_foo` */\n objectUri: string;\n /** Per-target lock options (transport, object name/type for the store). */\n options?: LockOptions;\n}\n\nexport interface BatchLockAcquisition {\n target: BatchLockTarget;\n handle: LockHandle;\n}\n\nexport interface BatchReleaseResult {\n released: number;\n failed: Array<{ objectUri: string; error: string }>;\n}\n\nexport interface BatchLockSessionOptions {\n /**\n * When true, `begin()` stops on the first failure and rolls back locks\n * already acquired in this session. When false (rare), errors are\n * collected and returned but already-acquired locks are kept — use only\n * for diagnostic tooling. Default: true.\n */\n rollbackOnError?: boolean;\n}\n\nexport interface BatchLockSession {\n /** Acquire all configured locks. Rollback on failure (by default). */\n begin(): Promise<BatchLockAcquisition[]>;\n /** Release every currently held handle. Safe to call multiple times. */\n release(): Promise<BatchReleaseResult>;\n /** Snapshot of currently held handles. */\n handles(): ReadonlyArray<BatchLockAcquisition>;\n /** Has `begin()` completed successfully? */\n readonly active: boolean;\n}\n\n/**\n * Create a batch lock session over a set of targets.\n *\n * The session does NOT auto-begin; callers drive the lifecycle explicitly so\n * error handling stays visible at the call-site (`try { begin(); … } finally\n * { release(); }`).\n */\nexport function createBatchLockSession(\n lockService: Pick<LockService, 'lock' | 'unlock'>,\n targets: BatchLockTarget[],\n options?: BatchLockSessionOptions,\n): BatchLockSession {\n const rollback = options?.rollbackOnError ?? true;\n const acquired: BatchLockAcquisition[] = [];\n let beganOnce = false;\n let released = false;\n\n return {\n async begin() {\n if (beganOnce) {\n throw new Error('BatchLockSession.begin() called more than once');\n }\n beganOnce = true;\n\n for (const target of targets) {\n try {\n const handle = await lockService.lock(\n target.objectUri,\n target.options,\n );\n acquired.push({ target, handle });\n } catch (err) {\n if (rollback) {\n // Best-effort: release what we already have, then propagate.\n for (const a of acquired.splice(0).reverse()) {\n try {\n await lockService.unlock(a.target.objectUri, {\n lockHandle: a.handle.handle,\n });\n } catch {\n /* swallow — primary failure is the real signal */\n }\n }\n // Session is done — no further release() work possible.\n released = true;\n }\n throw err instanceof Error\n ? err\n : new Error(`Batch lock failed: ${String(err)}`);\n }\n }\n return [...acquired];\n },\n\n async release() {\n if (released) return { released: 0, failed: [] };\n released = true;\n\n const failed: BatchReleaseResult['failed'] = [];\n let ok = 0;\n // Release in reverse acquisition order — symmetric with begin()'s\n // rollback path and avoids surprising the SAP enqueue server.\n for (const a of acquired.splice(0).reverse()) {\n try {\n await lockService.unlock(a.target.objectUri, {\n lockHandle: a.handle.handle,\n });\n ok++;\n } catch (err) {\n failed.push({\n objectUri: a.target.objectUri,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n return { released: ok, failed };\n },\n\n handles() {\n return [...acquired];\n },\n\n get active() {\n return beganOnce && !released;\n },\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abapify/adt-locks",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "ADT lock/unlock operations and lock store management",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "dependencies": {
13
- "@abapify/adt-client": "0.4.0"
13
+ "@abapify/adt-client": "0.4.3"
14
14
  },
15
15
  "publishConfig": {
16
16
  "access": "public"