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
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 }
|