fms-odata-js 0.3.0 → 0.4.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/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
+ }
@@ -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
+ }