Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-cooks-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@proofkit/fmodata": patch
---

Add configurable database name normalization for OData and webhook requests.
2 changes: 2 additions & 0 deletions apps/docs/content/docs/fmodata/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</Step>
</Steps>
1 change: 0 additions & 1 deletion apps/docs/content/docs/fmodata/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,3 @@ await db.webhook.remove(webhookId);
<Callout type="info">
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.
</Callout>

5 changes: 4 additions & 1 deletion packages/fmodata/src/client/batch-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -116,7 +117,9 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
}

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: {
Expand Down
7 changes: 5 additions & 2 deletions packages/fmodata/src/client/builders/table-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/fmodata/src/client/count-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<number>> {
Expand Down
62 changes: 62 additions & 0 deletions packages/fmodata/src/client/database-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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;
}

let normalizedInput = databaseSegment;

try {
normalizedInput = decodeURIComponent(databaseSegment);
} catch {
normalizedInput = databaseSegment;
}

const normalizedDatabaseSegment = normalizeDatabaseSegment(
normalizedInput,
options.normalizeDatabaseName,
options.mode,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

const encodedDatabaseSegment = encodeURIComponent(normalizedDatabaseSegment);
return hasDatabaseSegment
? `/${encodedDatabaseSegment}${path.slice(secondSlashIndex)}`
: `/${encodedDatabaseSegment}`;
}
21 changes: 18 additions & 3 deletions packages/fmodata/src/client/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/**
Expand All @@ -29,6 +28,7 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
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 */
Expand All @@ -38,6 +38,11 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
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
Expand All @@ -52,6 +57,7 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
},
) {
this.databaseName = databaseName;
this._normalizeDatabaseName = config?.normalizeDatabaseName ?? true;
this._useEntityIds = config?.useEntityIds ?? false;
this._includeSpecialColumns = (config?.includeSpecialColumns ?? false) as IncludeSpecialColumns;

Expand All @@ -60,6 +66,7 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
if (baseLayer) {
this._layer = createDatabaseLayer(baseLayer, {
databaseName: this.databaseName,
normalizeDatabaseName: this._normalizeDatabaseName,
useEntityIds: this._useEntityIds,
includeSpecialColumns: this._includeSpecialColumns,
});
Expand Down Expand Up @@ -89,6 +96,13 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
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
*/
Expand Down Expand Up @@ -122,6 +136,7 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
useEntityIds !== this._useEntityIds
? createDatabaseLayer(this._layer, {
databaseName: this.databaseName,
normalizeDatabaseName: this._normalizeDatabaseName,
useEntityIds,
includeSpecialColumns: this._includeSpecialColumns,
})
Expand Down Expand Up @@ -169,7 +184,7 @@ export class Database<IncludeSpecialColumns extends boolean = false> {
}

const metadataMap = data as Record<string, Metadata>;
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);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/fmodata/src/client/delete-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -172,7 +173,9 @@ export class ExecutableDeleteBuilder<Occ extends FMTable<any, any>>

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,
Expand Down
32 changes: 29 additions & 3 deletions packages/fmodata/src/client/filemaker-odata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /\/+$/;
Expand All @@ -26,10 +27,12 @@ export class FMServerConnection implements ExecutionContext {
private readonly fetchClient: ReturnType<typeof createClient>;
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: {
Expand Down Expand Up @@ -133,13 +136,20 @@ export class FMServerConnection implements ExecutionContext {
const httpLayer = Layer.succeed(HttpClient, {
request: <T>(
url: string,
options?: RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean },
options?: RequestInit &
FFetchOptions & {
normalizeDatabaseName?: boolean;
databaseNameNormalizationMode?: DatabaseNameNormalizationMode;
useEntityIds?: boolean;
includeSpecialColumns?: boolean;
},
) => this._makeRequestEffect<T>(url, options),
});

const configLayer = Layer.succeed(ODataConfig, {
baseUrl: this._getBaseUrl(),
databaseName: "",
normalizeDatabaseName: this.normalizeDatabaseName,
useEntityIds: this.useEntityIds,
includeSpecialColumns: this.includeSpecialColumns,
});
Expand Down Expand Up @@ -219,13 +229,26 @@ export class FMServerConnection implements ExecutionContext {
url: string,
options?: RequestInit &
FFetchOptions & {
normalizeDatabaseName?: boolean;
databaseNameNormalizationMode?: DatabaseNameNormalizationMode;
useEntityIds?: boolean;
includeSpecialColumns?: boolean;
},
): Effect.Effect<T, FMODataErrorType> {
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;
Expand Down Expand Up @@ -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,
});
}
Expand All @@ -370,6 +393,8 @@ export class FMServerConnection implements ExecutionContext {
url: string,
options?: RequestInit &
FFetchOptions & {
normalizeDatabaseName?: boolean;
databaseNameNormalizationMode?: DatabaseNameNormalizationMode;
useEntityIds?: boolean;
includeSpecialColumns?: boolean;
},
Expand All @@ -380,6 +405,7 @@ export class FMServerConnection implements ExecutionContext {
database<IncludeSpecialColumns extends boolean = false>(
name: string,
config?: {
normalizeDatabaseName?: boolean;
useEntityIds?: boolean;
includeSpecialColumns?: IncludeSpecialColumns;
},
Expand Down
5 changes: 4 additions & 1 deletion packages/fmodata/src/client/insert-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/fmodata/src/client/query/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion packages/fmodata/src/client/record-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion packages/fmodata/src/client/update-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading