fms-odata-js 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -0
- package/LICENSE +22 -0
- package/README.md +381 -0
- package/dist/batch.d.ts +154 -0
- package/dist/batch.d.ts.map +1 -0
- package/dist/client.d.ts +160 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/containers.d.ts +190 -0
- package/dist/containers.d.ts.map +1 -0
- package/dist/entity.d.ts +149 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/errors.d.ts +69 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fms-odata.esm.js +2201 -0
- package/dist/fms-odata.esm.min.js +6 -0
- package/dist/fms-odata.iife.min.js +6 -0
- package/dist/http.d.ts +51 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/metadata.d.ts +85 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/query.d.ts +198 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/scripts.d.ts +114 -0
- package/dist/scripts.d.ts.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/url.d.ts +49 -0
- package/dist/url.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/batch.ts +477 -0
- package/src/client.ts +268 -0
- package/src/containers.ts +457 -0
- package/src/entity.ts +334 -0
- package/src/errors.ts +134 -0
- package/src/http.ts +190 -0
- package/src/index.ts +81 -0
- package/src/metadata.ts +354 -0
- package/src/query.ts +442 -0
- package/src/scripts.ts +279 -0
- package/src/types.ts +30 -0
- package/src/url.ts +98 -0
|
@@ -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
|
+
};
|