fms-odata-js 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * $batch multipart composer and response parser (M6).
3
+ *
4
+ * OData $batch allows multiple operations in a single HTTP round-trip.
5
+ * A batch consists of:
6
+ * - Read operations (GET) at the top level
7
+ * - Changesets (atomic groups of writes: POST, PATCH, DELETE)
8
+ *
9
+ * FMS endpoint: POST /fmi/odata/v4/{db}/$batch
10
+ * Content-Type: multipart/mixed; boundary=batch_<uuid>
11
+ *
12
+ * The implementation generates unique boundaries using crypto.randomUUID()
13
+ * (available in Node 18+ and modern browsers), composes the multipart
14
+ * body, and parses the multipart response back into per-operation results.
15
+ *
16
+ * ⚠️ KNOWN FMS LIMITATIONS (pending investigation — tested against FMS 21.x):
17
+ *
18
+ * 1. GET operations must NOT appear before a changeset.
19
+ * Placing a read op before a changeset causes FMS to return HTTP 400:
20
+ * "Expected batch boundary at 'Content-Type: applic...'".
21
+ * FMS's parser appears to misread the changeset's nested boundary as a
22
+ * batch-level boundary when GETs precede it.
23
+ * → Workaround: always add changesets before reads in the batch.
24
+ *
25
+ * 2. Multiple consecutive GET operations return only one fewer response than
26
+ * expected — the last GET in a sequence is silently dropped by FMS.
27
+ * E.g. three GETs → only two responses.
28
+ * → Workaround: limit batches to one read op at the end, or accept the loss.
29
+ *
30
+ * 3. POST/PATCH within a changeset requires an explicit Content-Length header
31
+ * on the inner HTTP request (FMS cannot determine body length from multipart
32
+ * structure alone). This is handled by utf8ByteLength() during serialisation.
33
+ *
34
+ * These constraints differ from the OData 4.01 spec and the official Claris
35
+ * documentation examples. They were discovered empirically. Batch support in
36
+ * this library is functional but limited to: changeset (optional) followed by
37
+ * at most one read operation per batch call.
38
+ */
39
+ import type { FMSOData } from './client.js';
40
+ import type { ODataLiteral } from './url.js';
41
+ import type { RequestOptions } from './types.js';
42
+ /** Handle returned when adding an operation to a batch. Resolves to the operation result. */
43
+ export interface BatchHandle<T> {
44
+ readonly __brand: 'BatchHandle';
45
+ /** @internal */ readonly _promise: Promise<T>;
46
+ /** @internal */ readonly _index: number;
47
+ }
48
+ /** Input for a read operation (GET) in a batch. */
49
+ export interface BatchReadOp {
50
+ /** Entity set name (layout/table). */
51
+ entitySet: string;
52
+ /** Operation type. */
53
+ op: 'list';
54
+ /** Query options: $top, $skip, $filter, etc. */
55
+ query?: {
56
+ $top?: number;
57
+ $skip?: number;
58
+ $filter?: string;
59
+ $select?: string;
60
+ };
61
+ }
62
+ /** Result of a batch operation. */
63
+ export interface BatchOpResult<T = unknown> {
64
+ status: number;
65
+ body?: T;
66
+ headers: Headers;
67
+ ok: boolean;
68
+ }
69
+ /** Result of executing a batch. */
70
+ export interface BatchResult {
71
+ /** All responses in request order. */
72
+ responses: BatchOpResult[];
73
+ /** True if all responses have status < 400. */
74
+ ok: boolean;
75
+ }
76
+ /** Represents an operation within a changeset. */
77
+ interface ChangesetOp {
78
+ method: string;
79
+ path: string;
80
+ headers?: Record<string, string>;
81
+ body?: string;
82
+ }
83
+ /** Changeset builder for atomic write operations. */
84
+ export declare class Changeset {
85
+ private _ops;
86
+ private _handles;
87
+ private _baseUrl;
88
+ constructor(baseUrl: string);
89
+ /** @internal */
90
+ get _operations(): ChangesetOp[];
91
+ /** @internal */
92
+ get _handleSlots(): Array<{
93
+ resolve: (v: unknown) => void;
94
+ reject: (e: unknown) => void;
95
+ }>;
96
+ /**
97
+ * Create a new entity within this changeset.
98
+ */
99
+ create<T = unknown>(entitySet: string, body: Record<string, unknown>): BatchHandle<T>;
100
+ /**
101
+ * Patch an existing entity within this changeset.
102
+ */
103
+ patch<T = unknown>(entitySet: string, key: ODataLiteral, body: Record<string, unknown>, opts?: {
104
+ ifMatch?: string;
105
+ }): BatchHandle<T>;
106
+ /**
107
+ * Delete an entity within this changeset.
108
+ */
109
+ delete(entitySet: string, key: ODataLiteral, opts?: {
110
+ ifMatch?: string;
111
+ }): BatchHandle<void>;
112
+ }
113
+ /** Batch builder for composing multipart/mixed requests. */
114
+ export declare class Batch {
115
+ private _client;
116
+ private _parts;
117
+ private _changesets;
118
+ constructor(client: FMSOData);
119
+ /**
120
+ * Add a read operation (GET) to the batch.
121
+ * Read operations are not part of a changeset and execute independently.
122
+ */
123
+ add<T = unknown>(op: BatchReadOp): BatchHandle<T>;
124
+ /**
125
+ * Create an atomic changeset (group of write operations).
126
+ * All operations in a changeset succeed or fail together.
127
+ */
128
+ changeset(build: (cs: Changeset) => void): void;
129
+ /**
130
+ * Serialize the batch into a multipart/mixed body.
131
+ * @internal
132
+ */
133
+ _serialize(): {
134
+ boundary: string;
135
+ body: string;
136
+ };
137
+ /**
138
+ * Send the batch request and parse the multipart response.
139
+ */
140
+ send(opts?: RequestOptions): Promise<BatchResult>;
141
+ /**
142
+ * Parse a multipart/mixed batch response.
143
+ * @internal
144
+ */
145
+ _parseResponse(responseText: string, contentType: string): BatchResult;
146
+ /** @internal — parse a single HTTP response part. */
147
+ private _parseHttpPart;
148
+ /** @internal — parse a changeset multipart response. */
149
+ private _parseChangesetResponse;
150
+ /** @internal — resolve a batch handle with the result. */
151
+ private _resolveHandle;
152
+ }
153
+ export {};
154
+ //# sourceMappingURL=batch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch.d.ts","sourceRoot":"","sources":["../src/batch.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,160 @@
1
+ import { Batch } from './batch.js';
2
+ import { type HttpClientContext, type HttpRequestOptions } from './http.js';
3
+ import { type MetadataOptions, type ODataMetadata } from './metadata.js';
4
+ import { Query } from './query.js';
5
+ import { type ScriptOptions, type ScriptResult } from './scripts.js';
6
+ import type { FMSODataOptions, RequestOptions } from './types.js';
7
+ import { type FMVersionMajor, type FMVersionInfo, type FMFeatureFlags, type FMServerVersion } from '@fms-odata/spec-ts';
8
+ /**
9
+ * `FMSOData` is the entrypoint for all OData operations against a FileMaker
10
+ * Server database. Covers query/CRUD, script invocation, container I/O,
11
+ * `$metadata` introspection, and `$batch` operations.
12
+ */
13
+ export declare class FMSOData {
14
+ readonly host: string;
15
+ readonly database: string;
16
+ readonly baseUrl: string;
17
+ readonly timeoutMs: number | undefined;
18
+ /** @internal */ readonly _ctx: HttpClientContext;
19
+ /** @internal */ private _metadataFetcher?;
20
+ constructor(options: FMSODataOptions);
21
+ /**
22
+ * Start a query against the given entity set (FileMaker layout name).
23
+ */
24
+ from<T = Record<string, unknown>>(entitySet: string): Query<T>;
25
+ /**
26
+ * Low-level escape hatch: execute a raw request against a path relative to
27
+ * the database base URL (or an absolute URL). Returns the parsed JSON body.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const body = await db.request<{ value: unknown[] }>('/contact?$top=1')
32
+ * ```
33
+ */
34
+ request<T = unknown>(pathOrUrl: string, opts?: HttpRequestOptions): Promise<T>;
35
+ /**
36
+ * Low-level escape hatch: execute a raw request and return the `Response`
37
+ * object directly (useful for binary / streaming responses).
38
+ */
39
+ rawRequest(pathOrUrl: string, opts?: HttpRequestOptions): Promise<Response>;
40
+ /**
41
+ * Invoke a FileMaker script at database scope.
42
+ *
43
+ * ```ts
44
+ * const result = await db.script('Ping', { parameter: 'hello' })
45
+ * console.log(result.scriptResult) // => string value returned by the script
46
+ * ```
47
+ *
48
+ * A non-zero `scriptError` is thrown as `FMScriptError`.
49
+ */
50
+ script(name: string, opts?: ScriptOptions): Promise<ScriptResult>;
51
+ /**
52
+ * Invoke a FileMaker script by its immutable FMSID.
53
+ *
54
+ * Requires FileMaker Server 2026+ (v26). Use `hasFeature('scriptsByFMSID')`
55
+ * to check before calling. FMSID-based invocation is more stable than
56
+ * name-based: it survives script renames and works across database
57
+ * migrations.
58
+ *
59
+ * ```ts
60
+ * const result = await db.scriptById(42, { parameter: 'hello' })
61
+ * ```
62
+ */
63
+ scriptById(fmsid: number, opts?: ScriptOptions): Promise<ScriptResult>;
64
+ /**
65
+ * Fetch the OData CSDL `$metadata` XML and parse it into a typed structure.
66
+ * Results are cached; pass `refresh: true` to force a refetch.
67
+ *
68
+ * ```ts
69
+ * const meta = await db.metadata()
70
+ * console.log(meta.entitySets.map(es => es.name))
71
+ * ```
72
+ */
73
+ metadata(opts?: MetadataOptions): Promise<ODataMetadata>;
74
+ /**
75
+ * Fetch the raw `$metadata` XML (escape hatch for debugging or custom parsing).
76
+ */
77
+ metadataXml(opts?: RequestOptions): Promise<string>;
78
+ /** @internal */ private _detectedVersion;
79
+ /** @internal */ private _detectedServerVersion;
80
+ /**
81
+ * Detect the FileMaker Server major version by fetching `$metadata` and
82
+ * parsing the version annotation using a multi-strategy approach (see
83
+ * `@fms-odata/spec-ts` `parseServerVersion`). The result is cached for the
84
+ * lifetime of this `FMSOData` instance.
85
+ *
86
+ * Returns the major version string (`'19'`, `'21'`, `'22'`, `'26'`) or
87
+ * `'future'` if the version is newer than the spec knows about. Returns
88
+ * `null` if the version cannot be determined (e.g. the metadata lacks the
89
+ * annotation).
90
+ *
91
+ * ```ts
92
+ * const v = await db.version()
93
+ * if (v === '26') console.log('Server is FileMaker 2026')
94
+ * ```
95
+ */
96
+ version(): Promise<FMVersionMajor | null>;
97
+ /**
98
+ * Get the full parsed FileMaker Server version (major, minor, patch, raw)
99
+ * by fetching `$metadata` and parsing the version annotation. The result is
100
+ * cached for the lifetime of this `FMSOData` instance.
101
+ *
102
+ * Returns `null` if the version cannot be determined.
103
+ *
104
+ * ```ts
105
+ * const sv = await db.serverVersion()
106
+ * if (sv) console.log(`Server is ${sv.raw} (major ${sv.major})`)
107
+ * ```
108
+ */
109
+ serverVersion(): Promise<FMServerVersion | null>;
110
+ /**
111
+ * Get the full version info (feature flags + query option flags) for the
112
+ * detected server version. Fetches metadata if not already cached.
113
+ *
114
+ * Returns `null` if the version cannot be determined.
115
+ *
116
+ * ```ts
117
+ * const info = await db.versionInfo()
118
+ * if (info?.features.applyAggregation) {
119
+ * // Server supports $apply
120
+ * }
121
+ * ```
122
+ */
123
+ versionInfo(): Promise<FMVersionInfo | null>;
124
+ /**
125
+ * Check if the server supports a specific feature. Fetches metadata (to
126
+ * detect the version) on first call; subsequent calls use the cached result.
127
+ *
128
+ * Returns `false` if the version cannot be determined.
129
+ *
130
+ * ```ts
131
+ * if (await db.hasFeature('applyAggregation')) {
132
+ * const result = await db.from('orders').apply(...)
133
+ * }
134
+ * ```
135
+ */
136
+ hasFeature(feature: keyof FMFeatureFlags): Promise<boolean>;
137
+ /**
138
+ * Create a new `$batch` builder for composing multiple OData operations
139
+ * into a single HTTP round-trip.
140
+ *
141
+ * Read operations (`add`) are executed independently. Write operations
142
+ * (`changeset`) are grouped atomically — all succeed or all fail.
143
+ *
144
+ * ```ts
145
+ * const batch = db.batch()
146
+ * const contacts = batch.add({ op: 'list', entitySet: 'contact', query: { $top: 5 } })
147
+ * batch.changeset(cs => {
148
+ * cs.create('contact', { firstName: 'A', lastName: 'B' })
149
+ * cs.patch('contact', 123, { firstName: 'Updated' })
150
+ * })
151
+ * const result = await batch.send()
152
+ * console.log(await contacts._promise) // First op result
153
+ * ```
154
+ */
155
+ batch(): Batch;
156
+ /** @internal */
157
+ _resolveUrl(pathOrUrl: string): string;
158
+ }
159
+ export type { RequestOptions };
160
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAA+B,KAAK,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAA;AACxG,OAAO,EAAmB,KAAK,eAAe,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AACzF,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,OAAO,EAAgD,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,cAAc,CAAA;AAClH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AACjE,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,eAAe,EAGrB,MAAM,oBAAoB,CAAA;AAE3B;;;;GAIG;AACH,qBAAa,QAAQ;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAA;IAEtC,gBAAgB,CAAC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAA;IACjD,gBAAgB,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAiB;gBAE/C,OAAO,EAAE,eAAe;IAoBpC;;OAEG;IACH,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC;IAK9D;;;;;;;;OAQG;IACG,OAAO,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,GAAE,kBAAuB,GAAG,OAAO,CAAC,CAAC,CAAC;IAIxF;;;OAGG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,GAAE,kBAAuB,GAAG,OAAO,CAAC,QAAQ,CAAC;IAIrF;;;;;;;;;OASG;IACG,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC;IAI3E;;;;;;;;;;;OAWG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC;IAIhF;;;;;;;;OAQG;IACG,QAAQ,CAAC,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,aAAa,CAAC;IAOlE;;OAEG;IACG,WAAW,CAAC,IAAI,GAAE,cAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;IAW7D,gBAAgB,CAAC,OAAO,CAAC,gBAAgB,CAAmC;IAC5E,gBAAgB,CAAC,OAAO,CAAC,sBAAsB,CAAoC;IAEnF;;;;;;;;;;;;;;;OAeG;IACG,OAAO,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAkB/C;;;;;;;;;;;OAWG;IACG,aAAa,IAAI,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAYtD;;;;;;;;;;;;OAYG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAMlD;;;;;;;;;;;OAWG;IACG,UAAU,CAAC,OAAO,EAAE,MAAM,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC;IAMjE;;;;;;;;;;;;;;;;;OAiBG;IACH,KAAK,IAAI,KAAK;IAId,gBAAgB;IAChB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;CAKvC;AAGD,YAAY,EAAE,cAAc,EAAE,CAAA"}
@@ -0,0 +1,190 @@
1
+ /**
2
+ * FileMaker container field I/O.
3
+ *
4
+ * FMS exposes three container operations over OData v4 — see
5
+ * `docs/filemaker-odata-container-guide.md` for the full reference. The
6
+ * library maps to them as follows:
7
+ *
8
+ * GET /<EntitySet>(<key>)/<field>/$value → ContainerRef#get / getStream
9
+ * PATCH /<EntitySet>(<key>)/<field> → ContainerRef#upload({ encoding: 'binary' }) — DEFAULT
10
+ * PATCH /<EntitySet>(<key>) (JSON body) → ContainerRef#upload({ encoding: 'base64' })
11
+ * POST /<EntitySet> (JSON body) → Query#create({ … }) with container annotation keys
12
+ *
13
+ * Supported MIME types per the Claris guide:
14
+ * image/png, image/jpeg, image/gif, image/tiff, application/pdf
15
+ *
16
+ * Clearing a container is not in the Claris guide. The library PATCHes the
17
+ * record with `{ <field>: null }`.
18
+ */
19
+ import type { EntityRef } from './entity.js';
20
+ import type { RequestOptions } from './types.js';
21
+ /** Result of a successful container download. */
22
+ export interface ContainerDownload {
23
+ /** Binary contents as a `Blob` (works in browsers, Web Viewer, and Node 18+). */
24
+ blob: Blob;
25
+ /** MIME type reported by FMS (e.g. `image/png`); empty string if absent. */
26
+ contentType: string;
27
+ /** Filename parsed from `Content-Disposition`, when present. */
28
+ filename?: string;
29
+ /** Byte length. `0` indicates an empty container. */
30
+ size: number;
31
+ }
32
+ /** Input accepted by `ContainerRef#upload`. */
33
+ export interface ContainerUploadInput {
34
+ /** Binary payload. `fetch` accepts all three forms natively. */
35
+ data: Blob | ArrayBuffer | Uint8Array;
36
+ /**
37
+ * Content-Type to send with the upload. Optional: when omitted, the library
38
+ * sniffs the MIME from the payload's magic bytes (PNG, JPEG, GIF, TIFF, PDF).
39
+ * Throws if it cannot detect a supported type.
40
+ */
41
+ contentType?: string;
42
+ /**
43
+ * Optional filename to store in the container. Surfaced on download via
44
+ * `Content-Disposition: attachment; filename="…"`. When omitted, FMS stores
45
+ * (and surfaces back) the auto-generated name `Untitled.png`.
46
+ */
47
+ filename?: string;
48
+ /**
49
+ * Wire format:
50
+ *
51
+ * - `'binary'` (default): PATCH `…/<field>` with raw bytes. Restricted to
52
+ * PNG, JPEG, GIF, TIFF, and PDF.
53
+ * - `'base64'`: PATCH `…/<EntitySet>(<key>)` with a JSON body containing
54
+ * the file as a base64 string plus `@com.filemaker.odata.…` annotations.
55
+ * Use this when you need to update multiple container fields (or mix
56
+ * container and regular field updates) in a single request.
57
+ */
58
+ encoding?: 'binary' | 'base64';
59
+ }
60
+ /**
61
+ * MIME types FMS accepts for container fields. Other types (audio, video,
62
+ * zip, docx, …) may upload but won't be classified as a media type and won't
63
+ * preview correctly in FileMaker.
64
+ */
65
+ export declare const FM_CONTAINER_SUPPORTED_MIME_TYPES: readonly ["image/png", "image/jpeg", "image/gif", "image/tiff", "application/pdf"];
66
+ export type FMContainerMimeType = (typeof FM_CONTAINER_SUPPORTED_MIME_TYPES)[number];
67
+ /**
68
+ * Sniff the MIME type from the magic bytes of an uploaded payload. Returns
69
+ * `undefined` when none of the supported signatures match.
70
+ *
71
+ * Signatures:
72
+ * PNG — 89 50 4E 47 0D 0A 1A 0A
73
+ * JPEG — FF D8 FF
74
+ * GIF — 47 49 46 38 (`GIF8`)
75
+ * TIFF — 49 49 2A 00 (little-endian) | 4D 4D 00 2A (big-endian)
76
+ * PDF — 25 50 44 46 (`%PDF`)
77
+ *
78
+ * @internal
79
+ */
80
+ export declare function sniffContainerMime(bytes: Uint8Array): FMContainerMimeType | undefined;
81
+ /**
82
+ * Handle to a single FileMaker container field on a specific record. Created
83
+ * via `EntityRef#container(fieldName)`.
84
+ */
85
+ export declare class ContainerRef {
86
+ /** @internal */ readonly _entity: EntityRef<unknown>;
87
+ readonly fieldName: string;
88
+ constructor(entity: EntityRef<unknown>, fieldName: string);
89
+ /**
90
+ * Absolute URL of the container field itself
91
+ * (`…/<EntitySet>(<key>)/<fieldName>`). This is the URL used by binary
92
+ * `upload()`. Append `/$value` to download.
93
+ */
94
+ url(): string;
95
+ /** @internal — `…/<field>/$value` for downloads. */
96
+ private _valueUrl;
97
+ /**
98
+ * Download the container's contents and buffer them into a `Blob`. For
99
+ * very large payloads prefer `getStream()` to avoid buffering in memory.
100
+ */
101
+ get(opts?: RequestOptions): Promise<ContainerDownload>;
102
+ /**
103
+ * Stream the container's contents without buffering. Useful for large files
104
+ * (`pipeTo()` into a writable, forward to another `Response`, etc.).
105
+ *
106
+ * Throws if the underlying `Response` has no body.
107
+ */
108
+ getStream(opts?: RequestOptions): Promise<ReadableStream<Uint8Array>>;
109
+ /**
110
+ * Upload binary contents to the container. Replaces any existing value.
111
+ *
112
+ * Default `encoding: 'binary'` PATCHes `…/<field>` with raw bytes and a
113
+ * `Content-Type` header. Restricted to PNG / JPEG / GIF / TIFF / PDF.
114
+ *
115
+ * `encoding: 'base64'` PATCHes `…/<EntitySet>(<key>)` with a JSON body
116
+ * containing base64 data plus `@com.filemaker.odata.…` annotations. Use
117
+ * this when updating multiple container fields (or mixing container and
118
+ * regular fields) in a single round-trip.
119
+ *
120
+ * `contentType` is optional — when omitted, the library sniffs the MIME
121
+ * from the payload's magic bytes. Throws if no supported signature matches.
122
+ */
123
+ upload(input: ContainerUploadInput, opts?: RequestOptions): Promise<void>;
124
+ /**
125
+ * Clear the container value. FMS has no documented per-field DELETE for
126
+ * record-level data, so the supported path is to PATCH the record with
127
+ * `{ <fieldName>: null }`.
128
+ */
129
+ delete(opts?: RequestOptions): Promise<void>;
130
+ }
131
+ /**
132
+ * One container value to embed in a JSON body, in raw byte form. Used by
133
+ * `buildContainerJsonBody` and `EntityRef#patchContainers`.
134
+ */
135
+ export interface ContainerJsonValue {
136
+ /** Base64-encoded bytes (without `data:` prefix or whitespace). */
137
+ data: string;
138
+ /** REQUIRED MIME type. Stored as `@com.filemaker.odata.ContentType`. */
139
+ contentType: string;
140
+ /** Optional filename. Stored as `@com.filemaker.odata.Filename`. */
141
+ filename?: string;
142
+ }
143
+ /**
144
+ * Build a JSON body for a POST/PATCH that updates one or more container
145
+ * fields (and optionally regular fields). Inserts the FileMaker annotation
146
+ * keys for every container value.
147
+ *
148
+ * @example
149
+ * buildContainerJsonBody(
150
+ * {
151
+ * photo: { data: '<base64>', contentType: 'image/png', filename: 'p.png' },
152
+ * contract: { data: '<base64>', contentType: 'application/pdf' },
153
+ * },
154
+ * { website: 'https://example.com' },
155
+ * )
156
+ */
157
+ export declare function buildContainerJsonBody(containers: Record<string, ContainerJsonValue>, regularFields?: Record<string, unknown>): Record<string, unknown>;
158
+ /**
159
+ * Parse the `filename` (or RFC 5987 `filename*`) from a `Content-Disposition`
160
+ * header. Returns `undefined` when no filename parameter is present.
161
+ *
162
+ * Per RFC 6266 §4.3, `filename*` (with explicit charset) takes precedence
163
+ * over `filename` when both are supplied.
164
+ *
165
+ * @internal
166
+ */
167
+ export declare function parseContentDispositionFilename(value: string): string | undefined;
168
+ /**
169
+ * Build a `Content-Disposition` value for an upload. The disposition-type is
170
+ * `inline` to match the example in the Claris OData docs.
171
+ *
172
+ * The Claris doc example uses the **unquoted** form
173
+ * (`filename=ALFKI.png`); FMS has been observed to mis-parse the RFC 6266
174
+ * quoted form on some deployments. So when the filename is composed of
175
+ * RFC 7230 token characters this function emits the unquoted form, falls
176
+ * back to a quoted form when the name contains spaces or other separator
177
+ * characters, and additionally emits an RFC 5987 `filename*=UTF-8''…`
178
+ * parameter for non-ASCII names.
179
+ *
180
+ * @internal
181
+ */
182
+ export declare function formatContentDisposition(filename: string): string;
183
+ /**
184
+ * Encode bytes to a base64 string. Uses Node's `Buffer` when available, else
185
+ * a chunked `btoa` fallback (avoids stack overflow on large arrays).
186
+ *
187
+ * @internal
188
+ */
189
+ export declare function toBase64(bytes: Uint8Array): string;
190
+ //# sourceMappingURL=containers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"containers.d.ts","sourceRoot":"","sources":["../src/containers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAE5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAGhD,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAChC,iFAAiF;IACjF,IAAI,EAAE,IAAI,CAAA;IACV,4EAA4E;IAC5E,WAAW,EAAE,MAAM,CAAA;IACnB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAA;CACb;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,gEAAgE;IAChE,IAAI,EAAE,IAAI,GAAG,WAAW,GAAG,UAAU,CAAA;IACrC;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAA;CAC/B;AAED;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,oFAMpC,CAAA;AAEV,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,iCAAiC,CAAC,CAAC,MAAM,CAAC,CAAA;AAYpF;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,GAAG,mBAAmB,GAAG,SAAS,CAuBrF;AAED;;;GAGG;AACH,qBAAa,YAAY;IACvB,gBAAgB,CAAC,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,OAAO,CAAC,CAAA;IACrD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;gBAEd,MAAM,EAAE,SAAS,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,MAAM;IAQzD;;;;OAIG;IACH,GAAG,IAAI,MAAM;IAIb,oDAAoD;IACpD,OAAO,CAAC,SAAS;IAIjB;;;OAGG;IACG,GAAG,CAAC,IAAI,GAAE,cAAmB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAyBhE;;;;;OAKG;IACG,SAAS,CAAC,IAAI,GAAE,cAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;IAa/E;;;;;;;;;;;;;OAaG;IACG,MAAM,CAAC,KAAK,EAAE,oBAAoB,EAAE,IAAI,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiEnF;;;;OAIG;IACG,MAAM,CAAC,IAAI,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAavD;AAMD;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAA;IACZ,wEAAwE;IACxE,WAAW,EAAE,MAAM,CAAA;IACnB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,EAC9C,aAAa,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAC1C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAazB;AAMD;;;;;;;;GAQG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAyBjF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAejE;AAaD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAclD"}
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Single-entity handle returned by `Query#byKey(key)`. Supports `.get()`,
3
+ * `.patch()`, `.delete()`, `.script()` (record-scope FileMaker script
4
+ * invocation, v0.1.4+), and `.container(field)` (container-field binary
5
+ * I/O, v0.1.5+).
6
+ */
7
+ import type { FMSOData } from './client.js';
8
+ import { ContainerRef, type ContainerJsonValue } from './containers.js';
9
+ import { type ScriptOptions, type ScriptResult } from './scripts.js';
10
+ import type { RequestOptions } from './types.js';
11
+ import { type ODataLiteral } from './url.js';
12
+ /** Options accepted by mutating entity operations. */
13
+ export interface EntityWriteOptions extends RequestOptions {
14
+ /**
15
+ * Optional `If-Match` precondition. Pass an ETag returned from a prior read
16
+ * to enable optimistic concurrency.
17
+ */
18
+ ifMatch?: string;
19
+ /**
20
+ * Whether the server should return the updated representation. Defaults to
21
+ * `false` (`Prefer: return=minimal`) to match FMS behaviour; set to `true`
22
+ * to request `Prefer: return=representation`.
23
+ */
24
+ returnRepresentation?: boolean;
25
+ }
26
+ /**
27
+ * Handle to a single OData entity. Holds the client, entity-set name, and the
28
+ * primary key; every method builds a URL of the form
29
+ * `<baseUrl>/<EntitySet>(<key>)` and delegates to the shared HTTP executor.
30
+ */
31
+ export declare class EntityRef<T = Record<string, unknown>> {
32
+ readonly entitySet: string;
33
+ readonly key: ODataLiteral;
34
+ /** @internal */ readonly _client: FMSOData;
35
+ constructor(client: FMSOData, entitySet: string, key: ODataLiteral);
36
+ /** Absolute URL for this entity. */
37
+ toURL(): string;
38
+ /** `GET` the entity. Returns the parsed JSON row. */
39
+ get(opts?: RequestOptions): Promise<T>;
40
+ /**
41
+ * `GET` a single field's scalar value via the OData property URL
42
+ * (`…/<EntitySet>(<key>)/<fieldName>`). FMS responds with the JSON envelope
43
+ * `{ value: … }`; this method unwraps it and returns just the value.
44
+ *
45
+ * Useful when you only need one column without composing a `$select` query.
46
+ * For container fields use `container(name).get()` instead.
47
+ */
48
+ fieldValue<V = unknown>(fieldName: string, opts?: RequestOptions): Promise<V>;
49
+ /**
50
+ * `PATCH` the entity with partial values. Returns the updated row when the
51
+ * server echoes one (OData `Prefer: return=representation`), otherwise
52
+ * `undefined` on `204 No Content`.
53
+ */
54
+ patch(body: Partial<T> | Record<string, unknown>, opts?: EntityWriteOptions): Promise<T | undefined>;
55
+ /** `DELETE` the entity. Resolves on success; throws `FMSODataError` otherwise. */
56
+ delete(opts?: EntityWriteOptions): Promise<void>;
57
+ /**
58
+ * Invoke a FileMaker script in the context of this single record. FMS sets
59
+ * the script's current record to this entity before running it.
60
+ */
61
+ script(name: string, opts?: ScriptOptions): Promise<ScriptResult>;
62
+ /**
63
+ * Get a typed handle to one of this record's container fields, exposing
64
+ * `.get()`, `.getStream()`, `.upload(...)`, and `.delete()`.
65
+ */
66
+ container(fieldName: string): ContainerRef;
67
+ /**
68
+ * Update one or more container fields (and optionally regular fields) on
69
+ * this record in a single base64 PATCH request. This maps to the Claris
70
+ * "Operation 3" (`PATCH /<EntitySet>(<key>)` with JSON body containing
71
+ * `<field>`, `<field>@com.filemaker.odata.ContentType`, and
72
+ * `<field>@com.filemaker.odata.Filename`).
73
+ *
74
+ * Each container value's `data` must already be base64-encoded (use the
75
+ * library's exported `toBase64()` helper or `Buffer.from(bytes).toString('base64')`).
76
+ *
77
+ * @example
78
+ * await db.from('contact').byKey(7).patchContainers(
79
+ * {
80
+ * photo: { data: photoB64, contentType: 'image/png', filename: 'p.png' },
81
+ * contract: { data: contractB64, contentType: 'application/pdf', filename: 'c.pdf' },
82
+ * },
83
+ * { website: 'https://example.com' },
84
+ * )
85
+ */
86
+ patchContainers(containers: Record<string, ContainerJsonValue>, regularFields?: Record<string, unknown>, opts?: EntityWriteOptions): Promise<void>;
87
+ /**
88
+ * Get the references for a navigation property on this record.
89
+ *
90
+ * `GET /<EntitySet>(<key>)/<navProperty>/$ref`
91
+ *
92
+ * Returns an array of entity references. For a single-valued navigation
93
+ * property, the array has at most one element.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * const refs = await db.from('contact').byKey(7).getRefs('addresses')
98
+ * // [{ '@odata.id': 'https://fms.example.com/fmi/odata/v4/DB/address(1)' }, ...]
99
+ * ```
100
+ */
101
+ getRefs(navProperty: string, opts?: RequestOptions): Promise<EntityRefInfo[]>;
102
+ /**
103
+ * Add a reference to a related record via a navigation property.
104
+ *
105
+ * `POST /<EntitySet>(<key>)/<navProperty>/$ref`
106
+ *
107
+ * For single-valued navigation properties, use `setRef()` instead (PATCH).
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * await db.from('contact').byKey(7).addRef('addresses', 42)
112
+ * ```
113
+ */
114
+ addRef(navProperty: string, relatedKey: string | number, opts?: EntityWriteOptions): Promise<void>;
115
+ /**
116
+ * Set (replace) the reference for a single-valued navigation property.
117
+ *
118
+ * `PATCH /<EntitySet>(<key>)/<navProperty>/$ref`
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * await db.from('order').byKey(100).setRef('customer', 7)
123
+ * ```
124
+ */
125
+ setRef(navProperty: string, relatedKey: string | number, opts?: EntityWriteOptions): Promise<void>;
126
+ /**
127
+ * Remove a reference from a navigation property.
128
+ *
129
+ * `DELETE /<EntitySet>(<key>)/<navProperty>/$ref`
130
+ *
131
+ * For collection-valued navigation properties, pass the `relatedKey` to
132
+ * remove a specific reference. For single-valued, omit `relatedKey` to
133
+ * clear the reference.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * await db.from('contact').byKey(7).removeRef('addresses', 42)
138
+ * await db.from('order').byKey(100).removeRef('customer')
139
+ * ```
140
+ */
141
+ removeRef(navProperty: string, relatedKey?: string | number, opts?: EntityWriteOptions): Promise<void>;
142
+ /** @internal — build a relative @odata.id for a related entity. */
143
+ private _refId;
144
+ }
145
+ /** Entity reference info returned by `getRefs()`. */
146
+ export interface EntityRefInfo {
147
+ '@odata.id': string;
148
+ }
149
+ //# sourceMappingURL=entity.d.ts.map