@proofkit/fmodata 0.1.0-alpha.6 → 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.
- package/README.md +333 -3
- package/dist/esm/client/batch-builder.d.ts +54 -0
- package/dist/esm/client/batch-builder.js +179 -0
- package/dist/esm/client/batch-builder.js.map +1 -0
- package/dist/esm/client/batch-request.d.ts +61 -0
- package/dist/esm/client/batch-request.js +252 -0
- package/dist/esm/client/batch-request.js.map +1 -0
- package/dist/esm/client/database.d.ts +43 -11
- package/dist/esm/client/database.js +64 -10
- package/dist/esm/client/database.js.map +1 -1
- package/dist/esm/client/delete-builder.d.ts +21 -2
- package/dist/esm/client/delete-builder.js +76 -9
- package/dist/esm/client/delete-builder.js.map +1 -1
- package/dist/esm/client/entity-set.d.ts +15 -4
- package/dist/esm/client/entity-set.js +23 -7
- package/dist/esm/client/entity-set.js.map +1 -1
- package/dist/esm/client/filemaker-odata.d.ts +11 -5
- package/dist/esm/client/filemaker-odata.js +46 -14
- package/dist/esm/client/filemaker-odata.js.map +1 -1
- package/dist/esm/client/insert-builder.d.ts +38 -3
- package/dist/esm/client/insert-builder.js +195 -9
- package/dist/esm/client/insert-builder.js.map +1 -1
- package/dist/esm/client/query-builder.d.ts +19 -3
- package/dist/esm/client/query-builder.js +193 -17
- package/dist/esm/client/query-builder.js.map +1 -1
- package/dist/esm/client/record-builder.d.ts +17 -2
- package/dist/esm/client/record-builder.js +87 -5
- package/dist/esm/client/record-builder.js.map +1 -1
- package/dist/esm/client/response-processor.d.ts +38 -0
- package/dist/esm/client/schema-manager.d.ts +57 -0
- package/dist/esm/client/schema-manager.js +132 -0
- package/dist/esm/client/schema-manager.js.map +1 -0
- package/dist/esm/client/update-builder.d.ts +34 -11
- package/dist/esm/client/update-builder.js +119 -19
- package/dist/esm/client/update-builder.js.map +1 -1
- package/dist/esm/errors.d.ts +14 -1
- package/dist/esm/errors.js +26 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.js +3 -1
- package/dist/esm/transform.d.ts +9 -0
- package/dist/esm/transform.js +7 -0
- package/dist/esm/transform.js.map +1 -1
- package/dist/esm/types.d.ts +69 -1
- package/package.json +1 -1
- package/src/client/batch-builder.ts +265 -0
- package/src/client/batch-request.ts +485 -0
- package/src/client/database.ts +106 -52
- package/src/client/delete-builder.ts +116 -14
- package/src/client/entity-set.ts +80 -6
- package/src/client/filemaker-odata.ts +65 -19
- package/src/client/insert-builder.ts +296 -18
- package/src/client/query-builder.ts +278 -17
- package/src/client/record-builder.ts +119 -11
- package/src/client/response-processor.ts +103 -0
- package/src/client/schema-manager.ts +246 -0
- package/src/client/update-builder.ts +195 -37
- package/src/errors.ts +33 -1
- package/src/index.ts +13 -0
- package/src/transform.ts +19 -6
- package/src/types.ts +89 -1
|
@@ -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
|
+
}
|
package/src/client/database.ts
CHANGED
|
@@ -1,39 +1,10 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
-
import type { ExecutionContext } from "../types";
|
|
2
|
+
import type { ExecutionContext, ExecutableBuilder, Metadata } from "../types";
|
|
3
3
|
import type { BaseTable } from "./base-table";
|
|
4
4
|
import type { TableOccurrence } from "./table-occurrence";
|
|
5
5
|
import { EntitySet } from "./entity-set";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
type HasFmtId<T> = T extends { fmtId: string } ? true : false;
|
|
9
|
-
|
|
10
|
-
// Check if all occurrences in a tuple have fmtId
|
|
11
|
-
type AllHaveFmtId<Occurrences extends readonly any[]> =
|
|
12
|
-
Occurrences extends readonly [infer First, ...infer Rest]
|
|
13
|
-
? HasFmtId<First> extends true
|
|
14
|
-
? Rest extends readonly []
|
|
15
|
-
? true
|
|
16
|
-
: AllHaveFmtId<Rest>
|
|
17
|
-
: false
|
|
18
|
-
: true; // empty array is valid
|
|
19
|
-
|
|
20
|
-
// Check if none have fmtId
|
|
21
|
-
type NoneHaveFmtId<Occurrences extends readonly any[]> =
|
|
22
|
-
Occurrences extends readonly [infer First, ...infer Rest]
|
|
23
|
-
? HasFmtId<First> extends false
|
|
24
|
-
? Rest extends readonly []
|
|
25
|
-
? true
|
|
26
|
-
: NoneHaveFmtId<Rest>
|
|
27
|
-
: false
|
|
28
|
-
: true; // empty array is valid
|
|
29
|
-
|
|
30
|
-
// Valid if all have fmtId or none have fmtId (no mixing allowed)
|
|
31
|
-
export type ValidOccurrenceMix<Occurrences extends readonly any[]> =
|
|
32
|
-
AllHaveFmtId<Occurrences> extends true
|
|
33
|
-
? true
|
|
34
|
-
: NoneHaveFmtId<Occurrences> extends true
|
|
35
|
-
? true
|
|
36
|
-
: false;
|
|
6
|
+
import { BatchBuilder } from "./batch-builder";
|
|
7
|
+
import { SchemaManager } from "./schema-manager";
|
|
37
8
|
|
|
38
9
|
// Helper type to extract schema from a TableOccurrence
|
|
39
10
|
type ExtractSchemaFromOccurrence<O> =
|
|
@@ -75,16 +46,19 @@ export class Database<
|
|
|
75
46
|
> {
|
|
76
47
|
private occurrenceMap: Map<string, TableOccurrence<any, any, any, any>>;
|
|
77
48
|
private _useEntityIds: boolean = false;
|
|
49
|
+
public readonly schema: SchemaManager;
|
|
78
50
|
|
|
79
51
|
constructor(
|
|
80
52
|
private readonly databaseName: string,
|
|
81
53
|
private readonly context: ExecutionContext,
|
|
82
54
|
config?: {
|
|
83
|
-
occurrences?:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
55
|
+
occurrences?: Occurrences | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Whether to use entity IDs instead of field names in the actual requests to the server
|
|
58
|
+
* Defaults to true if all occurrences use entity IDs, false otherwise
|
|
59
|
+
* If set to false but some occurrences do not use entity IDs, an error will be thrown
|
|
60
|
+
*/
|
|
61
|
+
useEntityIds?: boolean;
|
|
88
62
|
},
|
|
89
63
|
) {
|
|
90
64
|
this.occurrenceMap = new Map();
|
|
@@ -102,7 +76,6 @@ export class Database<
|
|
|
102
76
|
// An occurrence uses entity IDs if it has both fmtId and fmfIds
|
|
103
77
|
if (hasTableId && hasFieldIds) {
|
|
104
78
|
occurrencesWithIds.push(occ.name);
|
|
105
|
-
this._useEntityIds = true;
|
|
106
79
|
} else if (!hasTableId && !hasFieldIds) {
|
|
107
80
|
occurrencesWithoutIds.push(occ.name);
|
|
108
81
|
} else {
|
|
@@ -114,21 +87,53 @@ export class Database<
|
|
|
114
87
|
}
|
|
115
88
|
}
|
|
116
89
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
90
|
+
// Determine default value: true if all occurrences use entity IDs, false otherwise
|
|
91
|
+
const allOccurrencesUseEntityIds =
|
|
92
|
+
occurrencesWithIds.length > 0 && occurrencesWithoutIds.length === 0;
|
|
93
|
+
const hasMixedUsage =
|
|
94
|
+
occurrencesWithIds.length > 0 && occurrencesWithoutIds.length > 0;
|
|
95
|
+
|
|
96
|
+
// Handle explicit useEntityIds config
|
|
97
|
+
if (config.useEntityIds !== undefined) {
|
|
98
|
+
if (config.useEntityIds === false) {
|
|
99
|
+
// If explicitly set to false, allow mixed usage and use false
|
|
100
|
+
this._useEntityIds = false;
|
|
101
|
+
} else if (config.useEntityIds === true) {
|
|
102
|
+
// If explicitly set to true, validate that all occurrences use entity IDs
|
|
103
|
+
if (hasMixedUsage || occurrencesWithoutIds.length > 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`useEntityIds is set to true but some occurrences do not use entity IDs. ` +
|
|
106
|
+
`Occurrences without entity IDs: [${occurrencesWithoutIds.join(", ")}]. ` +
|
|
107
|
+
`Either set useEntityIds to false or configure all occurrences with entity IDs.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
this._useEntityIds = true;
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
// Default: true if all occurrences use entity IDs, false otherwise
|
|
114
|
+
// But throw error if there's mixed usage when using defaults
|
|
115
|
+
if (hasMixedUsage) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Cannot mix TableOccurrence instances with and without entity IDs in the same database. ` +
|
|
118
|
+
`Occurrences with entity IDs: [${occurrencesWithIds.join(", ")}]. ` +
|
|
119
|
+
`Occurrences without entity IDs: [${occurrencesWithoutIds.join(", ")}]. ` +
|
|
120
|
+
`Either all table occurrences must use entity IDs (fmtId + fmfIds), none should, or explicitly set useEntityIds to false.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
this._useEntityIds = allOccurrencesUseEntityIds;
|
|
125
124
|
}
|
|
125
|
+
} else {
|
|
126
|
+
// No occurrences provided, use explicit config or default to false
|
|
127
|
+
this._useEntityIds = config?.useEntityIds ?? false;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
// Inform the execution context whether to use entity IDs
|
|
129
131
|
if (this.context._setUseEntityIds) {
|
|
130
132
|
this.context._setUseEntityIds(this._useEntityIds);
|
|
131
133
|
}
|
|
134
|
+
|
|
135
|
+
// Initialize schema manager
|
|
136
|
+
this.schema = new SchemaManager(this.databaseName, this.context);
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
/**
|
|
@@ -181,15 +186,39 @@ export class Database<
|
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Retrieves the OData metadata for this database.
|
|
191
|
+
* @param args Optional configuration object
|
|
192
|
+
* @param args.format The format to retrieve metadata in. Defaults to "json".
|
|
193
|
+
* @returns The metadata in the specified format
|
|
194
|
+
*/
|
|
195
|
+
async getMetadata(args: { format: "xml" }): Promise<string>;
|
|
196
|
+
async getMetadata(args?: { format?: "json" }): Promise<Metadata>;
|
|
197
|
+
async getMetadata(args?: {
|
|
198
|
+
format?: "xml" | "json";
|
|
199
|
+
}): Promise<string | Metadata> {
|
|
200
|
+
const result = await this.context._makeRequest<
|
|
201
|
+
Record<string, Metadata> | string
|
|
202
|
+
>(`/${this.databaseName}/$metadata`, {
|
|
203
|
+
headers: {
|
|
204
|
+
Accept: args?.format === "xml" ? "application/xml" : "application/json",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
189
207
|
if (result.error) {
|
|
190
208
|
throw result.error;
|
|
191
209
|
}
|
|
192
|
-
|
|
210
|
+
|
|
211
|
+
if (args?.format === "json") {
|
|
212
|
+
const data = result.data as Record<string, Metadata>;
|
|
213
|
+
const metadata = data[this.databaseName];
|
|
214
|
+
if (!metadata) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Metadata for database "${this.databaseName}" not found in response`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return metadata;
|
|
220
|
+
}
|
|
221
|
+
return result.data as string;
|
|
193
222
|
}
|
|
194
223
|
|
|
195
224
|
/**
|
|
@@ -277,4 +306,29 @@ export class Database<
|
|
|
277
306
|
result: response.scriptResult.resultParameter,
|
|
278
307
|
} as any;
|
|
279
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Create a batch operation builder that allows multiple queries to be executed together
|
|
312
|
+
* in a single atomic request. All operations succeed or fail together (transactional).
|
|
313
|
+
*
|
|
314
|
+
* @param builders - Array of executable query builders to batch
|
|
315
|
+
* @returns A BatchBuilder that can be executed
|
|
316
|
+
* @example
|
|
317
|
+
* ```ts
|
|
318
|
+
* const result = await db.batch([
|
|
319
|
+
* db.from('contacts').list().top(5),
|
|
320
|
+
* db.from('users').list().top(5),
|
|
321
|
+
* db.from('contacts').insert({ name: 'John' })
|
|
322
|
+
* ]).execute();
|
|
323
|
+
*
|
|
324
|
+
* if (result.data) {
|
|
325
|
+
* const [contacts, users, insertResult] = result.data;
|
|
326
|
+
* }
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
batch<const Builders extends readonly ExecutableBuilder<any>[]>(
|
|
330
|
+
builders: Builders,
|
|
331
|
+
): BatchBuilder<Builders> {
|
|
332
|
+
return new BatchBuilder(builders, this.databaseName, this.context);
|
|
333
|
+
}
|
|
280
334
|
}
|