fms-odata-js 0.3.0 → 0.4.1

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/schema.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Schema modification (DDL) for FileMaker Server via the OData system tables.
3
+ *
4
+ * FMS exposes DDL through two system tables:
5
+ *
6
+ * FileMaker_Tables — create tables, add fields, delete tables/fields
7
+ * FileMaker_Indexes — create and delete field indexes
8
+ *
9
+ * All operations require a FileMaker account with full access (schema
10
+ * modification) privileges in the target file.
11
+ *
12
+ * Destructive operations (`deleteTable`, `deleteField`) require an explicit
13
+ * `confirm: true` option to prevent accidental data loss, per the spec's
14
+ * security guidance (docs/10-schema-modification.md).
15
+ *
16
+ * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/10-schema-modification.md
17
+ */
18
+
19
+ import {
20
+ FIELD_TYPES,
21
+ parseFieldType,
22
+ type CreateTableParams,
23
+ type AddFieldsParams,
24
+ type FMFieldDefinition,
25
+ } from '@fms-odata/spec-ts'
26
+ import type { FMSOData } from './client.js'
27
+ import { executeJson } from './http.js'
28
+ import type { RequestOptions } from './types.js'
29
+ import { encodePathSegment } from './url.js'
30
+
31
+ /** Options accepted by schema operations. */
32
+ export interface SchemaOptions extends RequestOptions {}
33
+
34
+ /** Options for destructive schema operations (deleteTable, deleteField). */
35
+ export interface DeleteSchemaOptions extends SchemaOptions {
36
+ /**
37
+ * Must be `true` to proceed. Destructive DDL operations permanently destroy
38
+ * data with no undo. This guard prevents accidental invocation.
39
+ */
40
+ confirm: true
41
+ }
42
+
43
+ /** Validate that every field definition uses a supported base type. */
44
+ function validateFieldDefinitions(fields: FMFieldDefinition[]): void {
45
+ for (const f of fields) {
46
+ if (!f.name) throw new TypeError('SchemaEditor: field `name` is required')
47
+ if (!f.type) throw new TypeError('SchemaEditor: field `type` is required')
48
+ const { baseType } = parseFieldType(f.type)
49
+ const upper = baseType.toUpperCase()
50
+ if (!FIELD_TYPES.includes(upper as (typeof FIELD_TYPES)[number])) {
51
+ throw new TypeError(
52
+ `SchemaEditor: unsupported field type "${f.type}" (base: "${baseType}"). ` +
53
+ `Supported: ${FIELD_TYPES.join(', ')}`,
54
+ )
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Schema editor for FileMaker DDL operations.
61
+ *
62
+ * Obtain an instance via `db.schema()` or use the convenience methods on
63
+ * `FMSOData` (`db.createTable`, `db.addFields`, etc.).
64
+ */
65
+ export class SchemaEditor {
66
+ /** @internal */ readonly _client: FMSOData
67
+
68
+ constructor(client: FMSOData) {
69
+ this._client = client
70
+ }
71
+
72
+ /**
73
+ * Create a new table with the given field definitions.
74
+ *
75
+ * ```ts
76
+ * await db.schema().createTable({
77
+ * tableName: 'Company',
78
+ * fields: [
79
+ * { name: 'Company ID', type: 'int', primary: true },
80
+ * { name: 'Company Name', type: 'varchar(100)', nullable: false },
81
+ * ],
82
+ * })
83
+ * ```
84
+ */
85
+ async createTable(
86
+ params: CreateTableParams,
87
+ opts: SchemaOptions = {},
88
+ ): Promise<unknown> {
89
+ if (!params.tableName) throw new TypeError('SchemaEditor: `tableName` is required')
90
+ if (!params.fields?.length) throw new TypeError('SchemaEditor: `fields` must be a non-empty array')
91
+ validateFieldDefinitions(params.fields)
92
+
93
+ const url = `${this._client.baseUrl}/FileMaker_Tables`
94
+ return executeJson<unknown>(this._client._ctx, url, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify(params),
98
+ accept: 'json',
99
+ ...(opts.signal ? { signal: opts.signal } : {}),
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Add fields to an existing table.
105
+ *
106
+ * ```ts
107
+ * await db.schema().addFields({
108
+ * tableName: 'Company',
109
+ * fields: [{ name: 'Phone', type: 'varchar(30)' }],
110
+ * })
111
+ * ```
112
+ */
113
+ async addFields(
114
+ params: AddFieldsParams,
115
+ opts: SchemaOptions = {},
116
+ ): Promise<unknown> {
117
+ if (!params.tableName) throw new TypeError('SchemaEditor: `tableName` is required')
118
+ if (!params.fields?.length) throw new TypeError('SchemaEditor: `fields` must be a non-empty array')
119
+ validateFieldDefinitions(params.fields)
120
+
121
+ const url = `${this._client.baseUrl}/FileMaker_Tables/${encodePathSegment(params.tableName)}`
122
+ return executeJson<unknown>(this._client._ctx, url, {
123
+ method: 'PATCH',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ fields: params.fields }),
126
+ accept: 'json',
127
+ ...(opts.signal ? { signal: opts.signal } : {}),
128
+ })
129
+ }
130
+
131
+ /**
132
+ * Delete a table and ALL its records. Irreversible.
133
+ *
134
+ * Requires `opts.confirm === true` as a safety guard.
135
+ *
136
+ * ```ts
137
+ * await db.schema().deleteTable('OldTable', { confirm: true })
138
+ * ```
139
+ */
140
+ async deleteTable(
141
+ tableName: string,
142
+ opts: DeleteSchemaOptions,
143
+ ): Promise<void> {
144
+ if (!tableName) throw new TypeError('SchemaEditor: `tableName` is required')
145
+ if (opts.confirm !== true) {
146
+ throw new TypeError(
147
+ `SchemaEditor: deleteTable("${tableName}") requires { confirm: true } — ` +
148
+ 'this operation permanently destroys the table and all its records.',
149
+ )
150
+ }
151
+ const url = `${this._client.baseUrl}/FileMaker_Tables/${encodePathSegment(tableName)}`
152
+ await executeJson<void>(this._client._ctx, url, {
153
+ method: 'DELETE',
154
+ accept: 'json',
155
+ ...(opts.signal ? { signal: opts.signal } : {}),
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Delete a field from a table. Irreversible.
161
+ *
162
+ * Requires `opts.confirm === true` as a safety guard.
163
+ *
164
+ * ```ts
165
+ * await db.schema().deleteField('Company', 'OldField', { confirm: true })
166
+ * ```
167
+ */
168
+ async deleteField(
169
+ tableName: string,
170
+ fieldName: string,
171
+ opts: DeleteSchemaOptions,
172
+ ): Promise<void> {
173
+ if (!tableName) throw new TypeError('SchemaEditor: `tableName` is required')
174
+ if (!fieldName) throw new TypeError('SchemaEditor: `fieldName` is required')
175
+ if (opts.confirm !== true) {
176
+ throw new TypeError(
177
+ `SchemaEditor: deleteField("${tableName}", "${fieldName}") requires { confirm: true } — ` +
178
+ 'this operation permanently destroys the field and all its data.',
179
+ )
180
+ }
181
+ const url = `${this._client.baseUrl}/FileMaker_Tables/${encodePathSegment(tableName)}/${encodePathSegment(fieldName)}`
182
+ await executeJson<void>(this._client._ctx, url, {
183
+ method: 'DELETE',
184
+ accept: 'json',
185
+ ...(opts.signal ? { signal: opts.signal } : {}),
186
+ })
187
+ }
188
+
189
+ /**
190
+ * Create an index on a field.
191
+ *
192
+ * ```ts
193
+ * await db.schema().createIndex('Company', 'Company Name')
194
+ * ```
195
+ */
196
+ async createIndex(
197
+ tableName: string,
198
+ fieldName: string,
199
+ opts: SchemaOptions = {},
200
+ ): Promise<unknown> {
201
+ if (!tableName) throw new TypeError('SchemaEditor: `tableName` is required')
202
+ if (!fieldName) throw new TypeError('SchemaEditor: `fieldName` is required')
203
+
204
+ const url = `${this._client.baseUrl}/FileMaker_Indexes/${encodePathSegment(tableName)}`
205
+ return executeJson<unknown>(this._client._ctx, url, {
206
+ method: 'POST',
207
+ headers: { 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({ indexName: fieldName }),
209
+ accept: 'json',
210
+ ...(opts.signal ? { signal: opts.signal } : {}),
211
+ })
212
+ }
213
+
214
+ /**
215
+ * Delete an index from a field.
216
+ *
217
+ * ```ts
218
+ * await db.schema().deleteIndex('Company', 'Company Name')
219
+ * ```
220
+ */
221
+ async deleteIndex(
222
+ tableName: string,
223
+ fieldName: string,
224
+ opts: SchemaOptions = {},
225
+ ): Promise<void> {
226
+ if (!tableName) throw new TypeError('SchemaEditor: `tableName` is required')
227
+ if (!fieldName) throw new TypeError('SchemaEditor: `fieldName` is required')
228
+
229
+ const url = `${this._client.baseUrl}/FileMaker_Indexes/${encodePathSegment(tableName)}/${encodePathSegment(fieldName)}`
230
+ await executeJson<void>(this._client._ctx, url, {
231
+ method: 'DELETE',
232
+ accept: 'json',
233
+ ...(opts.signal ? { signal: opts.signal } : {}),
234
+ })
235
+ }
236
+ }
package/src/scripts.ts CHANGED
@@ -15,9 +15,12 @@
15
15
  * POST /<db>/<EntitySet>/Script.FMSID:<id> // entity-set context
16
16
  * POST /<db>/<EntitySet>(<key>)/Script.FMSID:<id> // single-record context
17
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`.
18
+ * The optional parameter is sent as `{ "scriptParameterValue": ... }` (string,
19
+ * number or JSON object). The response envelope depends on the FMS version:
20
+ * older servers reply `{ "scriptResult": "...", "scriptError": "0" }`, while
21
+ * FMS v26+ nests the result as
22
+ * `{ "scriptResult": { "code": 0, "resultParameter": "...", "message": "..." } }`.
23
+ * A non-zero error code becomes an `FMScriptError`.
21
24
  *
22
25
  * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md
23
26
  */
@@ -41,10 +44,11 @@ export type ScriptIdentifier =
41
44
  export interface ScriptOptions extends RequestOptions {
42
45
  /**
43
46
  * 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).
47
+ * field in the request body. FMS accepts a string, a number or a JSON
48
+ * object. If omitted, the body is empty and the script runs with no
49
+ * parameter (FileMaker's `Get(ScriptParameter)` returns empty).
46
50
  */
47
- parameter?: string
51
+ parameter?: string | number | object
48
52
  }
49
53
 
50
54
  /**
@@ -183,12 +187,23 @@ export function parseScriptEnvelope(
183
187
  const rawResult = envelope.scriptResult
184
188
  let scriptResult: string | undefined
185
189
  let scriptError: string
190
+ let fmsMessage: string | undefined
186
191
 
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 }
192
+ if (
193
+ rawResult !== null &&
194
+ typeof rawResult === 'object' &&
195
+ ('code' in rawResult || 'resultParameter' in rawResult)
196
+ ) {
197
+ // Nested envelope (FMS v26+): code + resultParameter (+ message) inside scriptResult
198
+ const nested = rawResult as { code?: unknown; resultParameter?: unknown; message?: unknown }
190
199
  scriptError = nested.code !== undefined ? String(nested.code) : '0'
191
- scriptResult = nested.resultParameter !== undefined ? String(nested.resultParameter) : undefined
200
+ scriptResult =
201
+ nested.resultParameter !== undefined
202
+ ? typeof nested.resultParameter === 'string'
203
+ ? nested.resultParameter
204
+ : JSON.stringify(nested.resultParameter)
205
+ : undefined
206
+ if (typeof nested.message === 'string') fmsMessage = nested.message
192
207
  } else {
193
208
  // Flat envelope (older FMS): scriptResult is a string, scriptError is a sibling
194
209
  scriptError = envelope.scriptError !== undefined ? String(envelope.scriptError) : '0'
@@ -202,7 +217,9 @@ export function parseScriptEnvelope(
202
217
 
203
218
  if (scriptError !== '0') {
204
219
  throw new FMScriptError(
205
- `FileMaker script error ${scriptError}`,
220
+ fmsMessage
221
+ ? `FileMaker script error ${scriptError}: ${fmsMessage}`
222
+ : `FileMaker script error ${scriptError}`,
206
223
  {
207
224
  status: 200,
208
225
  scriptError,
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Webhook management for FileMaker Server OData.
3
+ *
4
+ * FMS exposes webhook management through five endpoints following the
5
+ * standard OData function-call shape: read endpoints use GET, and action
6
+ * endpoints use POST with the `<id>` passed as a function argument in the
7
+ * URL path.
8
+ *
9
+ * GET /<db>/Webhook.GetAll — list all webhooks
10
+ * GET /<db>/Webhook.Get(<id>) — get one webhook by id
11
+ * POST /<db>/Webhook.Add — create a webhook (config in JSON body)
12
+ * POST /<db>/Webhook.Delete(<id>) — delete a webhook (id in URL path)
13
+ * POST /<db>/Webhook.Invoke(<id>) — manually trigger a webhook (id in URL path)
14
+ *
15
+ * Webhooks require FileMaker Server 2025+ (v22). Use
16
+ * `db.hasFeature('webhooks')` to check before calling.
17
+ *
18
+ * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/09-webhooks.md
19
+ */
20
+
21
+ import type { WebhookCreateParams, WebhookData } from '@fms-odata/spec-ts'
22
+ import type { FMSOData } from './client.js'
23
+ import { executeJson } from './http.js'
24
+ import type { RequestOptions } from './types.js'
25
+ import { encodePathSegment } from './url.js'
26
+
27
+ /** Options accepted by webhook operations. */
28
+ export interface WebhookOptions extends RequestOptions {}
29
+
30
+ /**
31
+ * Normalize create params: merge legacy `headers` into `endpointHeaders`
32
+ * (spec says `headers` is a legacy alias for `endpointHeaders`).
33
+ */
34
+ function normalizeCreateParams(params: WebhookCreateParams): Record<string, unknown> {
35
+ const out: Record<string, unknown> = {
36
+ webhook: params.webhook,
37
+ tableName: params.tableName,
38
+ }
39
+ if (params.endpointHeaders) out.endpointHeaders = params.endpointHeaders
40
+ if (params.headers && !params.endpointHeaders) out.endpointHeaders = params.headers
41
+ if (params.queryHeaders) out.queryHeaders = params.queryHeaders
42
+ if (params.notifySchemaChanges !== undefined) out.notifySchemaChanges = params.notifySchemaChanges
43
+ if (params.select) out.select = params.select
44
+ if (params.filter) out.filter = params.filter
45
+ if (params.maxFailedAttempts !== undefined) out.maxFailedAttempts = params.maxFailedAttempts
46
+ return out
47
+ }
48
+
49
+ /**
50
+ * Extract the webhook ID from a `Webhook.Add` response.
51
+ *
52
+ * FMS returns `{ webhookResult: { webhookID: N } }`. Older or alternate
53
+ * shapes (`{ webhookID: N }`, `{ id: N }`) are accepted as fallbacks.
54
+ */
55
+ function extractWebhookId(data: unknown): string | undefined {
56
+ if (!data || typeof data !== 'object') return undefined
57
+ const obj = data as Record<string, unknown>
58
+ const fromResult = obj.webhookResult as Record<string, unknown> | undefined
59
+ const id = fromResult?.webhookID ?? obj.webhookID ?? obj.id
60
+ return id !== undefined ? String(id) : undefined
61
+ }
62
+
63
+ /**
64
+ * Webhook manager for FileMaker Server webhook CRUD.
65
+ *
66
+ * Obtain an instance via `db.webhooks()` or use the convenience methods on
67
+ * `FMSOData` (`db.createWebhook`, `db.removeWebhook`, etc.).
68
+ *
69
+ * Requires FileMaker Server 2025+ (v22).
70
+ */
71
+ export class WebhookManager {
72
+ /** @internal */ readonly _client: FMSOData
73
+
74
+ constructor(client: FMSOData) {
75
+ this._client = client
76
+ }
77
+
78
+ /** Build the URL for a webhook operation. */
79
+ private _url(operation: 'Add' | 'Delete' | 'Get' | 'GetAll' | 'Invoke', id?: string | number): string {
80
+ const base = `${this._client.baseUrl}/Webhook.${operation}`
81
+ return id !== undefined ? `${base}(${encodePathSegment(String(id))})` : base
82
+ }
83
+
84
+ /**
85
+ * Create a webhook.
86
+ *
87
+ * ```ts
88
+ * const { id } = await db.webhooks().create({
89
+ * webhook: 'https://my.example.com:8080/webhook',
90
+ * tableName: 'contact',
91
+ * select: 'id,first_name',
92
+ * filter: "status eq 'active'",
93
+ * notifySchemaChanges: true,
94
+ * })
95
+ * ```
96
+ *
97
+ * @returns An object with the server-generated `id` of the new webhook.
98
+ */
99
+ async create(
100
+ params: WebhookCreateParams,
101
+ opts: WebhookOptions = {},
102
+ ): Promise<{ id: string }> {
103
+ if (!params.webhook) throw new TypeError('WebhookManager: `webhook` URL is required')
104
+ if (!params.tableName) throw new TypeError('WebhookManager: `tableName` is required')
105
+
106
+ const body = JSON.stringify(normalizeCreateParams(params))
107
+ const data = await executeJson<unknown>(this._client._ctx, this._url('Add'), {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body,
111
+ accept: 'json',
112
+ ...(opts.signal ? { signal: opts.signal } : {}),
113
+ })
114
+ const id = extractWebhookId(data)
115
+ if (id === undefined) {
116
+ throw new Error('WebhookManager: Webhook.Add did not return a webhook id')
117
+ }
118
+ return { id }
119
+ }
120
+
121
+ /**
122
+ * Delete a webhook by its ID.
123
+ *
124
+ * ```ts
125
+ * await db.webhooks().remove('1')
126
+ * ```
127
+ */
128
+ async remove(id: string | number, opts: WebhookOptions = {}): Promise<unknown> {
129
+ if (id === '' || id === undefined) throw new TypeError('WebhookManager: webhook `id` is required')
130
+ return executeJson<unknown>(this._client._ctx, this._url('Delete', id), {
131
+ method: 'POST',
132
+ accept: 'json',
133
+ ...(opts.signal ? { signal: opts.signal } : {}),
134
+ })
135
+ }
136
+
137
+ /** Alias for {@link remove} (matches the FMS endpoint name `Webhook.Delete`). */
138
+ async delete(id: string | number, opts: WebhookOptions = {}): Promise<unknown> {
139
+ return this.remove(id, opts)
140
+ }
141
+
142
+ /**
143
+ * Get a specific webhook's data by ID.
144
+ *
145
+ * ```ts
146
+ * const data = await db.webhooks().get('1')
147
+ * ```
148
+ */
149
+ async get(id: string | number, opts: WebhookOptions = {}): Promise<WebhookData | unknown> {
150
+ if (id === '' || id === undefined) throw new TypeError('WebhookManager: webhook `id` is required')
151
+ return executeJson<unknown>(this._client._ctx, this._url('Get', id), {
152
+ method: 'GET',
153
+ accept: 'json',
154
+ ...(opts.signal ? { signal: opts.signal } : {}),
155
+ })
156
+ }
157
+
158
+ /**
159
+ * List all webhooks.
160
+ *
161
+ * ```ts
162
+ * const result = await db.webhooks().getAll()
163
+ * ```
164
+ */
165
+ async getAll(opts: WebhookOptions = {}): Promise<unknown> {
166
+ return executeJson<unknown>(this._client._ctx, this._url('GetAll'), {
167
+ method: 'GET',
168
+ accept: 'json',
169
+ ...(opts.signal ? { signal: opts.signal } : {}),
170
+ })
171
+ }
172
+
173
+ /**
174
+ * Manually invoke (trigger) a webhook by ID. Useful for testing.
175
+ *
176
+ * Optionally pass `rowIDs` to target specific records; if omitted, FMS
177
+ * triggers the webhook for all pending records.
178
+ *
179
+ * ```ts
180
+ * await db.webhooks().invoke('1')
181
+ * await db.webhooks().invoke('1', { rowIDs: [10, 20] })
182
+ * ```
183
+ */
184
+ async invoke(
185
+ id: string | number,
186
+ opts: WebhookOptions & { rowIDs?: ReadonlyArray<string | number> } = {},
187
+ ): Promise<unknown> {
188
+ if (id === '' || id === undefined) throw new TypeError('WebhookManager: webhook `id` is required')
189
+ const { rowIDs, signal, ...rest } = opts
190
+ // FMS requires a JSON body for Invoke (it rejects empty bodies with a
191
+ // JSON syntax error). Send { rowIDs: [...] } — an empty array triggers
192
+ // the webhook for all pending records.
193
+ const body = JSON.stringify({ rowIDs: rowIDs ? [...rowIDs] : [] })
194
+ return executeJson<unknown>(this._client._ctx, this._url('Invoke', id), {
195
+ method: 'POST',
196
+ headers: { 'Content-Type': 'application/json' },
197
+ body,
198
+ accept: 'json',
199
+ ...(signal ? { signal } : {}),
200
+ ...rest,
201
+ })
202
+ }
203
+ }