From 25405d0064a0fa0991f38ca244d33d552b6e6433 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:29:11 -0500 Subject: [PATCH 01/11] feat(typegen): add fm-http adapter flow and tests --- btca.config.jsonc | 13 + packages/fmdapi/src/adapters/fm-http.ts | 217 ++++++++++++ packages/fmdapi/src/index.ts | 1 + packages/fmdapi/tests/fm-http-adapter.test.ts | 319 ++++++++++++++++++ .../live-fm-http-output/client/contacts.ts | 21 ++ .../live-fm-http-output/client/index.ts | 1 + .../typegen/live-fm-http-output/contacts.ts | 10 + .../proofkit-typegen.fm-http.local.jsonc | 18 + packages/typegen/schema/fmdapi_test.ts | 16 + packages/typegen/schema/index.ts | 6 + packages/typegen/schema/isolated_contacts.ts | 39 +++ packages/typegen/src/buildLayoutClient.ts | 29 +- packages/typegen/src/constants.ts | 2 + .../typegen/src/fmodata/downloadMetadata.ts | 4 + packages/typegen/src/getEnvValues.ts | 74 +++- .../typegen/src/server/createDataApiClient.ts | 55 ++- packages/typegen/src/typegen.ts | 56 ++- packages/typegen/src/types.ts | 32 ++ packages/typegen/tests/getEnvValues.test.ts | 100 ++++++ packages/typegen/tests/typegen.test.ts | 52 ++- packages/typegen/tests/utils/mock-fetch.ts | 45 +++ 21 files changed, 1087 insertions(+), 23 deletions(-) create mode 100644 btca.config.jsonc create mode 100644 packages/fmdapi/src/adapters/fm-http.ts create mode 100644 packages/fmdapi/tests/fm-http-adapter.test.ts create mode 100644 packages/typegen/live-fm-http-output/client/contacts.ts create mode 100644 packages/typegen/live-fm-http-output/client/index.ts create mode 100644 packages/typegen/live-fm-http-output/contacts.ts create mode 100644 packages/typegen/proofkit-typegen.fm-http.local.jsonc create mode 100644 packages/typegen/schema/fmdapi_test.ts create mode 100644 packages/typegen/schema/index.ts create mode 100644 packages/typegen/schema/isolated_contacts.ts create mode 100644 packages/typegen/tests/getEnvValues.test.ts diff --git a/btca.config.jsonc b/btca.config.jsonc new file mode 100644 index 00000000..3292be29 --- /dev/null +++ b/btca.config.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "https://btca.dev/btca.schema.json", + "resources": [ + { + "type": "git", + "name": "fm-mcp", + "url": "https://github.com/proofgeist/fm-mcp", + "branch": "main" + } + ], + "model": "GPT-5.3-Codex", + "provider": "openai" +} \ No newline at end of file diff --git a/packages/fmdapi/src/adapters/fm-http.ts b/packages/fmdapi/src/adapters/fm-http.ts new file mode 100644 index 00000000..056b62f9 --- /dev/null +++ b/packages/fmdapi/src/adapters/fm-http.ts @@ -0,0 +1,217 @@ +import type { + CreateResponse, + DeleteResponse, + GetResponse, + LayoutMetadataResponse, + RawFMResponse, + ScriptResponse, + UpdateResponse, +} from "../client-types.js"; +import { FileMakerError } from "../client-types.js"; +import type { + Adapter, + ContainerUploadOptions, + CreateOptions, + DeleteOptions, + ExecuteScriptOptions, + FindOptions, + GetOptions, + LayoutMetadataOptions, + ListOptions, + UpdateOptions, +} from "./core.js"; + +export interface FmHttpAdapterOptions { + /** Base URL of the local FM HTTP server (e.g. "http://localhost:3000") */ + baseUrl: string; + /** Name of the connected FileMaker file */ + connectedFileName: string; + /** Name of the FM script that executes Data API calls. Defaults to "execute_data_api" */ + scriptName?: string; +} + +export class FmHttpAdapter implements Adapter { + protected baseUrl: string; + protected connectedFileName: string; + protected scriptName: string; + + constructor(options: FmHttpAdapterOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.connectedFileName = options.connectedFileName; + this.scriptName = options.scriptName ?? "execute_data_api"; + } + + protected request = async (params: { + layout: string; + body: object; + action?: "read" | "metaData" | "create" | "update" | "delete"; + timeout?: number; + fetchOptions?: RequestInit; + }): Promise => { + const { action = "read", layout, body, fetchOptions = {} } = params; + + // Normalize underscore-prefixed keys to match FM script expectations + const normalizedBody: Record = { ...body } as Record; + if ("_offset" in normalizedBody) { + normalizedBody.offset = normalizedBody._offset; + normalizedBody._offset = undefined; + } + if ("_limit" in normalizedBody) { + normalizedBody.limit = normalizedBody._limit; + normalizedBody._limit = undefined; + } + if ("_sort" in normalizedBody) { + normalizedBody.sort = normalizedBody._sort; + normalizedBody._sort = undefined; + } + + const scriptParam = JSON.stringify({ + ...normalizedBody, + layouts: layout, + action, + version: "vLatest", + }); + + const controller = new AbortController(); + let timeout: NodeJS.Timeout | null = null; + if (params.timeout) { + timeout = setTimeout(() => controller.abort(), params.timeout); + } + + const headers = new Headers(fetchOptions?.headers); + headers.set("Content-Type", "application/json"); + + const res = await fetch(`${this.baseUrl}/callScript`, { + ...fetchOptions, + method: "POST", + headers, + body: JSON.stringify({ + connectedFileName: this.connectedFileName, + scriptName: this.scriptName, + data: scriptParam, + }), + signal: controller.signal, + }); + + if (timeout) { + clearTimeout(timeout); + } + + if (!res.ok) { + throw new FileMakerError( + String(res.status), + `FM HTTP request failed (${res.status}): ${await res.text()}`, + ); + } + + let respData: RawFMResponse; + const raw = await res.json(); + // The /callScript response wraps the script result as a string or object + const scriptResult = typeof raw.result === "string" ? JSON.parse(raw.result) : raw.result ?? raw; + respData = scriptResult as RawFMResponse; + + if (respData.messages?.[0].code !== "0") { + throw new FileMakerError( + respData?.messages?.[0].code ?? "500", + `Filemaker Data API failed with (${respData.messages?.[0].code}): ${JSON.stringify(respData, null, 2)}`, + ); + } + + return respData.response; + }; + + list = async (opts: ListOptions): Promise => { + return (await this.request({ + body: opts.data, + layout: opts.layout, + timeout: opts.timeout, + fetchOptions: opts.fetch, + })) as GetResponse; + }; + + get = async (opts: GetOptions): Promise => { + return (await this.request({ + body: opts.data, + layout: opts.layout, + timeout: opts.timeout, + fetchOptions: opts.fetch, + })) as GetResponse; + }; + + find = async (opts: FindOptions): Promise => { + return (await this.request({ + body: opts.data, + layout: opts.layout, + timeout: opts.timeout, + fetchOptions: opts.fetch, + })) as GetResponse; + }; + + create = async (opts: CreateOptions): Promise => { + return (await this.request({ + action: "create", + body: opts.data, + layout: opts.layout, + timeout: opts.timeout, + fetchOptions: opts.fetch, + })) as CreateResponse; + }; + + update = async (opts: UpdateOptions): Promise => { + return (await this.request({ + action: "update", + body: opts.data, + layout: opts.layout, + timeout: opts.timeout, + fetchOptions: opts.fetch, + })) as UpdateResponse; + }; + + delete = async (opts: DeleteOptions): Promise => { + return (await this.request({ + action: "delete", + body: opts.data, + layout: opts.layout, + timeout: opts.timeout, + fetchOptions: opts.fetch, + })) as DeleteResponse; + }; + + layoutMetadata = async (opts: LayoutMetadataOptions): Promise => { + return (await this.request({ + action: "metaData", + layout: opts.layout, + body: {}, + timeout: opts.timeout, + fetchOptions: opts.fetch, + })) as LayoutMetadataResponse; + }; + + executeScript = async (opts: ExecuteScriptOptions): Promise => { + const res = await fetch(`${this.baseUrl}/callScript`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectedFileName: this.connectedFileName, + scriptName: opts.script, + data: opts.scriptParam, + }), + }); + + if (!res.ok) { + throw new FileMakerError( + String(res.status), + `FM HTTP executeScript failed (${res.status}): ${await res.text()}`, + ); + } + + const raw = await res.json(); + return { + scriptResult: typeof raw.result === "string" ? raw.result : JSON.stringify(raw.result), + } as ScriptResponse; + }; + + containerUpload = (): Promise => { + throw new Error("Container upload is not supported via FM HTTP adapter"); + }; +} diff --git a/packages/fmdapi/src/index.ts b/packages/fmdapi/src/index.ts index 09514bb7..3c730bcf 100644 --- a/packages/fmdapi/src/index.ts +++ b/packages/fmdapi/src/index.ts @@ -1,4 +1,5 @@ export { FetchAdapter } from "./adapters/fetch.js"; +export { FmHttpAdapter, type FmHttpAdapterOptions } from "./adapters/fm-http.js"; export { OttoAdapter, type OttoAPIKey } from "./adapters/otto.js"; export { DataApi, DataApi as default } from "./client.js"; export * as clientTypes from "./client-types.js"; diff --git a/packages/fmdapi/tests/fm-http-adapter.test.ts b/packages/fmdapi/tests/fm-http-adapter.test.ts new file mode 100644 index 00000000..5e782353 --- /dev/null +++ b/packages/fmdapi/tests/fm-http-adapter.test.ts @@ -0,0 +1,319 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FmHttpAdapter } from "../src/adapters/fm-http"; +import { FileMakerError } from "../src/client-types"; +import { DataApi } from "../src/index"; + +/** Wraps a FM Data API response in the /callScript envelope */ +function callScriptResponse(fmResponse: object, { asString = false } = {}) { + return { + result: asString ? JSON.stringify(fmResponse) : fmResponse, + }; +} + +function mockFetch(body: object, status = 200): typeof fetch { + return () => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function createAdapter(opts?: Partial<{ scriptName: string }>) { + return new FmHttpAdapter({ + baseUrl: "http://localhost:3000", + connectedFileName: "MyFile", + ...opts, + }); +} + +function createClient(adapter = createAdapter()) { + return DataApi({ adapter, layout: "TestLayout" }); +} + +const successEnvelope = (response: unknown) => + callScriptResponse({ + messages: [{ code: "0" }], + response, + }); + +const errorEnvelope = (code: string) => + callScriptResponse({ + messages: [{ code }], + response: {}, + }); + +describe("FmHttpAdapter", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("list / read", () => { + it("sends correct payload to /callScript", async () => { + const spy = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify( + successEnvelope({ + data: [], + dataInfo: { totalRecordCount: 0, foundCount: 0, returnedCount: 0 }, + }), + ), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + vi.stubGlobal("fetch", spy); + + const client = createClient(); + await client.list(); + + expect(spy).toHaveBeenCalledOnce(); + const [url, init] = spy.mock.calls[0]; + expect(url).toBe("http://localhost:3000/callScript"); + expect(init?.method).toBe("POST"); + + const body = JSON.parse(init?.body as string); + expect(body.connectedFileName).toBe("MyFile"); + expect(body.scriptName).toBe("execute_data_api"); + + const param = JSON.parse(body.data); + expect(param.layouts).toBe("TestLayout"); + expect(param.action).toBe("read"); + expect(param.version).toBe("vLatest"); + }); + + it("returns records from list", async () => { + const records = [ + { fieldData: { name: "A" }, recordId: "1", modId: "1", portalData: {} }, + { fieldData: { name: "B" }, recordId: "2", modId: "1", portalData: {} }, + ]; + vi.stubGlobal( + "fetch", + mockFetch( + successEnvelope({ + data: records, + dataInfo: { totalRecordCount: 2, foundCount: 2, returnedCount: 2 }, + }), + ), + ); + + const client = createClient(); + const resp = await client.list(); + expect(resp.data.length).toBe(2); + }); + }); + + describe("create", () => { + it("sends create action", async () => { + const spy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify(successEnvelope({ recordId: "42", modId: "1" })), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + vi.stubGlobal("fetch", spy); + + const client = createClient(); + const resp = await client.create({ fieldData: { name: "test" } }); + expect(resp.recordId).toBe("42"); + + const body = JSON.parse(spy.mock.calls[0][1]?.body as string); + const param = JSON.parse(body.data); + expect(param.action).toBe("create"); + }); + }); + + describe("update", () => { + it("sends update action with recordId", async () => { + const spy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify(successEnvelope({ modId: "2" })), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + vi.stubGlobal("fetch", spy); + + const client = createClient(); + const resp = await client.update({ recordId: 1, fieldData: { name: "updated" } }); + expect(resp.modId).toBe("2"); + + const body = JSON.parse(spy.mock.calls[0][1]?.body as string); + const param = JSON.parse(body.data); + expect(param.action).toBe("update"); + expect(param.fieldData).toEqual({ name: "updated" }); + }); + }); + + describe("delete", () => { + it("sends delete action", async () => { + const spy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify(successEnvelope({})), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + vi.stubGlobal("fetch", spy); + + const client = createClient(); + await client.delete({ recordId: 5 }); + + const body = JSON.parse(spy.mock.calls[0][1]?.body as string); + const param = JSON.parse(body.data); + expect(param.action).toBe("delete"); + expect(param.recordId).toBe(5); + }); + }); + + describe("layoutMetadata", () => { + it("sends metaData action", async () => { + const metadata = { + fieldMetaData: [{ name: "name", type: "normal" }], + portalMetaData: {}, + }; + vi.stubGlobal("fetch", mockFetch(successEnvelope(metadata))); + + const client = createClient(); + const resp = await client.layoutMetadata(); + expect(resp.fieldMetaData).toHaveLength(1); + }); + }); + + describe("error handling", () => { + it("throws FileMakerError on non-0 message code", async () => { + vi.stubGlobal("fetch", mockFetch(errorEnvelope("401"))); + + const client = createClient(); + await expect(client.list()).rejects.toBeInstanceOf(FileMakerError); + await expect(client.list()).rejects.toHaveProperty("code", "401"); + }); + + it("throws FileMakerError on HTTP error", async () => { + vi.stubGlobal( + "fetch", + () => Promise.resolve(new Response("Internal Server Error", { status: 500 })), + ); + + const client = createClient(); + await expect(client.list()).rejects.toBeInstanceOf(FileMakerError); + }); + }); + + describe("string result parsing", () => { + it("parses result when returned as JSON string", async () => { + const records = [{ fieldData: { name: "A" }, recordId: "1", modId: "1", portalData: {} }]; + vi.stubGlobal( + "fetch", + mockFetch( + callScriptResponse( + { + messages: [{ code: "0" }], + response: { data: records, dataInfo: { totalRecordCount: 1, foundCount: 1, returnedCount: 1 } }, + }, + { asString: true }, + ), + ), + ); + + const client = createClient(); + const resp = await client.list(); + expect(resp.data.length).toBe(1); + }); + }); + + describe("custom script name", () => { + it("uses custom scriptName when provided", async () => { + const spy = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify( + successEnvelope({ + data: [], + dataInfo: { totalRecordCount: 0, foundCount: 0, returnedCount: 0 }, + }), + ), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + vi.stubGlobal("fetch", spy); + + const adapter = createAdapter({ scriptName: "my_custom_script" }); + const client = createClient(adapter); + await client.list(); + + const body = JSON.parse(spy.mock.calls[0][1]?.body as string); + expect(body.scriptName).toBe("my_custom_script"); + }); + }); + + describe("underscore param normalization", () => { + it("converts _offset/_limit/_sort to offset/limit/sort", async () => { + const spy = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify( + successEnvelope({ + data: [], + dataInfo: { totalRecordCount: 0, foundCount: 0, returnedCount: 0 }, + }), + ), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + vi.stubGlobal("fetch", spy); + + const client = createClient(); + await client.list({ + offset: 5, + limit: 10, + sort: { fieldName: "name", sortOrder: "ascend" }, + }); + + const body = JSON.parse(spy.mock.calls[0][1]?.body as string); + const param = JSON.parse(body.data); + expect(param.offset).toBe(5); + expect(param.limit).toBe(10); + expect(param._offset).toBeUndefined(); + expect(param._limit).toBeUndefined(); + }); + }); + + describe("containerUpload", () => { + it("throws not supported error", () => { + const adapter = createAdapter(); + expect(() => adapter.containerUpload({} as never)).toThrow("not supported"); + }); + }); + + describe("executeScript", () => { + it("calls /callScript with the given script name", async () => { + const spy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ result: "hello" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + vi.stubGlobal("fetch", spy); + + const client = createClient(); + const resp = await client.executeScript({ script: "MyScript", scriptParam: "param1" }); + + expect(resp.scriptResult).toBe("hello"); + const body = JSON.parse(spy.mock.calls[0][1]?.body as string); + expect(body.scriptName).toBe("MyScript"); + expect(body.data).toBe("param1"); + expect(body.connectedFileName).toBe("MyFile"); + }); + }); +}); diff --git a/packages/typegen/live-fm-http-output/client/contacts.ts b/packages/typegen/live-fm-http-output/client/contacts.ts new file mode 100644 index 00000000..1f811be4 --- /dev/null +++ b/packages/typegen/live-fm-http-output/client/contacts.ts @@ -0,0 +1,21 @@ +/** + * Generated by @proofkit/typegen package + * https://proofkit.dev/docs/typegen + * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten + */ +import { DataApi, FmHttpAdapter } from "@proofkit/fmdapi"; +import { Zcontacts } from "../contacts"; +if (!process.env.FM_HTTP_BASE_URL) + throw new Error("Missing env var: FM_HTTP_BASE_URL"); +if (!process.env.FM_CONNECTED_FILE_NAME) + throw new Error("Missing env var: FM_CONNECTED_FILE_NAME"); + +export const client = DataApi({ + adapter: new FmHttpAdapter({ + baseUrl: process.env.FM_HTTP_BASE_URL, + connectedFileName: process.env.FM_CONNECTED_FILE_NAME, + scriptName: "execute_data_api", + }), + layout: "Contacts", + schema: { fieldData: Zcontacts }, +}); diff --git a/packages/typegen/live-fm-http-output/client/index.ts b/packages/typegen/live-fm-http-output/client/index.ts new file mode 100644 index 00000000..07903551 --- /dev/null +++ b/packages/typegen/live-fm-http-output/client/index.ts @@ -0,0 +1 @@ +export { client as contactsLayout } from "./contacts"; diff --git a/packages/typegen/live-fm-http-output/contacts.ts b/packages/typegen/live-fm-http-output/contacts.ts new file mode 100644 index 00000000..4815c817 --- /dev/null +++ b/packages/typegen/live-fm-http-output/contacts.ts @@ -0,0 +1,10 @@ +/** + * Put your custom overrides or transformations here. + * Changes to this file will NOT be overwritten. + */ +import { z } from "zod/v4"; +import { Zcontacts as Zcontacts_generated } from "./generated/contacts"; + +export const Zcontacts = Zcontacts_generated; + +export type Tcontacts = z.infer; diff --git a/packages/typegen/proofkit-typegen.fm-http.local.jsonc b/packages/typegen/proofkit-typegen.fm-http.local.jsonc new file mode 100644 index 00000000..8bb6e73a --- /dev/null +++ b/packages/typegen/proofkit-typegen.fm-http.local.jsonc @@ -0,0 +1,18 @@ +{ + "$schema": "https://proofkit.dev/typegen-config-schema.json", + "config": { + "type": "fmdapi", + "fmHttp": { + "scriptName": "execute_data_api" + }, + "layouts": [ + { + "layoutName": "Contacts", + "schemaName": "contacts" + } + ], + "path": "packages/typegen/live-fm-http-output", + "validator": "zod/v4", + "generateClient": true + } +} diff --git a/packages/typegen/schema/fmdapi_test.ts b/packages/typegen/schema/fmdapi_test.ts new file mode 100644 index 00000000..5af5492c --- /dev/null +++ b/packages/typegen/schema/fmdapi_test.ts @@ -0,0 +1,16 @@ +import { fmTableOccurrence, textField, numberField } from "@proofkit/fmodata"; + +export const fmdapi_test = fmTableOccurrence( + "fmdapi_test", + { + PrimaryKey: textField() + .primaryKey() + .entityId("FMFID:4296032385") + .comment("Unique identifier of each record in this table"), + recordId: numberField().readOnly().entityId("FMFID:30065836161"), + }, + { + entityId: "FMTID:1065089", + navigationPaths: [], + }, +); diff --git a/packages/typegen/schema/index.ts b/packages/typegen/schema/index.ts new file mode 100644 index 00000000..27ba837b --- /dev/null +++ b/packages/typegen/schema/index.ts @@ -0,0 +1,6 @@ +// ============================================================================ +// Auto-generated index file - exports all table occurrences +// ============================================================================ + +export { isolated_contacts } from "./isolated_contacts"; +export { fmdapi_test } from "./fmdapi_test"; diff --git a/packages/typegen/schema/isolated_contacts.ts b/packages/typegen/schema/isolated_contacts.ts new file mode 100644 index 00000000..da06afc2 --- /dev/null +++ b/packages/typegen/schema/isolated_contacts.ts @@ -0,0 +1,39 @@ +import { + fmTableOccurrence, + textField, + timestampField, +} from "@proofkit/fmodata"; + +export const isolated_contacts = fmTableOccurrence( + "isolated_contacts", + { + PrimaryKey: textField() + .primaryKey() + .entityId("FMFID:4296032394") + .comment("Unique identifier of each record in this table"), + CreationTimestamp: timestampField() + .notNull() + .entityId("FMFID:8590999690") + .comment("Date and time each record was created"), + CreatedBy: textField() + .notNull() + .entityId("FMFID:12885966986") + .comment("Account name of the user who created each record"), + ModificationTimestamp: timestampField() + .notNull() + .entityId("FMFID:17180934282") + .comment("Date and time each record was last modified"), + ModifiedBy: textField() + .notNull() + .entityId("FMFID:21475901578") + .comment("Account name of the user who last modified each record"), + name: textField().entityId("FMFID:25770868874"), + hobby: textField().entityId("FMFID:30065836170"), + id_user: textField().entityId("FMFID:38655770762"), + my_calc: textField().readOnly().entityId("FMFID:42950738058"), + }, + { + entityId: "FMTID:1065098", + navigationPaths: [], + }, +); diff --git a/packages/typegen/src/buildLayoutClient.ts b/packages/typegen/src/buildLayoutClient.ts index 61abc06a..5c029222 100644 --- a/packages/typegen/src/buildLayoutClient.ts +++ b/packages/typegen/src/buildLayoutClient.ts @@ -3,7 +3,7 @@ import { defaultEnvNames } from "./constants"; import type { BuildSchemaArgs } from "./types"; export function buildLayoutClient(sourceFile: SourceFile, args: BuildSchemaArgs) { - const { schemaName, portalSchema, envNames, type, webviewerScriptName, layoutName } = args; + const { schemaName, portalSchema, envNames, type, webviewerScriptName, fmHttp, layoutName } = args; const fmdapiImport = sourceFile.addImportDeclaration({ moduleSpecifier: "@proofkit/fmdapi", @@ -15,6 +15,8 @@ export function buildLayoutClient(sourceFile: SourceFile, args: BuildSchemaArgs) moduleSpecifier: "@proofkit/webviewer/adapter", namedImports: ["WebViewerAdapter"], }); + } else if (fmHttp) { + fmdapiImport.addNamedImport({ name: "FmHttpAdapter" }); } else if (typeof envNames.auth === "object" && "apiKey" in envNames.auth && envNames.auth.apiKey !== undefined) { // if otto, add the OttoAdapter and OttoAPIKey imports fmdapiImport.addNamedImports([{ name: "OttoAdapter" }, { name: "OttoAPIKey", isTypeOnly: true }]); @@ -45,7 +47,15 @@ export function buildLayoutClient(sourceFile: SourceFile, args: BuildSchemaArgs) } } - if (!webviewerScriptName) { + if (fmHttp) { + // FM HTTP mode: guard baseUrl + connectedFileName + addTypeGuardStatements(sourceFile, { + envVarName: envNames.fmHttp?.baseUrl ?? defaultEnvNames.fmHttpBaseUrl, + }); + addTypeGuardStatements(sourceFile, { + envVarName: envNames.fmHttp?.connectedFileName ?? defaultEnvNames.fmHttpConnectedFileName, + }); + } else if (!webviewerScriptName) { addTypeGuardStatements(sourceFile, { envVarName: envNames.db ?? defaultEnvNames.db, }); @@ -110,12 +120,25 @@ function addTypeGuardStatements(sourceFile: SourceFile, { envVarName }: { envVar } function buildAdapter(writer: CodeBlockWriter, args: BuildSchemaArgs): string { - const { envNames, webviewerScriptName } = args; + const { envNames, webviewerScriptName, fmHttp } = args; if (webviewerScriptName) { writer.write("new WebViewerAdapter({scriptName: "); writer.quote(webviewerScriptName); writer.write("})"); + } else if (fmHttp) { + const baseUrlEnv = envNames.fmHttp?.baseUrl ?? defaultEnvNames.fmHttpBaseUrl; + const connectedFileEnv = envNames.fmHttp?.connectedFileName ?? defaultEnvNames.fmHttpConnectedFileName; + writer + .write("new FmHttpAdapter(") + .inlineBlock(() => { + writer.write(`baseUrl: process.env.${baseUrlEnv}`).write(",").newLine(); + writer.write(`connectedFileName: process.env.${connectedFileEnv}`).write(",").newLine(); + if (fmHttp.scriptName) { + writer.write("scriptName: ").quote(fmHttp.scriptName).write(",").newLine(); + } + }) + .write(")"); } else if (typeof envNames.auth === "object" && "apiKey" in envNames.auth && envNames.auth.apiKey !== undefined) { writer .write("new OttoAdapter(") diff --git a/packages/typegen/src/constants.ts b/packages/typegen/src/constants.ts index ed779e59..297457b5 100644 --- a/packages/typegen/src/constants.ts +++ b/packages/typegen/src/constants.ts @@ -29,4 +29,6 @@ export const defaultEnvNames = { password: "FM_PASSWORD", server: "FM_SERVER", db: "FM_DATABASE", + fmHttpBaseUrl: "FM_HTTP_BASE_URL", + fmHttpConnectedFileName: "FM_CONNECTED_FILE_NAME", }; diff --git a/packages/typegen/src/fmodata/downloadMetadata.ts b/packages/typegen/src/fmodata/downloadMetadata.ts index b0056a23..7bdf4702 100644 --- a/packages/typegen/src/fmodata/downloadMetadata.ts +++ b/packages/typegen/src/fmodata/downloadMetadata.ts @@ -29,6 +29,10 @@ export async function downloadTableMetadata({ throw new Error(validationResult.errorMessage); } + if (validationResult.mode === "fmHttp") { + throw new Error("FM HTTP mode is not supported for OData metadata download"); + } + const { server, db, auth } = validationResult; // Create connection based on authentication method diff --git a/packages/typegen/src/getEnvValues.ts b/packages/typegen/src/getEnvValues.ts index c0f54b28..2e541b36 100644 --- a/packages/typegen/src/getEnvValues.ts +++ b/packages/typegen/src/getEnvValues.ts @@ -11,15 +11,24 @@ export interface EnvValues { apiKey: string | undefined; username: string | undefined; password: string | undefined; + fmHttpBaseUrl: string | undefined; + fmHttpConnectedFileName: string | undefined; } export type EnvValidationResult = | { success: true; + mode: "standard"; server: string; db: string; auth: { apiKey: string } | { username: string; password: string }; } + | { + success: true; + mode: "fmHttp"; + baseUrl: string; + connectedFileName: string; + } | { success: false; errorMessage: string; @@ -61,12 +70,27 @@ export function getEnvValues(envNames?: EnvNames): EnvValues { const username = process.env[usernameEnvName]; const password = process.env[passwordEnvName]; + // FM HTTP env vars + const fmHttpBaseUrlEnvName = + envNames?.fmHttp && "baseUrl" in envNames.fmHttp + ? getEnvName(envNames.fmHttp.baseUrl, defaultEnvNames.fmHttpBaseUrl) + : defaultEnvNames.fmHttpBaseUrl; + const fmHttpConnectedFileNameEnvName = + envNames?.fmHttp && "connectedFileName" in envNames.fmHttp + ? getEnvName(envNames.fmHttp.connectedFileName, defaultEnvNames.fmHttpConnectedFileName) + : defaultEnvNames.fmHttpConnectedFileName; + + const fmHttpBaseUrl = process.env[fmHttpBaseUrlEnvName]; + const fmHttpConnectedFileName = process.env[fmHttpConnectedFileNameEnvName]; + return { server, db, apiKey, username, password, + fmHttpBaseUrl, + fmHttpConnectedFileName, }; } @@ -78,13 +102,50 @@ export function getEnvValues(envNames?: EnvNames): EnvValues { * @param envNames - Optional custom environment variable names (for error messages) * @returns Validation result with success flag and either data or error message */ -export function validateEnvValues(envValues: EnvValues, envNames?: EnvNames): EnvValidationResult { - const { server, db, apiKey, username, password } = envValues; +export function validateEnvValues( + envValues: EnvValues, + envNames?: EnvNames, + options?: { fmHttp?: boolean }, +): EnvValidationResult { + const { server, db, apiKey, username, password, fmHttpBaseUrl, fmHttpConnectedFileName } = envValues; // Helper to get env name, treating empty strings as undefined const getEnvName = (customName: string | undefined, defaultName: string) => customName && customName.trim() !== "" ? customName : defaultName; + // FM HTTP mode: only need baseUrl + connectedFileName + if (options?.fmHttp) { + const missingVars: string[] = []; + if (!fmHttpBaseUrl) { + missingVars.push( + getEnvName( + envNames?.fmHttp && "baseUrl" in envNames.fmHttp ? envNames.fmHttp.baseUrl : undefined, + defaultEnvNames.fmHttpBaseUrl, + ), + ); + } + if (!fmHttpConnectedFileName) { + missingVars.push( + getEnvName( + envNames?.fmHttp && "connectedFileName" in envNames.fmHttp ? envNames.fmHttp.connectedFileName : undefined, + defaultEnvNames.fmHttpConnectedFileName, + ), + ); + } + if (missingVars.length > 0) { + return { + success: false, + errorMessage: `Missing required environment variables for FM HTTP mode: ${missingVars.join(", ")}`, + }; + } + return { + success: true, + mode: "fmHttp", + baseUrl: fmHttpBaseUrl as string, + connectedFileName: fmHttpConnectedFileName as string, + }; + } + // Validate required env vars (server, db, and at least one auth method) if (!(server && db && (apiKey || username))) { // Build missing details @@ -159,6 +220,7 @@ export function validateEnvValues(envValues: EnvValues, envNames?: EnvNames): En return { success: true, + mode: "standard", server, db, auth, @@ -173,8 +235,12 @@ export function validateEnvValues(envValues: EnvValues, envNames?: EnvNames): En * @param envNames - Optional custom environment variable names (for error messages) * @returns Validated values or undefined if validation failed */ -export function validateAndLogEnvValues(envValues: EnvValues, envNames?: EnvNames): EnvValidationResult | undefined { - const result = validateEnvValues(envValues, envNames); +export function validateAndLogEnvValues( + envValues: EnvValues, + envNames?: EnvNames, + options?: { fmHttp?: boolean }, +): EnvValidationResult | undefined { + const result = validateEnvValues(envValues, envNames, options); if (!result.success) { console.log(chalk.red("ERROR: Could not get all required config values")); diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index d3440ab0..3b8b3952 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -1,6 +1,7 @@ import path from "node:path"; import DataApi from "@proofkit/fmdapi"; import { FetchAdapter } from "@proofkit/fmdapi/adapters/fetch"; +import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http"; import { OttoAdapter, type OttoAPIKey } from "@proofkit/fmdapi/adapters/otto"; import { memoryStore } from "@proofkit/fmdapi/tokenStore/memory"; import { type Database, FMServerConnection } from "@proofkit/fmodata"; @@ -13,11 +14,11 @@ import type { ApiContext } from "./app"; export interface CreateClientResult { // biome-ignore lint/suspicious/noExplicitAny: DataApi is a generic type - client: ReturnType>; + client: ReturnType>; config: Extract, { type: "fmdapi" }>; server: string; db: string; - authType: "apiKey" | "username"; + authType: "apiKey" | "username" | "fmHttp"; } export interface CreateClientError { @@ -195,14 +196,60 @@ export function createOdataClientFromConfig(config: FmodataConfig): OdataClientR * @returns The client, server, and db, or an error object */ export function createClientFromConfig(config: FmdapiConfig): Omit | CreateClientError { + // FM HTTP mode + if (config.fmHttp) { + const getEnvName = (customName: string | undefined, defaultName: string) => + customName && customName.trim() !== "" ? customName : defaultName; + + const baseUrl = + process.env[getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)]; + const connectedFileName = + process.env[getEnvName(config.envNames?.fmHttp?.connectedFileName, defaultEnvNames.fmHttpConnectedFileName)]; + + if (!baseUrl || !connectedFileName) { + const missing: string[] = []; + if (!baseUrl) missing.push(getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)); + if (!connectedFileName) + missing.push(getEnvName(config.envNames?.fmHttp?.connectedFileName, defaultEnvNames.fmHttpConnectedFileName)); + return { + error: "Missing required environment variables for FM HTTP mode", + statusCode: 400, + kind: "missing_env", + details: { missing: { baseUrl: !baseUrl, connectedFileName: !connectedFileName } }, + suspectedField: !baseUrl ? "server" : "db", + message: `Missing: ${missing.join(", ")}`, + }; + } + + try { + // biome-ignore lint/suspicious/noExplicitAny: DataApi is a generic type + const client: ReturnType> = DataApi({ + adapter: new FmHttpAdapter({ + baseUrl, + connectedFileName, + scriptName: config.fmHttp.scriptName, + }), + layout: "", + }); + return { client, server: baseUrl, db: connectedFileName, authType: "fmHttp" }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to create FM HTTP adapter"; + return { + error: errorMessage, + statusCode: 400, + kind: "adapter_error", + suspectedField: "server", + message: errorMessage, + }; + } + } + const result = getEnvVarsFromConfig(config.envNames); if ("error" in result) { return result; } const { server, db, authType, auth } = result; - // Determine which auth method will be used (prefer API key if available) - // Create DataApi client with error handling for adapter construction // biome-ignore lint/suspicious/noExplicitAny: DataApi is a generic type let client: ReturnType>; diff --git a/packages/typegen/src/typegen.ts b/packages/typegen/src/typegen.ts index a135d4dc..9108e211 100644 --- a/packages/typegen/src/typegen.ts +++ b/packages/typegen/src/typegen.ts @@ -1,6 +1,7 @@ import path from "node:path"; import DataApi from "@proofkit/fmdapi"; import { FetchAdapter } from "@proofkit/fmdapi/adapters/fetch"; +import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http"; import { OttoAdapter, type OttoAPIKey } from "@proofkit/fmdapi/adapters/otto"; import { memoryStore } from "@proofkit/fmdapi/tokenStore/memory"; import chalk from "chalk"; @@ -120,16 +121,30 @@ const generateTypedClientsSingle = async ( }, }); + const isFmHttpMode = !!config.fmHttp; const envValues = getEnvValues(envNames); - const validationResult = validateAndLogEnvValues(envValues, envNames); + const validationResult = validateAndLogEnvValues(envValues, envNames, { fmHttp: isFmHttpMode }); if (!validationResult?.success) { return; } - const { server, db, auth: validatedAuth } = validationResult; - const auth: { apiKey: OttoAPIKey } | { username: string; password: string } = - "apiKey" in validatedAuth ? { apiKey: validatedAuth.apiKey as OttoAPIKey } : validatedAuth; + // Extract connection details based on mode + let server: string | undefined; + let db: string | undefined; + let auth: { apiKey: OttoAPIKey } | { username: string; password: string } | undefined; + let fmHttpBaseUrl: string | undefined; + let fmHttpConnectedFileName: string | undefined; + + if (validationResult.mode === "fmHttp") { + fmHttpBaseUrl = validationResult.baseUrl; + fmHttpConnectedFileName = validationResult.connectedFileName; + } else { + server = validationResult.server; + db = validationResult.db; + const validatedAuth = validationResult.auth; + auth = "apiKey" in validatedAuth ? { apiKey: validatedAuth.apiKey as OttoAPIKey } : validatedAuth; + } await fs.ensureDir(rootDir); if (clearOldFiles) { @@ -145,17 +160,25 @@ const generateTypedClientsSingle = async ( for await (const item of layouts) { totalCount++; - const client = - "apiKey" in auth + const client = isFmHttpMode + ? DataApi({ + adapter: new FmHttpAdapter({ + baseUrl: fmHttpBaseUrl as string, + connectedFileName: fmHttpConnectedFileName as string, + scriptName: config.fmHttp?.scriptName, + }), + layout: item.layoutName, + }) + : auth && "apiKey" in auth ? DataApi({ - adapter: new OttoAdapter({ auth, server, db }), + adapter: new OttoAdapter({ auth, server: server as string, db: db as string }), layout: item.layoutName, }) : DataApi({ adapter: new FetchAdapter({ - auth, - server, - db, + auth: auth as { username: string; password: string }, + server: server as string, + db: db as string, tokenStore: memoryStore(), }), layout: item.layoutName, @@ -180,7 +203,18 @@ const generateTypedClientsSingle = async ( type: validator === "zod" || validator === "zod/v4" || validator === "zod/v3" ? validator : "ts", strictNumbers: item.strictNumbers, webviewerScriptName: config?.type === "fmdapi" ? config.webviewerScriptName : undefined, + fmHttp: config?.type === "fmdapi" ? config.fmHttp : undefined, envNames: (() => { + // FM HTTP mode: only need baseUrl + connectedFileName + if (isFmHttpMode) { + return { + fmHttp: { + baseUrl: envNames?.fmHttp?.baseUrl ?? defaultEnvNames.fmHttpBaseUrl, + connectedFileName: envNames?.fmHttp?.connectedFileName ?? defaultEnvNames.fmHttpConnectedFileName, + }, + }; + } + // Determine the intended auth type based on config AND runtime. // Priority: // 1. If user explicitly specified apiKey in config → use OttoAdapter @@ -192,7 +226,7 @@ const generateTypedClientsSingle = async ( // so both exist on the object but with undefined values when not specified. const configHasApiKey = envNames?.auth?.apiKey !== undefined; const configHasUsername = envNames?.auth?.username !== undefined; - const runtimeUsedApiKey = "apiKey" in auth; + const runtimeUsedApiKey = auth ? "apiKey" in auth : false; // Use apiKey if: explicitly specified in config, OR not explicitly set to username AND runtime used apiKey const useApiKey = configHasApiKey || (!configHasUsername && runtimeUsedApiKey); diff --git a/packages/typegen/src/types.ts b/packages/typegen/src/types.ts index 3fbcaffb..74e42683 100644 --- a/packages/typegen/src/types.ts +++ b/packages/typegen/src/types.ts @@ -35,6 +35,12 @@ export const envNamesBase = z password: z.string().optional(), }) .optional(), + fmHttp: z + .object({ + baseUrl: z.string().optional(), + connectedFileName: z.string().optional(), + }) + .optional(), }) .optional() .meta({ @@ -60,6 +66,12 @@ const envNames = envNamesBase password: val.auth.password === "" ? undefined : val.auth.password, } : undefined, + fmHttp: val.fmHttp + ? { + baseUrl: val.fmHttp.baseUrl === "" ? undefined : val.fmHttp.baseUrl, + connectedFileName: val.fmHttp.connectedFileName === "" ? undefined : val.fmHttp.connectedFileName, + } + : undefined, }; // Remove auth if all values are undefined @@ -67,6 +79,11 @@ const envNames = envNamesBase transformed.auth = undefined; } + // Remove fmHttp if all values are undefined + if (transformed.fmHttp && Object.values(transformed.fmHttp).every((v) => v === undefined)) { + transformed.fmHttp = undefined; + } + // Return undefined if all top-level values are undefined if (Object.values(transformed).every((v) => v === undefined)) { return undefined; @@ -173,6 +190,19 @@ const webviewerScriptNameField = z.string().optional().meta({ "The name of the webviewer script to be used. If this key is set, the generated client will use the @proofkit/webviewer adapter instead of the OttoFMS or Fetch adapter, which will only work when loaded inside of a FileMaker webviewer.", }); +const fmHttpField = z + .object({ + scriptName: z.string().optional().meta({ + description: + 'The name of the FileMaker script that executes Data API calls. Defaults to "execute_data_api".', + }), + }) + .optional() + .meta({ + description: + "If set, the generated client will use the FmHttpAdapter to connect via a local FM HTTP server instead of OttoFMS or direct FileMaker Data API.", + }); + const reduceMetadataField = z.boolean().optional().meta({ description: "If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", @@ -206,6 +236,7 @@ const createFmdapiConfig = (envNamesSchema: typeof envNames | typeof envNamesBas clientSuffix: clientSuffixField, generateClient: generateClientField, webviewerScriptName: webviewerScriptNameField, + fmHttp: fmHttpField, }); const createFmodataConfig = (envNamesSchema: typeof envNames | typeof envNamesBase) => @@ -279,4 +310,5 @@ export interface BuildSchemaArgs { layoutName: string; strictNumbers?: boolean; webviewerScriptName?: string; + fmHttp?: { scriptName?: string }; } diff --git a/packages/typegen/tests/getEnvValues.test.ts b/packages/typegen/tests/getEnvValues.test.ts new file mode 100644 index 00000000..683a3616 --- /dev/null +++ b/packages/typegen/tests/getEnvValues.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getEnvValues, validateEnvValues } from "../src/getEnvValues"; + +describe("getEnvValues + validateEnvValues", () => { + const originalEnv: Record = {}; + + beforeEach(() => { + for (const key of [ + "FM_SERVER", + "FM_DATABASE", + "OTTO_API_KEY", + "FM_USERNAME", + "FM_PASSWORD", + "FM_HTTP_BASE_URL", + "FM_CONNECTED_FILE_NAME", + "CUSTOM_SERVER", + "CUSTOM_DB", + "CUSTOM_KEY", + "CUSTOM_HTTP_URL", + "CUSTOM_HTTP_FILE", + ]) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("validates standard mode with api key", () => { + process.env.FM_SERVER = "https://example.com"; + process.env.FM_DATABASE = "MyDB"; + process.env.OTTO_API_KEY = "KEY_test_123"; + + const envValues = getEnvValues(); + const result = validateEnvValues(envValues); + + expect(result.success).toBe(true); + if (result.success && result.mode === "standard") { + expect(result.mode).toBe("standard"); + expect(result.server).toBe("https://example.com"); + expect(result.db).toBe("MyDB"); + expect(result.auth).toEqual({ apiKey: "KEY_test_123" }); + } + }); + + it("validates fmHttp mode with default env names", () => { + process.env.FM_HTTP_BASE_URL = "http://127.0.0.1:1365"; + process.env.FM_CONNECTED_FILE_NAME = "MyFile"; + + const envValues = getEnvValues(); + const result = validateEnvValues(envValues, undefined, { fmHttp: true }); + + expect(result.success).toBe(true); + if (result.success && result.mode === "fmHttp") { + expect(result.mode).toBe("fmHttp"); + expect(result.baseUrl).toBe("http://127.0.0.1:1365"); + expect(result.connectedFileName).toBe("MyFile"); + } + }); + + it("returns clear error when fmHttp env vars are missing", () => { + const envValues = getEnvValues(); + const result = validateEnvValues(envValues, undefined, { fmHttp: true }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errorMessage).toContain("FM_HTTP_BASE_URL"); + expect(result.errorMessage).toContain("FM_CONNECTED_FILE_NAME"); + } + }); + + it("reads custom env variable names", () => { + process.env.CUSTOM_SERVER = "https://custom.example.com"; + process.env.CUSTOM_DB = "CustomDB"; + process.env.CUSTOM_KEY = "KEY_custom_123"; + process.env.CUSTOM_HTTP_URL = "http://127.0.0.1:1365"; + process.env.CUSTOM_HTTP_FILE = "CustomFile"; + + const envValues = getEnvValues({ + server: "CUSTOM_SERVER", + db: "CUSTOM_DB", + auth: { apiKey: "CUSTOM_KEY" }, + fmHttp: { baseUrl: "CUSTOM_HTTP_URL", connectedFileName: "CUSTOM_HTTP_FILE" }, + }); + + expect(envValues.server).toBe("https://custom.example.com"); + expect(envValues.db).toBe("CustomDB"); + expect(envValues.apiKey).toBe("KEY_custom_123"); + expect(envValues.fmHttpBaseUrl).toBe("http://127.0.0.1:1365"); + expect(envValues.fmHttpConnectedFileName).toBe("CustomFile"); + }); +}); diff --git a/packages/typegen/tests/typegen.test.ts b/packages/typegen/tests/typegen.test.ts index 3d570fd1..7d4e15b5 100644 --- a/packages/typegen/tests/typegen.test.ts +++ b/packages/typegen/tests/typegen.test.ts @@ -39,7 +39,8 @@ describe("typegen unit tests", () => { originalEnv.FM_DATABASE = process.env.FM_DATABASE; originalEnv.FM_USERNAME = process.env.FM_USERNAME; originalEnv.FM_PASSWORD = process.env.FM_PASSWORD; - + originalEnv.FM_HTTP_BASE_URL = process.env.FM_HTTP_BASE_URL; + originalEnv.FM_CONNECTED_FILE_NAME = process.env.FM_CONNECTED_FILE_NAME; // Set mock env values for tests // Use valid Otto API key format (KEY_ prefix for Otto v3) process.env.OTTO_API_KEY = "KEY_test_api_key_12345"; @@ -465,4 +466,53 @@ describe("typegen unit tests", () => { expect(content).toContain("suffixSchemaLayout"); }); + + it("generates client using FmHttpAdapter when fmHttp config is provided", async () => { + process.env.FM_HTTP_BASE_URL = "http://127.0.0.1:1365"; + process.env.FM_CONNECTED_FILE_NAME = "TestFile"; + + const fetchMock = vi.fn( + createLayoutMetadataMock({ + FmHttpLayout: mockLayoutMetadata["basic-layout"], + }), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + envNames: undefined, + layouts: [ + { + layoutName: "FmHttpLayout", + schemaName: "fmHttpSchema", + }, + ], + path: "unit-typegen-output/fm-http", + generateClient: true, + validator: false, + fmHttp: { + scriptName: "execute_data_api_custom", + }, + }; + + await generateTypedClients(config, { cwd: import.meta.dirname }); + + const clientPath = path.join(__dirname, "unit-typegen-output/fm-http/client/fmHttpSchema.ts"); + const content = await fs.readFile(clientPath, "utf-8"); + + expect(content).toContain("FmHttpAdapter"); + expect(content).toContain("FM_HTTP_BASE_URL"); + expect(content).toContain("FM_CONNECTED_FILE_NAME"); + expect(content).toContain('scriptName: "execute_data_api_custom"'); + + expect(fetchMock).toHaveBeenCalled(); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(String(url)).toContain("/callScript"); + + const body = JSON.parse(String(init?.body ?? "{}")); + expect(body.scriptName).toBe("execute_data_api_custom"); + const scriptParam = JSON.parse(String(body.data ?? "{}")); + expect(scriptParam.action).toBe("metaData"); + expect(scriptParam.layouts).toBe("FmHttpLayout"); + }); }); diff --git a/packages/typegen/tests/utils/mock-fetch.ts b/packages/typegen/tests/utils/mock-fetch.ts index 3711ef50..cb03375e 100644 --- a/packages/typegen/tests/utils/mock-fetch.ts +++ b/packages/typegen/tests/utils/mock-fetch.ts @@ -29,6 +29,7 @@ import type { LayoutMetadata, MockLayoutMetadataKey } from "../fixtures/layout-m // Move regex to top level for performance const LAYOUT_URL_PATTERN = /\/layouts\/([^/?]+)(?:\?|$)/; const SESSIONS_URL_PATTERN = /\/sessions$/; +const CALL_SCRIPT_URL_PATTERN = /\/callScript$/; /** * Extract URL string from various input types @@ -54,6 +55,50 @@ export function createLayoutMetadataMock(layouts: Record return (input: RequestInfo | URL, _init?: RequestInit): Promise => { const url = getUrlString(input); + // Handle FmHttpAdapter requests to /callScript + if (CALL_SCRIPT_URL_PATTERN.test(url)) { + const body = _init?.body ? JSON.parse(String(_init.body)) : {}; + const scriptData = body?.data ? JSON.parse(String(body.data)) : {}; + const layoutName = scriptData.layouts; + const action = scriptData.action; + + if (action === "metaData" && layoutName && layouts[layoutName]) { + return Promise.resolve( + new Response( + JSON.stringify({ + result: { + messages: [{ code: "0", message: "OK" }], + response: layouts[layoutName], + }, + }), + { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }, + ), + ); + } + + if (action === "metaData" && layoutName && !layouts[layoutName]) { + return Promise.resolve( + new Response( + JSON.stringify({ + result: { + messages: [{ code: "105", message: "Layout is missing" }], + response: {}, + }, + }), + { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }, + ), + ); + } + } + // Handle FetchAdapter session/token requests // FetchAdapter expects token in X-FM-Data-Access-Token header if (SESSIONS_URL_PATTERN.test(url)) { From 6df5d1b6dc51feefe9df94997951e4800e07b017 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:51:31 -0500 Subject: [PATCH 02/11] typegen: keep fmHttp local while generating webviewer clients --- apps/docs/content/docs/typegen/config.mdx | 10 ++- .../live-fm-http-output/client/contacts.ts | 13 +-- packages/typegen/src/buildLayoutClient.ts | 48 +++++------ packages/typegen/src/types.ts | 5 +- packages/typegen/tests/typegen.test.ts | 9 ++- packages/typegen/typegen.schema.json | 79 ++++++++++++++----- 6 files changed, 97 insertions(+), 67 deletions(-) diff --git a/apps/docs/content/docs/typegen/config.mdx b/apps/docs/content/docs/typegen/config.mdx index 9ef5a49f..b7c10f00 100644 --- a/apps/docs/content/docs/typegen/config.mdx +++ b/apps/docs/content/docs/typegen/config.mdx @@ -75,6 +75,11 @@ The typegen tool is configured using the `proofkit-typegen-config.jsonc` file at description: "If set, will generate the client using the @proofkit/webviewer package", }, + fmHttp: { + type: "{ scriptName?: string }", + description: + "Optional local-only adapter mode for typegen metadata fetch. Generated clients still use the @proofkit/webviewer adapter.", + }, generateClient: { type: "boolean", default: "true", @@ -90,6 +95,9 @@ If set to `false`, will only generate the zod schema and/or typescript types, bu ### `webviewerScriptName` If set, will generate the client using the [@proofkit/webviewer](/docs/webviewer) package. This allows all calls to run via a FileMaker script rather than a network request. For more information, see the [@proofkit/webviewer](/docs/webviewer) documentation. +### `fmHttp` +If set, typegen uses the local FM HTTP adapter only while generating schemas and clients. The generated runtime client still uses [@proofkit/webviewer](/docs/webviewer). If `webviewerScriptName` is not set, `fmHttp.scriptName` (or `"execute_data_api"`) is used for the generated webviewer client script name. + ### `clientSuffix` (default: `"Layout"`) The suffix to add to the client name. @@ -156,4 +164,4 @@ Even if you ignore the value lists for type purposes, the value lists will still This setting will apply to all fields with value lists in the layout. For more granular control, override the Zod schema using the `extend` method. See the [Transformations](/docs/fmdapi/validation) page for more details. - \ No newline at end of file + diff --git a/packages/typegen/live-fm-http-output/client/contacts.ts b/packages/typegen/live-fm-http-output/client/contacts.ts index 1f811be4..3c22bd33 100644 --- a/packages/typegen/live-fm-http-output/client/contacts.ts +++ b/packages/typegen/live-fm-http-output/client/contacts.ts @@ -3,19 +3,12 @@ * https://proofkit.dev/docs/typegen * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten */ -import { DataApi, FmHttpAdapter } from "@proofkit/fmdapi"; +import { DataApi } from "@proofkit/fmdapi"; +import { WebViewerAdapter } from "@proofkit/webviewer/adapter"; import { Zcontacts } from "../contacts"; -if (!process.env.FM_HTTP_BASE_URL) - throw new Error("Missing env var: FM_HTTP_BASE_URL"); -if (!process.env.FM_CONNECTED_FILE_NAME) - throw new Error("Missing env var: FM_CONNECTED_FILE_NAME"); export const client = DataApi({ - adapter: new FmHttpAdapter({ - baseUrl: process.env.FM_HTTP_BASE_URL, - connectedFileName: process.env.FM_CONNECTED_FILE_NAME, - scriptName: "execute_data_api", - }), + adapter: new WebViewerAdapter({ scriptName: "execute_data_api" }), layout: "Contacts", schema: { fieldData: Zcontacts }, }); diff --git a/packages/typegen/src/buildLayoutClient.ts b/packages/typegen/src/buildLayoutClient.ts index 5c029222..f2a7259a 100644 --- a/packages/typegen/src/buildLayoutClient.ts +++ b/packages/typegen/src/buildLayoutClient.ts @@ -2,21 +2,33 @@ import { type CodeBlockWriter, type SourceFile, VariableDeclarationKind } from " import { defaultEnvNames } from "./constants"; import type { BuildSchemaArgs } from "./types"; +const defaultWebviewerScriptName = "execute_data_api"; + +function getGeneratedWebviewerScriptName(args: Pick) { + if (args.webviewerScriptName) { + return args.webviewerScriptName; + } + if (args.fmHttp) { + return args.fmHttp.scriptName ?? defaultWebviewerScriptName; + } + return undefined; +} + export function buildLayoutClient(sourceFile: SourceFile, args: BuildSchemaArgs) { const { schemaName, portalSchema, envNames, type, webviewerScriptName, fmHttp, layoutName } = args; + const generatedWebviewerScriptName = getGeneratedWebviewerScriptName({ webviewerScriptName, fmHttp }); + const usesWebviewerAdapter = generatedWebviewerScriptName !== undefined; const fmdapiImport = sourceFile.addImportDeclaration({ moduleSpecifier: "@proofkit/fmdapi", namedImports: ["DataApi"], }); const hasPortals = (portalSchema ?? []).length > 0; - if (webviewerScriptName) { + if (usesWebviewerAdapter) { sourceFile.addImportDeclaration({ moduleSpecifier: "@proofkit/webviewer/adapter", namedImports: ["WebViewerAdapter"], }); - } else if (fmHttp) { - fmdapiImport.addNamedImport({ name: "FmHttpAdapter" }); } else if (typeof envNames.auth === "object" && "apiKey" in envNames.auth && envNames.auth.apiKey !== undefined) { // if otto, add the OttoAdapter and OttoAPIKey imports fmdapiImport.addNamedImports([{ name: "OttoAdapter" }, { name: "OttoAPIKey", isTypeOnly: true }]); @@ -47,15 +59,7 @@ export function buildLayoutClient(sourceFile: SourceFile, args: BuildSchemaArgs) } } - if (fmHttp) { - // FM HTTP mode: guard baseUrl + connectedFileName - addTypeGuardStatements(sourceFile, { - envVarName: envNames.fmHttp?.baseUrl ?? defaultEnvNames.fmHttpBaseUrl, - }); - addTypeGuardStatements(sourceFile, { - envVarName: envNames.fmHttp?.connectedFileName ?? defaultEnvNames.fmHttpConnectedFileName, - }); - } else if (!webviewerScriptName) { + if (!usesWebviewerAdapter) { addTypeGuardStatements(sourceFile, { envVarName: envNames.db ?? defaultEnvNames.db, }); @@ -120,25 +124,13 @@ function addTypeGuardStatements(sourceFile: SourceFile, { envVarName }: { envVar } function buildAdapter(writer: CodeBlockWriter, args: BuildSchemaArgs): string { - const { envNames, webviewerScriptName, fmHttp } = args; + const { envNames } = args; + const generatedWebviewerScriptName = getGeneratedWebviewerScriptName(args); - if (webviewerScriptName) { + if (generatedWebviewerScriptName) { writer.write("new WebViewerAdapter({scriptName: "); - writer.quote(webviewerScriptName); + writer.quote(generatedWebviewerScriptName); writer.write("})"); - } else if (fmHttp) { - const baseUrlEnv = envNames.fmHttp?.baseUrl ?? defaultEnvNames.fmHttpBaseUrl; - const connectedFileEnv = envNames.fmHttp?.connectedFileName ?? defaultEnvNames.fmHttpConnectedFileName; - writer - .write("new FmHttpAdapter(") - .inlineBlock(() => { - writer.write(`baseUrl: process.env.${baseUrlEnv}`).write(",").newLine(); - writer.write(`connectedFileName: process.env.${connectedFileEnv}`).write(",").newLine(); - if (fmHttp.scriptName) { - writer.write("scriptName: ").quote(fmHttp.scriptName).write(",").newLine(); - } - }) - .write(")"); } else if (typeof envNames.auth === "object" && "apiKey" in envNames.auth && envNames.auth.apiKey !== undefined) { writer .write("new OttoAdapter(") diff --git a/packages/typegen/src/types.ts b/packages/typegen/src/types.ts index 74e42683..960d3730 100644 --- a/packages/typegen/src/types.ts +++ b/packages/typegen/src/types.ts @@ -193,14 +193,13 @@ const webviewerScriptNameField = z.string().optional().meta({ const fmHttpField = z .object({ scriptName: z.string().optional().meta({ - description: - 'The name of the FileMaker script that executes Data API calls. Defaults to "execute_data_api".', + description: 'The name of the FileMaker script that executes Data API calls. Defaults to "execute_data_api".', }), }) .optional() .meta({ description: - "If set, the generated client will use the FmHttpAdapter to connect via a local FM HTTP server instead of OttoFMS or direct FileMaker Data API.", + "If set, typegen will use the FmHttpAdapter to fetch metadata through a local FM HTTP server during generation. Generated clients will still use the @proofkit/webviewer adapter.", }); const reduceMetadataField = z.boolean().optional().meta({ diff --git a/packages/typegen/tests/typegen.test.ts b/packages/typegen/tests/typegen.test.ts index 7d4e15b5..1ec4e478 100644 --- a/packages/typegen/tests/typegen.test.ts +++ b/packages/typegen/tests/typegen.test.ts @@ -467,7 +467,7 @@ describe("typegen unit tests", () => { expect(content).toContain("suffixSchemaLayout"); }); - it("generates client using FmHttpAdapter when fmHttp config is provided", async () => { + it("generates client using WebViewerAdapter when fmHttp config is provided", async () => { process.env.FM_HTTP_BASE_URL = "http://127.0.0.1:1365"; process.env.FM_CONNECTED_FILE_NAME = "TestFile"; @@ -500,9 +500,10 @@ describe("typegen unit tests", () => { const clientPath = path.join(__dirname, "unit-typegen-output/fm-http/client/fmHttpSchema.ts"); const content = await fs.readFile(clientPath, "utf-8"); - expect(content).toContain("FmHttpAdapter"); - expect(content).toContain("FM_HTTP_BASE_URL"); - expect(content).toContain("FM_CONNECTED_FILE_NAME"); + expect(content).toContain("WebViewerAdapter"); + expect(content).not.toContain("FmHttpAdapter"); + expect(content).not.toContain("FM_HTTP_BASE_URL"); + expect(content).not.toContain("FM_CONNECTED_FILE_NAME"); expect(content).toContain('scriptName: "execute_data_api_custom"'); expect(fetchMock).toHaveBeenCalled(); diff --git a/packages/typegen/typegen.schema.json b/packages/typegen/typegen.schema.json index fc72fc9a..2feb2cb2 100644 --- a/packages/typegen/typegen.schema.json +++ b/packages/typegen/typegen.schema.json @@ -125,6 +125,14 @@ "$ref": "#/definitions/__schema14" } ] + }, + "fmHttp": { + "description": "If set, typegen will use the FmHttpAdapter to fetch metadata through a local FM HTTP server during generation. Generated clients will still use the @proofkit/webviewer adapter.", + "allOf": [ + { + "$ref": "#/definitions/__schema15" + } + ] } }, "required": ["type", "layouts"], @@ -154,7 +162,7 @@ "description": "If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", "allOf": [ { - "$ref": "#/definitions/__schema15" + "$ref": "#/definitions/__schema17" } ] }, @@ -165,7 +173,7 @@ "description": "If true (default), field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", "allOf": [ { - "$ref": "#/definitions/__schema16" + "$ref": "#/definitions/__schema18" } ] }, @@ -174,7 +182,7 @@ "description": "Required array of tables to generate. Only the tables specified here will be downloaded and generated. Each table can have field-level overrides for excluding fields, renaming variables, and overriding field types.", "allOf": [ { - "$ref": "#/definitions/__schema17" + "$ref": "#/definitions/__schema19" } ] }, @@ -182,7 +190,7 @@ "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", "allOf": [ { - "$ref": "#/definitions/__schema25" + "$ref": "#/definitions/__schema27" } ] } @@ -223,6 +231,18 @@ } }, "additionalProperties": false + }, + "fmHttp": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + }, + "connectedFileName": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -286,13 +306,30 @@ "type": "string" }, "__schema15": { - "type": "boolean" + "type": "object", + "properties": { + "scriptName": { + "description": "The name of the FileMaker script that executes Data API calls. Defaults to \"execute_data_api\".", + "allOf": [ + { + "$ref": "#/definitions/__schema16" + } + ] + } + }, + "additionalProperties": false }, "__schema16": { + "type": "string" + }, + "__schema17": { + "type": "boolean" + }, + "__schema18": { "default": true, "type": "boolean" }, - "__schema17": { + "__schema19": { "type": "array", "items": { "type": "object", @@ -305,7 +342,7 @@ "description": "Override the generated TypeScript variable name. The original entity set name is still used for the OData path.", "allOf": [ { - "$ref": "#/definitions/__schema18" + "$ref": "#/definitions/__schema20" } ] }, @@ -313,7 +350,7 @@ "description": "Field-specific overrides as an array", "allOf": [ { - "$ref": "#/definitions/__schema19" + "$ref": "#/definitions/__schema21" } ] }, @@ -321,7 +358,7 @@ "description": "If undefined, the top-level setting will be used. If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", "allOf": [ { - "$ref": "#/definitions/__schema22" + "$ref": "#/definitions/__schema24" } ] }, @@ -329,7 +366,7 @@ "description": "If undefined, the top-level setting will be used. If true, field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", "allOf": [ { - "$ref": "#/definitions/__schema23" + "$ref": "#/definitions/__schema25" } ] }, @@ -337,7 +374,7 @@ "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", "allOf": [ { - "$ref": "#/definitions/__schema24" + "$ref": "#/definitions/__schema26" } ] } @@ -346,10 +383,10 @@ "additionalProperties": false } }, - "__schema18": { + "__schema20": { "type": "string" }, - "__schema19": { + "__schema21": { "type": "array", "items": { "type": "object", @@ -362,7 +399,7 @@ "description": "If true, this field will be excluded from generation", "allOf": [ { - "$ref": "#/definitions/__schema20" + "$ref": "#/definitions/__schema22" } ] }, @@ -370,7 +407,7 @@ "description": "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list", "allOf": [ { - "$ref": "#/definitions/__schema21" + "$ref": "#/definitions/__schema23" } ] } @@ -379,10 +416,10 @@ "additionalProperties": false } }, - "__schema20": { + "__schema22": { "type": "boolean" }, - "__schema21": { + "__schema23": { "type": "string", "enum": [ "text", @@ -394,16 +431,16 @@ "list" ] }, - "__schema22": { + "__schema24": { "type": "boolean" }, - "__schema23": { + "__schema25": { "type": "boolean" }, - "__schema24": { + "__schema26": { "type": "boolean" }, - "__schema25": { + "__schema27": { "default": true, "type": "boolean" } From 19cb3eb50516bf939d1e21507808d4a1398375f7 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:05:55 -0500 Subject: [PATCH 03/11] Improve FM HTTP typegen client setup and env validation - Refactor typegen client creation to use explicit adapter branching, including FM HTTP mode - Tighten FM HTTP env var checks and error field attribution in `createDataApiClient` - Align generated/live type outputs (type-only `zod` import) and update related tests/formatting --- packages/fmdapi/src/adapters/fm-http.ts | 17 +++---- packages/fmdapi/tests/fm-http-adapter.test.ts | 5 +- .../typegen/live-fm-http-output/contacts.ts | 2 +- packages/typegen/schema/fmdapi_test.ts | 2 +- packages/typegen/schema/index.ts | 2 +- packages/typegen/schema/isolated_contacts.ts | 6 +-- packages/typegen/src/index.ts | 2 +- .../typegen/src/server/createDataApiClient.ts | 14 +++--- packages/typegen/src/typegen.ts | 49 ++++++++++--------- packages/typegen/tests/public-api.test.ts | 22 ++++----- 10 files changed, 55 insertions(+), 66 deletions(-) diff --git a/packages/fmdapi/src/adapters/fm-http.ts b/packages/fmdapi/src/adapters/fm-http.ts index 056b62f9..d5362980 100644 --- a/packages/fmdapi/src/adapters/fm-http.ts +++ b/packages/fmdapi/src/adapters/fm-http.ts @@ -10,7 +10,6 @@ import type { import { FileMakerError } from "../client-types.js"; import type { Adapter, - ContainerUploadOptions, CreateOptions, DeleteOptions, ExecuteScriptOptions, @@ -21,6 +20,8 @@ import type { UpdateOptions, } from "./core.js"; +const TRAILING_SLASHES_REGEX = /\/+$/; + export interface FmHttpAdapterOptions { /** Base URL of the local FM HTTP server (e.g. "http://localhost:3000") */ baseUrl: string; @@ -36,7 +37,7 @@ export class FmHttpAdapter implements Adapter { protected scriptName: string; constructor(options: FmHttpAdapterOptions) { - this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.baseUrl = options.baseUrl.replace(TRAILING_SLASHES_REGEX, ""); this.connectedFileName = options.connectedFileName; this.scriptName = options.scriptName ?? "execute_data_api"; } @@ -98,16 +99,13 @@ export class FmHttpAdapter implements Adapter { } if (!res.ok) { - throw new FileMakerError( - String(res.status), - `FM HTTP request failed (${res.status}): ${await res.text()}`, - ); + throw new FileMakerError(String(res.status), `FM HTTP request failed (${res.status}): ${await res.text()}`); } let respData: RawFMResponse; const raw = await res.json(); // The /callScript response wraps the script result as a string or object - const scriptResult = typeof raw.result === "string" ? JSON.parse(raw.result) : raw.result ?? raw; + const scriptResult = typeof raw.result === "string" ? JSON.parse(raw.result) : (raw.result ?? raw); respData = scriptResult as RawFMResponse; if (respData.messages?.[0].code !== "0") { @@ -199,10 +197,7 @@ export class FmHttpAdapter implements Adapter { }); if (!res.ok) { - throw new FileMakerError( - String(res.status), - `FM HTTP executeScript failed (${res.status}): ${await res.text()}`, - ); + throw new FileMakerError(String(res.status), `FM HTTP executeScript failed (${res.status}): ${await res.text()}`); } const raw = await res.json(); diff --git a/packages/fmdapi/tests/fm-http-adapter.test.ts b/packages/fmdapi/tests/fm-http-adapter.test.ts index 5e782353..f855886c 100644 --- a/packages/fmdapi/tests/fm-http-adapter.test.ts +++ b/packages/fmdapi/tests/fm-http-adapter.test.ts @@ -196,10 +196,7 @@ describe("FmHttpAdapter", () => { }); it("throws FileMakerError on HTTP error", async () => { - vi.stubGlobal( - "fetch", - () => Promise.resolve(new Response("Internal Server Error", { status: 500 })), - ); + vi.stubGlobal("fetch", () => Promise.resolve(new Response("Internal Server Error", { status: 500 }))); const client = createClient(); await expect(client.list()).rejects.toBeInstanceOf(FileMakerError); diff --git a/packages/typegen/live-fm-http-output/contacts.ts b/packages/typegen/live-fm-http-output/contacts.ts index 4815c817..c9556702 100644 --- a/packages/typegen/live-fm-http-output/contacts.ts +++ b/packages/typegen/live-fm-http-output/contacts.ts @@ -2,7 +2,7 @@ * Put your custom overrides or transformations here. * Changes to this file will NOT be overwritten. */ -import { z } from "zod/v4"; +import type { z } from "zod/v4"; import { Zcontacts as Zcontacts_generated } from "./generated/contacts"; export const Zcontacts = Zcontacts_generated; diff --git a/packages/typegen/schema/fmdapi_test.ts b/packages/typegen/schema/fmdapi_test.ts index 5af5492c..7be9195b 100644 --- a/packages/typegen/schema/fmdapi_test.ts +++ b/packages/typegen/schema/fmdapi_test.ts @@ -1,4 +1,4 @@ -import { fmTableOccurrence, textField, numberField } from "@proofkit/fmodata"; +import { fmTableOccurrence, numberField, textField } from "@proofkit/fmodata"; export const fmdapi_test = fmTableOccurrence( "fmdapi_test", diff --git a/packages/typegen/schema/index.ts b/packages/typegen/schema/index.ts index 27ba837b..0c7d1473 100644 --- a/packages/typegen/schema/index.ts +++ b/packages/typegen/schema/index.ts @@ -2,5 +2,5 @@ // Auto-generated index file - exports all table occurrences // ============================================================================ -export { isolated_contacts } from "./isolated_contacts"; export { fmdapi_test } from "./fmdapi_test"; +export { isolated_contacts } from "./isolated_contacts"; diff --git a/packages/typegen/schema/isolated_contacts.ts b/packages/typegen/schema/isolated_contacts.ts index da06afc2..9a1a8ebf 100644 --- a/packages/typegen/schema/isolated_contacts.ts +++ b/packages/typegen/schema/isolated_contacts.ts @@ -1,8 +1,4 @@ -import { - fmTableOccurrence, - textField, - timestampField, -} from "@proofkit/fmodata"; +import { fmTableOccurrence, textField, timestampField } from "@proofkit/fmodata"; export const isolated_contacts = fmTableOccurrence( "isolated_contacts", diff --git a/packages/typegen/src/index.ts b/packages/typegen/src/index.ts index aa25964b..2f2916c1 100644 --- a/packages/typegen/src/index.ts +++ b/packages/typegen/src/index.ts @@ -1,5 +1,5 @@ /** biome-ignore-all lint/performance/noBarrelFile: Re-exporting typegen functions */ export { buildSchema } from "./buildSchema"; export { generateTypedClients } from "./typegen"; -export { typegenConfig, typegenConfigSingle } from "./types"; export type { BuildSchemaArgs, FmodataConfig, TSchema, TypegenConfig, ValueListsOptions } from "./types"; +export { typegenConfig, typegenConfigSingle } from "./types"; diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index 3b8b3952..f2a7160a 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -201,22 +201,24 @@ export function createClientFromConfig(config: FmdapiConfig): Omit customName && customName.trim() !== "" ? customName : defaultName; - const baseUrl = - process.env[getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)]; + const baseUrl = process.env[getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)]; const connectedFileName = process.env[getEnvName(config.envNames?.fmHttp?.connectedFileName, defaultEnvNames.fmHttpConnectedFileName)]; - if (!baseUrl || !connectedFileName) { + if (!(baseUrl && connectedFileName)) { const missing: string[] = []; - if (!baseUrl) missing.push(getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)); - if (!connectedFileName) + if (!baseUrl) { + missing.push(getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)); + } + if (!connectedFileName) { missing.push(getEnvName(config.envNames?.fmHttp?.connectedFileName, defaultEnvNames.fmHttpConnectedFileName)); + } return { error: "Missing required environment variables for FM HTTP mode", statusCode: 400, kind: "missing_env", details: { missing: { baseUrl: !baseUrl, connectedFileName: !connectedFileName } }, - suspectedField: !baseUrl ? "server" : "db", + suspectedField: baseUrl ? "db" : "server", message: `Missing: ${missing.join(", ")}`, }; } diff --git a/packages/typegen/src/typegen.ts b/packages/typegen/src/typegen.ts index 9108e211..3474baa3 100644 --- a/packages/typegen/src/typegen.ts +++ b/packages/typegen/src/typegen.ts @@ -160,29 +160,32 @@ const generateTypedClientsSingle = async ( for await (const item of layouts) { totalCount++; - const client = isFmHttpMode - ? DataApi({ - adapter: new FmHttpAdapter({ - baseUrl: fmHttpBaseUrl as string, - connectedFileName: fmHttpConnectedFileName as string, - scriptName: config.fmHttp?.scriptName, - }), - layout: item.layoutName, - }) - : auth && "apiKey" in auth - ? DataApi({ - adapter: new OttoAdapter({ auth, server: server as string, db: db as string }), - layout: item.layoutName, - }) - : DataApi({ - adapter: new FetchAdapter({ - auth: auth as { username: string; password: string }, - server: server as string, - db: db as string, - tokenStore: memoryStore(), - }), - layout: item.layoutName, - }); + let client: ReturnType; + if (isFmHttpMode) { + client = DataApi({ + adapter: new FmHttpAdapter({ + baseUrl: fmHttpBaseUrl as string, + connectedFileName: fmHttpConnectedFileName as string, + scriptName: config.fmHttp?.scriptName, + }), + layout: item.layoutName, + }); + } else if (auth && "apiKey" in auth) { + client = DataApi({ + adapter: new OttoAdapter({ auth, server: server as string, db: db as string }), + layout: item.layoutName, + }); + } else { + client = DataApi({ + adapter: new FetchAdapter({ + auth: auth as { username: string; password: string }, + server: server as string, + db: db as string, + tokenStore: memoryStore(), + }), + layout: item.layoutName, + }); + } const result = await getLayoutMetadata({ client, valueLists: item.valueLists, diff --git a/packages/typegen/tests/public-api.test.ts b/packages/typegen/tests/public-api.test.ts index 34a7706c..ae9b5fc2 100644 --- a/packages/typegen/tests/public-api.test.ts +++ b/packages/typegen/tests/public-api.test.ts @@ -1,18 +1,14 @@ -import { describe, expect, it } from "vitest"; import { Project, ScriptKind } from "ts-morph"; -import { buildSchema, type BuildSchemaArgs } from "../src/index"; +import { describe, expect, it } from "vitest"; +import { type BuildSchemaArgs, buildSchema } from "../src/index"; describe("typegen public api", () => { it("exports buildSchema from the root entrypoint", () => { const project = new Project(); - const schemaFile = project.createSourceFile( - "customer.ts", - "", - { - overwrite: true, - scriptKind: ScriptKind.TS, - }, - ); + const schemaFile = project.createSourceFile("customer.ts", "", { + overwrite: true, + scriptKind: ScriptKind.TS, + }); const args: BuildSchemaArgs = { schemaName: "customer", @@ -37,8 +33,8 @@ describe("typegen public api", () => { const content = schemaFile.getFullText(); expect(content).toContain("export type Tcustomer"); - expect(content).toContain("\"recordId\": string"); - expect(content).toContain("\"balance\": string | number"); - expect(content).toContain("export const layoutName = \"Customer\""); + expect(content).toContain('"recordId": string'); + expect(content).toContain('"balance": string | number'); + expect(content).toContain('export const layoutName = "Customer"'); }); }); From ed55b5559a7b9699a8716f48d0ab3a3f6bee9cc4 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:14:03 -0500 Subject: [PATCH 04/11] remove beads --- .gitattributes | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 560f0635..00000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# mdx -*.mdx linguist-detectable=false From f10ced13bcce65298987ca14bf93f55aff1a74d4 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:25:24 -0500 Subject: [PATCH 05/11] Enhance error handling in FM HTTP adapter response parsing - Introduced a try-catch block to handle JSON parsing errors for the script result. - Improved error messaging for failed FM HTTP response parsing. --- packages/fmdapi/src/adapters/fm-http.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/fmdapi/src/adapters/fm-http.ts b/packages/fmdapi/src/adapters/fm-http.ts index d5362980..60d2eac4 100644 --- a/packages/fmdapi/src/adapters/fm-http.ts +++ b/packages/fmdapi/src/adapters/fm-http.ts @@ -102,11 +102,19 @@ export class FmHttpAdapter implements Adapter { throw new FileMakerError(String(res.status), `FM HTTP request failed (${res.status}): ${await res.text()}`); } - let respData: RawFMResponse; const raw = await res.json(); // The /callScript response wraps the script result as a string or object - const scriptResult = typeof raw.result === "string" ? JSON.parse(raw.result) : (raw.result ?? raw); - respData = scriptResult as RawFMResponse; + let scriptResult: unknown; + try { + scriptResult = typeof raw.result === "string" ? JSON.parse(raw.result) : (raw.result ?? raw); + } catch (err) { + throw new FileMakerError( + "500", + `FM HTTP response parse failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const respData = scriptResult as RawFMResponse; if (respData.messages?.[0].code !== "0") { throw new FileMakerError( From 18274165ee73ae832e6b05a072f665e1571853b3 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:28:28 -0500 Subject: [PATCH 06/11] Refactor FM HTTP adapter error handling and improve environment variable validation - Enhance error handling in `FmHttpAdapter` to ensure timeout is cleared properly. - Update environment variable validation in `createDataApiClient` to use a helper function for better readability. - Correct spelling errors in generated comments across multiple files. - Adjust type definitions for `suspectedField` in error interfaces to allow for more flexibility. --- packages/fmdapi/src/adapters/fm-http.ts | 31 ++++----- .../live-fm-http-output/client/contacts.ts | 2 +- packages/typegen/src/buildLayoutClient.ts | 14 +++-- packages/typegen/src/constants.ts | 2 +- packages/typegen/src/getEnvValues.ts | 63 ++++++++++++++----- .../typegen/src/server/createDataApiClient.ts | 20 +++--- .../__snapshots__/strict-numbers.snap.ts | 2 +- .../__snapshots__/zod-layout-client.snap.ts | 2 +- 8 files changed, 90 insertions(+), 46 deletions(-) diff --git a/packages/fmdapi/src/adapters/fm-http.ts b/packages/fmdapi/src/adapters/fm-http.ts index 60d2eac4..9cab1b3b 100644 --- a/packages/fmdapi/src/adapters/fm-http.ts +++ b/packages/fmdapi/src/adapters/fm-http.ts @@ -82,20 +82,23 @@ export class FmHttpAdapter implements Adapter { const headers = new Headers(fetchOptions?.headers); headers.set("Content-Type", "application/json"); - const res = await fetch(`${this.baseUrl}/callScript`, { - ...fetchOptions, - method: "POST", - headers, - body: JSON.stringify({ - connectedFileName: this.connectedFileName, - scriptName: this.scriptName, - data: scriptParam, - }), - signal: controller.signal, - }); - - if (timeout) { - clearTimeout(timeout); + let res: Response; + try { + res = await fetch(`${this.baseUrl}/callScript`, { + ...fetchOptions, + method: "POST", + headers, + body: JSON.stringify({ + connectedFileName: this.connectedFileName, + scriptName: this.scriptName, + data: scriptParam, + }), + signal: controller.signal, + }); + } finally { + if (timeout) { + clearTimeout(timeout); + } } if (!res.ok) { diff --git a/packages/typegen/live-fm-http-output/client/contacts.ts b/packages/typegen/live-fm-http-output/client/contacts.ts index 3c22bd33..ee29c212 100644 --- a/packages/typegen/live-fm-http-output/client/contacts.ts +++ b/packages/typegen/live-fm-http-output/client/contacts.ts @@ -1,7 +1,7 @@ /** * Generated by @proofkit/typegen package * https://proofkit.dev/docs/typegen - * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten + * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten */ import { DataApi } from "@proofkit/fmdapi"; import { WebViewerAdapter } from "@proofkit/webviewer/adapter"; diff --git a/packages/typegen/src/buildLayoutClient.ts b/packages/typegen/src/buildLayoutClient.ts index f2a7259a..de61d32c 100644 --- a/packages/typegen/src/buildLayoutClient.ts +++ b/packages/typegen/src/buildLayoutClient.ts @@ -4,12 +4,18 @@ import type { BuildSchemaArgs } from "./types"; const defaultWebviewerScriptName = "execute_data_api"; +function normalizeScriptName(scriptName?: string) { + const normalized = scriptName?.trim(); + return normalized ? normalized : undefined; +} + function getGeneratedWebviewerScriptName(args: Pick) { - if (args.webviewerScriptName) { - return args.webviewerScriptName; + const explicitWebviewerScriptName = normalizeScriptName(args.webviewerScriptName); + if (explicitWebviewerScriptName) { + return explicitWebviewerScriptName; } if (args.fmHttp) { - return args.fmHttp.scriptName ?? defaultWebviewerScriptName; + return normalizeScriptName(args.fmHttp.scriptName) ?? defaultWebviewerScriptName; } return undefined; } @@ -127,7 +133,7 @@ function buildAdapter(writer: CodeBlockWriter, args: BuildSchemaArgs): string { const { envNames } = args; const generatedWebviewerScriptName = getGeneratedWebviewerScriptName(args); - if (generatedWebviewerScriptName) { + if (generatedWebviewerScriptName !== undefined) { writer.write("new WebViewerAdapter({scriptName: "); writer.quote(generatedWebviewerScriptName); writer.write("})"); diff --git a/packages/typegen/src/constants.ts b/packages/typegen/src/constants.ts index 297457b5..668b5f68 100644 --- a/packages/typegen/src/constants.ts +++ b/packages/typegen/src/constants.ts @@ -1,7 +1,7 @@ export const commentHeader = `/** * Generated by @proofkit/typegen package * https://proofkit.dev/docs/typegen - * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten + * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten */ diff --git a/packages/typegen/src/getEnvValues.ts b/packages/typegen/src/getEnvValues.ts index 2e541b36..af301a91 100644 --- a/packages/typegen/src/getEnvValues.ts +++ b/packages/typegen/src/getEnvValues.ts @@ -246,31 +246,62 @@ export function validateAndLogEnvValues( console.log(chalk.red("ERROR: Could not get all required config values")); console.log("Ensure the following environment variables are set:"); - const { server, db, apiKey } = envValues; + const getEnvName = (customName: string | undefined, defaultName: string) => + customName && customName.trim() !== "" ? customName : defaultName; + + if (options?.fmHttp) { + if (!envValues.fmHttpBaseUrl) { + console.log( + getEnvName( + envNames?.fmHttp && "baseUrl" in envNames.fmHttp ? envNames.fmHttp.baseUrl : undefined, + defaultEnvNames.fmHttpBaseUrl, + ), + ); + } + if (!envValues.fmHttpConnectedFileName) { + console.log( + getEnvName( + envNames?.fmHttp && "connectedFileName" in envNames.fmHttp ? envNames.fmHttp.connectedFileName : undefined, + defaultEnvNames.fmHttpConnectedFileName, + ), + ); + } + console.log(); + return undefined; + } + + const { server, db, apiKey, username, password } = envValues; if (!server) { - console.log(`${envNames?.server ?? defaultEnvNames.server}`); + console.log(getEnvName(envNames?.server, defaultEnvNames.server)); } if (!db) { - console.log(`${envNames?.db ?? defaultEnvNames.db}`); + console.log(getEnvName(envNames?.db, defaultEnvNames.db)); } - if (!apiKey) { + if (!(apiKey || username)) { // Determine the names to display in the error message - const apiKeyNameToLog = - envNames?.auth && "apiKey" in envNames.auth && envNames.auth.apiKey - ? envNames.auth.apiKey - : defaultEnvNames.apiKey; - const usernameNameToLog = - envNames?.auth && "username" in envNames.auth && envNames.auth.username - ? envNames.auth.username - : defaultEnvNames.username; - const passwordNameToLog = - envNames?.auth && "password" in envNames.auth && envNames.auth.password - ? envNames.auth.password - : defaultEnvNames.password; + const apiKeyNameToLog = getEnvName( + envNames?.auth && "apiKey" in envNames.auth ? envNames.auth.apiKey : undefined, + defaultEnvNames.apiKey, + ); + const usernameNameToLog = getEnvName( + envNames?.auth && "username" in envNames.auth ? envNames.auth.username : undefined, + defaultEnvNames.username, + ); + const passwordNameToLog = getEnvName( + envNames?.auth && "password" in envNames.auth ? envNames.auth.password : undefined, + defaultEnvNames.password, + ); console.log(`${apiKeyNameToLog} (or ${usernameNameToLog} and ${passwordNameToLog})`); + } else if (username && !password) { + console.log( + getEnvName( + envNames?.auth && "password" in envNames.auth ? envNames.auth.password : undefined, + defaultEnvNames.password, + ), + ); } console.log(); diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index f2a7160a..8141f265 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -26,7 +26,7 @@ export interface CreateClientError { statusCode: number; details?: Record; kind?: "missing_env" | "adapter_error" | "connection_error" | "unknown"; - suspectedField?: "server" | "db" | "auth"; + suspectedField?: string; fmErrorCode?: string; message?: string; } @@ -170,7 +170,7 @@ export interface OdataClientError { error: string; statusCode: number; kind?: "missing_env" | "adapter_error" | "connection_error" | "unknown"; - suspectedField?: "server" | "db" | "auth"; + suspectedField?: string; } export function createOdataClientFromConfig(config: FmodataConfig): OdataClientResult | OdataClientError { @@ -201,24 +201,28 @@ export function createClientFromConfig(config: FmdapiConfig): Omit customName && customName.trim() !== "" ? customName : defaultName; - const baseUrl = process.env[getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)]; - const connectedFileName = - process.env[getEnvName(config.envNames?.fmHttp?.connectedFileName, defaultEnvNames.fmHttpConnectedFileName)]; + const baseUrlEnvName = getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl); + const connectedFileNameEnvName = getEnvName( + config.envNames?.fmHttp?.connectedFileName, + defaultEnvNames.fmHttpConnectedFileName, + ); + const baseUrl = process.env[baseUrlEnvName]; + const connectedFileName = process.env[connectedFileNameEnvName]; if (!(baseUrl && connectedFileName)) { const missing: string[] = []; if (!baseUrl) { - missing.push(getEnvName(config.envNames?.fmHttp?.baseUrl, defaultEnvNames.fmHttpBaseUrl)); + missing.push(baseUrlEnvName); } if (!connectedFileName) { - missing.push(getEnvName(config.envNames?.fmHttp?.connectedFileName, defaultEnvNames.fmHttpConnectedFileName)); + missing.push(connectedFileNameEnvName); } return { error: "Missing required environment variables for FM HTTP mode", statusCode: 400, kind: "missing_env", details: { missing: { baseUrl: !baseUrl, connectedFileName: !connectedFileName } }, - suspectedField: baseUrl ? "db" : "server", + suspectedField: baseUrl ? connectedFileNameEnvName : baseUrlEnvName, message: `Missing: ${missing.join(", ")}`, }; } diff --git a/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts b/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts index fea41bd7..02761941 100644 --- a/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts +++ b/packages/typegen/tests/__snapshots__/strict-numbers.snap.ts @@ -1,7 +1,7 @@ /** * Generated by @proofkit/typegen package * https://proofkit.dev/docs/typegen - * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten + * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten */ import type { InferZodPortals } from "@proofkit/fmdapi"; import { z } from "zod/v4"; diff --git a/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts b/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts index 2bdb5480..546809dc 100644 --- a/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts +++ b/packages/typegen/tests/__snapshots__/zod-layout-client.snap.ts @@ -1,7 +1,7 @@ /** * Generated by @proofkit/typegen package * https://proofkit.dev/docs/typegen - * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten + * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overwritten */ import type { InferZodPortals } from "@proofkit/fmdapi"; import { z } from "zod"; From 1d4b69dd486dbacd9f7c4c02aa20c0689181759d Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:44:08 -0500 Subject: [PATCH 07/11] changeset --- .changeset/fm-http-typegen-flow.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fm-http-typegen-flow.md diff --git a/.changeset/fm-http-typegen-flow.md b/.changeset/fm-http-typegen-flow.md new file mode 100644 index 00000000..235c6551 --- /dev/null +++ b/.changeset/fm-http-typegen-flow.md @@ -0,0 +1,6 @@ +--- +"@proofkit/fmdapi": minor +"@proofkit/typegen": minor +--- + +Add FM HTTP adapter support for type generation, including local FM HTTP metadata fetch flow, env name handling, and improved adapter error parsing. From 072502690abc4651770cd74fd17711750b7224ee Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:47:16 -0500 Subject: [PATCH 08/11] fix: address CodeRabbit review on PR 130 - guard against missing/empty messages array in fm-http adapter - use FM HTTP-specific suspectedField in adapter-error branch Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fmdapi/src/adapters/fm-http.ts | 7 ++++--- packages/typegen/src/server/createDataApiClient.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/fmdapi/src/adapters/fm-http.ts b/packages/fmdapi/src/adapters/fm-http.ts index 9cab1b3b..c7deccda 100644 --- a/packages/fmdapi/src/adapters/fm-http.ts +++ b/packages/fmdapi/src/adapters/fm-http.ts @@ -119,10 +119,11 @@ export class FmHttpAdapter implements Adapter { const respData = scriptResult as RawFMResponse; - if (respData.messages?.[0].code !== "0") { + const errorCode = respData.messages?.[0]?.code; + if (errorCode !== undefined && errorCode !== "0") { throw new FileMakerError( - respData?.messages?.[0].code ?? "500", - `Filemaker Data API failed with (${respData.messages?.[0].code}): ${JSON.stringify(respData, null, 2)}`, + errorCode, + `Filemaker Data API failed with (${errorCode}): ${JSON.stringify(respData, null, 2)}`, ); } diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index 8141f265..f2cf8c1d 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -244,7 +244,7 @@ export function createClientFromConfig(config: FmdapiConfig): Omit Date: Fri, 13 Mar 2026 22:52:04 -0500 Subject: [PATCH 09/11] fix: restore suspectedField union type, use semantic field names for FM HTTP FM HTTP errors now use "server"/"db" matching frontend expectations instead of raw env var names. Restores compile-time exhaustiveness. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/typegen/src/server/createDataApiClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index f2cf8c1d..1cd0c120 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -26,7 +26,7 @@ export interface CreateClientError { statusCode: number; details?: Record; kind?: "missing_env" | "adapter_error" | "connection_error" | "unknown"; - suspectedField?: string; + suspectedField?: "server" | "db" | "auth"; fmErrorCode?: string; message?: string; } @@ -170,7 +170,7 @@ export interface OdataClientError { error: string; statusCode: number; kind?: "missing_env" | "adapter_error" | "connection_error" | "unknown"; - suspectedField?: string; + suspectedField?: "server" | "db" | "auth"; } export function createOdataClientFromConfig(config: FmodataConfig): OdataClientResult | OdataClientError { @@ -222,7 +222,7 @@ export function createClientFromConfig(config: FmdapiConfig): Omit Date: Mon, 16 Mar 2026 14:27:43 -0500 Subject: [PATCH 10/11] feat: sensible defaults + auto-discovery for FM HTTP typegen config - fmHttp config is now { enabled, scriptName?, baseUrl?, connectedFileName? } - baseUrl defaults to http://127.0.0.1:1365 - connectedFileName auto-discovered from /connectedFiles and written back to config - webviewerScriptName controls generated client; fmHttp.scriptName overrides proxy call - backward compat: fmHttp: true preprocessed to { enabled: true } Co-Authored-By: Claude Opus 4.6 (1M context) --- .../proofkit-typegen.fm-http.local.jsonc | 4 +- packages/typegen/src/buildLayoutClient.ts | 2 +- packages/typegen/src/cli.ts | 1 + packages/typegen/src/constants.ts | 2 + packages/typegen/src/getEnvValues.ts | 48 +++---- .../typegen/src/server/createDataApiClient.ts | 34 +++-- packages/typegen/src/typegen.ts | 98 ++++++++++++-- packages/typegen/src/types.ts | 31 ++++- packages/typegen/tests/getEnvValues.test.ts | 27 +++- packages/typegen/tests/typegen.test.ts | 6 +- packages/typegen/typegen.schema.json | 123 +++++++++++++----- 11 files changed, 266 insertions(+), 110 deletions(-) diff --git a/packages/typegen/proofkit-typegen.fm-http.local.jsonc b/packages/typegen/proofkit-typegen.fm-http.local.jsonc index 8bb6e73a..1a8d13bb 100644 --- a/packages/typegen/proofkit-typegen.fm-http.local.jsonc +++ b/packages/typegen/proofkit-typegen.fm-http.local.jsonc @@ -2,8 +2,10 @@ "$schema": "https://proofkit.dev/typegen-config-schema.json", "config": { "type": "fmdapi", + "webviewerScriptName": "execute_data_api", "fmHttp": { - "scriptName": "execute_data_api" + "enabled": true, + "connectedFileName": "MCP_Test" }, "layouts": [ { diff --git a/packages/typegen/src/buildLayoutClient.ts b/packages/typegen/src/buildLayoutClient.ts index de61d32c..04bc5801 100644 --- a/packages/typegen/src/buildLayoutClient.ts +++ b/packages/typegen/src/buildLayoutClient.ts @@ -15,7 +15,7 @@ function getGeneratedWebviewerScriptName(args: Pick { console.error(err); return process.exit(1); diff --git a/packages/typegen/src/constants.ts b/packages/typegen/src/constants.ts index 668b5f68..3537996a 100644 --- a/packages/typegen/src/constants.ts +++ b/packages/typegen/src/constants.ts @@ -32,3 +32,5 @@ export const defaultEnvNames = { fmHttpBaseUrl: "FM_HTTP_BASE_URL", fmHttpConnectedFileName: "FM_CONNECTED_FILE_NAME", }; + +export const defaultFmHttpBaseUrl = "http://127.0.0.1:1365"; diff --git a/packages/typegen/src/getEnvValues.ts b/packages/typegen/src/getEnvValues.ts index af301a91..6a125576 100644 --- a/packages/typegen/src/getEnvValues.ts +++ b/packages/typegen/src/getEnvValues.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import type { z } from "zod/v4"; -import { defaultEnvNames } from "./constants"; +import { defaultEnvNames, defaultFmHttpBaseUrl } from "./constants"; import type { typegenConfigSingle } from "./types"; type EnvNames = z.infer["envNames"]; @@ -105,47 +105,29 @@ export function getEnvValues(envNames?: EnvNames): EnvValues { export function validateEnvValues( envValues: EnvValues, envNames?: EnvNames, - options?: { fmHttp?: boolean }, + options?: { fmHttp?: boolean; fmHttpConfig?: { baseUrl?: string; connectedFileName?: string } }, ): EnvValidationResult { const { server, db, apiKey, username, password, fmHttpBaseUrl, fmHttpConnectedFileName } = envValues; - // Helper to get env name, treating empty strings as undefined - const getEnvName = (customName: string | undefined, defaultName: string) => - customName && customName.trim() !== "" ? customName : defaultName; - - // FM HTTP mode: only need baseUrl + connectedFileName + // FM HTTP mode: resolve baseUrl and connectedFileName with fallback chain + // Priority: config value > env var > default/auto-discover if (options?.fmHttp) { - const missingVars: string[] = []; - if (!fmHttpBaseUrl) { - missingVars.push( - getEnvName( - envNames?.fmHttp && "baseUrl" in envNames.fmHttp ? envNames.fmHttp.baseUrl : undefined, - defaultEnvNames.fmHttpBaseUrl, - ), - ); - } - if (!fmHttpConnectedFileName) { - missingVars.push( - getEnvName( - envNames?.fmHttp && "connectedFileName" in envNames.fmHttp ? envNames.fmHttp.connectedFileName : undefined, - defaultEnvNames.fmHttpConnectedFileName, - ), - ); - } - if (missingVars.length > 0) { - return { - success: false, - errorMessage: `Missing required environment variables for FM HTTP mode: ${missingVars.join(", ")}`, - }; - } + const resolvedBaseUrl = options.fmHttpConfig?.baseUrl || fmHttpBaseUrl || defaultFmHttpBaseUrl; + const resolvedConnectedFileName = options.fmHttpConfig?.connectedFileName || fmHttpConnectedFileName; + + // connectedFileName will be auto-discovered later if still missing return { success: true, mode: "fmHttp", - baseUrl: fmHttpBaseUrl as string, - connectedFileName: fmHttpConnectedFileName as string, + baseUrl: resolvedBaseUrl, + connectedFileName: resolvedConnectedFileName ?? "", }; } + // Helper to get env name, treating empty strings as undefined + const getEnvName = (customName: string | undefined, defaultName: string) => + customName && customName.trim() !== "" ? customName : defaultName; + // Validate required env vars (server, db, and at least one auth method) if (!(server && db && (apiKey || username))) { // Build missing details @@ -238,7 +220,7 @@ export function validateEnvValues( export function validateAndLogEnvValues( envValues: EnvValues, envNames?: EnvNames, - options?: { fmHttp?: boolean }, + options?: { fmHttp?: boolean; fmHttpConfig?: { baseUrl?: string; connectedFileName?: string } }, ): EnvValidationResult | undefined { const result = validateEnvValues(envValues, envNames, options); diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index 1cd0c120..6c8456fb 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -8,7 +8,7 @@ import { type Database, FMServerConnection } from "@proofkit/fmodata"; import fs from "fs-extra"; import { parse } from "jsonc-parser"; import type { z } from "zod/v4"; -import { defaultEnvNames } from "../constants"; +import { defaultEnvNames, defaultFmHttpBaseUrl } from "../constants"; import { typegenConfig, type typegenConfigSingle } from "../types"; import type { ApiContext } from "./app"; @@ -197,7 +197,9 @@ export function createOdataClientFromConfig(config: FmodataConfig): OdataClientR */ export function createClientFromConfig(config: FmdapiConfig): Omit | CreateClientError { // FM HTTP mode - if (config.fmHttp) { + if (config.fmHttp != null && config.fmHttp.enabled !== false) { + const fmHttpObj = config.fmHttp; + const getEnvName = (customName: string | undefined, defaultName: string) => customName && customName.trim() !== "" ? customName : defaultName; @@ -206,24 +208,20 @@ export function createClientFromConfig(config: FmdapiConfig): Omit env var > default + const baseUrl = fmHttpObj?.baseUrl || process.env[baseUrlEnvName] || defaultFmHttpBaseUrl; + const connectedFileName = fmHttpObj?.connectedFileName || process.env[connectedFileNameEnvName]; + + // connectedFileName is required (auto-discovery not available in sync context) + if (!connectedFileName) { return { - error: "Missing required environment variables for FM HTTP mode", + error: "Missing connectedFileName for FM HTTP mode", statusCode: 400, kind: "missing_env", - details: { missing: { baseUrl: !baseUrl, connectedFileName: !connectedFileName } }, - suspectedField: baseUrl ? "db" : "server", - message: `Missing: ${missing.join(", ")}`, + details: { missing: { connectedFileName: true } }, + suspectedField: "db", + message: "Set connectedFileName in your fmHttp config or FM_CONNECTED_FILE_NAME env var", }; } @@ -233,7 +231,7 @@ export function createClientFromConfig(config: FmdapiConfig): Omit, "config">; export const generateTypedClients = async ( config: z.infer["config"], - options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string }, + options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string }, ): Promise< | { successCount: number; @@ -51,9 +51,17 @@ export const generateTypedClients = async ( let totalCount = 0; const outputPaths: string[] = []; - for (const singleConfig of configArray) { + const isConfigArray = Array.isArray(parsedConfig.data.config); + for (let configIndex = 0; configIndex < configArray.length; configIndex++) { + const singleConfig = configArray[configIndex]; + if (!singleConfig) continue; if (singleConfig.type === "fmdapi") { - const result = await generateTypedClientsSingle(singleConfig, { resetOverrides, cwd }); + const result = await generateTypedClientsSingle(singleConfig, { + resetOverrides, + cwd, + configPath: options?.configPath, + configIndex: isConfigArray ? configIndex : undefined, + }); if (result) { totalSuccessCount += result.successCount; totalErrorCount += result.errorCount; @@ -80,7 +88,7 @@ export const generateTypedClients = async ( const generateTypedClientsSingle = async ( config: Extract, { type: "fmdapi" }>, - options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string }, + options?: GlobalOptions & { resetOverrides?: boolean; cwd?: string; configPath?: string; configIndex?: number }, ) => { const { envNames, @@ -121,9 +129,18 @@ const generateTypedClientsSingle = async ( }, }); - const isFmHttpMode = !!config.fmHttp; + const isFmHttpMode = config.fmHttp != null && config.fmHttp.enabled !== false; + const fmHttpObj = config.fmHttp ?? undefined; + + if (isFmHttpMode && !config.webviewerScriptName) { + console.log(chalk.blue(`INFO: Generated clients will use WebViewerAdapter with script "${fmHttpObj?.scriptName ?? "execute_data_api"}".`)); + } + const envValues = getEnvValues(envNames); - const validationResult = validateAndLogEnvValues(envValues, envNames, { fmHttp: isFmHttpMode }); + const validationResult = validateAndLogEnvValues(envValues, envNames, { + fmHttp: isFmHttpMode, + fmHttpConfig: isFmHttpMode ? { baseUrl: fmHttpObj?.baseUrl, connectedFileName: fmHttpObj?.connectedFileName } : undefined, + }); if (!validationResult?.success) { return; @@ -139,6 +156,69 @@ const generateTypedClientsSingle = async ( if (validationResult.mode === "fmHttp") { fmHttpBaseUrl = validationResult.baseUrl; fmHttpConnectedFileName = validationResult.connectedFileName; + + // Auto-discover connectedFileName if not provided + if (!fmHttpConnectedFileName) { + try { + const res = await fetch(`${fmHttpBaseUrl}/connectedFiles`); + if (res.ok) { + const files = (await res.json()) as string[]; + if (files.length === 1) { + fmHttpConnectedFileName = files[0]; + console.log(chalk.green(`Auto-discovered connected file: ${fmHttpConnectedFileName}`)); + + // Write discovered connectedFileName back to config file + if (options?.configPath) { + const configFilePath = path.resolve(cwd, options.configPath); + try { + const raw = fs.readFileSync(configFilePath, "utf8"); + const { modify, applyEdits } = await import("jsonc-parser"); + const fmtOpts = { formattingOptions: { insertSpaces: true, tabSize: 2 } }; + // Build the JSON path: array configs use ["config", index, "fmHttp", ...], single uses ["config", "fmHttp", ...] + const basePath = options.configIndex !== undefined + ? ["config", options.configIndex, "fmHttp"] + : ["config", "fmHttp"]; + + // If fmHttp was `true` in the raw file, replace it with an object first + let current = raw; + const parsed = (await import("jsonc-parser")).parseTree(raw); + if (parsed) { + const { findNodeAtLocation } = await import("jsonc-parser"); + const fmHttpNode = findNodeAtLocation(parsed, basePath); + if (fmHttpNode?.type === "boolean") { + const replaceEdits = modify(current, basePath, { enabled: true, connectedFileName: fmHttpConnectedFileName }, fmtOpts); + current = applyEdits(current, replaceEdits); + fs.writeFileSync(configFilePath, current, "utf8"); + console.log(chalk.green(`Updated config with connectedFileName: ${fmHttpConnectedFileName}`)); + } else { + const edits = modify(current, [...basePath, "connectedFileName"], fmHttpConnectedFileName, fmtOpts); + current = applyEdits(current, edits); + fs.writeFileSync(configFilePath, current, "utf8"); + console.log(chalk.green(`Updated config with connectedFileName: ${fmHttpConnectedFileName}`)); + } + } + } catch (writeErr) { + console.log(chalk.yellow(`Could not update config file: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`)); + } + } + } else if (files.length > 1) { + console.log(chalk.red("ERROR: Multiple connected files found. Please specify connectedFileName in your fmHttp config.")); + console.log(chalk.yellow(`Connected files: ${files.join(", ")}`)); + return; + } else { + console.log(chalk.red("ERROR: No connected files found on the FM HTTP server.")); + return; + } + } else { + console.log(chalk.red(`ERROR: Failed to auto-discover connected files from ${fmHttpBaseUrl}/connectedFiles`)); + return; + } + } catch (err) { + console.log(chalk.red(`ERROR: Could not reach FM HTTP server at ${fmHttpBaseUrl}`)); + console.log(chalk.yellow("Ensure the FM HTTP server is running and accessible.")); + return; + } + } } else { server = validationResult.server; db = validationResult.db; @@ -166,7 +246,7 @@ const generateTypedClientsSingle = async ( adapter: new FmHttpAdapter({ baseUrl: fmHttpBaseUrl as string, connectedFileName: fmHttpConnectedFileName as string, - scriptName: config.fmHttp?.scriptName, + scriptName: fmHttpObj?.scriptName ?? config.webviewerScriptName, }), layout: item.layoutName, }); @@ -206,7 +286,7 @@ const generateTypedClientsSingle = async ( type: validator === "zod" || validator === "zod/v4" || validator === "zod/v3" ? validator : "ts", strictNumbers: item.strictNumbers, webviewerScriptName: config?.type === "fmdapi" ? config.webviewerScriptName : undefined, - fmHttp: config?.type === "fmdapi" ? config.fmHttp : undefined, + fmHttp: config?.type === "fmdapi" ? !!config.fmHttp : undefined, envNames: (() => { // FM HTTP mode: only need baseUrl + connectedFileName if (isFmHttpMode) { diff --git a/packages/typegen/src/types.ts b/packages/typegen/src/types.ts index 960d3730..f1e0db0f 100644 --- a/packages/typegen/src/types.ts +++ b/packages/typegen/src/types.ts @@ -190,16 +190,33 @@ const webviewerScriptNameField = z.string().optional().meta({ "The name of the webviewer script to be used. If this key is set, the generated client will use the @proofkit/webviewer adapter instead of the OttoFMS or Fetch adapter, which will only work when loaded inside of a FileMaker webviewer.", }); +const fmHttpFieldObject = z.object({ + enabled: z.boolean().default(true).optional().meta({ + description: "Enable the FM HTTP proxy for metadata fetching during typegen.", + }), + scriptName: z.string().optional().meta({ + description: + 'The FM script the HTTP proxy calls to execute Data API operations. Overrides webviewerScriptName for the proxy call. Defaults to "execute_data_api".', + }), + baseUrl: z.string().optional().meta({ + description: + 'Base URL of the local FM HTTP server. Defaults to "http://127.0.0.1:1365". Can also be set via FM_HTTP_BASE_URL env var.', + }), + connectedFileName: z.string().optional().meta({ + description: + "Name of the connected FileMaker file. If not provided, it will be auto-discovered from the FM HTTP server's /connectedFiles endpoint and written back to your config. Can also be set via FM_CONNECTED_FILE_NAME env var.", + }), +}); + const fmHttpField = z - .object({ - scriptName: z.string().optional().meta({ - description: 'The name of the FileMaker script that executes Data API calls. Defaults to "execute_data_api".', - }), - }) + .preprocess((val) => { + if (val === true) return { enabled: true }; + return val; + }, fmHttpFieldObject) .optional() .meta({ description: - "If set, typegen will use the FmHttpAdapter to fetch metadata through a local FM HTTP server during generation. Generated clients will still use the @proofkit/webviewer adapter.", + "Enable the FM HTTP proxy for metadata fetching during typegen. Generated clients will use the @proofkit/webviewer adapter with webviewerScriptName or 'execute_data_api' as the default.", }); const reduceMetadataField = z.boolean().optional().meta({ @@ -309,5 +326,5 @@ export interface BuildSchemaArgs { layoutName: string; strictNumbers?: boolean; webviewerScriptName?: string; - fmHttp?: { scriptName?: string }; + fmHttp?: boolean; } diff --git a/packages/typegen/tests/getEnvValues.test.ts b/packages/typegen/tests/getEnvValues.test.ts index 683a3616..2be311fe 100644 --- a/packages/typegen/tests/getEnvValues.test.ts +++ b/packages/typegen/tests/getEnvValues.test.ts @@ -66,14 +66,31 @@ describe("getEnvValues + validateEnvValues", () => { } }); - it("returns clear error when fmHttp env vars are missing", () => { + it("defaults baseUrl and allows empty connectedFileName for auto-discovery when fmHttp env vars are missing", () => { const envValues = getEnvValues(); const result = validateEnvValues(envValues, undefined, { fmHttp: true }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.errorMessage).toContain("FM_HTTP_BASE_URL"); - expect(result.errorMessage).toContain("FM_CONNECTED_FILE_NAME"); + expect(result.success).toBe(true); + if (result.success && result.mode === "fmHttp") { + expect(result.baseUrl).toBe("http://127.0.0.1:1365"); + expect(result.connectedFileName).toBe(""); + } + }); + + it("uses config values over env vars for fmHttp", () => { + process.env.FM_HTTP_BASE_URL = "http://env-url:9999"; + process.env.FM_CONNECTED_FILE_NAME = "EnvFile"; + + const envValues = getEnvValues(); + const result = validateEnvValues(envValues, undefined, { + fmHttp: true, + fmHttpConfig: { baseUrl: "http://config-url:1234", connectedFileName: "ConfigFile" }, + }); + + expect(result.success).toBe(true); + if (result.success && result.mode === "fmHttp") { + expect(result.baseUrl).toBe("http://config-url:1234"); + expect(result.connectedFileName).toBe("ConfigFile"); } }); diff --git a/packages/typegen/tests/typegen.test.ts b/packages/typegen/tests/typegen.test.ts index 1ec4e478..9f9cc843 100644 --- a/packages/typegen/tests/typegen.test.ts +++ b/packages/typegen/tests/typegen.test.ts @@ -490,9 +490,8 @@ describe("typegen unit tests", () => { path: "unit-typegen-output/fm-http", generateClient: true, validator: false, - fmHttp: { - scriptName: "execute_data_api_custom", - }, + webviewerScriptName: "execute_data_api_custom", + fmHttp: { enabled: true }, }; await generateTypedClients(config, { cwd: import.meta.dirname }); @@ -511,6 +510,7 @@ describe("typegen unit tests", () => { expect(String(url)).toContain("/callScript"); const body = JSON.parse(String(init?.body ?? "{}")); + // FmHttpAdapter uses webviewerScriptName when fmHttp.scriptName not set expect(body.scriptName).toBe("execute_data_api_custom"); const scriptParam = JSON.parse(String(body.data ?? "{}")); expect(scriptParam.action).toBe("metaData"); diff --git a/packages/typegen/typegen.schema.json b/packages/typegen/typegen.schema.json index 2feb2cb2..12793e50 100644 --- a/packages/typegen/typegen.schema.json +++ b/packages/typegen/typegen.schema.json @@ -24,7 +24,9 @@ ] } }, - "required": ["config"], + "required": [ + "config" + ], "additionalProperties": false, "definitions": { "__schema0": { @@ -84,7 +86,10 @@ ] } }, - "required": ["layoutName", "schemaName"], + "required": [ + "layoutName", + "schemaName" + ], "additionalProperties": false } }, @@ -127,7 +132,7 @@ ] }, "fmHttp": { - "description": "If set, typegen will use the FmHttpAdapter to fetch metadata through a local FM HTTP server during generation. Generated clients will still use the @proofkit/webviewer adapter.", + "description": "Enable the FM HTTP proxy for metadata fetching during typegen. Generated clients will use the @proofkit/webviewer adapter with webviewerScriptName or 'execute_data_api' as the default.", "allOf": [ { "$ref": "#/definitions/__schema15" @@ -135,7 +140,10 @@ ] } }, - "required": ["type", "layouts"], + "required": [ + "type", + "layouts" + ], "additionalProperties": false }, { @@ -162,7 +170,7 @@ "description": "If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", "allOf": [ { - "$ref": "#/definitions/__schema17" + "$ref": "#/definitions/__schema20" } ] }, @@ -173,7 +181,7 @@ "description": "If true (default), field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", "allOf": [ { - "$ref": "#/definitions/__schema18" + "$ref": "#/definitions/__schema21" } ] }, @@ -182,7 +190,7 @@ "description": "Required array of tables to generate. Only the tables specified here will be downloaded and generated. Each table can have field-level overrides for excluding fields, renaming variables, and overriding field types.", "allOf": [ { - "$ref": "#/definitions/__schema19" + "$ref": "#/definitions/__schema22" } ] }, @@ -190,12 +198,15 @@ "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", "allOf": [ { - "$ref": "#/definitions/__schema27" + "$ref": "#/definitions/__schema30" } ] } }, - "required": ["type", "tables"], + "required": [ + "type", + "tables" + ], "additionalProperties": false } ] @@ -249,7 +260,11 @@ }, "__schema4": { "type": "string", - "enum": ["strict", "allowEmpty", "ignore"] + "enum": [ + "strict", + "allowEmpty", + "ignore" + ] }, "__schema5": { "type": "boolean" @@ -286,7 +301,11 @@ "anyOf": [ { "type": "string", - "enum": ["zod", "zod/v4", "zod/v3"] + "enum": [ + "zod", + "zod/v4", + "zod/v3" + ] }, { "type": "boolean", @@ -308,28 +327,62 @@ "__schema15": { "type": "object", "properties": { - "scriptName": { - "description": "The name of the FileMaker script that executes Data API calls. Defaults to \"execute_data_api\".", + "enabled": { + "description": "Enable the FM HTTP proxy for metadata fetching during typegen.", "allOf": [ { "$ref": "#/definitions/__schema16" } ] + }, + "scriptName": { + "description": "The FM script the HTTP proxy calls to execute Data API operations. Overrides webviewerScriptName for the proxy call. Defaults to \"execute_data_api\".", + "allOf": [ + { + "$ref": "#/definitions/__schema17" + } + ] + }, + "baseUrl": { + "description": "Base URL of the local FM HTTP server. Defaults to \"http://127.0.0.1:1365\". Can also be set via FM_HTTP_BASE_URL env var.", + "allOf": [ + { + "$ref": "#/definitions/__schema18" + } + ] + }, + "connectedFileName": { + "description": "Name of the connected FileMaker file. If not provided, it will be auto-discovered from the FM HTTP server's /connectedFiles endpoint and written back to your config. Can also be set via FM_CONNECTED_FILE_NAME env var.", + "allOf": [ + { + "$ref": "#/definitions/__schema19" + } + ] } }, "additionalProperties": false }, "__schema16": { - "type": "string" + "default": true, + "type": "boolean" }, "__schema17": { - "type": "boolean" + "type": "string" }, "__schema18": { + "type": "string" + }, + "__schema19": { + "type": "string" + }, + "__schema20": { + "type": "boolean" + }, + "__schema21": { "default": true, "type": "boolean" }, - "__schema19": { + "__schema22": { "type": "array", "items": { "type": "object", @@ -342,7 +395,7 @@ "description": "Override the generated TypeScript variable name. The original entity set name is still used for the OData path.", "allOf": [ { - "$ref": "#/definitions/__schema20" + "$ref": "#/definitions/__schema23" } ] }, @@ -350,7 +403,7 @@ "description": "Field-specific overrides as an array", "allOf": [ { - "$ref": "#/definitions/__schema21" + "$ref": "#/definitions/__schema24" } ] }, @@ -358,7 +411,7 @@ "description": "If undefined, the top-level setting will be used. If true, reduced OData annotations will be requested from the server to reduce payload size. This will prevent comments, entity ids, and other properties from being generated.", "allOf": [ { - "$ref": "#/definitions/__schema24" + "$ref": "#/definitions/__schema27" } ] }, @@ -366,7 +419,7 @@ "description": "If undefined, the top-level setting will be used. If true, field names will always be updated to match metadata, even when matching by entity ID. If false, existing field names are preserved when matching by entity ID.", "allOf": [ { - "$ref": "#/definitions/__schema25" + "$ref": "#/definitions/__schema28" } ] }, @@ -374,19 +427,21 @@ "description": "If true, all fields will be included by default. If false, only fields that are explicitly listed in the `fields` array will be included.", "allOf": [ { - "$ref": "#/definitions/__schema26" + "$ref": "#/definitions/__schema29" } ] } }, - "required": ["tableName"], + "required": [ + "tableName" + ], "additionalProperties": false } }, - "__schema20": { + "__schema23": { "type": "string" }, - "__schema21": { + "__schema24": { "type": "array", "items": { "type": "object", @@ -399,7 +454,7 @@ "description": "If true, this field will be excluded from generation", "allOf": [ { - "$ref": "#/definitions/__schema22" + "$ref": "#/definitions/__schema25" } ] }, @@ -407,19 +462,21 @@ "description": "Override the inferred field type from metadata. Options: text, number, boolean, date, timestamp, container, list", "allOf": [ { - "$ref": "#/definitions/__schema23" + "$ref": "#/definitions/__schema26" } ] } }, - "required": ["fieldName"], + "required": [ + "fieldName" + ], "additionalProperties": false } }, - "__schema22": { + "__schema25": { "type": "boolean" }, - "__schema23": { + "__schema26": { "type": "string", "enum": [ "text", @@ -431,16 +488,16 @@ "list" ] }, - "__schema24": { + "__schema27": { "type": "boolean" }, - "__schema25": { + "__schema28": { "type": "boolean" }, - "__schema26": { + "__schema29": { "type": "boolean" }, - "__schema27": { + "__schema30": { "default": true, "type": "boolean" } From 675c83ed0e7da7d8c674154d1ab5eec7d5bfe4d7 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:50:30 -0500 Subject: [PATCH 11/11] fix: update FM HTTP mode documentation and config details - Changed model name in btca.config.jsonc to lowercase. - Updated domain_map.yaml with new FM HTTP features and troubleshooting tips. - Expanded skill_spec.md to reflect increased failure modes for typegen-setup. - Added detailed instructions for local WebViewer development using FM HTTP in SKILL.md. - Clarified critical mistakes related to FmHttpAdapter usage and environment variable settings in typegen-setup documentation. --- _artifacts/domain_map.yaml | 63 +++++++++- _artifacts/skill_spec.md | 8 +- btca.config.jsonc | 2 +- .../typegen/skills/getting-started/SKILL.md | 50 ++++++++ .../typegen/skills/typegen-setup/SKILL.md | 110 ++++++++++++++---- 5 files changed, 206 insertions(+), 27 deletions(-) diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index fe25c604..fd92049f 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -2,7 +2,7 @@ # Generated by skill-domain-discovery # Library: ProofKit # Version: fmdapi@5.0.3-beta.1, fmodata@0.1.0-beta.31, typegen@1.1.0-beta.15, webviewer@3.0.7-beta.0, better-auth@0.4.0-beta.7 -# Date: 2026-03-13 +# Date: 2026-03-16 # Status: reviewed library: @@ -50,6 +50,9 @@ skills: - 'Environment variables (FM_SERVER, FM_DATABASE, OTTO_API_KEY, FM_USERNAME, FM_PASSWORD)' - 'strictNumbers, strictValueLists options' - 'webviewerScriptName option' + - 'fmHttp config (local FM HTTP proxy for typegen metadata fetching)' + - 'FM HTTP env vars (FM_HTTP_BASE_URL, FM_CONNECTED_FILE_NAME)' + - 'FM HTTP auto-discovery of connectedFileName via /connectedFiles' - 'Field type overrides for OData' - 'reduceMetadata, alwaysOverrideFieldNames' - 'Entity ID preservation across regeneration' @@ -59,6 +62,8 @@ skills: - 'Regenerate types after FileMaker schema changes' - 'Switch from Data API to OData config' - 'Configure environment variables for FM connection' + - 'Set up FM HTTP mode for local typegen without credentials' + - 'Troubleshoot FM HTTP connectivity issues' - 'Customize generated schemas with override files' failure_modes: - mistake: 'Editing files in generated/ or client/ directories' @@ -168,6 +173,60 @@ skills: status: active skills: ['typegen-setup', 'fmdapi-client', 'fmodata-client'] + - mistake: 'Using FmHttpAdapter in production application code' + mechanism: 'FmHttpAdapter is internal to typegen metadata fetching. Generated clients use WebViewerAdapter. Agent sees the adapter import in typegen source and uses it in app code.' + wrong_pattern: | + import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http"; + const client = DataApi({ + adapter: new FmHttpAdapter({ baseUrl: "http://127.0.0.1:1365", connectedFileName: "MyFile" }), + layout: "Contacts", + }); + correct_pattern: | + // Use typegen-generated client (which uses WebViewerAdapter internally) + import { ContactsLayout } from "./schema/client"; + const { data } = await ContactsLayout.find({ query: { name: "==John" } }); + source: 'source: typegen.ts, buildLayoutClient.ts' + priority: CRITICAL + status: active + skills: ['typegen-setup'] + + - mistake: 'Setting standard FM env vars when using fmHttp mode' + mechanism: 'fmHttp mode does not need FM_SERVER, FM_DATABASE, or OTTO_API_KEY. Agent configures both standard and fmHttp env vars, causing confusion when standard vars fail validation.' + wrong_pattern: | + # .env — agent sets both + FM_SERVER=https://fm.example.com + FM_DATABASE=MyFile.fmp12 + OTTO_API_KEY=dk_abc123 + FM_HTTP_BASE_URL=http://127.0.0.1:1365 + correct_pattern: | + # fmHttp mode only needs these (baseUrl defaults to 127.0.0.1:1365) + # connectedFileName is auto-discovered if not set + FM_CONNECTED_FILE_NAME=MyFile + source: 'source: getEnvValues.ts, constants.ts' + priority: HIGH + status: active + skills: ['typegen-setup'] + + - mistake: 'Suggesting OttoFMS/FetchAdapter fallback when FM HTTP fails' + mechanism: 'Agent troubleshoots FM HTTP connection failure by suggesting standard auth. Developer chose fmHttp because they have no hosted credentials or are working offline. Correct troubleshooting: check daemon health (GET http://127.0.0.1:1365/health), check /connectedFiles, ensure FM file has run "Connect to MCP" script and WebViewer window is open in Browse mode.' + source: 'maintainer interview' + priority: HIGH + status: active + skills: ['typegen-setup'] + + - mistake: 'FM HTTP WebViewer window closed or in Layout mode' + mechanism: 'The FM HTTP proxy works via a WebViewer window opened by the "Connect to MCP" script in FileMaker. If this window is closed or switched to Layout mode, all proxy requests fail. Error may not be obvious — typegen just fails to connect.' + correct_pattern: | + # Troubleshooting checklist: + # 1. Is the fm-http daemon running? GET http://127.0.0.1:1365/health + # 2. Is the file connected? GET http://127.0.0.1:1365/connectedFiles + # 3. If file not listed: open it in FileMaker, run "Connect to MCP" script + # 4. Ensure the WebViewer window stays open in Browse mode (not Layout mode) + source: 'maintainer interview' + priority: HIGH + status: active + skills: ['typegen-setup'] + - name: 'Data API Client' slug: 'fmdapi-client' domain: 'data-access' @@ -646,10 +705,12 @@ skills: - 'First typegen run' - 'First query with generated client' - 'Choosing between Data API and OData' + - 'FM HTTP mode for local/offline WebViewer development (no credentials needed)' tasks: - 'Set up a new project with ProofKit' - 'Connect to FileMaker server for the first time' - 'Generate types and make first data query' + - 'Set up FM HTTP mode for local WebViewer development' failure_modes: - mistake: 'Missing fmrest or fmodata privilege on FM account' mechanism: 'Data API requires fmrest extended privilege. OData requires fmodata privilege. Without these, all API calls return 401/403.' diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index da30a122..4d2ab0ce 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -15,7 +15,7 @@ ProofKit is a monorepo of TypeScript tools for building web applications integra | Skill | Type | Domain | What it covers | Failure modes | | --- | --- | --- | --- | --- | -| typegen-setup | core | connecting | Config, CLI, Data API + OData modes, validators, generated file structure, env vars | 7 | +| typegen-setup | core | connecting | Config, CLI, Data API + OData + FM HTTP modes, validators, generated file structure, env vars | 11 | | fmdapi-client | core | data-access | DataApi factory, adapters, token stores, CRUD, find variants, scripts, validation | 6 | | fmodata-client | core | data-access | FMServerConnection, schema/field builders, query builder, CRUD, relationships, batch, errors | 8 | | webviewer-integration | core | webviewer | fmFetch, callFMScript, WebViewerAdapter, browser-only constraints, local mode perf | 6 | @@ -25,7 +25,7 @@ ProofKit is a monorepo of TypeScript tools for building web applications integra ## Failure Mode Inventory -### typegen-setup (7 failure modes) +### typegen-setup (11 failure modes) | # | Mistake | Priority | Source | Cross-skill? | | --- | --- | --- | --- | --- | @@ -36,6 +36,10 @@ ProofKit is a monorepo of TypeScript tools for building web applications integra | 5 | Omitting type discriminator for OData config | HIGH | source + docs | — | | 6 | Manually redefining types instead of using generated/inferred types | CRITICAL | maintainer | fmdapi-client, fmodata-client | | 7 | Mixing Zod v3 and v4 in the same project | HIGH | maintainer | fmdapi-client, fmodata-client | +| 8 | Using FmHttpAdapter in production application code | CRITICAL | source | — | +| 9 | Setting standard FM env vars when using fmHttp mode | HIGH | source | — | +| 10 | Suggesting OttoFMS/FetchAdapter fallback when FM HTTP fails | HIGH | maintainer | — | +| 11 | FM HTTP WebViewer window closed or in Layout mode | HIGH | maintainer | — | ### fmdapi-client (6 failure modes) diff --git a/btca.config.jsonc b/btca.config.jsonc index 3292be29..2543b4b0 100644 --- a/btca.config.jsonc +++ b/btca.config.jsonc @@ -8,6 +8,6 @@ "branch": "main" } ], - "model": "GPT-5.3-Codex", + "model": "gpt-5.3-codex", "provider": "openai" } \ No newline at end of file diff --git a/packages/typegen/skills/getting-started/SKILL.md b/packages/typegen/skills/getting-started/SKILL.md index 029075f5..4eda27f7 100644 --- a/packages/typegen/skills/getting-started/SKILL.md +++ b/packages/typegen/skills/getting-started/SKILL.md @@ -187,6 +187,56 @@ if (error) { } ``` +### Path D: Local WebViewer Development (FM HTTP — no credentials) + +For WebViewer apps running inside FileMaker, you can use FM HTTP mode to generate types from a local FileMaker file without any server credentials. + +**Prerequisites:** +- FM HTTP daemon installed and running (`curl http://127.0.0.1:1365/health`) +- FileMaker file open locally with "Connect to MCP" script run + +1. Install packages: + +```bash +pnpm add @proofkit/fmdapi @proofkit/webviewer zod +``` + +2. No `.env` file needed for typegen (baseUrl defaults, connectedFileName is auto-discovered). Optionally set "connectedFileName" in the config.fmHttp.connectedFileName to override the auto-discovery. + +3. Create typegen config with `fmHttp` enabled: + +```jsonc +{ + "$schema": "https://proofkit.dev/typegen-config-schema.json", + "config": { + "type": "fmdapi", + "fmHttp": { "enabled": true }, + "layouts": [ + { "layoutName": "api_Contacts", "schemaName": "Contacts" } + ], + "path": "schema" + } +} +``` + +4. Run typegen — it auto-discovers the connected file and writes it back to config: + +```bash +npx @proofkit/typegen +``` + +5. First query (runs inside FileMaker WebViewer): + +```ts +import { ContactsLayout } from "./schema/client"; + +const { data } = await ContactsLayout.findOne({ + query: { id: "==123" }, +}); +``` + +The generated client uses `WebViewerAdapter` with `"execute_data_api"` as the default script name. No server URL or API keys are needed at runtime — all calls go through the FileMaker script engine. + ## Choosing Data API vs OData | Aspect | Data API (`@proofkit/fmdapi`) | OData (`@proofkit/fmodata`) | diff --git a/packages/typegen/skills/typegen-setup/SKILL.md b/packages/typegen/skills/typegen-setup/SKILL.md index 65ab327e..e052bc7d 100644 --- a/packages/typegen/skills/typegen-setup/SKILL.md +++ b/packages/typegen/skills/typegen-setup/SKILL.md @@ -5,7 +5,8 @@ description: > validators from FileMaker layouts or OData metadata. Covers proofkit-typegen-config.jsonc, validator options (zod/v4, zod/v3), generated vs override file structure, envNames, InferTableSchema, - InferZodPortals, and the schema/generated/client directory layout. + InferZodPortals, schema/generated/client directory layout, and + fmHttp mode for local typegen without credentials. type: core library: proofkit library_version: "1.1.0-beta.15" @@ -181,35 +182,42 @@ import { Customers } from "./schema/odata/generated/Customers"; type CustomerRow = InferTableSchema; ``` -### Multiple configs (mixed Data API + OData) +### Multiple configs + +The `config` key can be an array mixing `fmdapi` and `fmodata` entries, each with its own `path` and `envNames`. + +### FM HTTP mode (local development, no credentials) + +FM HTTP mode lets typegen fetch layout metadata from a locally running FileMaker file via the FM HTTP proxy, without needing OttoFMS, a hosted server, or any credentials. Generated clients still use `WebViewerAdapter` — FM HTTP is only used during typegen. ```jsonc { "$schema": "https://proofkit.dev/typegen-config-schema.json", - "config": [ - { - "type": "fmdapi", - "path": "schema/dapi", - "layouts": [ - { "layoutName": "api_Contacts", "schemaName": "Contacts" } - ] - }, - { - "type": "fmodata", - "path": "schema/odata", - "envNames": { - "server": "ODATA_SERVER_URL", - "db": "ODATA_DATABASE_NAME", - "auth": { "apiKey": "ODATA_API_KEY" } - }, - "tables": [ - { "tableName": "Products" } - ] - } - ] + "config": { + "type": "fmdapi", + "fmHttp": { "enabled": true }, + "layouts": [ + { "layoutName": "api_Contacts", "schemaName": "Contacts" } + ] + } } ``` +`fmHttp` accepts an object with optional overrides: + +- `enabled` — `true` to enable (default when object is present) +- `scriptName` — FM script the proxy calls for Data API operations. Resolution: `fmHttp.scriptName` > `webviewerScriptName` > `"execute_data_api"` +- `baseUrl` — FM HTTP server URL (default: `http://127.0.0.1:1365`). Can also be set via `FM_HTTP_BASE_URL` env var +- `connectedFileName` — FileMaker file name. If omitted, auto-discovered from `GET /connectedFiles` and written back to config + +The generated client uses `WebViewerAdapter` with `webviewerScriptName` if set, otherwise `"execute_data_api"`. + +**Prerequisites:** +1. FM HTTP daemon running locally (`GET http://127.0.0.1:1365/health` should return OK) +2. FileMaker file open on the local machine +3. "Connect to MCP" script run in the FileMaker file (opens a WebViewer window that bridges HTTP requests) +4. That WebViewer window must stay open in **Browse mode** (not Layout mode) + ### Custom env variable names ```jsonc @@ -418,6 +426,62 @@ Zod v3 and v4 have incompatible APIs (`z.infer` vs `z.output`, different `extend Source: apps/docs/content/docs/typegen/config.mdx +### CRITICAL: Using FmHttpAdapter in production application code + +Wrong: +```ts +import { FmHttpAdapter } from "@proofkit/fmdapi/adapters/fm-http"; + +const client = DataApi({ + adapter: new FmHttpAdapter({ + baseUrl: "http://127.0.0.1:1365", + connectedFileName: "MyFile", + }), + layout: "Contacts", +}); +``` + +Correct: +```ts +// Use the typegen-generated client (uses WebViewerAdapter internally) +import { ContactsLayout } from "./schema/client"; + +const { data } = await ContactsLayout.find({ query: { name: "==John" } }); +``` + +`FmHttpAdapter` is internal to typegen's metadata fetching process. It only runs during code generation, never in production. Generated clients use `WebViewerAdapter` for runtime data access inside FileMaker WebViewer. + +### HIGH: Setting standard FM env vars when using fmHttp mode + +Wrong: +```bash +# Agent configures both standard and fmHttp vars +FM_SERVER=https://fm.example.com +FM_DATABASE=MyFile.fmp12 +OTTO_API_KEY=dk_abc123 +FM_HTTP_BASE_URL=http://127.0.0.1:1365 +``` + +Correct: +```bash +# fmHttp mode only — no server/db/auth needed +# baseUrl defaults to http://127.0.0.1:1365 if not set +# connectedFileName is auto-discovered if not set +FM_CONNECTED_FILE_NAME=MyFile +``` + +fmHttp mode bypasses the standard FM_SERVER/FM_DATABASE/auth env vars entirely. Setting both causes confusion when standard validation reports missing values. + +### HIGH: FM HTTP connection failures — troubleshooting + +If typegen fails to connect in fmHttp mode, do NOT suggest falling back to OttoFMS or FetchAdapter. The developer chose fmHttp because they don't have hosted credentials or are working with a local-only file. + +Troubleshooting checklist: +1. **Daemon running?** `curl http://127.0.0.1:1365/health` — should return `{"service":"fm-http","status":"ok"}` +2. **File connected?** `curl http://127.0.0.1:1365/connectedFiles` — should list the target file +3. **File not listed?** Open the FileMaker file and run the **"Connect to MCP"** script +4. **Still not working?** Ensure the WebViewer window opened by "Connect to MCP" is in **Browse mode**, not Layout mode. Closing this window or switching to Layout mode silently breaks the proxy. + ## References - **fmdapi-client**: Typegen generates the layout-specific clients consumed by `@proofkit/fmdapi`. Override files and `InferZodPortals` bridge typegen output into fmdapi usage.