From e229b35f8d00ec2d173c16f19342ad07c6eba9e5 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:41:08 -0500 Subject: [PATCH 1/2] Normalize database names in fmodata requests - add per-database and per-request normalization control - ensure webhook paths use .fmp12 and OData strips it by default - update docs, tests, and changeset --- .changeset/fresh-cooks-kneel.md | 5 + .../docs/content/docs/fmodata/quick-start.mdx | 2 + apps/docs/content/docs/fmodata/webhooks.mdx | 1 - packages/fmodata/src/client/batch-builder.ts | 5 +- .../src/client/builders/table-utils.ts | 7 +- packages/fmodata/src/client/count-builder.ts | 5 +- packages/fmodata/src/client/database-name.ts | 54 ++++++++ packages/fmodata/src/client/database.ts | 21 ++- packages/fmodata/src/client/delete-builder.ts | 5 +- .../fmodata/src/client/filemaker-odata.ts | 32 ++++- packages/fmodata/src/client/insert-builder.ts | 5 +- .../fmodata/src/client/query/query-builder.ts | 5 +- packages/fmodata/src/client/record-builder.ts | 5 +- packages/fmodata/src/client/update-builder.ts | 5 +- .../fmodata/src/client/webhook-builder.ts | 11 +- packages/fmodata/src/effect.ts | 17 ++- packages/fmodata/src/services.ts | 6 + packages/fmodata/src/testing.ts | 19 ++- packages/fmodata/src/types.ts | 5 + .../tests/effect-layer-execution.test.ts | 1 + .../tests/normalize-database-name.test.ts | 121 ++++++++++++++++++ 21 files changed, 313 insertions(+), 24 deletions(-) create mode 100644 .changeset/fresh-cooks-kneel.md create mode 100644 packages/fmodata/src/client/database-name.ts create mode 100644 packages/fmodata/tests/normalize-database-name.test.ts diff --git a/.changeset/fresh-cooks-kneel.md b/.changeset/fresh-cooks-kneel.md new file mode 100644 index 00000000..5cb3484f --- /dev/null +++ b/.changeset/fresh-cooks-kneel.md @@ -0,0 +1,5 @@ +--- +"@proofkit/fmodata": patch +--- + +Add configurable database name normalization for OData and webhook requests. diff --git a/apps/docs/content/docs/fmodata/quick-start.mdx b/apps/docs/content/docs/fmodata/quick-start.mdx index 758398a5..f3269c6d 100644 --- a/apps/docs/content/docs/fmodata/quick-start.mdx +++ b/apps/docs/content/docs/fmodata/quick-start.mdx @@ -136,5 +136,7 @@ Learn more about the [@proofkit/typegen](/docs/typegen) tool. .execute(); ``` + `connection.database()` also accepts `normalizeDatabaseName` (default `true`). Standard OData requests strip a trailing `.fmp12`, while webhook create/list/remove requests ensure `.fmp12` is present. + diff --git a/apps/docs/content/docs/fmodata/webhooks.mdx b/apps/docs/content/docs/fmodata/webhooks.mdx index 5e308121..ffead8f5 100644 --- a/apps/docs/content/docs/fmodata/webhooks.mdx +++ b/apps/docs/content/docs/fmodata/webhooks.mdx @@ -174,4 +174,3 @@ await db.webhook.remove(webhookId); Webhooks are triggered automatically by FileMaker when records matching the webhook's filter are created, updated, or deleted. The `invoke()` method allows you to manually trigger webhooks for testing or on-demand processing. - diff --git a/packages/fmodata/src/client/batch-builder.ts b/packages/fmodata/src/client/batch-builder.ts index 7c9e402e..71da2a8b 100644 --- a/packages/fmodata/src/client/batch-builder.ts +++ b/packages/fmodata/src/client/batch-builder.ts @@ -12,6 +12,7 @@ import type { Result, } from "../types"; import { formatBatchRequestFromNative, type ParsedBatchResponse, parseBatchResponse } from "./batch-request"; +import { normalizeDatabasePath } from "./database-name"; import { createClientRuntime } from "./runtime"; /** @@ -116,7 +117,9 @@ export class BatchBuilder[]> { } toRequest(baseUrl: string, _options?: ExecuteOptions): Request { - const fullUrl = `${baseUrl}/${this.config.databaseName}/$batch`; + const fullUrl = `${baseUrl}${normalizeDatabasePath(`/${this.config.databaseName}/$batch`, { + normalizeDatabaseName: _options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, + })}`; return new Request(fullUrl, { method: "POST", headers: { diff --git a/packages/fmodata/src/client/builders/table-utils.ts b/packages/fmodata/src/client/builders/table-utils.ts index aedc86c7..ca5c7b37 100644 --- a/packages/fmodata/src/client/builders/table-utils.ts +++ b/packages/fmodata/src/client/builders/table-utils.ts @@ -3,6 +3,7 @@ import type { FMTable } from "../../orm/table"; import { getTableId as getTableIdHelper, getTableName, isUsingEntityIds } from "../../orm/table"; import type { ExecuteOptions } from "../../types"; import { getAcceptHeader } from "../../types"; +import { normalizeDatabasePath } from "../database-name"; /** * Resolves table identifier based on entity ID settings. @@ -65,9 +66,11 @@ export function mergeExecuteOptions( export function createODataRequest( baseUrl: string, config: { method: string; url: string }, - options?: { includeODataAnnotations?: boolean }, + options?: { includeODataAnnotations?: boolean; normalizeDatabaseName?: boolean }, ): Request { - const fullUrl = `${baseUrl}${config.url}`; + const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { + normalizeDatabaseName: options?.normalizeDatabaseName ?? true, + })}`; return new Request(fullUrl, { method: config.method, diff --git a/packages/fmodata/src/client/count-builder.ts b/packages/fmodata/src/client/count-builder.ts index e6975e1c..c3fb5fec 100644 --- a/packages/fmodata/src/client/count-builder.ts +++ b/packages/fmodata/src/client/count-builder.ts @@ -149,7 +149,10 @@ export class CountBuilder< toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); - return createODataRequest(baseUrl, config, options); + return createODataRequest(baseUrl, config, { + ...options, + normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, + }); } async processResponse(response: Response, _options?: ExecuteOptions): Promise> { diff --git a/packages/fmodata/src/client/database-name.ts b/packages/fmodata/src/client/database-name.ts new file mode 100644 index 00000000..239240e2 --- /dev/null +++ b/packages/fmodata/src/client/database-name.ts @@ -0,0 +1,54 @@ +const FMP12_EXT_REGEX = /\.fmp12$/i; + +export type DatabaseNameNormalizationMode = "default" | "ensureExtension"; + +export function stripFmp12Extension(databaseName: string): string { + return databaseName.replace(FMP12_EXT_REGEX, ""); +} + +export function ensureFmp12Extension(databaseName: string): string { + return FMP12_EXT_REGEX.test(databaseName) ? databaseName : `${databaseName}.fmp12`; +} + +export function normalizeDatabaseSegment( + databaseName: string, + normalizeDatabaseName: boolean, + mode: DatabaseNameNormalizationMode = "default", +): string { + if (!normalizeDatabaseName) { + return databaseName; + } + + return mode === "ensureExtension" ? ensureFmp12Extension(databaseName) : stripFmp12Extension(databaseName); +} + +export function normalizeDatabasePath( + path: string, + options: { + normalizeDatabaseName: boolean; + mode?: DatabaseNameNormalizationMode; + }, +): string { + if (!path.startsWith("/")) { + return path; + } + + const secondSlashIndex = path.indexOf("/", 1); + const hasDatabaseSegment = secondSlashIndex !== -1; + const databaseSegment = hasDatabaseSegment ? path.slice(1, secondSlashIndex) : path.slice(1); + + if (!databaseSegment || databaseSegment.startsWith("$")) { + return path; + } + + const normalizedDatabaseSegment = normalizeDatabaseSegment( + decodeURIComponent(databaseSegment), + options.normalizeDatabaseName, + options.mode, + ); + + const encodedDatabaseSegment = encodeURIComponent(normalizedDatabaseSegment); + return hasDatabaseSegment + ? `/${encodedDatabaseSegment}${path.slice(secondSlashIndex)}` + : `/${encodedDatabaseSegment}`; +} diff --git a/packages/fmodata/src/client/database.ts b/packages/fmodata/src/client/database.ts index 64adafed..974ebdc0 100644 --- a/packages/fmodata/src/client/database.ts +++ b/packages/fmodata/src/client/database.ts @@ -6,12 +6,11 @@ import { FMTable } from "../orm/table"; import { createDatabaseLayer, type FMODataLayer } from "../services"; import type { ExecutableBuilder, ExecutionContext, Metadata, Result } from "../types"; import { BatchBuilder } from "./batch-builder"; +import { stripFmp12Extension } from "./database-name"; import { EntitySet } from "./entity-set"; import { SchemaManager } from "./schema-manager"; import { WebhookManager } from "./webhook-builder"; -const FMP12_EXT_REGEX = /\.fmp12$/i; - interface MetadataArgs { format?: "xml" | "json"; /** @@ -29,6 +28,7 @@ export class Database { readonly schema: SchemaManager; readonly webhook: WebhookManager; private readonly databaseName: string; + private readonly _normalizeDatabaseName: boolean; private readonly _useEntityIds: boolean; private readonly _includeSpecialColumns: IncludeSpecialColumns; /** @internal Database-scoped Effect Layer for dependency injection */ @@ -38,6 +38,11 @@ export class Database { databaseName: string, context: ExecutionContext, config?: { + /** + * Whether to normalize the database name in requests. + * Defaults to true. + */ + normalizeDatabaseName?: boolean; /** * Whether to use entity IDs instead of field names in the actual requests to the server * Defaults to true if all occurrences use entity IDs, false otherwise @@ -52,6 +57,7 @@ export class Database { }, ) { this.databaseName = databaseName; + this._normalizeDatabaseName = config?.normalizeDatabaseName ?? true; this._useEntityIds = config?.useEntityIds ?? false; this._includeSpecialColumns = (config?.includeSpecialColumns ?? false) as IncludeSpecialColumns; @@ -60,6 +66,7 @@ export class Database { if (baseLayer) { this._layer = createDatabaseLayer(baseLayer, { databaseName: this.databaseName, + normalizeDatabaseName: this._normalizeDatabaseName, useEntityIds: this._useEntityIds, includeSpecialColumns: this._includeSpecialColumns, }); @@ -89,6 +96,13 @@ export class Database { return this._useEntityIds; } + /** + * @internal Used by EntitySet to access database configuration + */ + get _getNormalizeDatabaseName(): boolean { + return this._normalizeDatabaseName; + } + /** * @internal Used by EntitySet to access database configuration */ @@ -122,6 +136,7 @@ export class Database { useEntityIds !== this._useEntityIds ? createDatabaseLayer(this._layer, { databaseName: this.databaseName, + normalizeDatabaseName: this._normalizeDatabaseName, useEntityIds, includeSpecialColumns: this._includeSpecialColumns, }) @@ -169,7 +184,7 @@ export class Database { } const metadataMap = data as Record; - const metadata = metadataMap[this.databaseName] ?? metadataMap[this.databaseName.replace(FMP12_EXT_REGEX, "")]; + const metadata = metadataMap[this.databaseName] ?? metadataMap[stripFmp12Extension(this.databaseName)]; if (!metadata) { throw new MetadataNotFoundError(this.databaseName); } diff --git a/packages/fmodata/src/client/delete-builder.ts b/packages/fmodata/src/client/delete-builder.ts index 84e81f75..ccde014c 100644 --- a/packages/fmodata/src/client/delete-builder.ts +++ b/packages/fmodata/src/client/delete-builder.ts @@ -12,6 +12,7 @@ import { type RecordLocator, resolveMutationTableId, } from "./builders/mutation-helpers"; +import { normalizeDatabasePath } from "./database-name"; import { parseErrorResponse } from "./error-parser"; import { QueryBuilder } from "./query-builder"; import { createClientRuntime } from "./runtime"; @@ -172,7 +173,9 @@ export class ExecutableDeleteBuilder> toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); - const fullUrl = `${baseUrl}${config.url}`; + const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { + normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, + })}`; return new Request(fullUrl, { method: config.method, diff --git a/packages/fmodata/src/client/filemaker-odata.ts b/packages/fmodata/src/client/filemaker-odata.ts index f0c38385..a8c3f62c 100644 --- a/packages/fmodata/src/client/filemaker-odata.ts +++ b/packages/fmodata/src/client/filemaker-odata.ts @@ -18,6 +18,7 @@ import { getAcceptHeader } from "../types"; import { mergePreferHeaderValues } from "./builders/mutation-helpers"; import { ClarisIdAuthManager } from "./claris-id"; import { Database } from "./database"; +import { type DatabaseNameNormalizationMode, normalizeDatabasePath } from "./database-name"; import { safeJsonParse } from "./sanitize-json"; const TRAILING_SLASH_REGEX = /\/+$/; @@ -26,10 +27,12 @@ export class FMServerConnection implements ExecutionContext { private readonly fetchClient: ReturnType; private readonly serverUrl: string; private readonly auth: Auth; + private readonly normalizeDatabaseName = true; private useEntityIds = false; private includeSpecialColumns = false; private readonly logger: InternalLogger; private readonly clarisIdAuthManager: ClarisIdAuthManager | null; + private hasWarnedAboutOttoDatabaseNormalization = false; /** @internal Stored so credential-override flows can inherit non-auth config. */ readonly _fetchClientOptions: FFetchOptions | undefined; constructor(config: { @@ -133,13 +136,20 @@ export class FMServerConnection implements ExecutionContext { const httpLayer = Layer.succeed(HttpClient, { request: ( url: string, - options?: RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean }, + options?: RequestInit & + FFetchOptions & { + normalizeDatabaseName?: boolean; + databaseNameNormalizationMode?: DatabaseNameNormalizationMode; + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + }, ) => this._makeRequestEffect(url, options), }); const configLayer = Layer.succeed(ODataConfig, { baseUrl: this._getBaseUrl(), databaseName: "", + normalizeDatabaseName: this.normalizeDatabaseName, useEntityIds: this.useEntityIds, includeSpecialColumns: this.includeSpecialColumns, }); @@ -219,13 +229,26 @@ export class FMServerConnection implements ExecutionContext { url: string, options?: RequestInit & FFetchOptions & { + normalizeDatabaseName?: boolean; + databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; }, ): Effect.Effect { const logger = this._getLogger(); const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`; - const fullUrl = baseUrl + url; + const normalizeDatabaseName = options?.normalizeDatabaseName ?? this.normalizeDatabaseName; + if ("apiKey" in this.auth && normalizeDatabaseName === false && !this.hasWarnedAboutOttoDatabaseNormalization) { + logger.warn( + "normalizeDatabaseName=false cannot disable filename normalization with Otto auth; FileMaker Server normalizes it automatically.", + ); + this.hasWarnedAboutOttoDatabaseNormalization = true; + } + const normalizedUrl = normalizeDatabasePath(url, { + normalizeDatabaseName, + mode: options?.databaseNameNormalizationMode, + }); + const fullUrl = baseUrl + normalizedUrl; // Use per-request override if provided, otherwise use the database-level setting const useEntityIds = options?.useEntityIds ?? this.useEntityIds; @@ -358,7 +381,7 @@ export class FMServerConnection implements ExecutionContext { // Apply retry policy and tracing span return withSpan(requestEffect, "fmodata.request", { - "fmodata.url": url, + "fmodata.url": normalizedUrl, "fmodata.method": method, }); } @@ -370,6 +393,8 @@ export class FMServerConnection implements ExecutionContext { url: string, options?: RequestInit & FFetchOptions & { + normalizeDatabaseName?: boolean; + databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; }, @@ -380,6 +405,7 @@ export class FMServerConnection implements ExecutionContext { database( name: string, config?: { + normalizeDatabaseName?: boolean; useEntityIds?: boolean; includeSpecialColumns?: IncludeSpecialColumns; }, diff --git a/packages/fmodata/src/client/insert-builder.ts b/packages/fmodata/src/client/insert-builder.ts index 27129054..d6c84c50 100644 --- a/packages/fmodata/src/client/insert-builder.ts +++ b/packages/fmodata/src/client/insert-builder.ts @@ -25,6 +25,7 @@ import { parseRowIdFromLocationHeader, resolveMutationTableId, } from "./builders/mutation-helpers"; +import { normalizeDatabasePath } from "./database-name"; import { parseErrorResponse } from "./error-parser"; import { createClientRuntime } from "./runtime"; import { safeJsonParse } from "./sanitize-json"; @@ -268,7 +269,9 @@ export class InsertBuilder< toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); - const fullUrl = `${baseUrl}${config.url}`; + const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { + normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, + })}`; const preferHeader = mergePreferHeaderValues( this.returnPreference === "minimal" ? "return=minimal" : "return=representation", (options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined, diff --git a/packages/fmodata/src/client/query/query-builder.ts b/packages/fmodata/src/client/query/query-builder.ts index d21620df..a517f569 100644 --- a/packages/fmodata/src/client/query/query-builder.ts +++ b/packages/fmodata/src/client/query/query-builder.ts @@ -984,7 +984,10 @@ export class QueryBuilder< toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); - return createODataRequest(baseUrl, config, options); + return createODataRequest(baseUrl, config, { + ...options, + normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, + }); } async processResponse( diff --git a/packages/fmodata/src/client/record-builder.ts b/packages/fmodata/src/client/record-builder.ts index 0424d846..8026e1fc 100644 --- a/packages/fmodata/src/client/record-builder.ts +++ b/packages/fmodata/src/client/record-builder.ts @@ -801,7 +801,10 @@ export class RecordBuilder< toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); - return createODataRequest(baseUrl, config, options); + return createODataRequest(baseUrl, config, { + ...options, + normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, + }); } async processResponse( diff --git a/packages/fmodata/src/client/update-builder.ts b/packages/fmodata/src/client/update-builder.ts index 936fccc2..817e4c93 100644 --- a/packages/fmodata/src/client/update-builder.ts +++ b/packages/fmodata/src/client/update-builder.ts @@ -24,6 +24,7 @@ import { type RecordLocator, resolveMutationTableId, } from "./builders/mutation-helpers"; +import { normalizeDatabasePath } from "./database-name"; import { parseErrorResponse } from "./error-parser"; import { QueryBuilder } from "./query-builder"; import { createClientRuntime } from "./runtime"; @@ -288,7 +289,9 @@ export class ExecutableUpdateBuilder< toRequest(baseUrl: string, options?: ExecuteOptions): Request { const config = this.getRequestConfig(); - const fullUrl = `${baseUrl}${config.url}`; + const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { + normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName, + })}`; const preferHeader = mergePreferHeaderValues( this.returnPreference === "representation" ? "return=representation" : undefined, (options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined, diff --git a/packages/fmodata/src/client/webhook-builder.ts b/packages/fmodata/src/client/webhook-builder.ts index 3234fcd6..b334c336 100644 --- a/packages/fmodata/src/client/webhook-builder.ts +++ b/packages/fmodata/src/client/webhook-builder.ts @@ -153,9 +153,10 @@ export class WebhookManager { const pipeline = Effect.gen(this, function* () { return yield* requestFromService(`/${this.config.databaseName}/Webhook.Add`, { + ...options, method: "POST", body: JSON.stringify(requestBody), - ...options, + databaseNameNormalizationMode: "ensureExtension", }); }); @@ -174,8 +175,9 @@ export class WebhookManager { async remove(webhookId: number, options?: ExecuteMethodOptions): Promise { const pipeline = Effect.gen(this, function* () { return yield* requestFromService(`/${this.config.databaseName}/Webhook.Delete(${webhookId})`, { - method: "POST", ...options, + method: "POST", + databaseNameNormalizationMode: "ensureExtension", }); }); @@ -212,7 +214,10 @@ export class WebhookManager { */ list(options?: ExecuteMethodOptions): Promise { const pipeline = Effect.gen(this, function* () { - return yield* requestFromService(`/${this.config.databaseName}/Webhook.GetAll`, options); + return yield* requestFromService(`/${this.config.databaseName}/Webhook.GetAll`, { + ...options, + databaseNameNormalizationMode: "ensureExtension", + }); }); return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.list"); diff --git a/packages/fmodata/src/effect.ts b/packages/fmodata/src/effect.ts index 1cead7a3..a07ae416 100644 --- a/packages/fmodata/src/effect.ts +++ b/packages/fmodata/src/effect.ts @@ -10,6 +10,7 @@ import type { FFetchOptions } from "@fetchkit/ffetch"; import { Effect, Schedule } from "effect"; +import type { DatabaseNameNormalizationMode } from "./client/database-name"; import type { FMODataErrorType } from "./errors"; import { BuilderInvariantError, isFMODataError, isTransientError } from "./errors"; import type { @@ -18,7 +19,7 @@ import type { ODataConfig as ODataConfigService, ODataLogger as ODataLoggerService, } from "./services"; -import { HttpClient } from "./services"; +import { HttpClient, ODataConfig } from "./services"; import type { Result, RetryPolicy } from "./types"; type FMODataServices = HttpClientService | ODataConfigService | ODataLoggerService; @@ -42,13 +43,23 @@ export function requestFromService( url: string, options?: RequestInit & FFetchOptions & { + normalizeDatabaseName?: boolean; + databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; includeODataAnnotations?: boolean; retryPolicy?: RetryPolicy; }, -): Effect.Effect { - return Effect.flatMap(HttpClient, (client) => client.request(url, options)); +): Effect.Effect { + return Effect.gen(function* () { + const client = yield* HttpClient; + const config = yield* ODataConfig; + + return yield* client.request(url, { + ...options, + normalizeDatabaseName: options?.normalizeDatabaseName ?? config.normalizeDatabaseName, + }); + }); } /** diff --git a/packages/fmodata/src/services.ts b/packages/fmodata/src/services.ts index 2fe9e1cd..3bd8698a 100644 --- a/packages/fmodata/src/services.ts +++ b/packages/fmodata/src/services.ts @@ -13,6 +13,7 @@ import type { FFetchOptions } from "@fetchkit/ffetch"; import { Context, Effect, Layer } from "effect"; +import type { DatabaseNameNormalizationMode } from "./client/database-name"; import type { FMODataErrorType } from "./errors"; import { MissingLayerServiceError } from "./errors"; import type { InternalLogger } from "./logger"; @@ -24,6 +25,8 @@ export interface HttpClient { url: string, options?: RequestInit & FFetchOptions & { + normalizeDatabaseName?: boolean; + databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; includeODataAnnotations?: boolean; @@ -39,6 +42,7 @@ export const HttpClient = Context.GenericTag("@proofkit/fmodata/Http export interface ODataConfig { readonly baseUrl: string; readonly databaseName: string; + readonly normalizeDatabaseName: boolean; readonly useEntityIds: boolean; readonly includeSpecialColumns: boolean; } @@ -84,6 +88,7 @@ export function createDatabaseLayer( baseLayer: FMODataLayer, overrides: { databaseName: string; + normalizeDatabaseName: boolean; useEntityIds: boolean; includeSpecialColumns: boolean; }, @@ -94,6 +99,7 @@ export function createDatabaseLayer( const dbConfigLayer = Layer.succeed(ODataConfig, { baseUrl: baseConfig.baseUrl, databaseName: overrides.databaseName, + normalizeDatabaseName: overrides.normalizeDatabaseName, useEntityIds: overrides.useEntityIds, includeSpecialColumns: overrides.includeSpecialColumns, }); diff --git a/packages/fmodata/src/testing.ts b/packages/fmodata/src/testing.ts index 46d30f1e..b671dd0c 100644 --- a/packages/fmodata/src/testing.ts +++ b/packages/fmodata/src/testing.ts @@ -20,6 +20,7 @@ import type { Database } from "./client/database"; import { FMServerConnection } from "./client/filemaker-odata"; +import type { Logger } from "./logger"; // --- MockRoute type --- @@ -49,6 +50,10 @@ export interface RequestSpy { forUrl(pattern: string | RegExp): ReadonlyArray<{ url: string; method: string; body?: string }>; } +function getLegacyCompatiblePattern(pattern: string): string { + return pattern.replace(/\.fmp12(?=\/|$)/gi, ""); +} + /** * Strips @id and @editLink fields from response data when Accept header requests no metadata. */ @@ -122,7 +127,10 @@ function createRouterFetch( // Find matching route (first-match-wins) const route = routes.find((r) => { - const urlMatch = typeof r.urlPattern === "string" ? url.includes(r.urlPattern) : r.urlPattern.test(url); + const urlMatch = + typeof r.urlPattern === "string" + ? url.includes(r.urlPattern) || url.includes(getLegacyCompatiblePattern(r.urlPattern)) + : r.urlPattern.test(url); const methodMatch = !r.method || r.method.toUpperCase() === method.toUpperCase(); return urlMatch && methodMatch; }); @@ -223,6 +231,7 @@ export class MockFMServerConnection { routes?: MockRoute[]; baseUrl?: string; enableSpy?: boolean; + logger?: Logger; }) { this.routes = config?.routes ? [...config.routes] : []; this._spy = config?.enableSpy ? { calls: [] } : undefined; @@ -230,6 +239,7 @@ export class MockFMServerConnection { this.connection = new FMServerConnection({ serverUrl: config?.baseUrl ?? "https://test.example.com", auth: { apiKey: "test-api-key" }, + logger: config?.logger, fetchClientOptions: { retries: 0, fetchHandler: createRouterFetch(this.routes, this._spy), @@ -272,7 +282,11 @@ export class MockFMServerConnection { spy.calls.length = 0; }, forUrl(pattern: string | RegExp) { - return spy.calls.filter((c) => (typeof pattern === "string" ? c.url.includes(pattern) : pattern.test(c.url))); + return spy.calls.filter((c) => + typeof pattern === "string" + ? c.url.includes(pattern) || c.url.includes(getLegacyCompatiblePattern(pattern)) + : pattern.test(c.url), + ); }, }; } @@ -283,6 +297,7 @@ export class MockFMServerConnection { database( name: string, config?: { + normalizeDatabaseName?: boolean; useEntityIds?: boolean; includeSpecialColumns?: IncludeSpecialColumns; }, diff --git a/packages/fmodata/src/types.ts b/packages/fmodata/src/types.ts index c3cd14f1..3bea9af2 100644 --- a/packages/fmodata/src/types.ts +++ b/packages/fmodata/src/types.ts @@ -180,6 +180,11 @@ export interface RetryPolicy { export interface ExecuteOptions { includeODataAnnotations?: boolean; skipValidation?: boolean; + /** + * Overrides the default behavior of the database to normalize the database name in THIS REQUEST ONLY. + * Defaults to true at the database level. + */ + normalizeDatabaseName?: boolean; /** * Overrides the default behavior of the database to use entity IDs (rather than field names) in THIS REQUEST ONLY */ diff --git a/packages/fmodata/tests/effect-layer-execution.test.ts b/packages/fmodata/tests/effect-layer-execution.test.ts index ff0efc3a..ee5b4b81 100644 --- a/packages/fmodata/tests/effect-layer-execution.test.ts +++ b/packages/fmodata/tests/effect-layer-execution.test.ts @@ -18,6 +18,7 @@ const logger = { const baseConfig = { baseUrl: "https://example.com", databaseName: "test_db", + normalizeDatabaseName: true, useEntityIds: false, includeSpecialColumns: false, }; diff --git a/packages/fmodata/tests/normalize-database-name.test.ts b/packages/fmodata/tests/normalize-database-name.test.ts new file mode 100644 index 00000000..88add2b9 --- /dev/null +++ b/packages/fmodata/tests/normalize-database-name.test.ts @@ -0,0 +1,121 @@ +import { fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; +import { describe, expect, it, vi } from "vitest"; + +const contacts = fmTableOccurrence("contacts", { + id: textField().primaryKey(), + name: textField(), +}); + +describe("normalizeDatabaseName", () => { + it("strips .fmp12 by default for normal requests", async () => { + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { value: [{ id: "1", name: "John" }] }, + }); + + const db = mock.database("TestDB.fmp12"); + await db.from(contacts).list().execute(); + + expect(mock.spy?.calls[0]?.url).toContain("/TestDB/contacts"); + expect(mock.spy?.calls[0]?.url).not.toContain("/TestDB.fmp12/contacts"); + }); + + it("preserves the provided database name when disabled at database level", async () => { + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB.fmp12/contacts", + response: { value: [{ id: "1", name: "John" }] }, + }); + + const db = mock.database("TestDB.fmp12", { normalizeDatabaseName: false }); + await db.from(contacts).list().execute(); + + expect(mock.spy?.calls[0]?.url).toContain("/TestDB.fmp12/contacts"); + }); + + it("supports per-request override for normal requests", async () => { + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB.fmp12/contacts", + response: { value: [{ id: "1", name: "John" }] }, + }); + + const db = mock.database("TestDB.fmp12"); + await db.from(contacts).list().execute({ normalizeDatabaseName: false }); + + expect(mock.spy?.calls[0]?.url).toContain("/TestDB.fmp12/contacts"); + }); + + it("adds .fmp12 for webhook list by default", async () => { + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB.fmp12/Webhook.GetAll", + response: { status: "ACTIVE", webhooks: [] }, + }); + + const db = mock.database("TestDB"); + await db.webhook.list(); + + expect(mock.spy?.calls[0]?.url).toContain("/TestDB.fmp12/Webhook.GetAll"); + }); + + it("adds .fmp12 for webhook add and remove by default", async () => { + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB.fmp12/Webhook.Add", + method: "POST", + response: { webhookResult: { webhookID: 1 } }, + }); + mock.addRoute({ + urlPattern: "/TestDB.fmp12/Webhook.Delete(1)", + method: "POST", + response: { webhookResult: { webhookID: 1 } }, + }); + + const db = mock.database("TestDB"); + await db.webhook.add({ + webhook: "https://example.com/webhook", + tableName: contacts, + }); + await db.webhook.remove(1); + + expect(mock.spy?.calls[0]?.url).toContain("/TestDB.fmp12/Webhook.Add"); + expect(mock.spy?.calls[1]?.url).toContain("/TestDB.fmp12/Webhook.Delete(1)"); + }); + + it("supports per-request override for webhook list", async () => { + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB/Webhook.GetAll", + response: { status: "ACTIVE", webhooks: [] }, + }); + + const db = mock.database("TestDB"); + await db.webhook.list({ normalizeDatabaseName: false }); + + expect(mock.spy?.calls[0]?.url).toContain("/TestDB/Webhook.GetAll"); + expect(mock.spy?.calls[0]?.url).not.toContain("/TestDB.fmp12/Webhook.GetAll"); + }); + + it("warns once for Otto auth when normalizeDatabaseName=false is requested", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const mock = new MockFMServerConnection({ + enableSpy: true, + logger: { level: "warn" }, + }); + mock.addRoute({ + urlPattern: "/TestDB.fmp12/contacts", + response: { value: [{ id: "1", name: "John" }] }, + }); + + const db = mock.database("TestDB.fmp12"); + await db.from(contacts).list().execute({ normalizeDatabaseName: false }); + await db.from(contacts).list().execute({ normalizeDatabaseName: false }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain("normalizeDatabaseName=false"); + warnSpy.mockRestore(); + }); +}); From 5a3152c75b3606298d1873e4fd5961137906eac3 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:20:32 -0500 Subject: [PATCH 2/2] Decode database path segments safely - tolerate invalid percent-encoding in database segments - keep normalization behavior unchanged for valid inputs --- packages/fmodata/src/client/database-name.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/fmodata/src/client/database-name.ts b/packages/fmodata/src/client/database-name.ts index 239240e2..e9383f08 100644 --- a/packages/fmodata/src/client/database-name.ts +++ b/packages/fmodata/src/client/database-name.ts @@ -41,8 +41,16 @@ export function normalizeDatabasePath( return path; } + let normalizedInput = databaseSegment; + + try { + normalizedInput = decodeURIComponent(databaseSegment); + } catch { + normalizedInput = databaseSegment; + } + const normalizedDatabaseSegment = normalizeDatabaseSegment( - decodeURIComponent(databaseSegment), + normalizedInput, options.normalizeDatabaseName, options.mode, );