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/src/scripts.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * FileMaker script invocation.
3
+ *
4
+ * FMS exposes FileMaker scripts through the OData v4 Action mechanism at a
5
+ * `Script.<ScriptName>` path suffix. Scripts can be invoked at three scopes:
6
+ *
7
+ * POST /<db>/Script.<name> // database-level
8
+ * POST /<db>/<EntitySet>/Script.<name> // entity-set context
9
+ * POST /<db>/<EntitySet>(<key>)/Script.<name> // single-record context
10
+ *
11
+ * Starting with FileMaker Server 2026 (v26), scripts can also be invoked by
12
+ * their immutable FMSID instead of the name:
13
+ *
14
+ * POST /<db>/Script.FMSID:<id> // database-level
15
+ * POST /<db>/<EntitySet>/Script.FMSID:<id> // entity-set context
16
+ * POST /<db>/<EntitySet>(<key>)/Script.FMSID:<id> // single-record context
17
+ *
18
+ * The optional parameter is sent as `{ "scriptParameterValue": "<string>" }`. The
19
+ * response envelope is `{ "scriptResult": "...", "scriptError": "0" }`; a
20
+ * non-zero `scriptError` becomes an `FMScriptError`.
21
+ *
22
+ * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md
23
+ */
24
+
25
+ import type { FMSOData } from './client.js'
26
+ import { FMScriptError } from './errors.js'
27
+ import { executeJson } from './http.js'
28
+ import type { RequestOptions } from './types.js'
29
+ import {
30
+ encodePathSegment,
31
+ escapeStringLiteral,
32
+ type ODataLiteral,
33
+ } from './url.js'
34
+
35
+ /** Script identifier: either by name or by FMSID (v26+). */
36
+ export type ScriptIdentifier =
37
+ | { type: 'name'; name: string }
38
+ | { type: 'fmsid'; id: number }
39
+
40
+ /** Options accepted by a script invocation. */
41
+ export interface ScriptOptions extends RequestOptions {
42
+ /**
43
+ * Optional script parameter. Serialized to the FMS `scriptParameterValue`
44
+ * field in the request body. If omitted, the body is empty and the script
45
+ * runs with no parameter (FileMaker's `Get(ScriptParameter)` returns empty).
46
+ */
47
+ parameter?: string
48
+ }
49
+
50
+ /**
51
+ * Result envelope returned by a successful script invocation. A non-zero
52
+ * `scriptError` is promoted to an `FMScriptError` before reaching the caller,
53
+ * so values you receive here always represent success (`scriptError === "0"`).
54
+ */
55
+ export interface ScriptResult {
56
+ /** Raw value returned by `Exit Script [Text Result: ...]`. */
57
+ scriptResult?: string
58
+ /** Always `"0"` in a resolved result; non-zero raises `FMScriptError`. */
59
+ scriptError: string
60
+ /** Full parsed response body, for forward-compatible field access. */
61
+ raw: unknown
62
+ }
63
+
64
+ /** Format an OData primary-key literal for embedding in a URL path. */
65
+ function formatKey(key: ODataLiteral): string {
66
+ if (typeof key === 'number') {
67
+ if (!Number.isFinite(key)) {
68
+ throw new TypeError('ScriptInvoker: key must be a finite number')
69
+ }
70
+ return String(key)
71
+ }
72
+ if (typeof key === 'string') return `'${escapeStringLiteral(key)}'`
73
+ if (typeof key === 'boolean') return key ? 'true' : 'false'
74
+ throw new TypeError('ScriptInvoker: unsupported key type')
75
+ }
76
+
77
+ /** Scope describing where a `ScriptInvoker` is rooted. */
78
+ export interface ScriptScope {
79
+ /** When omitted the script runs at database scope. */
80
+ entitySet?: string
81
+ /** When present alongside `entitySet`, the script runs at record scope. */
82
+ key?: ODataLiteral
83
+ }
84
+
85
+ /**
86
+ * Low-level handle used internally by `FMSOData#script`, `Query#script`, and
87
+ * `EntityRef#script`. Exposed so advanced callers can build their own
88
+ * invocation paths if needed.
89
+ */
90
+ export class ScriptInvoker {
91
+ /** @internal */ readonly _client: FMSOData
92
+ readonly entitySet: string | undefined
93
+ readonly key: ODataLiteral | undefined
94
+
95
+ constructor(client: FMSOData, scope: ScriptScope = {}) {
96
+ this._client = client
97
+ if (scope.entitySet !== undefined) this.entitySet = scope.entitySet
98
+ if (scope.key !== undefined) this.key = scope.key
99
+ }
100
+
101
+ /** Build the absolute URL for invoking `name` at this scope. */
102
+ url(name: string): string {
103
+ if (!name) throw new TypeError('ScriptInvoker: script name is required')
104
+ return this._urlForSegment(`Script.${encodePathSegment(name)}`)
105
+ }
106
+
107
+ /** Build the absolute URL for invoking by FMSID at this scope. */
108
+ urlById(fmsid: number): string {
109
+ if (!Number.isFinite(fmsid)) throw new TypeError('ScriptInvoker: fmsid must be a finite number')
110
+ return this._urlForSegment(`Script.FMSID:${fmsid}`)
111
+ }
112
+
113
+ /** @internal — build URL from a script path segment. */
114
+ private _urlForSegment(scriptSegment: string): string {
115
+ const base = this._client.baseUrl
116
+ if (this.entitySet === undefined) {
117
+ return `${base}/${scriptSegment}`
118
+ }
119
+ const setSegment = encodePathSegment(this.entitySet)
120
+ if (this.key === undefined) {
121
+ return `${base}/${setSegment}/${scriptSegment}`
122
+ }
123
+ return `${base}/${setSegment}(${formatKey(this.key)})/${scriptSegment}`
124
+ }
125
+
126
+ /** Invoke the script by name. Resolves to a `ScriptResult` on success. */
127
+ async run(name: string, opts: ScriptOptions = {}): Promise<ScriptResult> {
128
+ return this._runAtUrl(this.url(name), opts)
129
+ }
130
+
131
+ /**
132
+ * Invoke the script by its immutable FMSID.
133
+ *
134
+ * Requires FileMaker Server 2026+ (v26). Use `db.hasFeature('scriptsByFMSID')`
135
+ * to check before calling.
136
+ *
137
+ * ```ts
138
+ * const result = await db.scriptById(42, { parameter: 'hello' })
139
+ * ```
140
+ */
141
+ async runById(fmsid: number, opts: ScriptOptions = {}): Promise<ScriptResult> {
142
+ return this._runAtUrl(this.urlById(fmsid), opts)
143
+ }
144
+
145
+ /** @internal — execute a script POST at the given URL. */
146
+ private async _runAtUrl(url: string, opts: ScriptOptions): Promise<ScriptResult> {
147
+ const headers: Record<string, string> = {}
148
+ let body: string | undefined
149
+ if (opts.parameter !== undefined) {
150
+ headers['Content-Type'] = 'application/json'
151
+ body = JSON.stringify({ scriptParameterValue: opts.parameter })
152
+ }
153
+
154
+ const method = 'POST'
155
+ const json = await executeJson<unknown>(this._client._ctx, url, {
156
+ method,
157
+ headers,
158
+ ...(body !== undefined ? { body } : {}),
159
+ accept: 'json',
160
+ ...(opts.signal ? { signal: opts.signal } : {}),
161
+ })
162
+
163
+ return parseScriptEnvelope(json, { url, method })
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Parse the `{ scriptResult, scriptError }` envelope FMS returns from a
169
+ * script action, promoting a non-zero `scriptError` to `FMScriptError`.
170
+ *
171
+ * @internal
172
+ */
173
+ export function parseScriptEnvelope(
174
+ raw: unknown,
175
+ request: { url: string; method: string },
176
+ ): ScriptResult {
177
+ // FMS may wrap the envelope under a single top-level key in some versions;
178
+ // tolerate both shapes by looking for the fields at depth 0 or 1.
179
+ const envelope = extractEnvelope(raw)
180
+
181
+ // FMS v26+ wraps the result as: {"scriptResult": {"code": 0, "resultParameter": "..."}}
182
+ // Older FMS uses flat: {"scriptResult": "...", "scriptError": "0"}
183
+ const rawResult = envelope.scriptResult
184
+ let scriptResult: string | undefined
185
+ let scriptError: string
186
+
187
+ if (rawResult !== null && typeof rawResult === 'object' && 'resultParameter' in rawResult) {
188
+ // Nested envelope (FMS v26+): code + resultParameter inside scriptResult
189
+ const nested = rawResult as { code?: unknown; resultParameter?: unknown }
190
+ scriptError = nested.code !== undefined ? String(nested.code) : '0'
191
+ scriptResult = nested.resultParameter !== undefined ? String(nested.resultParameter) : undefined
192
+ } else {
193
+ // Flat envelope (older FMS): scriptResult is a string, scriptError is a sibling
194
+ scriptError = envelope.scriptError !== undefined ? String(envelope.scriptError) : '0'
195
+ scriptResult =
196
+ rawResult !== undefined
197
+ ? typeof rawResult === 'string'
198
+ ? rawResult
199
+ : JSON.stringify(rawResult)
200
+ : undefined
201
+ }
202
+
203
+ if (scriptError !== '0') {
204
+ throw new FMScriptError(
205
+ `FileMaker script error ${scriptError}`,
206
+ {
207
+ status: 200,
208
+ scriptError,
209
+ ...(scriptResult !== undefined ? { scriptResult } : {}),
210
+ odataError: raw,
211
+ request,
212
+ },
213
+ )
214
+ }
215
+
216
+ const out: ScriptResult = { scriptError, raw }
217
+ if (scriptResult !== undefined) out.scriptResult = scriptResult
218
+ return out
219
+ }
220
+
221
+ function extractEnvelope(raw: unknown): {
222
+ scriptResult?: unknown
223
+ scriptError?: unknown
224
+ } {
225
+ if (raw === null || typeof raw !== 'object') return {}
226
+ const obj = raw as Record<string, unknown>
227
+ if ('scriptError' in obj || 'scriptResult' in obj) {
228
+ return obj as { scriptResult?: unknown; scriptError?: unknown }
229
+ }
230
+ // Some callers may see the envelope nested under a single wrapping key.
231
+ for (const v of Object.values(obj)) {
232
+ if (v && typeof v === 'object') {
233
+ const inner = v as Record<string, unknown>
234
+ if ('scriptError' in inner || 'scriptResult' in inner) {
235
+ return inner as { scriptResult?: unknown; scriptError?: unknown }
236
+ }
237
+ }
238
+ }
239
+ return {}
240
+ }
241
+
242
+ /** @internal — convenience factory used by client/query/entity helpers. */
243
+ export function runScriptAtDatabase(
244
+ client: FMSOData,
245
+ name: string,
246
+ opts?: ScriptOptions,
247
+ ): Promise<ScriptResult> {
248
+ return new ScriptInvoker(client).run(name, opts)
249
+ }
250
+
251
+ /** @internal — convenience factory for FMSID-based invocation. */
252
+ export function runScriptByIdAtDatabase(
253
+ client: FMSOData,
254
+ fmsid: number,
255
+ opts?: ScriptOptions,
256
+ ): Promise<ScriptResult> {
257
+ return new ScriptInvoker(client).runById(fmsid, opts)
258
+ }
259
+
260
+ /** @internal */
261
+ export function runScriptAtEntitySet(
262
+ client: FMSOData,
263
+ entitySet: string,
264
+ name: string,
265
+ opts?: ScriptOptions,
266
+ ): Promise<ScriptResult> {
267
+ return new ScriptInvoker(client, { entitySet }).run(name, opts)
268
+ }
269
+
270
+ /** @internal */
271
+ export function runScriptAtEntity(
272
+ client: FMSOData,
273
+ entitySet: string,
274
+ key: ODataLiteral,
275
+ name: string,
276
+ opts?: ScriptOptions,
277
+ ): Promise<ScriptResult> {
278
+ return new ScriptInvoker(client, { entitySet, key }).run(name, opts)
279
+ }
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Public TypeScript types for fms-odata-js.
3
+ *
4
+ * These are intentionally minimal during M1 and will be expanded in later
5
+ * milestones (query, CRUD, containers, scripts, metadata, batch).
6
+ */
7
+
8
+ export type TokenProvider =
9
+ | string
10
+ | (() => string | Promise<string>)
11
+
12
+ export interface FMSODataOptions {
13
+ /** Base FMS host, e.g. `https://fms.example.com` (no trailing slash). */
14
+ host: string
15
+ /** FileMaker database (solution) name. */
16
+ database: string
17
+ /** Bearer token or resolver. */
18
+ token: TokenProvider
19
+ /** Invoked once on 401 to refresh credentials before a single retry. */
20
+ onUnauthorized?: () => void | Promise<void>
21
+ /** Injectable fetch implementation (defaults to `globalThis.fetch`). */
22
+ fetch?: typeof globalThis.fetch
23
+ /** Per-request timeout in milliseconds. */
24
+ timeoutMs?: number
25
+ }
26
+
27
+ /** Common request-level options accepted by every method. */
28
+ export interface RequestOptions {
29
+ signal?: AbortSignal
30
+ }
package/src/url.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * OData URL encoding helpers.
3
+ *
4
+ * Aligned with `@fms-odata/spec-ts` for the shared helpers (`escapeStringLiteral`,
5
+ * `formatLiteral`). fms-odata-js keeps its richer implementations for
6
+ * `formatDateTime`, `parseDateTime`, `odataEncode`, `buildQueryString`, and
7
+ * `encodePathSegment` which go beyond the spec's minimal versions.
8
+ *
9
+ * - String literals are single-quoted with inner single-quotes doubled.
10
+ * - Dates serialize as UTC ISO-8601 without milliseconds (FileMaker quirk).
11
+ * - Query-string keys are emitted verbatim (always safe ASCII such as `$filter`)
12
+ * while values are percent-encoded via `odataEncode`, which uses `%20`
13
+ * for spaces rather than `+` (required by OData `$filter`) and preserves
14
+ * commas (required by `$select`, `$orderby`, `$expand`).
15
+ *
16
+ * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/13-quirks.md
17
+ */
18
+
19
+ /** Any value that can appear on the right-hand side of an OData `$filter` comparison. */
20
+ export type ODataLiteral = string | number | boolean | Date | null | undefined
21
+
22
+ /** Escape a string for inclusion inside a single-quoted OData literal. */
23
+ export function escapeStringLiteral(s: string): string {
24
+ return s.replace(/'/g, "''")
25
+ }
26
+
27
+ /** Format a `Date` as UTC ISO-8601 without milliseconds (e.g. `2026-04-17T14:45:00Z`). */
28
+ export function formatDateTime(d: Date): string {
29
+ if (!(d instanceof Date) || Number.isNaN(d.getTime())) {
30
+ throw new TypeError('formatDateTime: invalid Date')
31
+ }
32
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z')
33
+ }
34
+
35
+ /** Parse an OData DateTimeOffset string (with or without milliseconds). */
36
+ export function parseDateTime(s: string): Date {
37
+ const d = new Date(s)
38
+ if (Number.isNaN(d.getTime())) {
39
+ throw new TypeError(`parseDateTime: invalid date string: ${s}`)
40
+ }
41
+ return d
42
+ }
43
+
44
+ /** Format a primitive as an OData literal suitable for `$filter`, `$search`, etc. */
45
+ export function formatLiteral(v: ODataLiteral): string {
46
+ if (v === null || v === undefined) return 'null'
47
+ if (typeof v === 'string') return `'${escapeStringLiteral(v)}'`
48
+ if (typeof v === 'number') {
49
+ if (!Number.isFinite(v)) {
50
+ throw new TypeError(`formatLiteral: cannot encode non-finite number: ${v}`)
51
+ }
52
+ return String(v)
53
+ }
54
+ if (typeof v === 'boolean') return v ? 'true' : 'false'
55
+ if (v instanceof Date) return formatDateTime(v)
56
+ throw new TypeError(`formatLiteral: unsupported OData literal type: ${typeof v}`)
57
+ }
58
+
59
+ /** Encode a single URL path segment (spaces become `%20`). */
60
+ export function encodePathSegment(s: string): string {
61
+ return encodeURIComponent(s)
62
+ }
63
+
64
+ /**
65
+ * Encode an OData query-string value.
66
+ *
67
+ * - Spaces become `%20` (not `+`) — required by OData `$filter`.
68
+ * - Commas stay literal (not `%2C`) — required by `$select`, `$orderby`, `$expand`.
69
+ * - Parentheses stay literal (not `%28`/`%29`) — required by `$apply` expressions
70
+ * such as `aggregate(...)` and `groupby((...))`.
71
+ * - `$`, `=`, and `;` stay literal — required for nested `$expand` options.
72
+ * FileMaker Server rejects percent-encoded forms of these characters.
73
+ */
74
+ export function odataEncode(v: string): string {
75
+ return encodeURIComponent(v)
76
+ .replace(/%2C/gi, ',')
77
+ .replace(/%24/g, '$')
78
+ .replace(/%3D/g, '=')
79
+ .replace(/%3B/g, ';')
80
+ .replace(/%28/g, '(')
81
+ .replace(/%29/g, ')')
82
+ }
83
+
84
+ /**
85
+ * Build a querystring from ordered `[key, value]` pairs.
86
+ *
87
+ * Keys are emitted verbatim (the caller controls them and they are always safe
88
+ * ASCII such as `$filter`). Values are percent-encoded via `odataEncode`.
89
+ * Empty / nullish values are skipped.
90
+ */
91
+ export function buildQueryString(params: ReadonlyArray<readonly [string, string]>): string {
92
+ const parts: string[] = []
93
+ for (const [k, v] of params) {
94
+ if (v === '' || v === undefined || v === null) continue
95
+ parts.push(`${k}=${odataEncode(v)}`)
96
+ }
97
+ return parts.join('&')
98
+ }