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.
@@ -0,0 +1,2201 @@
1
+ // src/errors.ts
2
+ var FMSODataError = class extends Error {
3
+ constructor(message, init) {
4
+ super(message);
5
+ this.name = "FMSODataError";
6
+ this.status = init.status;
7
+ if (init.code !== void 0) this.code = init.code;
8
+ if (init.odataError !== void 0) this.odataError = init.odataError;
9
+ if (init.request !== void 0) this.request = init.request;
10
+ }
11
+ };
12
+ async function parseErrorResponse(res, request) {
13
+ const status = res.status;
14
+ let body = "";
15
+ try {
16
+ body = await res.text();
17
+ } catch {
18
+ }
19
+ let code;
20
+ let message = res.statusText || `HTTP ${status}`;
21
+ let odataError = body;
22
+ const ctype = (res.headers.get("content-type") ?? "").toLowerCase();
23
+ const looksJson = ctype.includes("json") || body.startsWith("{") && body.endsWith("}");
24
+ const looksXml = ctype.includes("xml") || body.trimStart().startsWith("<?xml") || body.includes("<m:error");
25
+ if (looksJson) {
26
+ try {
27
+ const json = JSON.parse(body);
28
+ odataError = json;
29
+ const errCode = json?.error?.code;
30
+ const rawMsg = json?.error?.message;
31
+ const msg = typeof rawMsg === "string" ? rawMsg : rawMsg?.value;
32
+ if (errCode) code = String(errCode);
33
+ if (msg) message = msg;
34
+ } catch {
35
+ }
36
+ } else if (looksXml) {
37
+ const codeMatch = body.match(/<m:code>([^<]+)<\/m:code>/);
38
+ const msgMatch = body.match(/<m:message(?:\s[^>]*)?>([^<]+)<\/m:message>/);
39
+ if (codeMatch?.[1]) code = codeMatch[1];
40
+ if (msgMatch?.[1]) message = msgMatch[1];
41
+ }
42
+ return new FMSODataError(message, { status, ...code !== void 0 ? { code } : {}, odataError, request });
43
+ }
44
+ var FMScriptError = class extends FMSODataError {
45
+ constructor(message, init) {
46
+ super(message, {
47
+ status: init.status,
48
+ code: init.scriptError,
49
+ ...init.odataError !== void 0 ? { odataError: init.odataError } : {},
50
+ ...init.request !== void 0 ? { request: init.request } : {}
51
+ });
52
+ this.name = "FMScriptError";
53
+ this.scriptError = init.scriptError;
54
+ if (init.scriptResult !== void 0) this.scriptResult = init.scriptResult;
55
+ }
56
+ };
57
+ function isFMSODataError(err) {
58
+ return err instanceof FMSODataError;
59
+ }
60
+ function isFMScriptError(err) {
61
+ return err instanceof FMScriptError;
62
+ }
63
+
64
+ // src/http.ts
65
+ var AUTH_SCHEME_RE = /^(basic|bearer|fmid|negotiate|digest)\s+\S/i;
66
+ async function resolveAuthHeader(provider) {
67
+ const raw = typeof provider === "function" ? await provider() : provider;
68
+ if (typeof raw !== "string" || raw.length === 0) {
69
+ throw new TypeError("fms-odata-js: token resolver produced an empty value");
70
+ }
71
+ return AUTH_SCHEME_RE.test(raw) ? raw : `Bearer ${raw}`;
72
+ }
73
+ function basicAuth(user, password) {
74
+ const raw = `${user}:${password}`;
75
+ const b64 = typeof Buffer !== "undefined" ? Buffer.from(raw, "utf8").toString("base64") : btoa(unescape(encodeURIComponent(raw)));
76
+ return `Basic ${b64}`;
77
+ }
78
+ function fmidAuth(token) {
79
+ return `FMID ${token}`;
80
+ }
81
+ function combineSignals(signals) {
82
+ const filtered = signals.filter((s) => s !== void 0);
83
+ if (filtered.length === 0) return void 0;
84
+ if (filtered.length === 1) return filtered[0];
85
+ const ctrl = new AbortController();
86
+ for (const s of filtered) {
87
+ if (s.aborted) {
88
+ ctrl.abort(s.reason);
89
+ return ctrl.signal;
90
+ }
91
+ s.addEventListener(
92
+ "abort",
93
+ () => ctrl.abort(s.reason),
94
+ { once: true }
95
+ );
96
+ }
97
+ return ctrl.signal;
98
+ }
99
+ var ACCEPT_DEFAULTS = {
100
+ json: "application/json",
101
+ xml: "application/xml",
102
+ text: "text/plain",
103
+ binary: "application/octet-stream",
104
+ none: "*/*"
105
+ };
106
+ async function executeRequest(ctx, url, opts = {}) {
107
+ return executeRequestImpl(
108
+ ctx,
109
+ url,
110
+ opts,
111
+ /* retried */
112
+ false
113
+ );
114
+ }
115
+ async function executeRequestImpl(ctx, url, opts, retried) {
116
+ const method = opts.method ?? "GET";
117
+ const headers = new Headers(opts.headers);
118
+ headers.set("Authorization", await resolveAuthHeader(ctx.token));
119
+ if (!headers.has("OData-Version")) headers.set("OData-Version", "4.0");
120
+ if (!headers.has("OData-MaxVersion")) headers.set("OData-MaxVersion", "4.0");
121
+ if (!headers.has("Accept")) {
122
+ headers.set("Accept", ACCEPT_DEFAULTS[opts.accept ?? "json"]);
123
+ }
124
+ const timeoutCtrl = new AbortController();
125
+ const timeoutId = ctx.timeoutMs && ctx.timeoutMs > 0 ? setTimeout(() => timeoutCtrl.abort(new Error(`Timeout after ${ctx.timeoutMs}ms`)), ctx.timeoutMs) : void 0;
126
+ const signal = combineSignals([opts.signal, ctx.timeoutMs ? timeoutCtrl.signal : void 0]);
127
+ let res;
128
+ try {
129
+ res = await ctx.fetch(url, {
130
+ method,
131
+ headers,
132
+ keepalive: true,
133
+ ...opts.body !== void 0 ? { body: opts.body } : {},
134
+ ...signal ? { signal } : {}
135
+ });
136
+ } catch (err) {
137
+ if (timeoutId) clearTimeout(timeoutId);
138
+ throw err;
139
+ }
140
+ if (timeoutId) clearTimeout(timeoutId);
141
+ if (res.status === 401 && ctx.onUnauthorized && !retried) {
142
+ await ctx.onUnauthorized();
143
+ return executeRequestImpl(ctx, url, opts, true);
144
+ }
145
+ if (!res.ok) {
146
+ throw await parseErrorResponse(res, { url, method });
147
+ }
148
+ return res;
149
+ }
150
+ async function executeJson(ctx, url, opts = {}) {
151
+ const res = await executeRequest(ctx, url, opts);
152
+ if (res.status === 204) return void 0;
153
+ const ctype = (res.headers.get("content-type") ?? "").toLowerCase();
154
+ if (!ctype.includes("json")) {
155
+ const text = await res.text();
156
+ if (!text) return void 0;
157
+ try {
158
+ return JSON.parse(text);
159
+ } catch {
160
+ throw new FMSODataError(`Expected JSON response, got "${ctype || "no content-type"}"`, {
161
+ status: res.status,
162
+ odataError: text,
163
+ request: { url, method: opts.method ?? "GET" }
164
+ });
165
+ }
166
+ }
167
+ return await res.json();
168
+ }
169
+
170
+ // src/url.ts
171
+ function escapeStringLiteral(s) {
172
+ return s.replace(/'/g, "''");
173
+ }
174
+ function formatDateTime(d) {
175
+ if (!(d instanceof Date) || Number.isNaN(d.getTime())) {
176
+ throw new TypeError("formatDateTime: invalid Date");
177
+ }
178
+ return d.toISOString().replace(/\.\d{3}Z$/, "Z");
179
+ }
180
+ function formatLiteral(v) {
181
+ if (v === null || v === void 0) return "null";
182
+ if (typeof v === "string") return `'${escapeStringLiteral(v)}'`;
183
+ if (typeof v === "number") {
184
+ if (!Number.isFinite(v)) {
185
+ throw new TypeError(`formatLiteral: cannot encode non-finite number: ${v}`);
186
+ }
187
+ return String(v);
188
+ }
189
+ if (typeof v === "boolean") return v ? "true" : "false";
190
+ if (v instanceof Date) return formatDateTime(v);
191
+ throw new TypeError(`formatLiteral: unsupported OData literal type: ${typeof v}`);
192
+ }
193
+ function encodePathSegment(s) {
194
+ return encodeURIComponent(s);
195
+ }
196
+ function odataEncode(v) {
197
+ return encodeURIComponent(v).replace(/%2C/gi, ",").replace(/%24/g, "$").replace(/%3D/g, "=").replace(/%3B/g, ";").replace(/%28/g, "(").replace(/%29/g, ")");
198
+ }
199
+ function buildQueryString(params) {
200
+ const parts = [];
201
+ for (const [k, v] of params) {
202
+ if (v === "" || v === void 0 || v === null) continue;
203
+ parts.push(`${k}=${odataEncode(v)}`);
204
+ }
205
+ return parts.join("&");
206
+ }
207
+
208
+ // src/batch.ts
209
+ function utf8ByteLength(str) {
210
+ if (typeof TextEncoder !== "undefined") {
211
+ return new TextEncoder().encode(str).byteLength;
212
+ }
213
+ return Buffer.byteLength(str, "utf8");
214
+ }
215
+ function generateBoundary(prefix) {
216
+ const uuid = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
217
+ return `${prefix}_${uuid}`;
218
+ }
219
+ function buildEntitySetPath(baseUrl, entitySet, query) {
220
+ const parts = [];
221
+ if (query?.$top !== void 0) parts.push(`$top=${odataEncode(String(query.$top))}`);
222
+ if (query?.$skip !== void 0) parts.push(`$skip=${odataEncode(String(query.$skip))}`);
223
+ if (query?.$filter) parts.push(`$filter=${odataEncode(query.$filter)}`);
224
+ if (query?.$select) parts.push(`$select=${odataEncode(query.$select)}`);
225
+ const qs = parts.join("&");
226
+ const encodedSet = encodePathSegment(entitySet);
227
+ return qs ? `${baseUrl}/${encodedSet}?${qs}` : `${baseUrl}/${encodedSet}`;
228
+ }
229
+ function buildEntityPath(baseUrl, entitySet, key) {
230
+ const encodedSet = encodePathSegment(entitySet);
231
+ let keySegment;
232
+ if (typeof key === "number") {
233
+ keySegment = String(key);
234
+ } else if (typeof key === "string") {
235
+ keySegment = `'${key.replace(/'/g, "''")}'`;
236
+ } else if (typeof key === "boolean") {
237
+ keySegment = key ? "true" : "false";
238
+ } else {
239
+ throw new TypeError("Batch: unsupported key type");
240
+ }
241
+ return `${baseUrl}/${encodedSet}(${keySegment})`;
242
+ }
243
+ var Changeset = class {
244
+ constructor(baseUrl) {
245
+ this._ops = [];
246
+ this._handles = [];
247
+ this._baseUrl = baseUrl;
248
+ }
249
+ /** @internal */
250
+ get _operations() {
251
+ return this._ops;
252
+ }
253
+ /** @internal */
254
+ get _handleSlots() {
255
+ return this._handles;
256
+ }
257
+ /**
258
+ * Create a new entity within this changeset.
259
+ */
260
+ create(entitySet, body) {
261
+ const path = buildEntitySetPath(this._baseUrl, entitySet);
262
+ let resolve;
263
+ let reject;
264
+ const promise = new Promise((res, rej) => {
265
+ resolve = res;
266
+ reject = rej;
267
+ });
268
+ const index = this._handles.length;
269
+ this._handles.push({ resolve, reject });
270
+ this._ops.push({
271
+ method: "POST",
272
+ path,
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify(body)
275
+ });
276
+ return { __brand: "BatchHandle", _promise: promise, _index: index };
277
+ }
278
+ /**
279
+ * Patch an existing entity within this changeset.
280
+ */
281
+ patch(entitySet, key, body, opts) {
282
+ const path = buildEntityPath(this._baseUrl, entitySet, key);
283
+ const headers = { "Content-Type": "application/json" };
284
+ if (opts?.ifMatch) headers["If-Match"] = opts.ifMatch;
285
+ let resolve;
286
+ let reject;
287
+ const promise = new Promise((res, rej) => {
288
+ resolve = res;
289
+ reject = rej;
290
+ });
291
+ const index = this._handles.length;
292
+ this._handles.push({ resolve, reject });
293
+ this._ops.push({
294
+ method: "PATCH",
295
+ path,
296
+ headers,
297
+ body: JSON.stringify(body)
298
+ });
299
+ return { __brand: "BatchHandle", _promise: promise, _index: index };
300
+ }
301
+ /**
302
+ * Delete an entity within this changeset.
303
+ */
304
+ delete(entitySet, key, opts) {
305
+ const path = buildEntityPath(this._baseUrl, entitySet, key);
306
+ const headers = {};
307
+ if (opts?.ifMatch) headers["If-Match"] = opts.ifMatch;
308
+ let resolve;
309
+ let reject;
310
+ const promise = new Promise((res, rej) => {
311
+ resolve = res;
312
+ reject = rej;
313
+ });
314
+ const index = this._handles.length;
315
+ this._handles.push({ resolve, reject });
316
+ this._ops.push({ method: "DELETE", path, headers });
317
+ return { __brand: "BatchHandle", _promise: promise, _index: index };
318
+ }
319
+ };
320
+ var Batch = class {
321
+ constructor(client) {
322
+ this._parts = [];
323
+ this._changesets = [];
324
+ this._client = client;
325
+ }
326
+ /**
327
+ * Add a read operation (GET) to the batch.
328
+ * Read operations are not part of a changeset and execute independently.
329
+ */
330
+ add(op) {
331
+ const path = buildEntitySetPath(this._client.baseUrl, op.entitySet, op.query);
332
+ let resolve;
333
+ let reject;
334
+ const promise = new Promise((res, rej) => {
335
+ resolve = res;
336
+ reject = rej;
337
+ });
338
+ const handle = { __brand: "BatchHandle", _promise: promise, _index: this._parts.length };
339
+ this._parts.push({
340
+ type: "read",
341
+ content: { method: "GET", path },
342
+ handle
343
+ });
344
+ return handle;
345
+ }
346
+ /**
347
+ * Create an atomic changeset (group of write operations).
348
+ * All operations in a changeset succeed or fail together.
349
+ */
350
+ changeset(build) {
351
+ const cs = new Changeset(this._client.baseUrl);
352
+ build(cs);
353
+ this._changesets.push(cs);
354
+ this._parts.push({ type: "changeset", content: cs });
355
+ }
356
+ /**
357
+ * Serialize the batch into a multipart/mixed body.
358
+ * @internal
359
+ */
360
+ _serialize() {
361
+ const batchBoundary = generateBoundary("batch");
362
+ const lines = [];
363
+ for (const part of this._parts) {
364
+ lines.push(`--${batchBoundary}`);
365
+ if (part.type === "read") {
366
+ const op = part.content;
367
+ lines.push("Content-Type: application/http");
368
+ lines.push("Content-Transfer-Encoding: binary");
369
+ lines.push("");
370
+ lines.push(`${op.method} ${op.path} HTTP/1.1`);
371
+ } else if (part.type === "changeset") {
372
+ const cs = part.content;
373
+ const csBoundary = generateBoundary("changeset");
374
+ lines.push("Content-Type: multipart/mixed; boundary=" + csBoundary);
375
+ lines.push("");
376
+ for (const op of cs._operations) {
377
+ lines.push(`--${csBoundary}`);
378
+ lines.push("Content-Type: application/http");
379
+ lines.push("Content-Transfer-Encoding: binary");
380
+ lines.push("");
381
+ lines.push(`${op.method} ${op.path} HTTP/1.1`);
382
+ if (op.headers) {
383
+ for (const [k, v] of Object.entries(op.headers)) {
384
+ lines.push(`${k}: ${v}`);
385
+ }
386
+ }
387
+ if (op.body) {
388
+ lines.push(`Content-Length: ${utf8ByteLength(op.body)}`);
389
+ lines.push("");
390
+ lines.push(op.body);
391
+ }
392
+ }
393
+ lines.push(`--${csBoundary}--`);
394
+ }
395
+ }
396
+ lines.push(`--${batchBoundary}--`);
397
+ return { boundary: batchBoundary, body: lines.join("\r\n") };
398
+ }
399
+ /**
400
+ * Send the batch request and parse the multipart response.
401
+ */
402
+ async send(opts = {}) {
403
+ const { boundary, body } = this._serialize();
404
+ const res = await executeRequest(
405
+ this._client._ctx,
406
+ `${this._client.baseUrl}/$batch`,
407
+ {
408
+ method: "POST",
409
+ headers: {
410
+ "Content-Type": `multipart/mixed; boundary=${boundary}`
411
+ },
412
+ body,
413
+ accept: "none",
414
+ ...opts.signal ? { signal: opts.signal } : {}
415
+ }
416
+ );
417
+ const responseText = await res.text();
418
+ return this._parseResponse(responseText, res.headers.get("content-type") ?? "");
419
+ }
420
+ /**
421
+ * Parse a multipart/mixed batch response.
422
+ * @internal
423
+ */
424
+ _parseResponse(responseText, contentType) {
425
+ const boundaryMatch = contentType.match(/boundary=([^;\s]+)/);
426
+ const boundary = boundaryMatch?.[1];
427
+ if (!boundary) {
428
+ throw new Error("Batch response missing boundary in Content-Type");
429
+ }
430
+ const parts = responseText.split(`--${boundary}`);
431
+ const results = [];
432
+ let partIndex = 0;
433
+ for (let i = 1; i < parts.length - 1; i++) {
434
+ const part = parts[i].trim();
435
+ if (!part || part === "--") continue;
436
+ const batchPart = this._parts[partIndex];
437
+ if (!batchPart) continue;
438
+ if (batchPart.type === "read") {
439
+ const result = this._parseHttpPart(part);
440
+ results.push(result);
441
+ if (batchPart.handle) {
442
+ this._resolveHandle(batchPart.handle, result);
443
+ }
444
+ partIndex++;
445
+ } else if (batchPart.type === "changeset") {
446
+ const cs = batchPart.content;
447
+ const csResults = this._parseChangesetResponse(part, cs);
448
+ results.push(...csResults);
449
+ const failed = csResults.find((r) => !r.ok);
450
+ for (let j = 0; j < cs._handleSlots.length; j++) {
451
+ const slot = cs._handleSlots[j];
452
+ const csResult = csResults[j];
453
+ if (failed) {
454
+ slot.reject(new Error(`Changeset failed: ${failed.status}`));
455
+ } else if (csResult) {
456
+ slot.resolve(csResult.body);
457
+ } else {
458
+ slot.resolve(void 0);
459
+ }
460
+ }
461
+ partIndex++;
462
+ }
463
+ }
464
+ return {
465
+ responses: results,
466
+ ok: results.every((r) => r.ok)
467
+ };
468
+ }
469
+ /** @internal — parse a single HTTP response part. */
470
+ _parseHttpPart(part) {
471
+ const outerEnd = part.indexOf("\r\n\r\n");
472
+ const innerHttpText = outerEnd >= 0 ? part.slice(outerEnd + 4) : part;
473
+ const innerHeadEnd = innerHttpText.indexOf("\r\n\r\n");
474
+ const innerHead = innerHeadEnd >= 0 ? innerHttpText.slice(0, innerHeadEnd) : innerHttpText;
475
+ const innerBody = innerHeadEnd >= 0 ? innerHttpText.slice(innerHeadEnd + 4).trim() : "";
476
+ const statusMatch = innerHead.match(/^HTTP\/1\.\d (\d+)/);
477
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
478
+ let parsedBody = void 0;
479
+ if (innerBody && innerHead.toLowerCase().includes("application/json")) {
480
+ try {
481
+ parsedBody = JSON.parse(innerBody);
482
+ } catch {
483
+ parsedBody = innerBody;
484
+ }
485
+ } else if (innerBody) {
486
+ parsedBody = innerBody;
487
+ }
488
+ return {
489
+ status,
490
+ body: parsedBody,
491
+ headers: new Headers(),
492
+ ok: status >= 200 && status < 300
493
+ };
494
+ }
495
+ /** @internal — parse a changeset multipart response. */
496
+ _parseChangesetResponse(part, cs) {
497
+ const boundaryMatch = part.match(/boundary=([^\s]+)/);
498
+ const csBoundary = boundaryMatch?.[1];
499
+ if (!csBoundary) {
500
+ return [this._parseHttpPart(part)];
501
+ }
502
+ const csParts = part.split(`--${csBoundary}`);
503
+ const results = [];
504
+ for (let i = 1; i < csParts.length - 1; i++) {
505
+ const csPart = csParts[i].trim();
506
+ if (!csPart || csPart === "--") continue;
507
+ results.push(this._parseHttpPart(csPart));
508
+ }
509
+ return results;
510
+ }
511
+ /** @internal — resolve a batch handle with the result. */
512
+ _resolveHandle(handle, result) {
513
+ }
514
+ };
515
+
516
+ // node_modules/@fms-odata/spec-ts/dist/versions.js
517
+ var FM_VERSION_NAMES = {
518
+ "19": "FileMaker 19.x",
519
+ "21": "Claris FileMaker 2023",
520
+ "22": "Claris FileMaker 2024",
521
+ "26": "Claris FileMaker 2026",
522
+ future: "Future / next"
523
+ };
524
+ var ODATA_PROTOCOL_VERSION = "4.0";
525
+ var FM_VERSION_MATRIX = {
526
+ "19": {
527
+ major: "19",
528
+ name: "FileMaker 19.x",
529
+ releaseYear: null,
530
+ internalVersion: "19.x",
531
+ status: "baseline",
532
+ features: {
533
+ serviceDocument: true,
534
+ metadata: true,
535
+ databaseListing: true,
536
+ tableListing: true,
537
+ recordCRUD: true,
538
+ recordReferences: true,
539
+ crossJoin: true,
540
+ batch: true,
541
+ scripts: true,
542
+ scriptsByFMSID: false,
543
+ scriptListing: false,
544
+ containerBinaryUpload: true,
545
+ containerBase64Upload: true,
546
+ containerDownload: true,
547
+ schemaModification: true,
548
+ webhooks: false,
549
+ webhookQueryHeaders: false,
550
+ applyAggregation: false,
551
+ typeCasting: false,
552
+ parameterizedFilters: false,
553
+ immutableIdUrls: false,
554
+ aiAnnotation: false,
555
+ serverVersionAnnotation: false,
556
+ enrichedFMComment: false,
557
+ authBasic: true,
558
+ authFMID: false,
559
+ authOAuth: false
560
+ },
561
+ queryOptions: {
562
+ $filter: true,
563
+ $select: true,
564
+ $orderby: true,
565
+ $top: true,
566
+ $skip: true,
567
+ $expand: true,
568
+ $count: true,
569
+ $apply: false,
570
+ $search: false,
571
+ $compute: false
572
+ }
573
+ },
574
+ "21": {
575
+ major: "21",
576
+ name: "Claris FileMaker 2023",
577
+ releaseYear: 2023,
578
+ internalVersion: "21.x",
579
+ status: "supported",
580
+ features: {
581
+ serviceDocument: true,
582
+ metadata: true,
583
+ databaseListing: true,
584
+ tableListing: true,
585
+ recordCRUD: true,
586
+ recordReferences: true,
587
+ crossJoin: true,
588
+ batch: true,
589
+ scripts: true,
590
+ scriptsByFMSID: false,
591
+ scriptListing: false,
592
+ containerBinaryUpload: true,
593
+ containerBase64Upload: true,
594
+ containerDownload: true,
595
+ schemaModification: true,
596
+ webhooks: true,
597
+ webhookQueryHeaders: false,
598
+ applyAggregation: false,
599
+ typeCasting: true,
600
+ parameterizedFilters: true,
601
+ immutableIdUrls: false,
602
+ aiAnnotation: false,
603
+ serverVersionAnnotation: false,
604
+ enrichedFMComment: false,
605
+ authBasic: true,
606
+ authFMID: true,
607
+ authOAuth: true
608
+ },
609
+ queryOptions: {
610
+ $filter: true,
611
+ $select: true,
612
+ $orderby: true,
613
+ $top: true,
614
+ $skip: true,
615
+ $expand: true,
616
+ $count: true,
617
+ $apply: false,
618
+ $search: false,
619
+ $compute: false
620
+ }
621
+ },
622
+ "22": {
623
+ major: "22",
624
+ name: "Claris FileMaker 2024",
625
+ releaseYear: 2024,
626
+ internalVersion: "22.x",
627
+ status: "supported",
628
+ features: {
629
+ serviceDocument: true,
630
+ metadata: true,
631
+ databaseListing: true,
632
+ tableListing: true,
633
+ recordCRUD: true,
634
+ recordReferences: true,
635
+ crossJoin: true,
636
+ batch: true,
637
+ scripts: true,
638
+ scriptsByFMSID: false,
639
+ scriptListing: false,
640
+ containerBinaryUpload: true,
641
+ containerBase64Upload: true,
642
+ containerDownload: true,
643
+ schemaModification: true,
644
+ webhooks: true,
645
+ webhookQueryHeaders: true,
646
+ applyAggregation: true,
647
+ typeCasting: true,
648
+ parameterizedFilters: true,
649
+ immutableIdUrls: false,
650
+ aiAnnotation: false,
651
+ serverVersionAnnotation: false,
652
+ enrichedFMComment: false,
653
+ authBasic: true,
654
+ authFMID: true,
655
+ authOAuth: true
656
+ },
657
+ queryOptions: {
658
+ $filter: true,
659
+ $select: true,
660
+ $orderby: true,
661
+ $top: true,
662
+ $skip: true,
663
+ $expand: true,
664
+ $count: true,
665
+ $apply: true,
666
+ $search: false,
667
+ $compute: false
668
+ }
669
+ },
670
+ "26": {
671
+ major: "26",
672
+ name: "Claris FileMaker 2026",
673
+ releaseYear: 2026,
674
+ internalVersion: "26.x",
675
+ status: "current",
676
+ features: {
677
+ serviceDocument: true,
678
+ metadata: true,
679
+ databaseListing: true,
680
+ tableListing: true,
681
+ recordCRUD: true,
682
+ recordReferences: true,
683
+ crossJoin: true,
684
+ batch: true,
685
+ scripts: true,
686
+ scriptsByFMSID: true,
687
+ scriptListing: true,
688
+ containerBinaryUpload: true,
689
+ containerBase64Upload: true,
690
+ containerDownload: true,
691
+ schemaModification: true,
692
+ webhooks: true,
693
+ webhookQueryHeaders: true,
694
+ applyAggregation: true,
695
+ typeCasting: true,
696
+ parameterizedFilters: true,
697
+ immutableIdUrls: true,
698
+ aiAnnotation: true,
699
+ serverVersionAnnotation: true,
700
+ enrichedFMComment: true,
701
+ authBasic: true,
702
+ authFMID: true,
703
+ authOAuth: true
704
+ },
705
+ queryOptions: {
706
+ $filter: true,
707
+ $select: true,
708
+ $orderby: true,
709
+ $top: true,
710
+ $skip: true,
711
+ $expand: true,
712
+ $count: true,
713
+ $apply: true,
714
+ $search: false,
715
+ $compute: false
716
+ }
717
+ },
718
+ future: {
719
+ major: "future",
720
+ name: "Future / next",
721
+ releaseYear: null,
722
+ internalVersion: "unknown",
723
+ status: "future",
724
+ features: {
725
+ serviceDocument: true,
726
+ metadata: true,
727
+ databaseListing: true,
728
+ tableListing: true,
729
+ recordCRUD: true,
730
+ recordReferences: true,
731
+ crossJoin: true,
732
+ batch: true,
733
+ scripts: true,
734
+ scriptsByFMSID: true,
735
+ scriptListing: true,
736
+ containerBinaryUpload: true,
737
+ containerBase64Upload: true,
738
+ containerDownload: true,
739
+ schemaModification: true,
740
+ webhooks: true,
741
+ webhookQueryHeaders: true,
742
+ applyAggregation: true,
743
+ typeCasting: true,
744
+ parameterizedFilters: true,
745
+ immutableIdUrls: true,
746
+ aiAnnotation: true,
747
+ serverVersionAnnotation: true,
748
+ enrichedFMComment: true,
749
+ authBasic: true,
750
+ authFMID: true,
751
+ authOAuth: true
752
+ },
753
+ queryOptions: {
754
+ $filter: true,
755
+ $select: true,
756
+ $orderby: true,
757
+ $top: true,
758
+ $skip: true,
759
+ $expand: true,
760
+ $count: true,
761
+ $apply: true,
762
+ $search: false,
763
+ $compute: false
764
+ }
765
+ }
766
+ };
767
+ function hasFeature(version, feature) {
768
+ return FM_VERSION_MATRIX[version]?.features[feature] ?? false;
769
+ }
770
+ function hasQueryOption(version, option) {
771
+ return FM_VERSION_MATRIX[version]?.queryOptions[option] ?? false;
772
+ }
773
+ function minVersionForFeature(feature) {
774
+ const order = ["19", "21", "22", "26"];
775
+ for (const v of order) {
776
+ if (FM_VERSION_MATRIX[v].features[feature])
777
+ return v;
778
+ }
779
+ return null;
780
+ }
781
+
782
+ // node_modules/@fms-odata/spec-ts/dist/metadata.js
783
+ function parseVersionString(raw) {
784
+ const m = raw.trim().match(/(\d+)\.(\d+)\.(\d+)/);
785
+ if (!m)
786
+ return null;
787
+ return {
788
+ major: parseInt(m[1], 10),
789
+ minor: parseInt(m[2], 10),
790
+ patch: parseInt(m[3], 10),
791
+ raw: raw.trim()
792
+ };
793
+ }
794
+ function parseServerVersion(metadataXml) {
795
+ if (!metadataXml || typeof metadataXml !== "string")
796
+ return null;
797
+ const annotationMatch = metadataXml.match(/Term\s*=\s*["']Org\.OData\.Core\.V1\.ProductVersion["'][^>]*String\s*=\s*["']([^"']+)["']/);
798
+ if (annotationMatch) {
799
+ const v = parseVersionString(annotationMatch[1]);
800
+ if (v)
801
+ return v;
802
+ }
803
+ const annotationMatchRev = metadataXml.match(/String\s*=\s*["']([^"']+)["'][^>]*Term\s*=\s*["']Org\.OData\.Core\.V1\.ProductVersion["']/);
804
+ if (annotationMatchRev) {
805
+ const v = parseVersionString(annotationMatchRev[1]);
806
+ if (v)
807
+ return v;
808
+ }
809
+ const serverVersionMatch = metadataXml.match(/Term\s*=\s*["']ServerVersion["'][^>]*String\s*=\s*["']([^"']+)["']/i);
810
+ if (serverVersionMatch) {
811
+ const v = parseVersionString(serverVersionMatch[1]);
812
+ if (v && v.major >= 17)
813
+ return v;
814
+ }
815
+ const serverVersionMatchRev = metadataXml.match(/String\s*=\s*["']([^"']+)["'][^>]*Term\s*=\s*["']ServerVersion["']/i);
816
+ if (serverVersionMatchRev) {
817
+ const v = parseVersionString(serverVersionMatchRev[1]);
818
+ if (v && v.major >= 17)
819
+ return v;
820
+ }
821
+ const genericMatch = metadataXml.match(/Term\s*=\s*["'][^"']*Version[^"']*["'][^>]*String\s*=\s*["'][^"']*?(\d{2,}\.\d+\.\d+(?:\.\d+)?)[^"']*?["']/i);
822
+ if (genericMatch) {
823
+ const v = parseVersionString(genericMatch[1]);
824
+ if (v && v.major >= 17)
825
+ return v;
826
+ }
827
+ return null;
828
+ }
829
+
830
+ // src/metadata.ts
831
+ function parseBoolAttr(value, defaultValue) {
832
+ if (value === void 0) return defaultValue;
833
+ return value === "true";
834
+ }
835
+ function getAttr(text, name) {
836
+ const re = new RegExp(`${name}="([^"]*)"`, "i");
837
+ const m = text.match(re);
838
+ return m?.[1];
839
+ }
840
+ function getAttrs(text) {
841
+ const out = {};
842
+ const re = /(\w+)="([^"]*)"/g;
843
+ let m;
844
+ while ((m = re.exec(text)) !== null) {
845
+ out[m[1]] = m[2];
846
+ }
847
+ return out;
848
+ }
849
+ function findElements(xml, tagName) {
850
+ const results = [];
851
+ const openRe = new RegExp(`<([\\w]+:)?${tagName}\\b[^>]*>`, "gi");
852
+ let m;
853
+ while ((m = openRe.exec(xml)) !== null) {
854
+ const openTag = m[0];
855
+ if (/\/>\s*$/.test(openTag)) {
856
+ results.push(openTag);
857
+ continue;
858
+ }
859
+ const startIdx = m.index;
860
+ const closeRe = new RegExp(`</([\\w]+:)?${tagName}>`, "gi");
861
+ closeRe.lastIndex = openRe.lastIndex;
862
+ const closeM = closeRe.exec(xml);
863
+ if (closeM) {
864
+ results.push(xml.slice(startIdx, closeM.index + closeM[0].length));
865
+ }
866
+ }
867
+ return results;
868
+ }
869
+ function elementContent(xml, tagName) {
870
+ const openRe = new RegExp(`<(?:[\\w]+:)?${tagName}\\b[^>]*>`, "i");
871
+ const openM = openRe.exec(xml);
872
+ if (!openM) return void 0;
873
+ const startIdx = openM.index + openM[0].length;
874
+ const closeRe = new RegExp(`</(?:[\\w]+:)?${tagName}>`, "i");
875
+ closeRe.lastIndex = startIdx;
876
+ const closeM = closeRe.exec(xml);
877
+ if (!closeM) return void 0;
878
+ return xml.slice(startIdx, closeM.index);
879
+ }
880
+ function parseProperty(xml) {
881
+ const attrs = getAttrs(xml);
882
+ const nullable = parseBoolAttr(attrs.Nullable, true);
883
+ const out = {
884
+ name: attrs.Name ?? "",
885
+ type: attrs.Type ?? "",
886
+ nullable
887
+ };
888
+ if (attrs.MaxLength !== void 0) {
889
+ const n = parseInt(attrs.MaxLength, 10);
890
+ if (!Number.isNaN(n)) out.maxLength = n;
891
+ }
892
+ return out;
893
+ }
894
+ function parseNavigationProperty(xml) {
895
+ const attrs = getAttrs(xml);
896
+ const type = attrs.Type ?? "";
897
+ const collection = type.startsWith("Collection(");
898
+ const target = collection ? type.slice(11, -1) : type;
899
+ return {
900
+ name: attrs.Name ?? "",
901
+ target,
902
+ collection
903
+ };
904
+ }
905
+ function parseKey(xml) {
906
+ const keys = [];
907
+ const refs = findElements(xml, "PropertyRef");
908
+ for (const ref of refs) {
909
+ const name = getAttr(ref, "Name");
910
+ if (name) keys.push(name);
911
+ }
912
+ return keys;
913
+ }
914
+ function parseEntityType(xml) {
915
+ const name = getAttr(xml, "Name") ?? "";
916
+ const keys = [];
917
+ const properties = [];
918
+ const navigationProperties = [];
919
+ const inner = elementContent(xml, "EntityType") ?? xml;
920
+ const keyEl = findElements(inner, "Key")[0];
921
+ if (keyEl) {
922
+ keys.push(...parseKey(keyEl));
923
+ }
924
+ for (const prop of findElements(inner, "Property")) {
925
+ properties.push(parseProperty(prop));
926
+ }
927
+ for (const nav of findElements(inner, "NavigationProperty")) {
928
+ navigationProperties.push(parseNavigationProperty(nav));
929
+ }
930
+ return { name, keys, properties, navigationProperties };
931
+ }
932
+ function parseEntitySet(xml) {
933
+ const attrs = getAttrs(xml);
934
+ return {
935
+ name: attrs.Name ?? "",
936
+ entityType: attrs.EntityType ?? ""
937
+ };
938
+ }
939
+ function extractServerVersion(xml) {
940
+ return parseServerVersion(xml) ?? void 0;
941
+ }
942
+ function parseAction(xml) {
943
+ const name = getAttr(xml, "Name") ?? "";
944
+ const attrs = getAttrs(xml);
945
+ const boundTo = attrs["IsBound"] === "true" ? attrs["EntityType"] : void 0;
946
+ const parameters = [];
947
+ const inner = elementContent(xml, "Action") ?? xml;
948
+ for (const p of findElements(inner, "Parameter")) {
949
+ const pAttrs = getAttrs(p);
950
+ parameters.push({
951
+ name: pAttrs.Name ?? "",
952
+ type: pAttrs.Type ?? ""
953
+ });
954
+ }
955
+ return boundTo !== void 0 ? { name, boundTo, parameters } : { name, parameters };
956
+ }
957
+ function parseMetadata(xml) {
958
+ try {
959
+ const dataServices = elementContent(xml, "DataServices");
960
+ if (!dataServices) {
961
+ throw new Error("Missing <edmx:DataServices> in metadata");
962
+ }
963
+ const schema = findElements(dataServices, "Schema")[0];
964
+ if (!schema) {
965
+ throw new Error("Missing <Schema> in metadata");
966
+ }
967
+ const namespace = getAttr(schema, "Namespace") ?? "";
968
+ const schemaInner = elementContent(schema, "Schema") ?? schema;
969
+ const entityTypes = [];
970
+ for (const et of findElements(schemaInner, "EntityType")) {
971
+ entityTypes.push(parseEntityType(et));
972
+ }
973
+ const entitySets = [];
974
+ const container = findElements(schemaInner, "EntityContainer")[0];
975
+ if (container) {
976
+ const containerInner = elementContent(container, "EntityContainer") ?? container;
977
+ for (const es of findElements(containerInner, "EntitySet")) {
978
+ entitySets.push(parseEntitySet(es));
979
+ }
980
+ }
981
+ const actions = [];
982
+ for (const a of findElements(schemaInner, "Action")) {
983
+ actions.push(parseAction(a));
984
+ }
985
+ const serverVersion = extractServerVersion(xml);
986
+ const result = {
987
+ namespace,
988
+ entityTypes,
989
+ entitySets,
990
+ actions,
991
+ raw: xml
992
+ };
993
+ if (serverVersion) {
994
+ result.serverVersion = serverVersion;
995
+ result.productVersion = serverVersion.raw;
996
+ }
997
+ return result;
998
+ } catch (err) {
999
+ const message = err instanceof Error ? err.message : String(err);
1000
+ throw new FMSODataError(`Failed to parse $metadata: ${message}`, {
1001
+ status: 0,
1002
+ odataError: xml.slice(0, 500)
1003
+ });
1004
+ }
1005
+ }
1006
+ async function fetchMetadataXml(ctx, baseUrl, opts = {}) {
1007
+ const res = await executeRequest(ctx, `${baseUrl}/$metadata`, {
1008
+ method: "GET",
1009
+ accept: "xml",
1010
+ ...opts.signal ? { signal: opts.signal } : {}
1011
+ });
1012
+ return res.text();
1013
+ }
1014
+ var MetadataFetcher = class {
1015
+ constructor(_ctx, _baseUrl) {
1016
+ this._ctx = _ctx;
1017
+ this._baseUrl = _baseUrl;
1018
+ }
1019
+ async fetchXml(opts = {}) {
1020
+ return fetchMetadataXml(this._ctx, this._baseUrl, opts);
1021
+ }
1022
+ async fetch(opts = {}) {
1023
+ if (!opts.refresh && this._cache) {
1024
+ return this._cache;
1025
+ }
1026
+ const promise = this.fetchXml(opts).then(parseMetadata);
1027
+ this._cache = promise;
1028
+ return promise;
1029
+ }
1030
+ };
1031
+
1032
+ // src/containers.ts
1033
+ var FM_CONTAINER_SUPPORTED_MIME_TYPES = [
1034
+ "image/png",
1035
+ "image/jpeg",
1036
+ "image/gif",
1037
+ "image/tiff",
1038
+ "application/pdf"
1039
+ ];
1040
+ function normalizeMime(value) {
1041
+ return (value.split(";")[0] ?? "").trim().toLowerCase();
1042
+ }
1043
+ function isSupportedContainerMime(value) {
1044
+ const normalized = normalizeMime(value);
1045
+ return FM_CONTAINER_SUPPORTED_MIME_TYPES.includes(normalized);
1046
+ }
1047
+ function sniffContainerMime(bytes) {
1048
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) {
1049
+ return "image/png";
1050
+ }
1051
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
1052
+ return "image/jpeg";
1053
+ }
1054
+ if (bytes.length >= 4 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56) {
1055
+ return "image/gif";
1056
+ }
1057
+ if (bytes.length >= 4 && (bytes[0] === 73 && bytes[1] === 73 && bytes[2] === 42 && bytes[3] === 0 || bytes[0] === 77 && bytes[1] === 77 && bytes[2] === 0 && bytes[3] === 42)) {
1058
+ return "image/tiff";
1059
+ }
1060
+ if (bytes.length >= 4 && bytes[0] === 37 && bytes[1] === 80 && bytes[2] === 68 && bytes[3] === 70) {
1061
+ return "application/pdf";
1062
+ }
1063
+ return void 0;
1064
+ }
1065
+ var ContainerRef = class {
1066
+ constructor(entity, fieldName) {
1067
+ if (!fieldName) {
1068
+ throw new TypeError("ContainerRef: fieldName is required");
1069
+ }
1070
+ this._entity = entity;
1071
+ this.fieldName = fieldName;
1072
+ }
1073
+ /**
1074
+ * Absolute URL of the container field itself
1075
+ * (`…/<EntitySet>(<key>)/<fieldName>`). This is the URL used by binary
1076
+ * `upload()`. Append `/$value` to download.
1077
+ */
1078
+ url() {
1079
+ return `${this._entity.toURL()}/${encodePathSegment(this.fieldName)}`;
1080
+ }
1081
+ /** @internal — `…/<field>/$value` for downloads. */
1082
+ _valueUrl() {
1083
+ return `${this.url()}/$value`;
1084
+ }
1085
+ /**
1086
+ * Download the container's contents and buffer them into a `Blob`. For
1087
+ * very large payloads prefer `getStream()` to avoid buffering in memory.
1088
+ */
1089
+ async get(opts = {}) {
1090
+ const res = await executeRequest(this._entity._client._ctx, this._valueUrl(), {
1091
+ method: "GET",
1092
+ // FMS quirk: `Accept: application/octet-stream` makes `$value` return the
1093
+ // stored filename string as `text/plain` instead of the binary. Use the
1094
+ // wildcard so FMS returns the actual bytes with a sniffed Content-Type.
1095
+ // See `docs/filemaker-quirks.md`.
1096
+ accept: "none",
1097
+ ...opts.signal ? { signal: opts.signal } : {}
1098
+ });
1099
+ const contentType = res.headers.get("content-type") ?? "";
1100
+ const disposition = res.headers.get("content-disposition");
1101
+ const filename = disposition ? parseContentDispositionFilename(disposition) : void 0;
1102
+ const blob = await res.blob();
1103
+ const out = {
1104
+ blob,
1105
+ contentType,
1106
+ size: blob.size
1107
+ };
1108
+ if (filename !== void 0) out.filename = filename;
1109
+ return out;
1110
+ }
1111
+ /**
1112
+ * Stream the container's contents without buffering. Useful for large files
1113
+ * (`pipeTo()` into a writable, forward to another `Response`, etc.).
1114
+ *
1115
+ * Throws if the underlying `Response` has no body.
1116
+ */
1117
+ async getStream(opts = {}) {
1118
+ const res = await executeRequest(this._entity._client._ctx, this._valueUrl(), {
1119
+ method: "GET",
1120
+ // See note in `get()` — Accept: */* avoids the FMS `octet-stream` quirk.
1121
+ accept: "none",
1122
+ ...opts.signal ? { signal: opts.signal } : {}
1123
+ });
1124
+ if (!res.body) {
1125
+ throw new TypeError("ContainerRef.getStream: response has no body");
1126
+ }
1127
+ return res.body;
1128
+ }
1129
+ /**
1130
+ * Upload binary contents to the container. Replaces any existing value.
1131
+ *
1132
+ * Default `encoding: 'binary'` PATCHes `…/<field>` with raw bytes and a
1133
+ * `Content-Type` header. Restricted to PNG / JPEG / GIF / TIFF / PDF.
1134
+ *
1135
+ * `encoding: 'base64'` PATCHes `…/<EntitySet>(<key>)` with a JSON body
1136
+ * containing base64 data plus `@com.filemaker.odata.…` annotations. Use
1137
+ * this when updating multiple container fields (or mixing container and
1138
+ * regular fields) in a single round-trip.
1139
+ *
1140
+ * `contentType` is optional — when omitted, the library sniffs the MIME
1141
+ * from the payload's magic bytes. Throws if no supported signature matches.
1142
+ */
1143
+ async upload(input, opts = {}) {
1144
+ const encoding = input.encoding ?? "binary";
1145
+ const bytes = await toUint8Array(input.data);
1146
+ let contentType = input.contentType;
1147
+ if (!contentType) {
1148
+ const sniffed = sniffContainerMime(bytes);
1149
+ if (!sniffed) {
1150
+ throw new TypeError(
1151
+ `ContainerRef.upload: contentType is required and could not be sniffed from the payload. Pass a contentType explicitly (one of ${FM_CONTAINER_SUPPORTED_MIME_TYPES.join(", ")}).`
1152
+ );
1153
+ }
1154
+ contentType = sniffed;
1155
+ }
1156
+ if (encoding === "binary") {
1157
+ if (!isSupportedContainerMime(contentType)) {
1158
+ throw new TypeError(
1159
+ `ContainerRef.upload (binary): contentType "${contentType}" is not a FileMaker-supported container type. Use one of ${FM_CONTAINER_SUPPORTED_MIME_TYPES.join(", ")}, or switch to { encoding: 'base64' }.`
1160
+ );
1161
+ }
1162
+ const headers = {
1163
+ "Content-Type": contentType
1164
+ };
1165
+ if (input.filename) {
1166
+ headers["Content-Disposition"] = formatContentDisposition(input.filename);
1167
+ }
1168
+ const body2 = new ArrayBuffer(bytes.byteLength);
1169
+ new Uint8Array(body2).set(bytes);
1170
+ await executeRequest(this._entity._client._ctx, this.url(), {
1171
+ method: "PATCH",
1172
+ headers,
1173
+ body: body2,
1174
+ accept: "none",
1175
+ ...opts.signal ? { signal: opts.signal } : {}
1176
+ });
1177
+ return;
1178
+ }
1179
+ const body = buildContainerJsonBody({
1180
+ [this.fieldName]: {
1181
+ data: toBase64(bytes),
1182
+ contentType,
1183
+ ...input.filename ? { filename: input.filename } : {}
1184
+ }
1185
+ });
1186
+ await executeRequest(this._entity._client._ctx, this._entity.toURL(), {
1187
+ method: "PATCH",
1188
+ headers: {
1189
+ "Content-Type": "application/json",
1190
+ Prefer: "return=minimal"
1191
+ },
1192
+ body: JSON.stringify(body),
1193
+ accept: "none",
1194
+ ...opts.signal ? { signal: opts.signal } : {}
1195
+ });
1196
+ }
1197
+ /**
1198
+ * Clear the container value. FMS has no documented per-field DELETE for
1199
+ * record-level data, so the supported path is to PATCH the record with
1200
+ * `{ <fieldName>: null }`.
1201
+ */
1202
+ async delete(opts = {}) {
1203
+ const body = { [this.fieldName]: null };
1204
+ await executeRequest(this._entity._client._ctx, this._entity.toURL(), {
1205
+ method: "PATCH",
1206
+ headers: {
1207
+ "Content-Type": "application/json",
1208
+ Prefer: "return=minimal"
1209
+ },
1210
+ body: JSON.stringify(body),
1211
+ accept: "none",
1212
+ ...opts.signal ? { signal: opts.signal } : {}
1213
+ });
1214
+ }
1215
+ };
1216
+ function buildContainerJsonBody(containers, regularFields = {}) {
1217
+ const body = { ...regularFields };
1218
+ for (const [field, value] of Object.entries(containers)) {
1219
+ if (!value.contentType) {
1220
+ throw new TypeError(`buildContainerJsonBody: "${field}".contentType is required`);
1221
+ }
1222
+ body[field] = value.data;
1223
+ body[`${field}@com.filemaker.odata.ContentType`] = value.contentType;
1224
+ if (value.filename) {
1225
+ body[`${field}@com.filemaker.odata.Filename`] = value.filename;
1226
+ }
1227
+ }
1228
+ return body;
1229
+ }
1230
+ function parseContentDispositionFilename(value) {
1231
+ const ext = value.match(/filename\*\s*=\s*([^']+)'[^']*'([^;]+)/i);
1232
+ if (ext) {
1233
+ const charset = ext[1].trim().toLowerCase();
1234
+ const encoded = ext[2].trim();
1235
+ try {
1236
+ const decoded = decodeURIComponent(encoded);
1237
+ return charset === "utf-8" || charset === "utf8" ? decoded : decoded;
1238
+ } catch {
1239
+ }
1240
+ }
1241
+ const plain = value.match(/filename\s*=\s*("([^"\\]*(?:\\.[^"\\]*)*)"|([^;]+))/i);
1242
+ if (plain) {
1243
+ const quoted = plain[2];
1244
+ if (quoted !== void 0) return quoted.replace(/\\(.)/g, "$1").trim();
1245
+ const unquoted = plain[3];
1246
+ if (unquoted !== void 0) return unquoted.trim();
1247
+ }
1248
+ return void 0;
1249
+ }
1250
+ function formatContentDisposition(filename) {
1251
+ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
1252
+ const needsRfc5987 = /[^\x00-\x7F]/.test(filename);
1253
+ let base;
1254
+ if (TOKEN_RE.test(filename)) {
1255
+ base = `inline; filename=${filename}`;
1256
+ } else {
1257
+ const safeAscii = filename.replace(/[^\x20-\x7E]/g, "_").replace(/"/g, '\\"');
1258
+ base = `inline; filename="${safeAscii}"`;
1259
+ }
1260
+ if (!needsRfc5987) return base;
1261
+ return `${base}; filename*=UTF-8''${encodeURIComponent(filename)}`;
1262
+ }
1263
+ async function toUint8Array(data) {
1264
+ if (data instanceof Uint8Array) return data;
1265
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
1266
+ const ab = await data.arrayBuffer();
1267
+ return new Uint8Array(ab);
1268
+ }
1269
+ function toBase64(bytes) {
1270
+ if (typeof Buffer !== "undefined") {
1271
+ return Buffer.from(bytes).toString("base64");
1272
+ }
1273
+ let bin = "";
1274
+ const chunk = 32768;
1275
+ for (let i = 0; i < bytes.length; i += chunk) {
1276
+ bin += String.fromCharCode.apply(
1277
+ null,
1278
+ Array.from(bytes.subarray(i, i + chunk))
1279
+ );
1280
+ }
1281
+ return btoa(bin);
1282
+ }
1283
+
1284
+ // src/scripts.ts
1285
+ function formatKey(key) {
1286
+ if (typeof key === "number") {
1287
+ if (!Number.isFinite(key)) {
1288
+ throw new TypeError("ScriptInvoker: key must be a finite number");
1289
+ }
1290
+ return String(key);
1291
+ }
1292
+ if (typeof key === "string") return `'${escapeStringLiteral(key)}'`;
1293
+ if (typeof key === "boolean") return key ? "true" : "false";
1294
+ throw new TypeError("ScriptInvoker: unsupported key type");
1295
+ }
1296
+ var ScriptInvoker = class {
1297
+ constructor(client, scope = {}) {
1298
+ this._client = client;
1299
+ if (scope.entitySet !== void 0) this.entitySet = scope.entitySet;
1300
+ if (scope.key !== void 0) this.key = scope.key;
1301
+ }
1302
+ /** Build the absolute URL for invoking `name` at this scope. */
1303
+ url(name) {
1304
+ if (!name) throw new TypeError("ScriptInvoker: script name is required");
1305
+ return this._urlForSegment(`Script.${encodePathSegment(name)}`);
1306
+ }
1307
+ /** Build the absolute URL for invoking by FMSID at this scope. */
1308
+ urlById(fmsid) {
1309
+ if (!Number.isFinite(fmsid)) throw new TypeError("ScriptInvoker: fmsid must be a finite number");
1310
+ return this._urlForSegment(`Script.FMSID:${fmsid}`);
1311
+ }
1312
+ /** @internal — build URL from a script path segment. */
1313
+ _urlForSegment(scriptSegment) {
1314
+ const base = this._client.baseUrl;
1315
+ if (this.entitySet === void 0) {
1316
+ return `${base}/${scriptSegment}`;
1317
+ }
1318
+ const setSegment = encodePathSegment(this.entitySet);
1319
+ if (this.key === void 0) {
1320
+ return `${base}/${setSegment}/${scriptSegment}`;
1321
+ }
1322
+ return `${base}/${setSegment}(${formatKey(this.key)})/${scriptSegment}`;
1323
+ }
1324
+ /** Invoke the script by name. Resolves to a `ScriptResult` on success. */
1325
+ async run(name, opts = {}) {
1326
+ return this._runAtUrl(this.url(name), opts);
1327
+ }
1328
+ /**
1329
+ * Invoke the script by its immutable FMSID.
1330
+ *
1331
+ * Requires FileMaker Server 2026+ (v26). Use `db.hasFeature('scriptsByFMSID')`
1332
+ * to check before calling.
1333
+ *
1334
+ * ```ts
1335
+ * const result = await db.scriptById(42, { parameter: 'hello' })
1336
+ * ```
1337
+ */
1338
+ async runById(fmsid, opts = {}) {
1339
+ return this._runAtUrl(this.urlById(fmsid), opts);
1340
+ }
1341
+ /** @internal — execute a script POST at the given URL. */
1342
+ async _runAtUrl(url, opts) {
1343
+ const headers = {};
1344
+ let body;
1345
+ if (opts.parameter !== void 0) {
1346
+ headers["Content-Type"] = "application/json";
1347
+ body = JSON.stringify({ scriptParameterValue: opts.parameter });
1348
+ }
1349
+ const method = "POST";
1350
+ const json = await executeJson(this._client._ctx, url, {
1351
+ method,
1352
+ headers,
1353
+ ...body !== void 0 ? { body } : {},
1354
+ accept: "json",
1355
+ ...opts.signal ? { signal: opts.signal } : {}
1356
+ });
1357
+ return parseScriptEnvelope(json, { url, method });
1358
+ }
1359
+ };
1360
+ function parseScriptEnvelope(raw, request) {
1361
+ const envelope = extractEnvelope(raw);
1362
+ const rawResult = envelope.scriptResult;
1363
+ let scriptResult;
1364
+ let scriptError;
1365
+ if (rawResult !== null && typeof rawResult === "object" && "resultParameter" in rawResult) {
1366
+ const nested = rawResult;
1367
+ scriptError = nested.code !== void 0 ? String(nested.code) : "0";
1368
+ scriptResult = nested.resultParameter !== void 0 ? String(nested.resultParameter) : void 0;
1369
+ } else {
1370
+ scriptError = envelope.scriptError !== void 0 ? String(envelope.scriptError) : "0";
1371
+ scriptResult = rawResult !== void 0 ? typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult) : void 0;
1372
+ }
1373
+ if (scriptError !== "0") {
1374
+ throw new FMScriptError(
1375
+ `FileMaker script error ${scriptError}`,
1376
+ {
1377
+ status: 200,
1378
+ scriptError,
1379
+ ...scriptResult !== void 0 ? { scriptResult } : {},
1380
+ odataError: raw,
1381
+ request
1382
+ }
1383
+ );
1384
+ }
1385
+ const out = { scriptError, raw };
1386
+ if (scriptResult !== void 0) out.scriptResult = scriptResult;
1387
+ return out;
1388
+ }
1389
+ function extractEnvelope(raw) {
1390
+ if (raw === null || typeof raw !== "object") return {};
1391
+ const obj = raw;
1392
+ if ("scriptError" in obj || "scriptResult" in obj) {
1393
+ return obj;
1394
+ }
1395
+ for (const v of Object.values(obj)) {
1396
+ if (v && typeof v === "object") {
1397
+ const inner = v;
1398
+ if ("scriptError" in inner || "scriptResult" in inner) {
1399
+ return inner;
1400
+ }
1401
+ }
1402
+ }
1403
+ return {};
1404
+ }
1405
+ function runScriptAtDatabase(client, name, opts) {
1406
+ return new ScriptInvoker(client).run(name, opts);
1407
+ }
1408
+ function runScriptByIdAtDatabase(client, fmsid, opts) {
1409
+ return new ScriptInvoker(client).runById(fmsid, opts);
1410
+ }
1411
+ function runScriptAtEntitySet(client, entitySet, name, opts) {
1412
+ return new ScriptInvoker(client, { entitySet }).run(name, opts);
1413
+ }
1414
+ function runScriptAtEntity(client, entitySet, key, name, opts) {
1415
+ return new ScriptInvoker(client, { entitySet, key }).run(name, opts);
1416
+ }
1417
+
1418
+ // src/entity.ts
1419
+ function formatKey2(key) {
1420
+ if (typeof key === "number") {
1421
+ if (!Number.isFinite(key)) {
1422
+ throw new TypeError("EntityRef: key must be a finite number");
1423
+ }
1424
+ return String(key);
1425
+ }
1426
+ if (typeof key === "string") return `'${escapeStringLiteral(key)}'`;
1427
+ if (typeof key === "boolean") return key ? "true" : "false";
1428
+ throw new TypeError("EntityRef: unsupported key type");
1429
+ }
1430
+ var EntityRef = class {
1431
+ constructor(client, entitySet, key) {
1432
+ this._client = client;
1433
+ this.entitySet = entitySet;
1434
+ this.key = key;
1435
+ }
1436
+ /** Absolute URL for this entity. */
1437
+ toURL() {
1438
+ return `${this._client.baseUrl}/${encodePathSegment(this.entitySet)}(${formatKey2(this.key)})`;
1439
+ }
1440
+ /** `GET` the entity. Returns the parsed JSON row. */
1441
+ async get(opts = {}) {
1442
+ const json = await executeJson(this._client._ctx, this.toURL(), {
1443
+ method: "GET",
1444
+ accept: "json",
1445
+ ...opts.signal ? { signal: opts.signal } : {}
1446
+ });
1447
+ return json;
1448
+ }
1449
+ /**
1450
+ * `GET` a single field's scalar value via the OData property URL
1451
+ * (`…/<EntitySet>(<key>)/<fieldName>`). FMS responds with the JSON envelope
1452
+ * `{ value: … }`; this method unwraps it and returns just the value.
1453
+ *
1454
+ * Useful when you only need one column without composing a `$select` query.
1455
+ * For container fields use `container(name).get()` instead.
1456
+ */
1457
+ async fieldValue(fieldName, opts = {}) {
1458
+ const url = `${this.toURL()}/${encodePathSegment(fieldName)}`;
1459
+ const json = await executeJson(this._client._ctx, url, {
1460
+ method: "GET",
1461
+ accept: "json",
1462
+ ...opts.signal ? { signal: opts.signal } : {}
1463
+ });
1464
+ return json.value;
1465
+ }
1466
+ /**
1467
+ * `PATCH` the entity with partial values. Returns the updated row when the
1468
+ * server echoes one (OData `Prefer: return=representation`), otherwise
1469
+ * `undefined` on `204 No Content`.
1470
+ */
1471
+ async patch(body, opts = {}) {
1472
+ const headers = {
1473
+ "Content-Type": "application/json",
1474
+ Prefer: opts.returnRepresentation ? "return=representation" : "return=minimal"
1475
+ };
1476
+ if (opts.ifMatch) headers["If-Match"] = opts.ifMatch;
1477
+ const json = await executeJson(this._client._ctx, this.toURL(), {
1478
+ method: "PATCH",
1479
+ headers,
1480
+ body: JSON.stringify(body),
1481
+ accept: "json",
1482
+ ...opts.signal ? { signal: opts.signal } : {}
1483
+ });
1484
+ return json;
1485
+ }
1486
+ /** `DELETE` the entity. Resolves on success; throws `FMSODataError` otherwise. */
1487
+ async delete(opts = {}) {
1488
+ const headers = {};
1489
+ if (opts.ifMatch) headers["If-Match"] = opts.ifMatch;
1490
+ await executeRequest(this._client._ctx, this.toURL(), {
1491
+ method: "DELETE",
1492
+ headers,
1493
+ accept: "none",
1494
+ ...opts.signal ? { signal: opts.signal } : {}
1495
+ });
1496
+ }
1497
+ /**
1498
+ * Invoke a FileMaker script in the context of this single record. FMS sets
1499
+ * the script's current record to this entity before running it.
1500
+ */
1501
+ async script(name, opts = {}) {
1502
+ return runScriptAtEntity(this._client, this.entitySet, this.key, name, opts);
1503
+ }
1504
+ /**
1505
+ * Get a typed handle to one of this record's container fields, exposing
1506
+ * `.get()`, `.getStream()`, `.upload(...)`, and `.delete()`.
1507
+ */
1508
+ container(fieldName) {
1509
+ return new ContainerRef(this, fieldName);
1510
+ }
1511
+ /**
1512
+ * Update one or more container fields (and optionally regular fields) on
1513
+ * this record in a single base64 PATCH request. This maps to the Claris
1514
+ * "Operation 3" (`PATCH /<EntitySet>(<key>)` with JSON body containing
1515
+ * `<field>`, `<field>@com.filemaker.odata.ContentType`, and
1516
+ * `<field>@com.filemaker.odata.Filename`).
1517
+ *
1518
+ * Each container value's `data` must already be base64-encoded (use the
1519
+ * library's exported `toBase64()` helper or `Buffer.from(bytes).toString('base64')`).
1520
+ *
1521
+ * @example
1522
+ * await db.from('contact').byKey(7).patchContainers(
1523
+ * {
1524
+ * photo: { data: photoB64, contentType: 'image/png', filename: 'p.png' },
1525
+ * contract: { data: contractB64, contentType: 'application/pdf', filename: 'c.pdf' },
1526
+ * },
1527
+ * { website: 'https://example.com' },
1528
+ * )
1529
+ */
1530
+ async patchContainers(containers, regularFields = {}, opts = {}) {
1531
+ const headers = {
1532
+ "Content-Type": "application/json",
1533
+ Prefer: opts.returnRepresentation ? "return=representation" : "return=minimal"
1534
+ };
1535
+ if (opts.ifMatch) headers["If-Match"] = opts.ifMatch;
1536
+ const body = buildContainerJsonBody(containers, regularFields);
1537
+ await executeRequest(this._client._ctx, this.toURL(), {
1538
+ method: "PATCH",
1539
+ headers,
1540
+ body: JSON.stringify(body),
1541
+ accept: "none",
1542
+ ...opts.signal ? { signal: opts.signal } : {}
1543
+ });
1544
+ }
1545
+ // -------------------------------------------------------------------------
1546
+ // Record references ($ref) — OData standard, supported since FMS 19
1547
+ // -------------------------------------------------------------------------
1548
+ /**
1549
+ * Get the references for a navigation property on this record.
1550
+ *
1551
+ * `GET /<EntitySet>(<key>)/<navProperty>/$ref`
1552
+ *
1553
+ * Returns an array of entity references. For a single-valued navigation
1554
+ * property, the array has at most one element.
1555
+ *
1556
+ * @example
1557
+ * ```ts
1558
+ * const refs = await db.from('contact').byKey(7).getRefs('addresses')
1559
+ * // [{ '@odata.id': 'https://fms.example.com/fmi/odata/v4/DB/address(1)' }, ...]
1560
+ * ```
1561
+ */
1562
+ async getRefs(navProperty, opts = {}) {
1563
+ const url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`;
1564
+ const json = await executeJson(
1565
+ this._client._ctx,
1566
+ url,
1567
+ {
1568
+ method: "GET",
1569
+ accept: "json",
1570
+ ...opts.signal ? { signal: opts.signal } : {}
1571
+ }
1572
+ );
1573
+ if (json?.value) return json.value;
1574
+ if (json?.["@odata.id"]) return [json];
1575
+ return [];
1576
+ }
1577
+ /**
1578
+ * Add a reference to a related record via a navigation property.
1579
+ *
1580
+ * `POST /<EntitySet>(<key>)/<navProperty>/$ref`
1581
+ *
1582
+ * For single-valued navigation properties, use `setRef()` instead (PATCH).
1583
+ *
1584
+ * @example
1585
+ * ```ts
1586
+ * await db.from('contact').byKey(7).addRef('addresses', 42)
1587
+ * ```
1588
+ */
1589
+ async addRef(navProperty, relatedKey, opts = {}) {
1590
+ const url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`;
1591
+ const headers = { "Content-Type": "application/json" };
1592
+ if (opts.ifMatch) headers["If-Match"] = opts.ifMatch;
1593
+ const body = JSON.stringify({ "@odata.id": this._refId(navProperty, relatedKey) });
1594
+ await executeRequest(this._client._ctx, url, {
1595
+ method: "POST",
1596
+ headers,
1597
+ body,
1598
+ accept: "none",
1599
+ ...opts.signal ? { signal: opts.signal } : {}
1600
+ });
1601
+ }
1602
+ /**
1603
+ * Set (replace) the reference for a single-valued navigation property.
1604
+ *
1605
+ * `PATCH /<EntitySet>(<key>)/<navProperty>/$ref`
1606
+ *
1607
+ * @example
1608
+ * ```ts
1609
+ * await db.from('order').byKey(100).setRef('customer', 7)
1610
+ * ```
1611
+ */
1612
+ async setRef(navProperty, relatedKey, opts = {}) {
1613
+ const url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`;
1614
+ const headers = { "Content-Type": "application/json" };
1615
+ if (opts.ifMatch) headers["If-Match"] = opts.ifMatch;
1616
+ const body = JSON.stringify({ "@odata.id": this._refId(navProperty, relatedKey) });
1617
+ await executeRequest(this._client._ctx, url, {
1618
+ method: "PATCH",
1619
+ headers,
1620
+ body,
1621
+ accept: "none",
1622
+ ...opts.signal ? { signal: opts.signal } : {}
1623
+ });
1624
+ }
1625
+ /**
1626
+ * Remove a reference from a navigation property.
1627
+ *
1628
+ * `DELETE /<EntitySet>(<key>)/<navProperty>/$ref`
1629
+ *
1630
+ * For collection-valued navigation properties, pass the `relatedKey` to
1631
+ * remove a specific reference. For single-valued, omit `relatedKey` to
1632
+ * clear the reference.
1633
+ *
1634
+ * @example
1635
+ * ```ts
1636
+ * await db.from('contact').byKey(7).removeRef('addresses', 42)
1637
+ * await db.from('order').byKey(100).removeRef('customer')
1638
+ * ```
1639
+ */
1640
+ async removeRef(navProperty, relatedKey, opts = {}) {
1641
+ let url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`;
1642
+ const headers = {};
1643
+ if (opts.ifMatch) headers["If-Match"] = opts.ifMatch;
1644
+ if (relatedKey !== void 0) {
1645
+ url += `('${escapeStringLiteral(this._refId(navProperty, relatedKey))}')`;
1646
+ }
1647
+ await executeRequest(this._client._ctx, url, {
1648
+ method: "DELETE",
1649
+ headers,
1650
+ accept: "none",
1651
+ ...opts.signal ? { signal: opts.signal } : {}
1652
+ });
1653
+ }
1654
+ /** @internal — build a relative @odata.id for a related entity. */
1655
+ _refId(navProperty, relatedKey) {
1656
+ const navSet = navProperty;
1657
+ if (typeof relatedKey === "number") return `${navSet}(${relatedKey})`;
1658
+ return `${navSet}('${escapeStringLiteral(relatedKey)}')`;
1659
+ }
1660
+ };
1661
+
1662
+ // src/query.ts
1663
+ var Filter = class _Filter {
1664
+ constructor(expr) {
1665
+ this.expr = expr;
1666
+ }
1667
+ toString() {
1668
+ return this.expr;
1669
+ }
1670
+ and(other) {
1671
+ return new _Filter(`(${this.expr}) and (${_Filter.coerce(other)})`);
1672
+ }
1673
+ or(other) {
1674
+ return new _Filter(`(${this.expr}) or (${_Filter.coerce(other)})`);
1675
+ }
1676
+ not() {
1677
+ return new _Filter(`not (${this.expr})`);
1678
+ }
1679
+ /** @internal */
1680
+ static coerce(x) {
1681
+ return x instanceof _Filter ? x.expr : x;
1682
+ }
1683
+ };
1684
+ var filterFactory = {
1685
+ eq: (f, v) => new Filter(`${f} eq ${formatLiteral(v)}`),
1686
+ ne: (f, v) => new Filter(`${f} ne ${formatLiteral(v)}`),
1687
+ gt: (f, v) => new Filter(`${f} gt ${formatLiteral(v)}`),
1688
+ ge: (f, v) => new Filter(`${f} ge ${formatLiteral(v)}`),
1689
+ lt: (f, v) => new Filter(`${f} lt ${formatLiteral(v)}`),
1690
+ le: (f, v) => new Filter(`${f} le ${formatLiteral(v)}`),
1691
+ startswith: (f, v) => new Filter(`startswith(${f},${formatLiteral(v)})`),
1692
+ endswith: (f, v) => new Filter(`endswith(${f},${formatLiteral(v)})`),
1693
+ contains: (f, v) => new Filter(`contains(${f},${formatLiteral(v)})`),
1694
+ and: (a, b) => new Filter(`(${Filter.coerce(a)}) and (${Filter.coerce(b)})`),
1695
+ or: (a, b) => new Filter(`(${Filter.coerce(a)}) or (${Filter.coerce(b)})`),
1696
+ not: (a) => new Filter(`not (${Filter.coerce(a)})`),
1697
+ raw: (s) => new Filter(s)
1698
+ };
1699
+ function resolveFilter(input) {
1700
+ if (typeof input === "function") return Filter.coerce(input(filterFactory));
1701
+ return Filter.coerce(input);
1702
+ }
1703
+ var Query = class _Query {
1704
+ constructor(baseUrl, entitySet, client) {
1705
+ /** @internal */
1706
+ this._state = {};
1707
+ this._baseUrl = baseUrl;
1708
+ this._entitySet = entitySet;
1709
+ if (client) this._client = client;
1710
+ }
1711
+ select(...fields) {
1712
+ this._state.select = [...this._state.select ?? [], ...fields];
1713
+ return this;
1714
+ }
1715
+ filter(input) {
1716
+ const expr = resolveFilter(input);
1717
+ this._state.filter = this._state.filter ? `(${this._state.filter}) and (${expr})` : expr;
1718
+ return this;
1719
+ }
1720
+ or(input) {
1721
+ const expr = resolveFilter(input);
1722
+ this._state.filter = this._state.filter ? `(${this._state.filter}) or (${expr})` : expr;
1723
+ return this;
1724
+ }
1725
+ expand(name, build) {
1726
+ const entry = { name };
1727
+ if (build) {
1728
+ const nested = new _Query("", name);
1729
+ build(nested);
1730
+ entry.options = nested._state;
1731
+ }
1732
+ this._state.expand = [...this._state.expand ?? [], entry];
1733
+ return this;
1734
+ }
1735
+ orderby(field, dir = "asc") {
1736
+ this._state.orderby = [...this._state.orderby ?? [], { field, dir }];
1737
+ return this;
1738
+ }
1739
+ top(n) {
1740
+ if (!Number.isInteger(n) || n < 0) {
1741
+ throw new RangeError(`Query#top: expected non-negative integer, got ${n}`);
1742
+ }
1743
+ this._state.top = n;
1744
+ return this;
1745
+ }
1746
+ skip(n) {
1747
+ if (!Number.isInteger(n) || n < 0) {
1748
+ throw new RangeError(`Query#skip: expected non-negative integer, got ${n}`);
1749
+ }
1750
+ this._state.skip = n;
1751
+ return this;
1752
+ }
1753
+ count(enabled = true) {
1754
+ this._state.count = enabled;
1755
+ return this;
1756
+ }
1757
+ search(term) {
1758
+ this._state.search = term;
1759
+ return this;
1760
+ }
1761
+ // -------------------------------------------------------------------------
1762
+ // $apply (aggregation) — requires FMS 22.0.1+ (FileMaker 2024)
1763
+ // -------------------------------------------------------------------------
1764
+ /**
1765
+ * Set a raw `$apply` expression. Use this for advanced transformations
1766
+ * that the `aggregate()` / `groupBy()` helpers don't cover.
1767
+ *
1768
+ * Requires FileMaker Server 2024+ (v22). Use `db.hasFeature('applyAggregation')`
1769
+ * to check before calling.
1770
+ *
1771
+ * @example
1772
+ * ```ts
1773
+ * const result = await db.from('orders').apply('aggregate(total with sum as totalSum)')
1774
+ * .get()
1775
+ * ```
1776
+ */
1777
+ apply(expr) {
1778
+ this._state.apply = expr;
1779
+ return this;
1780
+ }
1781
+ /**
1782
+ * Aggregate the entity set. Produces a `$apply=aggregate(...)` expression.
1783
+ *
1784
+ * Requires FileMaker Server 2024+ (v22).
1785
+ *
1786
+ * @example
1787
+ * ```ts
1788
+ * const result = await db.from('orders')
1789
+ * .aggregate([{ field: 'total', function: 'sum', alias: 'totalSum' }])
1790
+ * .get()
1791
+ * // $apply=aggregate(total with sum as totalSum)
1792
+ * ```
1793
+ */
1794
+ aggregate(expressions) {
1795
+ const parts = expressions.map((e) => `${e.field} with ${e.function} as ${e.alias}`);
1796
+ this._state.apply = `aggregate(${parts.join(",")})`;
1797
+ return this;
1798
+ }
1799
+ /**
1800
+ * Group the entity set by one or more fields, optionally with aggregation.
1801
+ * Produces a `$apply=groupby((fields), aggregate(...))` expression.
1802
+ *
1803
+ * Requires FileMaker Server 2024+ (v22).
1804
+ *
1805
+ * @example
1806
+ * ```ts
1807
+ * const result = await db.from('orders')
1808
+ * .groupBy(
1809
+ * ['customerId'],
1810
+ * [{ field: 'total', function: 'sum', alias: 'totalSum' }],
1811
+ * )
1812
+ * .get()
1813
+ * // $apply=groupby((customerId),aggregate(total with sum as totalSum))
1814
+ * ```
1815
+ */
1816
+ groupBy(fields, aggregateExpressions) {
1817
+ const fieldList = fields.join(",");
1818
+ if (aggregateExpressions && aggregateExpressions.length > 0) {
1819
+ const aggParts = aggregateExpressions.map((e) => `${e.field} with ${e.function} as ${e.alias}`);
1820
+ this._state.apply = `groupby((${fieldList}),aggregate(${aggParts.join(",")}))`;
1821
+ } else {
1822
+ this._state.apply = `groupby((${fieldList}))`;
1823
+ }
1824
+ return this;
1825
+ }
1826
+ /** Build the absolute request URL for this query. */
1827
+ toURL() {
1828
+ const qs = serializeOptions(this._state, { topLevel: true });
1829
+ const base = `${this._baseUrl}/${encodePathSegment(this._entitySet)}`;
1830
+ return qs ? `${base}?${qs}` : base;
1831
+ }
1832
+ /**
1833
+ * Get a handle to a single entity by its primary key. Subsequent operations
1834
+ * (`.get()`, `.patch()`, `.delete()`) hit `/<EntitySet>(<key>)`.
1835
+ */
1836
+ byKey(key) {
1837
+ if (!this._client) {
1838
+ throw new Error("Query#byKey: no client attached (use FMSOData#from)");
1839
+ }
1840
+ return new EntityRef(this._client, this._entitySet, key);
1841
+ }
1842
+ /**
1843
+ * `POST` a new entity to the collection. Returns the created row (FMS echoes
1844
+ * it by default).
1845
+ */
1846
+ async create(body, opts = {}) {
1847
+ if (!this._client) {
1848
+ throw new Error("Query#create: no client attached (use FMSOData#from)");
1849
+ }
1850
+ const url = `${this._baseUrl}/${encodePathSegment(this._entitySet)}`;
1851
+ const json = await executeJson(this._client._ctx, url, {
1852
+ method: "POST",
1853
+ headers: { "Content-Type": "application/json" },
1854
+ body: JSON.stringify(body),
1855
+ accept: "json",
1856
+ ...opts.signal ? { signal: opts.signal } : {}
1857
+ });
1858
+ return json;
1859
+ }
1860
+ /**
1861
+ * `POST` a new entity carrying one or more container fields. Maps to the
1862
+ * Claris "Operation 1" — the request body is a JSON object containing
1863
+ * regular field values plus, for each container field, the base64 data
1864
+ * and the `@com.filemaker.odata.{ContentType,Filename}` annotations.
1865
+ *
1866
+ * Each container value's `data` must already be base64-encoded.
1867
+ *
1868
+ * @example
1869
+ * await db.from('contact').createWithContainers(
1870
+ * { first_name: 'Bob', last_name: 'Jones' },
1871
+ * { photo: { data: photoB64, contentType: 'image/png', filename: 'BJONES.png' } },
1872
+ * )
1873
+ */
1874
+ async createWithContainers(regularFields, containers, opts = {}) {
1875
+ if (!this._client) {
1876
+ throw new Error("Query#createWithContainers: no client attached (use FMSOData#from)");
1877
+ }
1878
+ const url = `${this._baseUrl}/${encodePathSegment(this._entitySet)}`;
1879
+ const body = buildContainerJsonBody(
1880
+ containers,
1881
+ regularFields
1882
+ );
1883
+ const json = await executeJson(this._client._ctx, url, {
1884
+ method: "POST",
1885
+ headers: { "Content-Type": "application/json" },
1886
+ body: JSON.stringify(body),
1887
+ accept: "json",
1888
+ ...opts.signal ? { signal: opts.signal } : {}
1889
+ });
1890
+ return json;
1891
+ }
1892
+ /**
1893
+ * Execute the query. Returns the parsed OData collection envelope.
1894
+ */
1895
+ async get(opts = {}) {
1896
+ if (!this._client) {
1897
+ throw new Error("Query#get: no client attached (use FMSOData#from)");
1898
+ }
1899
+ const json = await executeJson(this._client._ctx, this.toURL(), {
1900
+ method: "GET",
1901
+ accept: "json",
1902
+ ...opts.signal ? { signal: opts.signal } : {}
1903
+ });
1904
+ const out = { value: json?.value ?? [] };
1905
+ if (json && typeof json["@odata.count"] === "number") out.count = json["@odata.count"];
1906
+ if (json && typeof json["@odata.nextLink"] === "string") out.nextLink = json["@odata.nextLink"];
1907
+ return out;
1908
+ }
1909
+ /**
1910
+ * Invoke a FileMaker script in the context of this query's entity set.
1911
+ *
1912
+ * Filter / select / orderby / paging state on the `Query` is **ignored** —
1913
+ * the underlying OData Action only cares about the entity set. Use
1914
+ * `EntityRef#script` to run a script in the context of a specific record.
1915
+ */
1916
+ async script(name, opts = {}) {
1917
+ if (!this._client) {
1918
+ throw new Error("Query#script: no client attached (use FMSOData#from)");
1919
+ }
1920
+ return runScriptAtEntitySet(this._client, this._entitySet, name, opts);
1921
+ }
1922
+ };
1923
+ function serializeOptions(s, opts) {
1924
+ const pairs = [];
1925
+ if (s.select && s.select.length > 0) {
1926
+ pairs.push(["$select", s.select.join(",")]);
1927
+ }
1928
+ if (s.filter) {
1929
+ pairs.push(["$filter", s.filter]);
1930
+ }
1931
+ if (s.expand && s.expand.length > 0) {
1932
+ const parts = s.expand.map((e) => {
1933
+ if (!e.options) return e.name;
1934
+ const inner = serializeOptions(e.options, { topLevel: false });
1935
+ return inner ? `${e.name}(${inner})` : e.name;
1936
+ });
1937
+ pairs.push(["$expand", parts.join(",")]);
1938
+ }
1939
+ if (s.orderby && s.orderby.length > 0) {
1940
+ pairs.push([
1941
+ "$orderby",
1942
+ s.orderby.map((o) => `${o.field} ${o.dir}`).join(",")
1943
+ ]);
1944
+ }
1945
+ if (s.top !== void 0) pairs.push(["$top", String(s.top)]);
1946
+ if (s.skip !== void 0) pairs.push(["$skip", String(s.skip)]);
1947
+ if (s.count) pairs.push(["$count", "true"]);
1948
+ if (s.search) pairs.push(["$search", s.search]);
1949
+ if (s.apply) pairs.push(["$apply", s.apply]);
1950
+ if (opts.topLevel) return buildQueryString(pairs);
1951
+ return pairs.map(([k, v]) => `${k}=${v}`).join(";");
1952
+ }
1953
+
1954
+ // src/client.ts
1955
+ var FMSOData = class {
1956
+ constructor(options) {
1957
+ if (!options.host) throw new TypeError("FMSOData: `host` is required");
1958
+ if (!options.database) throw new TypeError("FMSOData: `database` is required");
1959
+ if (options.token === void 0 || options.token === null) {
1960
+ throw new TypeError("FMSOData: `token` is required");
1961
+ }
1962
+ this.host = options.host.replace(/\/+$/, "");
1963
+ this.database = options.database;
1964
+ this.baseUrl = `${this.host}/fmi/odata/v4/${encodeURIComponent(this.database)}`;
1965
+ this.timeoutMs = options.timeoutMs;
1966
+ this._ctx = {
1967
+ token: options.token,
1968
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
1969
+ timeoutMs: options.timeoutMs,
1970
+ ...options.onUnauthorized ? { onUnauthorized: options.onUnauthorized } : {}
1971
+ };
1972
+ }
1973
+ /**
1974
+ * Start a query against the given entity set (FileMaker layout name).
1975
+ */
1976
+ from(entitySet) {
1977
+ if (!entitySet) throw new TypeError("FMSOData#from: entitySet is required");
1978
+ return new Query(this.baseUrl, entitySet, this);
1979
+ }
1980
+ /**
1981
+ * Low-level escape hatch: execute a raw request against a path relative to
1982
+ * the database base URL (or an absolute URL). Returns the parsed JSON body.
1983
+ *
1984
+ * @example
1985
+ * ```ts
1986
+ * const body = await db.request<{ value: unknown[] }>('/contact?$top=1')
1987
+ * ```
1988
+ */
1989
+ async request(pathOrUrl, opts = {}) {
1990
+ return executeJson(this._ctx, this._resolveUrl(pathOrUrl), opts);
1991
+ }
1992
+ /**
1993
+ * Low-level escape hatch: execute a raw request and return the `Response`
1994
+ * object directly (useful for binary / streaming responses).
1995
+ */
1996
+ async rawRequest(pathOrUrl, opts = {}) {
1997
+ return executeRequest(this._ctx, this._resolveUrl(pathOrUrl), opts);
1998
+ }
1999
+ /**
2000
+ * Invoke a FileMaker script at database scope.
2001
+ *
2002
+ * ```ts
2003
+ * const result = await db.script('Ping', { parameter: 'hello' })
2004
+ * console.log(result.scriptResult) // => string value returned by the script
2005
+ * ```
2006
+ *
2007
+ * A non-zero `scriptError` is thrown as `FMScriptError`.
2008
+ */
2009
+ async script(name, opts = {}) {
2010
+ return runScriptAtDatabase(this, name, opts);
2011
+ }
2012
+ /**
2013
+ * Invoke a FileMaker script by its immutable FMSID.
2014
+ *
2015
+ * Requires FileMaker Server 2026+ (v26). Use `hasFeature('scriptsByFMSID')`
2016
+ * to check before calling. FMSID-based invocation is more stable than
2017
+ * name-based: it survives script renames and works across database
2018
+ * migrations.
2019
+ *
2020
+ * ```ts
2021
+ * const result = await db.scriptById(42, { parameter: 'hello' })
2022
+ * ```
2023
+ */
2024
+ async scriptById(fmsid, opts = {}) {
2025
+ return runScriptByIdAtDatabase(this, fmsid, opts);
2026
+ }
2027
+ /**
2028
+ * Fetch the OData CSDL `$metadata` XML and parse it into a typed structure.
2029
+ * Results are cached; pass `refresh: true` to force a refetch.
2030
+ *
2031
+ * ```ts
2032
+ * const meta = await db.metadata()
2033
+ * console.log(meta.entitySets.map(es => es.name))
2034
+ * ```
2035
+ */
2036
+ async metadata(opts = {}) {
2037
+ if (!this._metadataFetcher) {
2038
+ this._metadataFetcher = new MetadataFetcher(this._ctx, this.baseUrl);
2039
+ }
2040
+ return this._metadataFetcher.fetch(opts);
2041
+ }
2042
+ /**
2043
+ * Fetch the raw `$metadata` XML (escape hatch for debugging or custom parsing).
2044
+ */
2045
+ async metadataXml(opts = {}) {
2046
+ if (!this._metadataFetcher) {
2047
+ this._metadataFetcher = new MetadataFetcher(this._ctx, this.baseUrl);
2048
+ }
2049
+ return this._metadataFetcher.fetchXml(opts);
2050
+ }
2051
+ /**
2052
+ * Detect the FileMaker Server major version by fetching `$metadata` and
2053
+ * parsing the version annotation using a multi-strategy approach (see
2054
+ * `@fms-odata/spec-ts` `parseServerVersion`). The result is cached for the
2055
+ * lifetime of this `FMSOData` instance.
2056
+ *
2057
+ * Returns the major version string (`'19'`, `'21'`, `'22'`, `'26'`) or
2058
+ * `'future'` if the version is newer than the spec knows about. Returns
2059
+ * `null` if the version cannot be determined (e.g. the metadata lacks the
2060
+ * annotation).
2061
+ *
2062
+ * ```ts
2063
+ * const v = await db.version()
2064
+ * if (v === '26') console.log('Server is FileMaker 2026')
2065
+ * ```
2066
+ */
2067
+ async version() {
2068
+ if (this._detectedVersion !== void 0) return this._detectedVersion;
2069
+ try {
2070
+ const sv = await this.serverVersion();
2071
+ if (!sv) {
2072
+ this._detectedVersion = null;
2073
+ return null;
2074
+ }
2075
+ const major = String(sv.major);
2076
+ this._detectedVersion = major in FM_VERSION_MATRIX ? major : "future";
2077
+ return this._detectedVersion;
2078
+ } catch {
2079
+ this._detectedVersion = null;
2080
+ return null;
2081
+ }
2082
+ }
2083
+ /**
2084
+ * Get the full parsed FileMaker Server version (major, minor, patch, raw)
2085
+ * by fetching `$metadata` and parsing the version annotation. The result is
2086
+ * cached for the lifetime of this `FMSOData` instance.
2087
+ *
2088
+ * Returns `null` if the version cannot be determined.
2089
+ *
2090
+ * ```ts
2091
+ * const sv = await db.serverVersion()
2092
+ * if (sv) console.log(`Server is ${sv.raw} (major ${sv.major})`)
2093
+ * ```
2094
+ */
2095
+ async serverVersion() {
2096
+ if (this._detectedServerVersion !== void 0) return this._detectedServerVersion;
2097
+ try {
2098
+ const meta = await this.metadata();
2099
+ this._detectedServerVersion = meta.serverVersion ?? null;
2100
+ return this._detectedServerVersion;
2101
+ } catch {
2102
+ this._detectedServerVersion = null;
2103
+ return null;
2104
+ }
2105
+ }
2106
+ /**
2107
+ * Get the full version info (feature flags + query option flags) for the
2108
+ * detected server version. Fetches metadata if not already cached.
2109
+ *
2110
+ * Returns `null` if the version cannot be determined.
2111
+ *
2112
+ * ```ts
2113
+ * const info = await db.versionInfo()
2114
+ * if (info?.features.applyAggregation) {
2115
+ * // Server supports $apply
2116
+ * }
2117
+ * ```
2118
+ */
2119
+ async versionInfo() {
2120
+ const v = await this.version();
2121
+ if (!v) return null;
2122
+ return FM_VERSION_MATRIX[v];
2123
+ }
2124
+ /**
2125
+ * Check if the server supports a specific feature. Fetches metadata (to
2126
+ * detect the version) on first call; subsequent calls use the cached result.
2127
+ *
2128
+ * Returns `false` if the version cannot be determined.
2129
+ *
2130
+ * ```ts
2131
+ * if (await db.hasFeature('applyAggregation')) {
2132
+ * const result = await db.from('orders').apply(...)
2133
+ * }
2134
+ * ```
2135
+ */
2136
+ async hasFeature(feature) {
2137
+ const v = await this.version();
2138
+ if (!v) return false;
2139
+ return hasFeature(v, feature);
2140
+ }
2141
+ /**
2142
+ * Create a new `$batch` builder for composing multiple OData operations
2143
+ * into a single HTTP round-trip.
2144
+ *
2145
+ * Read operations (`add`) are executed independently. Write operations
2146
+ * (`changeset`) are grouped atomically — all succeed or all fail.
2147
+ *
2148
+ * ```ts
2149
+ * const batch = db.batch()
2150
+ * const contacts = batch.add({ op: 'list', entitySet: 'contact', query: { $top: 5 } })
2151
+ * batch.changeset(cs => {
2152
+ * cs.create('contact', { firstName: 'A', lastName: 'B' })
2153
+ * cs.patch('contact', 123, { firstName: 'Updated' })
2154
+ * })
2155
+ * const result = await batch.send()
2156
+ * console.log(await contacts._promise) // First op result
2157
+ * ```
2158
+ */
2159
+ batch() {
2160
+ return new Batch(this);
2161
+ }
2162
+ /** @internal */
2163
+ _resolveUrl(pathOrUrl) {
2164
+ if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
2165
+ if (pathOrUrl.startsWith("/")) return `${this.baseUrl}${pathOrUrl}`;
2166
+ return `${this.baseUrl}/${pathOrUrl}`;
2167
+ }
2168
+ };
2169
+ export {
2170
+ Batch,
2171
+ Changeset,
2172
+ ContainerRef,
2173
+ EntityRef,
2174
+ FMSOData as FMOData,
2175
+ FMSODataError as FMODataError,
2176
+ FMSOData,
2177
+ FMSODataError,
2178
+ FMScriptError,
2179
+ FM_CONTAINER_SUPPORTED_MIME_TYPES,
2180
+ FM_VERSION_MATRIX,
2181
+ FM_VERSION_NAMES,
2182
+ Filter,
2183
+ MetadataFetcher,
2184
+ ODATA_PROTOCOL_VERSION,
2185
+ Query,
2186
+ ScriptInvoker,
2187
+ basicAuth,
2188
+ buildContainerJsonBody,
2189
+ filterFactory,
2190
+ fmidAuth,
2191
+ hasFeature,
2192
+ hasQueryOption,
2193
+ isFMSODataError as isFMODataError,
2194
+ isFMSODataError,
2195
+ isFMScriptError,
2196
+ minVersionForFeature,
2197
+ parseServerVersion,
2198
+ parseVersionString,
2199
+ sniffContainerMime,
2200
+ toBase64
2201
+ };