fms-odata-js 0.3.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/src/batch.ts ADDED
@@ -0,0 +1,477 @@
1
+ /**
2
+ * $batch multipart composer and response parser (M6).
3
+ *
4
+ * OData $batch allows multiple operations in a single HTTP round-trip.
5
+ * A batch consists of:
6
+ * - Read operations (GET) at the top level
7
+ * - Changesets (atomic groups of writes: POST, PATCH, DELETE)
8
+ *
9
+ * FMS endpoint: POST /fmi/odata/v4/{db}/$batch
10
+ * Content-Type: multipart/mixed; boundary=batch_<uuid>
11
+ *
12
+ * The implementation generates unique boundaries using crypto.randomUUID()
13
+ * (available in Node 18+ and modern browsers), composes the multipart
14
+ * body, and parses the multipart response back into per-operation results.
15
+ *
16
+ * ⚠️ KNOWN FMS LIMITATIONS (pending investigation — tested against FMS 21.x):
17
+ *
18
+ * 1. GET operations must NOT appear before a changeset.
19
+ * Placing a read op before a changeset causes FMS to return HTTP 400:
20
+ * "Expected batch boundary at 'Content-Type: applic...'".
21
+ * FMS's parser appears to misread the changeset's nested boundary as a
22
+ * batch-level boundary when GETs precede it.
23
+ * → Workaround: always add changesets before reads in the batch.
24
+ *
25
+ * 2. Multiple consecutive GET operations return only one fewer response than
26
+ * expected — the last GET in a sequence is silently dropped by FMS.
27
+ * E.g. three GETs → only two responses.
28
+ * → Workaround: limit batches to one read op at the end, or accept the loss.
29
+ *
30
+ * 3. POST/PATCH within a changeset requires an explicit Content-Length header
31
+ * on the inner HTTP request (FMS cannot determine body length from multipart
32
+ * structure alone). This is handled by utf8ByteLength() during serialisation.
33
+ *
34
+ * These constraints differ from the OData 4.01 spec and the official Claris
35
+ * documentation examples. They were discovered empirically. Batch support in
36
+ * this library is functional but limited to: changeset (optional) followed by
37
+ * at most one read operation per batch call.
38
+ */
39
+
40
+ import type { FMSOData } from './client.js'
41
+ import { executeRequest, type HttpRequestOptions } from './http.js'
42
+ import type { ODataLiteral } from './url.js'
43
+ import { encodePathSegment, odataEncode } from './url.js'
44
+ import type { RequestOptions } from './types.js'
45
+
46
+ /** Handle returned when adding an operation to a batch. Resolves to the operation result. */
47
+ export interface BatchHandle<T> {
48
+ readonly __brand: 'BatchHandle'
49
+ /** @internal */ readonly _promise: Promise<T>
50
+ /** @internal */ readonly _index: number
51
+ }
52
+
53
+ /** Input for a read operation (GET) in a batch. */
54
+ export interface BatchReadOp {
55
+ /** Entity set name (layout/table). */
56
+ entitySet: string
57
+ /** Operation type. */
58
+ op: 'list'
59
+ /** Query options: $top, $skip, $filter, etc. */
60
+ query?: { $top?: number; $skip?: number; $filter?: string; $select?: string }
61
+ }
62
+
63
+ /** Result of a batch operation. */
64
+ export interface BatchOpResult<T = unknown> {
65
+ status: number
66
+ body?: T
67
+ headers: Headers
68
+ ok: boolean
69
+ }
70
+
71
+ /** Result of executing a batch. */
72
+ export interface BatchResult {
73
+ /** All responses in request order. */
74
+ responses: BatchOpResult[]
75
+ /** True if all responses have status < 400. */
76
+ ok: boolean
77
+ }
78
+
79
+ /** @internal — byte length of a string in UTF-8. */
80
+ function utf8ByteLength(str: string): number {
81
+ if (typeof TextEncoder !== 'undefined') {
82
+ return new TextEncoder().encode(str).byteLength
83
+ }
84
+ // Node.js fallback
85
+ return Buffer.byteLength(str, 'utf8')
86
+ }
87
+
88
+ /** @internal — generate a unique boundary string. */
89
+ function generateBoundary(prefix: string): string {
90
+ // crypto.randomUUID() is available in Node 18+ and modern browsers
91
+ const uuid =
92
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
93
+ ? crypto.randomUUID()
94
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
95
+ return `${prefix}_${uuid}`
96
+ }
97
+
98
+ /** @internal — build the URL path for an entity set query. */
99
+ function buildEntitySetPath(baseUrl: string, entitySet: string, query?: BatchReadOp['query']): string {
100
+ // Build OData query string manually to avoid URLSearchParams percent-encoding
101
+ // the leading '$' in OData system query options ($top, $filter, etc.).
102
+ // Use odataEncode which preserves commas (required by $select, $orderby, $expand).
103
+ const parts: string[] = []
104
+ if (query?.$top !== undefined) parts.push(`$top=${odataEncode(String(query.$top))}`)
105
+ if (query?.$skip !== undefined) parts.push(`$skip=${odataEncode(String(query.$skip))}`)
106
+ if (query?.$filter) parts.push(`$filter=${odataEncode(query.$filter)}`)
107
+ if (query?.$select) parts.push(`$select=${odataEncode(query.$select)}`)
108
+ const qs = parts.join('&')
109
+ const encodedSet = encodePathSegment(entitySet)
110
+ return qs ? `${baseUrl}/${encodedSet}?${qs}` : `${baseUrl}/${encodedSet}`
111
+ }
112
+
113
+ /** @internal — build the URL path for a single entity. */
114
+ function buildEntityPath(baseUrl: string, entitySet: string, key: ODataLiteral): string {
115
+ const encodedSet = encodePathSegment(entitySet)
116
+ let keySegment: string
117
+ if (typeof key === 'number') {
118
+ keySegment = String(key)
119
+ } else if (typeof key === 'string') {
120
+ keySegment = `'${key.replace(/'/g, "''")}'`
121
+ } else if (typeof key === 'boolean') {
122
+ keySegment = key ? 'true' : 'false'
123
+ } else {
124
+ throw new TypeError('Batch: unsupported key type')
125
+ }
126
+ return `${baseUrl}/${encodedSet}(${keySegment})`
127
+ }
128
+
129
+ /** Represents an operation within a changeset. */
130
+ interface ChangesetOp {
131
+ method: string
132
+ path: string
133
+ headers?: Record<string, string>
134
+ body?: string
135
+ }
136
+
137
+ /** Changeset builder for atomic write operations. */
138
+ export class Changeset {
139
+ private _ops: ChangesetOp[] = []
140
+ private _handles: Array<{ resolve: (v: unknown) => void; reject: (e: unknown) => void }> = []
141
+ private _baseUrl: string
142
+
143
+ constructor(baseUrl: string) {
144
+ this._baseUrl = baseUrl
145
+ }
146
+
147
+ /** @internal */
148
+ get _operations(): ChangesetOp[] {
149
+ return this._ops
150
+ }
151
+
152
+ /** @internal */
153
+ get _handleSlots(): Array<{ resolve: (v: unknown) => void; reject: (e: unknown) => void }> {
154
+ return this._handles
155
+ }
156
+
157
+ /**
158
+ * Create a new entity within this changeset.
159
+ */
160
+ create<T = unknown>(entitySet: string, body: Record<string, unknown>): BatchHandle<T> {
161
+ const path = buildEntitySetPath(this._baseUrl, entitySet)
162
+ let resolve: (v: unknown) => void
163
+ let reject: (e: unknown) => void
164
+ const promise = new Promise<unknown>((res, rej) => {
165
+ resolve = res
166
+ reject = rej
167
+ })
168
+ const index = this._handles.length
169
+ this._handles.push({ resolve: resolve!, reject: reject! })
170
+ this._ops.push({
171
+ method: 'POST',
172
+ path,
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify(body),
175
+ })
176
+ return { __brand: 'BatchHandle', _promise: promise, _index: index } as BatchHandle<T>
177
+ }
178
+
179
+ /**
180
+ * Patch an existing entity within this changeset.
181
+ */
182
+ patch<T = unknown>(
183
+ entitySet: string,
184
+ key: ODataLiteral,
185
+ body: Record<string, unknown>,
186
+ opts?: { ifMatch?: string },
187
+ ): BatchHandle<T> {
188
+ const path = buildEntityPath(this._baseUrl, entitySet, key)
189
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
190
+ if (opts?.ifMatch) headers['If-Match'] = opts.ifMatch
191
+ let resolve: (v: unknown) => void
192
+ let reject: (e: unknown) => void
193
+ const promise = new Promise<unknown>((res, rej) => {
194
+ resolve = res
195
+ reject = rej
196
+ })
197
+ const index = this._handles.length
198
+ this._handles.push({ resolve: resolve!, reject: reject! })
199
+ this._ops.push({
200
+ method: 'PATCH',
201
+ path,
202
+ headers,
203
+ body: JSON.stringify(body),
204
+ })
205
+ return { __brand: 'BatchHandle', _promise: promise, _index: index } as BatchHandle<T>
206
+ }
207
+
208
+ /**
209
+ * Delete an entity within this changeset.
210
+ */
211
+ delete(entitySet: string, key: ODataLiteral, opts?: { ifMatch?: string }): BatchHandle<void> {
212
+ const path = buildEntityPath(this._baseUrl, entitySet, key)
213
+ const headers: Record<string, string> = {}
214
+ if (opts?.ifMatch) headers['If-Match'] = opts.ifMatch
215
+ let resolve: (v: unknown) => void
216
+ let reject: (e: unknown) => void
217
+ const promise = new Promise<unknown>((res, rej) => {
218
+ resolve = res
219
+ reject = rej
220
+ })
221
+ const index = this._handles.length
222
+ this._handles.push({ resolve: resolve!, reject: reject! })
223
+ this._ops.push({ method: 'DELETE', path, headers })
224
+ return { __brand: 'BatchHandle', _promise: promise, _index: index } as BatchHandle<void>
225
+ }
226
+ }
227
+
228
+ /** Internal representation of a batch part. */
229
+ interface BatchPart {
230
+ type: 'read' | 'changeset'
231
+ content: ChangesetOp | Changeset
232
+ handle?: BatchHandle<unknown>
233
+ changesetIndex?: number // for changeset parts, index into changeset handles
234
+ }
235
+
236
+ /** Batch builder for composing multipart/mixed requests. */
237
+ export class Batch {
238
+ private _client: FMSOData
239
+ private _parts: BatchPart[] = []
240
+ private _changesets: Changeset[] = []
241
+
242
+ constructor(client: FMSOData) {
243
+ this._client = client
244
+ }
245
+
246
+ /**
247
+ * Add a read operation (GET) to the batch.
248
+ * Read operations are not part of a changeset and execute independently.
249
+ */
250
+ add<T = unknown>(op: BatchReadOp): BatchHandle<T> {
251
+ const path = buildEntitySetPath(this._client.baseUrl, op.entitySet, op.query)
252
+ let resolve: (v: unknown) => void
253
+ let reject: (e: unknown) => void
254
+ const promise = new Promise<unknown>((res, rej) => {
255
+ resolve = res
256
+ reject = rej
257
+ })
258
+ const handle = { __brand: 'BatchHandle', _promise: promise, _index: this._parts.length } as BatchHandle<T>
259
+ this._parts.push({
260
+ type: 'read',
261
+ content: { method: 'GET', path },
262
+ handle,
263
+ })
264
+ return handle
265
+ }
266
+
267
+ /**
268
+ * Create an atomic changeset (group of write operations).
269
+ * All operations in a changeset succeed or fail together.
270
+ */
271
+ changeset(build: (cs: Changeset) => void): void {
272
+ const cs = new Changeset(this._client.baseUrl)
273
+ build(cs)
274
+ this._changesets.push(cs)
275
+ // Add a placeholder part for the changeset (actual ops are inside cs)
276
+ this._parts.push({ type: 'changeset', content: cs })
277
+ }
278
+
279
+ /**
280
+ * Serialize the batch into a multipart/mixed body.
281
+ * @internal
282
+ */
283
+ _serialize(): { boundary: string; body: string } {
284
+ const batchBoundary = generateBoundary('batch')
285
+ const lines: string[] = []
286
+
287
+ for (const part of this._parts) {
288
+ lines.push(`--${batchBoundary}`)
289
+
290
+ if (part.type === 'read') {
291
+ const op = part.content as ChangesetOp
292
+ lines.push('Content-Type: application/http')
293
+ lines.push('Content-Transfer-Encoding: binary')
294
+ lines.push('')
295
+ lines.push(`${op.method} ${op.path} HTTP/1.1`)
296
+ } else if (part.type === 'changeset') {
297
+ const cs = part.content as Changeset
298
+ const csBoundary = generateBoundary('changeset')
299
+ lines.push('Content-Type: multipart/mixed; boundary=' + csBoundary)
300
+ lines.push('')
301
+
302
+ for (const op of cs._operations) {
303
+ lines.push(`--${csBoundary}`)
304
+ lines.push('Content-Type: application/http')
305
+ lines.push('Content-Transfer-Encoding: binary')
306
+ lines.push('')
307
+ lines.push(`${op.method} ${op.path} HTTP/1.1`)
308
+ if (op.headers) {
309
+ for (const [k, v] of Object.entries(op.headers)) {
310
+ lines.push(`${k}: ${v}`)
311
+ }
312
+ }
313
+ if (op.body) {
314
+ lines.push(`Content-Length: ${utf8ByteLength(op.body)}`)
315
+ lines.push('')
316
+ lines.push(op.body)
317
+ }
318
+ }
319
+ lines.push(`--${csBoundary}--`)
320
+ }
321
+ }
322
+
323
+ lines.push(`--${batchBoundary}--`)
324
+ return { boundary: batchBoundary, body: lines.join('\r\n') }
325
+ }
326
+
327
+ /**
328
+ * Send the batch request and parse the multipart response.
329
+ */
330
+ async send(opts: RequestOptions = {}): Promise<BatchResult> {
331
+ const { boundary, body } = this._serialize()
332
+
333
+ const res = await executeRequest(
334
+ this._client._ctx,
335
+ `${this._client.baseUrl}/$batch`,
336
+ {
337
+ method: 'POST',
338
+ headers: {
339
+ 'Content-Type': `multipart/mixed; boundary=${boundary}`,
340
+ },
341
+ body,
342
+ accept: 'none',
343
+ ...(opts.signal ? { signal: opts.signal } : {}),
344
+ },
345
+ )
346
+
347
+ const responseText = await res.text()
348
+ return this._parseResponse(responseText, res.headers.get('content-type') ?? '')
349
+ }
350
+
351
+ /**
352
+ * Parse a multipart/mixed batch response.
353
+ * @internal
354
+ */
355
+ _parseResponse(responseText: string, contentType: string): BatchResult {
356
+ // Extract boundary from content-type header
357
+ const boundaryMatch = contentType.match(/boundary=([^;\s]+)/)
358
+ const boundary = boundaryMatch?.[1]
359
+ if (!boundary) {
360
+ throw new Error('Batch response missing boundary in Content-Type')
361
+ }
362
+
363
+ // Split by batch boundary
364
+ const parts = responseText.split(`--${boundary}`)
365
+ const results: BatchOpResult[] = []
366
+ let partIndex = 0
367
+
368
+ // First part is preamble (usually empty), last is epilogue
369
+ for (let i = 1; i < parts.length - 1; i++) {
370
+ const part = parts[i]!.trim()
371
+ if (!part || part === '--') continue
372
+
373
+ const batchPart = this._parts[partIndex]
374
+ if (!batchPart) continue
375
+
376
+ if (batchPart.type === 'read') {
377
+ const result = this._parseHttpPart(part)
378
+ results.push(result)
379
+ if (batchPart.handle) {
380
+ this._resolveHandle(batchPart.handle, result)
381
+ }
382
+ partIndex++
383
+ } else if (batchPart.type === 'changeset') {
384
+ const cs = batchPart.content as Changeset
385
+ const csResults = this._parseChangesetResponse(part, cs)
386
+ results.push(...csResults)
387
+ // Resolve or reject all changeset handles based on atomicity
388
+ const failed = csResults.find(r => !r.ok)
389
+ for (let j = 0; j < cs._handleSlots.length; j++) {
390
+ const slot = cs._handleSlots[j]!
391
+ const csResult = csResults[j]
392
+ if (failed) {
393
+ slot.reject(new Error(`Changeset failed: ${failed.status}`))
394
+ } else if (csResult) {
395
+ slot.resolve(csResult.body)
396
+ } else {
397
+ slot.resolve(undefined)
398
+ }
399
+ }
400
+ partIndex++
401
+ }
402
+ }
403
+
404
+ return {
405
+ responses: results,
406
+ ok: results.every(r => r.ok),
407
+ }
408
+ }
409
+
410
+ /** @internal — parse a single HTTP response part. */
411
+ private _parseHttpPart(part: string): BatchOpResult {
412
+ // Each MIME part has outer headers (e.g. Content-Type: application/http),
413
+ // then a blank line (\r\n\r\n), then the inner HTTP response.
414
+ // Inner HTTP response: status line + inner headers + blank line + body.
415
+
416
+ // Step 1: skip outer MIME headers to reach the inner HTTP response
417
+ const outerEnd = part.indexOf('\r\n\r\n')
418
+ const innerHttpText = outerEnd >= 0 ? part.slice(outerEnd + 4) : part
419
+
420
+ // Step 2: split inner HTTP response into head (status + headers) and body
421
+ const innerHeadEnd = innerHttpText.indexOf('\r\n\r\n')
422
+ const innerHead = innerHeadEnd >= 0 ? innerHttpText.slice(0, innerHeadEnd) : innerHttpText
423
+ const innerBody = innerHeadEnd >= 0 ? innerHttpText.slice(innerHeadEnd + 4).trim() : ''
424
+
425
+ // Step 3: extract status code from the HTTP status line (e.g. "HTTP/1.1 200 OK")
426
+ const statusMatch = innerHead.match(/^HTTP\/1\.\d (\d+)/)
427
+ const status = statusMatch ? parseInt(statusMatch[1]!, 10) : 0
428
+
429
+ // Step 4: parse body as JSON if the inner headers indicate application/json
430
+ let parsedBody: unknown = undefined
431
+ if (innerBody && innerHead.toLowerCase().includes('application/json')) {
432
+ try {
433
+ parsedBody = JSON.parse(innerBody)
434
+ } catch {
435
+ parsedBody = innerBody
436
+ }
437
+ } else if (innerBody) {
438
+ parsedBody = innerBody
439
+ }
440
+
441
+ return {
442
+ status,
443
+ body: parsedBody,
444
+ headers: new Headers(),
445
+ ok: status >= 200 && status < 300,
446
+ }
447
+ }
448
+
449
+ /** @internal — parse a changeset multipart response. */
450
+ private _parseChangesetResponse(part: string, cs: Changeset): BatchOpResult[] {
451
+ // Extract changeset boundary
452
+ const boundaryMatch = part.match(/boundary=([^\s]+)/)
453
+ const csBoundary = boundaryMatch?.[1]
454
+ if (!csBoundary) {
455
+ // Single response (error case)
456
+ return [this._parseHttpPart(part)]
457
+ }
458
+
459
+ const csParts = part.split(`--${csBoundary}`)
460
+ const results: BatchOpResult[] = []
461
+
462
+ // Parts 1 to length-2 are actual responses
463
+ for (let i = 1; i < csParts.length - 1; i++) {
464
+ const csPart = csParts[i]!.trim()
465
+ if (!csPart || csPart === '--') continue
466
+ results.push(this._parseHttpPart(csPart))
467
+ }
468
+
469
+ return results
470
+ }
471
+
472
+ /** @internal — resolve a batch handle with the result. */
473
+ private _resolveHandle(handle: BatchHandle<unknown>, result: BatchOpResult): void {
474
+ // The handle's promise is resolved via the Changeset or direct assignment
475
+ // This is handled in the caller (_parseResponse)
476
+ }
477
+ }