@proofkit/fmodata 0.1.0-alpha.4 → 0.1.0-alpha.7

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.
Files changed (74) hide show
  1. package/README.md +690 -31
  2. package/dist/esm/client/base-table.d.ts +122 -5
  3. package/dist/esm/client/base-table.js +46 -5
  4. package/dist/esm/client/base-table.js.map +1 -1
  5. package/dist/esm/client/batch-builder.d.ts +54 -0
  6. package/dist/esm/client/batch-builder.js +179 -0
  7. package/dist/esm/client/batch-builder.js.map +1 -0
  8. package/dist/esm/client/batch-request.d.ts +61 -0
  9. package/dist/esm/client/batch-request.js +252 -0
  10. package/dist/esm/client/batch-request.js.map +1 -0
  11. package/dist/esm/client/database.d.ts +54 -5
  12. package/dist/esm/client/database.js +118 -15
  13. package/dist/esm/client/database.js.map +1 -1
  14. package/dist/esm/client/delete-builder.d.ts +21 -2
  15. package/dist/esm/client/delete-builder.js +96 -32
  16. package/dist/esm/client/delete-builder.js.map +1 -1
  17. package/dist/esm/client/entity-set.d.ts +22 -8
  18. package/dist/esm/client/entity-set.js +28 -8
  19. package/dist/esm/client/entity-set.js.map +1 -1
  20. package/dist/esm/client/filemaker-odata.d.ts +22 -3
  21. package/dist/esm/client/filemaker-odata.js +122 -27
  22. package/dist/esm/client/filemaker-odata.js.map +1 -1
  23. package/dist/esm/client/insert-builder.d.ts +38 -3
  24. package/dist/esm/client/insert-builder.js +231 -34
  25. package/dist/esm/client/insert-builder.js.map +1 -1
  26. package/dist/esm/client/query-builder.d.ts +26 -5
  27. package/dist/esm/client/query-builder.js +455 -208
  28. package/dist/esm/client/query-builder.js.map +1 -1
  29. package/dist/esm/client/record-builder.d.ts +19 -4
  30. package/dist/esm/client/record-builder.js +132 -40
  31. package/dist/esm/client/record-builder.js.map +1 -1
  32. package/dist/esm/client/response-processor.d.ts +38 -0
  33. package/dist/esm/client/schema-manager.d.ts +57 -0
  34. package/dist/esm/client/schema-manager.js +132 -0
  35. package/dist/esm/client/schema-manager.js.map +1 -0
  36. package/dist/esm/client/table-occurrence.d.ts +66 -2
  37. package/dist/esm/client/table-occurrence.js +36 -1
  38. package/dist/esm/client/table-occurrence.js.map +1 -1
  39. package/dist/esm/client/update-builder.d.ts +34 -11
  40. package/dist/esm/client/update-builder.js +135 -31
  41. package/dist/esm/client/update-builder.js.map +1 -1
  42. package/dist/esm/errors.d.ts +73 -0
  43. package/dist/esm/errors.js +148 -0
  44. package/dist/esm/errors.js.map +1 -0
  45. package/dist/esm/index.d.ts +7 -3
  46. package/dist/esm/index.js +27 -3
  47. package/dist/esm/index.js.map +1 -1
  48. package/dist/esm/transform.d.ts +65 -0
  49. package/dist/esm/transform.js +114 -0
  50. package/dist/esm/transform.js.map +1 -0
  51. package/dist/esm/types.d.ts +89 -5
  52. package/dist/esm/validation.d.ts +6 -3
  53. package/dist/esm/validation.js +104 -33
  54. package/dist/esm/validation.js.map +1 -1
  55. package/package.json +10 -1
  56. package/src/client/base-table.ts +155 -8
  57. package/src/client/batch-builder.ts +265 -0
  58. package/src/client/batch-request.ts +485 -0
  59. package/src/client/database.ts +173 -16
  60. package/src/client/delete-builder.ts +149 -48
  61. package/src/client/entity-set.ts +99 -15
  62. package/src/client/filemaker-odata.ts +178 -34
  63. package/src/client/insert-builder.ts +350 -40
  64. package/src/client/query-builder.ts +609 -236
  65. package/src/client/record-builder.ts +186 -53
  66. package/src/client/response-processor.ts +103 -0
  67. package/src/client/schema-manager.ts +246 -0
  68. package/src/client/table-occurrence.ts +118 -4
  69. package/src/client/update-builder.ts +235 -49
  70. package/src/errors.ts +217 -0
  71. package/src/index.ts +43 -1
  72. package/src/transform.ts +249 -0
  73. package/src/types.ts +201 -35
  74. package/src/validation.ts +120 -36
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Batch Request Utilities
3
+ *
4
+ * Utilities for formatting and parsing OData batch requests using multipart/mixed format.
5
+ * OData batch requests allow bundling multiple operations into a single HTTP request,
6
+ * with support for transactional changesets.
7
+ */
8
+
9
+ export interface RequestConfig {
10
+ method: string;
11
+ url: string;
12
+ body?: string;
13
+ headers?: Record<string, string>;
14
+ }
15
+
16
+ export interface ParsedBatchResponse {
17
+ status: number;
18
+ statusText: string;
19
+ headers: Record<string, string>;
20
+ body: any;
21
+ }
22
+
23
+ /**
24
+ * Generates a random boundary string for multipart requests
25
+ * @param prefix - Prefix for the boundary (e.g., "batch_" or "changeset_")
26
+ * @returns A boundary string with the prefix and 32 random hex characters
27
+ */
28
+ export function generateBoundary(prefix: string = "batch_"): string {
29
+ const randomHex = Array.from({ length: 32 }, () =>
30
+ Math.floor(Math.random() * 16).toString(16),
31
+ ).join("");
32
+ return `${prefix}${randomHex}`;
33
+ }
34
+
35
+ /**
36
+ * Converts a native Request object to RequestConfig
37
+ * @param request - Native Request object
38
+ * @returns RequestConfig object
39
+ */
40
+ async function requestToConfig(request: Request): Promise<RequestConfig> {
41
+ const headers: Record<string, string> = {};
42
+ request.headers.forEach((value, key) => {
43
+ headers[key] = value;
44
+ });
45
+
46
+ let body: string | undefined;
47
+ if (request.body) {
48
+ // Clone the request to read the body without consuming it
49
+ const clonedRequest = request.clone();
50
+ body = await clonedRequest.text();
51
+ }
52
+
53
+ return {
54
+ method: request.method,
55
+ url: request.url,
56
+ body,
57
+ headers,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Formats a single HTTP request for inclusion in a batch
63
+ * @param request - The request configuration
64
+ * @param baseUrl - The base URL to prepend to relative URLs
65
+ * @returns Formatted request string with CRLF line endings
66
+ *
67
+ * Formatting rules for FileMaker OData:
68
+ * - GET (no body): request line → blank → blank
69
+ * - POST/PATCH (with body): request line → headers → blank → body (NO blank after!)
70
+ */
71
+ function formatSubRequest(request: RequestConfig, baseUrl: string): string {
72
+ const lines: string[] = [];
73
+
74
+ // Add required headers for sub-request
75
+ lines.push("Content-Type: application/http");
76
+ lines.push("Content-Transfer-Encoding: binary");
77
+ lines.push(""); // Empty line after multipart headers
78
+
79
+ // Construct full URL (convert relative to absolute)
80
+ const fullUrl = request.url.startsWith("http")
81
+ ? request.url
82
+ : `${baseUrl}${request.url}`;
83
+
84
+ // Add HTTP request line
85
+ lines.push(`${request.method} ${fullUrl} HTTP/1.1`);
86
+
87
+ // For requests with body, add headers
88
+ if (request.body) {
89
+ // Add request headers (excluding Authorization - it's in the outer request)
90
+ if (request.headers) {
91
+ for (const [key, value] of Object.entries(request.headers)) {
92
+ if (key.toLowerCase() !== "authorization") {
93
+ lines.push(`${key}: ${value}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ // Check if Content-Type is already set
99
+ const hasContentType =
100
+ request.headers &&
101
+ Object.keys(request.headers).some(
102
+ (k) => k.toLowerCase() === "content-type",
103
+ );
104
+
105
+ if (!hasContentType) {
106
+ lines.push("Content-Type: application/json");
107
+ }
108
+
109
+ // Add Content-Length (required for FileMaker to read the body)
110
+ const hasContentLength =
111
+ request.headers &&
112
+ Object.keys(request.headers).some(
113
+ (k) => k.toLowerCase() === "content-length",
114
+ );
115
+
116
+ if (!hasContentLength) {
117
+ lines.push(`Content-Length: ${request.body.length}`);
118
+ }
119
+
120
+ lines.push(""); // Empty line between headers and body
121
+ lines.push(request.body);
122
+ // NO blank line after body - the boundary comes immediately
123
+ } else {
124
+ // For GET requests (no body), add TWO blank lines
125
+ lines.push(""); // First blank
126
+ lines.push(""); // Second blank
127
+ }
128
+
129
+ return lines.join("\r\n");
130
+ }
131
+
132
+ /**
133
+ * Formats a changeset containing multiple non-GET operations
134
+ * @param requests - Array of request configurations (should be non-GET)
135
+ * @param baseUrl - The base URL to prepend to relative URLs
136
+ * @param changesetBoundary - Boundary string for the changeset
137
+ * @returns Formatted changeset string with CRLF line endings
138
+ */
139
+ function formatChangeset(
140
+ requests: RequestConfig[],
141
+ baseUrl: string,
142
+ changesetBoundary: string,
143
+ ): string {
144
+ const lines: string[] = [];
145
+
146
+ lines.push(`Content-Type: multipart/mixed; boundary=${changesetBoundary}`);
147
+ lines.push(""); // Empty line after headers
148
+
149
+ // Add each request in the changeset
150
+ for (const request of requests) {
151
+ lines.push(`--${changesetBoundary}`);
152
+ lines.push(formatSubRequest(request, baseUrl));
153
+ }
154
+
155
+ // Close the changeset
156
+ lines.push(`--${changesetBoundary}--`);
157
+
158
+ return lines.join("\r\n");
159
+ }
160
+
161
+ /**
162
+ * Formats multiple requests into a batch request body
163
+ * @param requests - Array of request configurations
164
+ * @param baseUrl - The base URL to prepend to relative URLs
165
+ * @param batchBoundary - Optional boundary string for the batch (generated if not provided)
166
+ * @returns Object containing the formatted body and boundary
167
+ */
168
+ export function formatBatchRequest(
169
+ requests: RequestConfig[],
170
+ baseUrl: string,
171
+ batchBoundary?: string,
172
+ ): { body: string; boundary: string } {
173
+ const boundary = batchBoundary || generateBoundary("batch_");
174
+ const lines: string[] = [];
175
+
176
+ // Group requests: consecutive non-GET operations go into changesets
177
+ let currentChangeset: RequestConfig[] | null = null;
178
+
179
+ for (const request of requests) {
180
+ if (request.method === "GET") {
181
+ // GET operations break changesets and are added individually
182
+ if (currentChangeset) {
183
+ // Close and add the current changeset
184
+ const changesetBoundary = generateBoundary("changeset_");
185
+ lines.push(`--${boundary}`);
186
+ lines.push(
187
+ formatChangeset(currentChangeset, baseUrl, changesetBoundary),
188
+ );
189
+ currentChangeset = null;
190
+ }
191
+
192
+ // Add GET request
193
+ lines.push(`--${boundary}`);
194
+ lines.push(formatSubRequest(request, baseUrl));
195
+ } else {
196
+ // Non-GET operations: add to current changeset or create new one
197
+ if (!currentChangeset) {
198
+ currentChangeset = [];
199
+ }
200
+ currentChangeset.push(request);
201
+ }
202
+ }
203
+
204
+ // Add any remaining changeset
205
+ if (currentChangeset) {
206
+ const changesetBoundary = generateBoundary("changeset_");
207
+ lines.push(`--${boundary}`);
208
+ lines.push(formatChangeset(currentChangeset, baseUrl, changesetBoundary));
209
+ }
210
+
211
+ // Close the batch
212
+ lines.push(`--${boundary}--`);
213
+
214
+ return {
215
+ body: lines.join("\r\n"),
216
+ boundary,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Formats multiple Request objects into a batch request body
222
+ * Supports explicit changesets via Request arrays
223
+ * @param requests - Array of Request objects or Request arrays (for explicit changesets)
224
+ * @param baseUrl - The base URL to prepend to relative URLs
225
+ * @param batchBoundary - Optional boundary string for the batch (generated if not provided)
226
+ * @returns Promise resolving to object containing the formatted body and boundary
227
+ */
228
+ export async function formatBatchRequestFromNative(
229
+ requests: Array<Request | Request[]>,
230
+ baseUrl: string,
231
+ batchBoundary?: string,
232
+ ): Promise<{ body: string; boundary: string }> {
233
+ const boundary = batchBoundary || generateBoundary("batch_");
234
+ const lines: string[] = [];
235
+
236
+ for (const item of requests) {
237
+ if (Array.isArray(item)) {
238
+ // Explicit changeset - array of Requests
239
+ const changesetBoundary = generateBoundary("changeset_");
240
+ const changesetConfigs: RequestConfig[] = [];
241
+
242
+ for (const request of item) {
243
+ changesetConfigs.push(await requestToConfig(request));
244
+ }
245
+
246
+ lines.push(`--${boundary}`);
247
+ lines.push(formatChangeset(changesetConfigs, baseUrl, changesetBoundary));
248
+ } else {
249
+ // Single request
250
+ const config = await requestToConfig(item);
251
+
252
+ if (config.method === "GET") {
253
+ // GET requests are always individual
254
+ lines.push(`--${boundary}`);
255
+ lines.push(formatSubRequest(config, baseUrl));
256
+ } else {
257
+ // Non-GET operations wrapped in a changeset
258
+ const changesetBoundary = generateBoundary("changeset_");
259
+ lines.push(`--${boundary}`);
260
+ lines.push(formatChangeset([config], baseUrl, changesetBoundary));
261
+ }
262
+ }
263
+ }
264
+
265
+ // Close the batch
266
+ lines.push(`--${boundary}--`);
267
+
268
+ return {
269
+ body: lines.join("\r\n"),
270
+ boundary,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Extracts the boundary from a Content-Type header
276
+ * @param contentType - The Content-Type header value
277
+ * @returns The boundary string, or null if not found
278
+ */
279
+ export function extractBoundary(contentType: string): string | null {
280
+ const match = contentType.match(/boundary=([^;]+)/);
281
+ return match && match[1] ? match[1].trim() : null;
282
+ }
283
+
284
+ /**
285
+ * Parses an HTTP response line (status line)
286
+ * @param line - The HTTP status line (e.g., "HTTP/1.1 200 OK")
287
+ * @returns Object containing status code and status text
288
+ */
289
+ function parseStatusLine(line: string): {
290
+ status: number;
291
+ statusText: string;
292
+ } {
293
+ const match = line.match(/HTTP\/\d\.\d\s+(\d+)\s*(.*)/);
294
+ if (!match || !match[1]) {
295
+ return { status: 0, statusText: "" };
296
+ }
297
+ return {
298
+ status: parseInt(match[1], 10),
299
+ statusText: match[2]?.trim() || "",
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Parses headers from an array of header lines
305
+ * @param lines - Array of header lines
306
+ * @returns Object containing parsed headers
307
+ */
308
+ function parseHeaders(lines: string[]): Record<string, string> {
309
+ const headers: Record<string, string> = {};
310
+ for (const line of lines) {
311
+ const colonIndex = line.indexOf(":");
312
+ if (colonIndex > 0) {
313
+ const key = line.substring(0, colonIndex).trim();
314
+ const value = line.substring(colonIndex + 1).trim();
315
+ headers[key.toLowerCase()] = value;
316
+ }
317
+ }
318
+ return headers;
319
+ }
320
+
321
+ /**
322
+ * Parses a single HTTP response from a batch part
323
+ * @param part - The raw HTTP response string
324
+ * @returns Parsed response object
325
+ */
326
+ function parseHttpResponse(part: string): ParsedBatchResponse {
327
+ const lines = part.split(/\r\n/);
328
+
329
+ // Find the HTTP status line (skip multipart headers)
330
+ let statusLineIndex = -1;
331
+ for (let i = 0; i < lines.length; i++) {
332
+ const line = lines[i];
333
+ if (line && line.startsWith("HTTP/")) {
334
+ statusLineIndex = i;
335
+ break;
336
+ }
337
+ }
338
+
339
+ if (statusLineIndex === -1) {
340
+ return {
341
+ status: 0,
342
+ statusText: "Invalid response",
343
+ headers: {},
344
+ body: null,
345
+ };
346
+ }
347
+
348
+ const statusLine = lines[statusLineIndex];
349
+ if (!statusLine) {
350
+ return {
351
+ status: 0,
352
+ statusText: "Invalid response",
353
+ headers: {},
354
+ body: null,
355
+ };
356
+ }
357
+
358
+ const { status, statusText } = parseStatusLine(statusLine);
359
+
360
+ // Parse headers (between status line and empty line)
361
+ const headerLines: string[] = [];
362
+ let bodyStartIndex = lines.length; // Default to end of lines (no body)
363
+ let foundEmptyLine = false;
364
+
365
+ for (let i = statusLineIndex + 1; i < lines.length; i++) {
366
+ const line = lines[i];
367
+ if (line === "") {
368
+ bodyStartIndex = i + 1;
369
+ foundEmptyLine = true;
370
+ break;
371
+ }
372
+ // Stop at boundary markers (for responses without bodies like 204)
373
+ if (line && line.startsWith("--")) {
374
+ break;
375
+ }
376
+ if (line) {
377
+ headerLines.push(line);
378
+ }
379
+ }
380
+
381
+ const headers = parseHeaders(headerLines);
382
+
383
+ // Parse body (everything after the empty line, if there was one)
384
+ let bodyText = "";
385
+ if (foundEmptyLine && bodyStartIndex < lines.length) {
386
+ const bodyLines = lines.slice(bodyStartIndex);
387
+ // Stop at boundary markers
388
+ const bodyLinesFiltered: string[] = [];
389
+ for (const line of bodyLines) {
390
+ if (line.startsWith("--")) {
391
+ break;
392
+ }
393
+ bodyLinesFiltered.push(line);
394
+ }
395
+ bodyText = bodyLinesFiltered.join("\r\n").trim();
396
+ }
397
+
398
+ let body: any = null;
399
+ if (bodyText) {
400
+ try {
401
+ body = JSON.parse(bodyText);
402
+ } catch {
403
+ // If not JSON, return as text
404
+ body = bodyText;
405
+ }
406
+ }
407
+
408
+ return {
409
+ status,
410
+ statusText,
411
+ headers,
412
+ body,
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Parses a batch response into individual responses
418
+ * @param responseText - The raw batch response text
419
+ * @param contentType - The Content-Type header from the response
420
+ * @returns Array of parsed responses in the same order as the request
421
+ */
422
+ export function parseBatchResponse(
423
+ responseText: string,
424
+ contentType: string,
425
+ ): ParsedBatchResponse[] {
426
+ const boundary = extractBoundary(contentType);
427
+ if (!boundary) {
428
+ throw new Error("Could not extract boundary from Content-Type header");
429
+ }
430
+
431
+ const results: ParsedBatchResponse[] = [];
432
+
433
+ // Split by boundary (handle both --boundary and --boundary--)
434
+ const boundaryPattern = `--${boundary}`;
435
+ const parts = responseText.split(boundaryPattern);
436
+
437
+ for (const part of parts) {
438
+ const trimmedPart = part.trim();
439
+
440
+ // Skip empty parts and the closing boundary marker
441
+ if (!trimmedPart || trimmedPart === "--") {
442
+ continue;
443
+ }
444
+
445
+ // Check if this part is a changeset (nested multipart)
446
+ if (trimmedPart.includes("Content-Type: multipart/mixed")) {
447
+ // Extract the changeset boundary
448
+ const changesetContentTypeMatch = trimmedPart.match(
449
+ /Content-Type: multipart\/mixed;\s*boundary=([^\r\n]+)/,
450
+ );
451
+ if (changesetContentTypeMatch) {
452
+ const changesetBoundary = changesetContentTypeMatch?.[1]?.trim();
453
+ const changesetPattern = `--${changesetBoundary}`;
454
+ const changesetParts = trimmedPart.split(changesetPattern);
455
+
456
+ for (const changesetPart of changesetParts) {
457
+ const trimmedChangesetPart = changesetPart.trim();
458
+ if (!trimmedChangesetPart || trimmedChangesetPart === "--") {
459
+ continue;
460
+ }
461
+
462
+ // Skip the changeset header
463
+ if (
464
+ trimmedChangesetPart.startsWith("Content-Type: multipart/mixed")
465
+ ) {
466
+ continue;
467
+ }
468
+
469
+ const response = parseHttpResponse(trimmedChangesetPart);
470
+ if (response.status > 0) {
471
+ results.push(response);
472
+ }
473
+ }
474
+ }
475
+ } else {
476
+ // Regular response (not a changeset)
477
+ const response = parseHttpResponse(trimmedPart);
478
+ if (response.status > 0) {
479
+ results.push(response);
480
+ }
481
+ }
482
+ }
483
+
484
+ return results;
485
+ }