@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.
Files changed (61) hide show
  1. package/README.md +333 -3
  2. package/dist/esm/client/batch-builder.d.ts +54 -0
  3. package/dist/esm/client/batch-builder.js +179 -0
  4. package/dist/esm/client/batch-builder.js.map +1 -0
  5. package/dist/esm/client/batch-request.d.ts +61 -0
  6. package/dist/esm/client/batch-request.js +252 -0
  7. package/dist/esm/client/batch-request.js.map +1 -0
  8. package/dist/esm/client/database.d.ts +43 -11
  9. package/dist/esm/client/database.js +64 -10
  10. package/dist/esm/client/database.js.map +1 -1
  11. package/dist/esm/client/delete-builder.d.ts +21 -2
  12. package/dist/esm/client/delete-builder.js +76 -9
  13. package/dist/esm/client/delete-builder.js.map +1 -1
  14. package/dist/esm/client/entity-set.d.ts +15 -4
  15. package/dist/esm/client/entity-set.js +23 -7
  16. package/dist/esm/client/entity-set.js.map +1 -1
  17. package/dist/esm/client/filemaker-odata.d.ts +11 -5
  18. package/dist/esm/client/filemaker-odata.js +46 -14
  19. package/dist/esm/client/filemaker-odata.js.map +1 -1
  20. package/dist/esm/client/insert-builder.d.ts +38 -3
  21. package/dist/esm/client/insert-builder.js +195 -9
  22. package/dist/esm/client/insert-builder.js.map +1 -1
  23. package/dist/esm/client/query-builder.d.ts +19 -3
  24. package/dist/esm/client/query-builder.js +193 -17
  25. package/dist/esm/client/query-builder.js.map +1 -1
  26. package/dist/esm/client/record-builder.d.ts +17 -2
  27. package/dist/esm/client/record-builder.js +87 -5
  28. package/dist/esm/client/record-builder.js.map +1 -1
  29. package/dist/esm/client/response-processor.d.ts +38 -0
  30. package/dist/esm/client/schema-manager.d.ts +57 -0
  31. package/dist/esm/client/schema-manager.js +132 -0
  32. package/dist/esm/client/schema-manager.js.map +1 -0
  33. package/dist/esm/client/update-builder.d.ts +34 -11
  34. package/dist/esm/client/update-builder.js +119 -19
  35. package/dist/esm/client/update-builder.js.map +1 -1
  36. package/dist/esm/errors.d.ts +14 -1
  37. package/dist/esm/errors.js +26 -0
  38. package/dist/esm/errors.js.map +1 -1
  39. package/dist/esm/index.d.ts +3 -2
  40. package/dist/esm/index.js +3 -1
  41. package/dist/esm/transform.d.ts +9 -0
  42. package/dist/esm/transform.js +7 -0
  43. package/dist/esm/transform.js.map +1 -1
  44. package/dist/esm/types.d.ts +69 -1
  45. package/package.json +1 -1
  46. package/src/client/batch-builder.ts +265 -0
  47. package/src/client/batch-request.ts +485 -0
  48. package/src/client/database.ts +106 -52
  49. package/src/client/delete-builder.ts +116 -14
  50. package/src/client/entity-set.ts +80 -6
  51. package/src/client/filemaker-odata.ts +65 -19
  52. package/src/client/insert-builder.ts +296 -18
  53. package/src/client/query-builder.ts +278 -17
  54. package/src/client/record-builder.ts +119 -11
  55. package/src/client/response-processor.ts +103 -0
  56. package/src/client/schema-manager.ts +246 -0
  57. package/src/client/update-builder.ts +195 -37
  58. package/src/errors.ts +33 -1
  59. package/src/index.ts +13 -0
  60. package/src/transform.ts +19 -6
  61. 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
+ }
@@ -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
- // Type-level validation: Check if a TableOccurrence has fmtId (is TableOccurrenceWithIds)
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?: ValidOccurrenceMix<Occurrences> extends true
84
- ? Occurrences
85
- : Occurrences & {
86
- __type_error__: "❌ Cannot mix TableOccurrence with and without entity IDs. Either all occurrences must use TableOccurrenceWithIds (with fmtId and fmfIds) or all must be regular TableOccurrence.";
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
- // Check for mixed usage
118
- if (occurrencesWithIds.length > 0 && occurrencesWithoutIds.length > 0) {
119
- throw new Error(
120
- `Cannot mix TableOccurrence instances with and without entity IDs in the same database. ` +
121
- `Occurrences with entity IDs: [${occurrencesWithIds.join(", ")}]. ` +
122
- `Occurrences without entity IDs: [${occurrencesWithoutIds.join(", ")}]. ` +
123
- `Either all table occurrences must use entity IDs (fmtId + fmfIds) or none should.`,
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
- // Example method showing how to use the request method
185
- async getMetadata() {
186
- const result = await this.context._makeRequest(
187
- `/${this.databaseName}/$metadata`,
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
- return result.data;
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
  }