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/client.ts ADDED
@@ -0,0 +1,268 @@
1
+ import { Batch } from './batch.js'
2
+ import { executeJson, executeRequest, type HttpClientContext, type HttpRequestOptions } from './http.js'
3
+ import { MetadataFetcher, type MetadataOptions, type ODataMetadata } from './metadata.js'
4
+ import { Query } from './query.js'
5
+ import { runScriptAtDatabase, runScriptByIdAtDatabase, type ScriptOptions, type ScriptResult } from './scripts.js'
6
+ import type { FMSODataOptions, RequestOptions } from './types.js'
7
+ import {
8
+ type FMVersionMajor,
9
+ type FMVersionInfo,
10
+ type FMFeatureFlags,
11
+ type FMServerVersion,
12
+ FM_VERSION_MATRIX,
13
+ hasFeature as specHasFeature,
14
+ } from '@fms-odata/spec-ts'
15
+
16
+ /**
17
+ * `FMSOData` is the entrypoint for all OData operations against a FileMaker
18
+ * Server database. Covers query/CRUD, script invocation, container I/O,
19
+ * `$metadata` introspection, and `$batch` operations.
20
+ */
21
+ export class FMSOData {
22
+ readonly host: string
23
+ readonly database: string
24
+ readonly baseUrl: string
25
+ readonly timeoutMs: number | undefined
26
+
27
+ /** @internal */ readonly _ctx: HttpClientContext
28
+ /** @internal */ private _metadataFetcher?: MetadataFetcher
29
+
30
+ constructor(options: FMSODataOptions) {
31
+ if (!options.host) throw new TypeError('FMSOData: `host` is required')
32
+ if (!options.database) throw new TypeError('FMSOData: `database` is required')
33
+ if (options.token === undefined || options.token === null) {
34
+ throw new TypeError('FMSOData: `token` is required')
35
+ }
36
+
37
+ this.host = options.host.replace(/\/+$/, '')
38
+ this.database = options.database
39
+ this.baseUrl = `${this.host}/fmi/odata/v4/${encodeURIComponent(this.database)}`
40
+ this.timeoutMs = options.timeoutMs
41
+
42
+ this._ctx = {
43
+ token: options.token,
44
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
45
+ timeoutMs: options.timeoutMs,
46
+ ...(options.onUnauthorized ? { onUnauthorized: options.onUnauthorized } : {}),
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Start a query against the given entity set (FileMaker layout name).
52
+ */
53
+ from<T = Record<string, unknown>>(entitySet: string): Query<T> {
54
+ if (!entitySet) throw new TypeError('FMSOData#from: entitySet is required')
55
+ return new Query<T>(this.baseUrl, entitySet, this)
56
+ }
57
+
58
+ /**
59
+ * Low-level escape hatch: execute a raw request against a path relative to
60
+ * the database base URL (or an absolute URL). Returns the parsed JSON body.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const body = await db.request<{ value: unknown[] }>('/contact?$top=1')
65
+ * ```
66
+ */
67
+ async request<T = unknown>(pathOrUrl: string, opts: HttpRequestOptions = {}): Promise<T> {
68
+ return executeJson<T>(this._ctx, this._resolveUrl(pathOrUrl), opts)
69
+ }
70
+
71
+ /**
72
+ * Low-level escape hatch: execute a raw request and return the `Response`
73
+ * object directly (useful for binary / streaming responses).
74
+ */
75
+ async rawRequest(pathOrUrl: string, opts: HttpRequestOptions = {}): Promise<Response> {
76
+ return executeRequest(this._ctx, this._resolveUrl(pathOrUrl), opts)
77
+ }
78
+
79
+ /**
80
+ * Invoke a FileMaker script at database scope.
81
+ *
82
+ * ```ts
83
+ * const result = await db.script('Ping', { parameter: 'hello' })
84
+ * console.log(result.scriptResult) // => string value returned by the script
85
+ * ```
86
+ *
87
+ * A non-zero `scriptError` is thrown as `FMScriptError`.
88
+ */
89
+ async script(name: string, opts: ScriptOptions = {}): Promise<ScriptResult> {
90
+ return runScriptAtDatabase(this, name, opts)
91
+ }
92
+
93
+ /**
94
+ * Invoke a FileMaker script by its immutable FMSID.
95
+ *
96
+ * Requires FileMaker Server 2026+ (v26). Use `hasFeature('scriptsByFMSID')`
97
+ * to check before calling. FMSID-based invocation is more stable than
98
+ * name-based: it survives script renames and works across database
99
+ * migrations.
100
+ *
101
+ * ```ts
102
+ * const result = await db.scriptById(42, { parameter: 'hello' })
103
+ * ```
104
+ */
105
+ async scriptById(fmsid: number, opts: ScriptOptions = {}): Promise<ScriptResult> {
106
+ return runScriptByIdAtDatabase(this, fmsid, opts)
107
+ }
108
+
109
+ /**
110
+ * Fetch the OData CSDL `$metadata` XML and parse it into a typed structure.
111
+ * Results are cached; pass `refresh: true` to force a refetch.
112
+ *
113
+ * ```ts
114
+ * const meta = await db.metadata()
115
+ * console.log(meta.entitySets.map(es => es.name))
116
+ * ```
117
+ */
118
+ async metadata(opts: MetadataOptions = {}): Promise<ODataMetadata> {
119
+ if (!this._metadataFetcher) {
120
+ this._metadataFetcher = new MetadataFetcher(this._ctx, this.baseUrl)
121
+ }
122
+ return this._metadataFetcher.fetch(opts)
123
+ }
124
+
125
+ /**
126
+ * Fetch the raw `$metadata` XML (escape hatch for debugging or custom parsing).
127
+ */
128
+ async metadataXml(opts: RequestOptions = {}): Promise<string> {
129
+ if (!this._metadataFetcher) {
130
+ this._metadataFetcher = new MetadataFetcher(this._ctx, this.baseUrl)
131
+ }
132
+ return this._metadataFetcher.fetchXml(opts)
133
+ }
134
+
135
+ // -------------------------------------------------------------------------
136
+ // Version detection & feature gating (Phase 2 — spec alignment)
137
+ // -------------------------------------------------------------------------
138
+
139
+ /** @internal */ private _detectedVersion: FMVersionMajor | null | undefined
140
+ /** @internal */ private _detectedServerVersion: FMServerVersion | null | undefined
141
+
142
+ /**
143
+ * Detect the FileMaker Server major version by fetching `$metadata` and
144
+ * parsing the version annotation using a multi-strategy approach (see
145
+ * `@fms-odata/spec-ts` `parseServerVersion`). The result is cached for the
146
+ * lifetime of this `FMSOData` instance.
147
+ *
148
+ * Returns the major version string (`'19'`, `'21'`, `'22'`, `'26'`) or
149
+ * `'future'` if the version is newer than the spec knows about. Returns
150
+ * `null` if the version cannot be determined (e.g. the metadata lacks the
151
+ * annotation).
152
+ *
153
+ * ```ts
154
+ * const v = await db.version()
155
+ * if (v === '26') console.log('Server is FileMaker 2026')
156
+ * ```
157
+ */
158
+ async version(): Promise<FMVersionMajor | null> {
159
+ if (this._detectedVersion !== undefined) return this._detectedVersion
160
+ try {
161
+ const sv = await this.serverVersion()
162
+ if (!sv) {
163
+ this._detectedVersion = null
164
+ return null
165
+ }
166
+ const major = String(sv.major) as FMVersionMajor
167
+ // If the version is known in the spec matrix, use it; otherwise 'future'.
168
+ this._detectedVersion = major in FM_VERSION_MATRIX ? major : 'future'
169
+ return this._detectedVersion
170
+ } catch {
171
+ this._detectedVersion = null
172
+ return null
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Get the full parsed FileMaker Server version (major, minor, patch, raw)
178
+ * by fetching `$metadata` and parsing the version annotation. The result is
179
+ * cached for the lifetime of this `FMSOData` instance.
180
+ *
181
+ * Returns `null` if the version cannot be determined.
182
+ *
183
+ * ```ts
184
+ * const sv = await db.serverVersion()
185
+ * if (sv) console.log(`Server is ${sv.raw} (major ${sv.major})`)
186
+ * ```
187
+ */
188
+ async serverVersion(): Promise<FMServerVersion | null> {
189
+ if (this._detectedServerVersion !== undefined) return this._detectedServerVersion
190
+ try {
191
+ const meta = await this.metadata()
192
+ this._detectedServerVersion = meta.serverVersion ?? null
193
+ return this._detectedServerVersion
194
+ } catch {
195
+ this._detectedServerVersion = null
196
+ return null
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Get the full version info (feature flags + query option flags) for the
202
+ * detected server version. Fetches metadata if not already cached.
203
+ *
204
+ * Returns `null` if the version cannot be determined.
205
+ *
206
+ * ```ts
207
+ * const info = await db.versionInfo()
208
+ * if (info?.features.applyAggregation) {
209
+ * // Server supports $apply
210
+ * }
211
+ * ```
212
+ */
213
+ async versionInfo(): Promise<FMVersionInfo | null> {
214
+ const v = await this.version()
215
+ if (!v) return null
216
+ return FM_VERSION_MATRIX[v]
217
+ }
218
+
219
+ /**
220
+ * Check if the server supports a specific feature. Fetches metadata (to
221
+ * detect the version) on first call; subsequent calls use the cached result.
222
+ *
223
+ * Returns `false` if the version cannot be determined.
224
+ *
225
+ * ```ts
226
+ * if (await db.hasFeature('applyAggregation')) {
227
+ * const result = await db.from('orders').apply(...)
228
+ * }
229
+ * ```
230
+ */
231
+ async hasFeature(feature: keyof FMFeatureFlags): Promise<boolean> {
232
+ const v = await this.version()
233
+ if (!v) return false
234
+ return specHasFeature(v, feature)
235
+ }
236
+
237
+ /**
238
+ * Create a new `$batch` builder for composing multiple OData operations
239
+ * into a single HTTP round-trip.
240
+ *
241
+ * Read operations (`add`) are executed independently. Write operations
242
+ * (`changeset`) are grouped atomically — all succeed or all fail.
243
+ *
244
+ * ```ts
245
+ * const batch = db.batch()
246
+ * const contacts = batch.add({ op: 'list', entitySet: 'contact', query: { $top: 5 } })
247
+ * batch.changeset(cs => {
248
+ * cs.create('contact', { firstName: 'A', lastName: 'B' })
249
+ * cs.patch('contact', 123, { firstName: 'Updated' })
250
+ * })
251
+ * const result = await batch.send()
252
+ * console.log(await contacts._promise) // First op result
253
+ * ```
254
+ */
255
+ batch(): Batch {
256
+ return new Batch(this)
257
+ }
258
+
259
+ /** @internal */
260
+ _resolveUrl(pathOrUrl: string): string {
261
+ if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl
262
+ if (pathOrUrl.startsWith('/')) return `${this.baseUrl}${pathOrUrl}`
263
+ return `${this.baseUrl}/${pathOrUrl}`
264
+ }
265
+ }
266
+
267
+ // Re-export for ergonomic imports in callers (`import { RequestOptions } …`).
268
+ export type { RequestOptions }