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,
);