diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..4f479f37 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,6 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "proofkit" + +[setup] +script = "pnpm i" diff --git a/packages/fmodata/package.json b/packages/fmodata/package.json index d786eb19..6d9593bd 100644 --- a/packages/fmodata/package.json +++ b/packages/fmodata/package.json @@ -20,6 +20,24 @@ "default": "./dist/esm/index.js" } }, + "./testing": { + "import": { + "types": "./dist/esm/testing.d.ts", + "default": "./dist/esm/testing.js" + } + }, + "./services": { + "import": { + "types": "./dist/esm/services.d.ts", + "default": "./dist/esm/services.js" + } + }, + "./types": { + "import": { + "types": "./dist/esm/types.d.ts", + "default": "./dist/esm/types.js" + } + }, "./package.json": "./package.json" }, "scripts": { @@ -48,7 +66,6 @@ "dotenv": "^16.6.1", "effect": "^3.20.0", "es-toolkit": "^1.43.0", - "neverthrow": "^8.2.0", "odata-query": "^8.0.7" }, "peerDependencies": { diff --git a/packages/fmodata/skills/fmodata-client/SKILL.md b/packages/fmodata/skills/fmodata-client/SKILL.md index 81926a86..d6d798d4 100644 --- a/packages/fmodata/skills/fmodata-client/SKILL.md +++ b/packages/fmodata/skills/fmodata-client/SKILL.md @@ -5,7 +5,7 @@ description: > timestampField containerField calcField listField query builder execute() filter operators eq ne gt gte lt lte contains startsWith endsWith matchesPattern inArray notInArray isNull isNotNull and or not tolower toupper trim CRUD insert update delete byId where navigate expand relationships batch - Result error handling neverthrow pattern FMODataError HTTPError ODataError ValidationError + Result error handling Effect.ts pattern FMODataError HTTPError ODataError ValidationError BatchTruncatedError entity IDs FMTID FMFID defaultSelect readValidator writeValidator orderBy asc desc top skip single maybeSingle count getSingleField FileMaker OData API schema management webhooks getTableColumns select("all") diff --git a/packages/fmodata/src/cli/commands/query.ts b/packages/fmodata/src/cli/commands/query.ts index 413f3daa..476c1447 100644 --- a/packages/fmodata/src/cli/commands/query.ts +++ b/packages/fmodata/src/cli/commands/query.ts @@ -117,7 +117,13 @@ export function makeRecordsCommand(): Command { try { if (!(opts.where || opts.confirm)) { printResult( - { dryRun: true, action: "update", table: opts.table, affectsAllRows: true, hint: "Add --where to filter or --confirm to update all records" }, + { + dryRun: true, + action: "update", + table: opts.table, + affectsAllRows: true, + hint: "Add --where to filter or --confirm to update all records", + }, { pretty: globalOpts.pretty ?? false }, ); return; @@ -159,7 +165,13 @@ export function makeRecordsCommand(): Command { try { if (!(opts.where || opts.confirm)) { printResult( - { dryRun: true, action: "delete", table: opts.table, affectsAllRows: true, hint: "Add --where to filter or --confirm to delete all records" }, + { + dryRun: true, + action: "delete", + table: opts.table, + affectsAllRows: true, + hint: "Add --where to filter or --confirm to delete all records", + }, { pretty: globalOpts.pretty ?? false }, ); return; diff --git a/packages/fmodata/src/client/batch-builder.ts b/packages/fmodata/src/client/batch-builder.ts index fc5d24c7..6ad92a3f 100644 --- a/packages/fmodata/src/client/batch-builder.ts +++ b/packages/fmodata/src/client/batch-builder.ts @@ -1,14 +1,18 @@ +import { Effect } from "effect"; +import { requestFromService, runLayerResult } from "../effect"; +import type { FMODataErrorType } from "../errors"; import { BatchTruncatedError } from "../errors"; +import type { FMODataLayer, ODataConfig } from "../services"; import type { BatchItemResult, BatchResult, ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, - ExecutionContext, Result, } from "../types"; import { formatBatchRequestFromNative, type ParsedBatchResponse, parseBatchResponse } from "./batch-request"; +import { createClientRuntime } from "./runtime"; /** * Helper type to extract result types from a tuple of ExecutableBuilders. @@ -64,15 +68,15 @@ function parsedToResponse(parsed: ParsedBatchResponse): Response { export class BatchBuilder[]> { // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExecutableBuilder result type private readonly builders: ExecutableBuilder[]; - private readonly databaseName: string; - private readonly context: ExecutionContext; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; - constructor(builders: Builders, databaseName: string, context: ExecutionContext) { + constructor(builders: Builders, layer: FMODataLayer) { // Convert readonly tuple to mutable array for dynamic additions this.builders = [...builders]; - // Store original tuple for type preservation - this.databaseName = databaseName; - this.context = context; + const runtime = createClientRuntime(layer); + this.layer = runtime.layer; + this.config = runtime.config; } /** @@ -80,7 +84,7 @@ export class BatchBuilder[]> { * This allows building up batch operations programmatically. * * @param builder - An executable builder to add to the batch - * @returns This BatchBuilder for method chaining + * @returns A BatchBuilder typed with the appended request result * @example * ```ts * const batch = db.batch([]); @@ -89,9 +93,9 @@ export class BatchBuilder[]> { * const result = await batch.execute(); * ``` */ - addRequest(builder: ExecutableBuilder): this { + addRequest(builder: ExecutableBuilder): BatchBuilder<[...Builders, ExecutableBuilder]> { this.builders.push(builder); - return this; + return this as unknown as BatchBuilder<[...Builders, ExecutableBuilder]>; } /** @@ -100,19 +104,15 @@ export class BatchBuilder[]> { */ // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { - // Note: This method is kept for compatibility but batch operations - // should use execute() directly which handles the full Request/Response flow return { method: "POST", - url: `/${this.databaseName}/$batch`, + url: `/${this.config.databaseName}/$batch`, body: undefined, // Body is constructed in execute() }; } toRequest(baseUrl: string, _options?: ExecuteOptions): Request { - // Batch operations are not designed to be nested, but we provide - // a basic implementation for interface compliance - const fullUrl = `${baseUrl}/${this.databaseName}/$batch`; + const fullUrl = `${baseUrl}/${this.config.databaseName}/$batch`; return new Request(fullUrl, { method: "POST", headers: { @@ -124,8 +124,6 @@ export class BatchBuilder[]> { // biome-ignore lint/suspicious/noExplicitAny: Generic return type for interface compliance processResponse(_response: Response, _options?: ExecuteOptions): Promise> { - // This should not typically be called for batch operations - // as they handle their own response processing return Promise.resolve({ data: undefined, error: { @@ -137,6 +135,28 @@ export class BatchBuilder[]> { }); } + /** + * Creates a failed BatchResult where all operations are marked as failed with the given error. + */ + // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type + private failAllResults(error: any): BatchResult> { + const errorCount = this.builders.length; + // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type + const results: BatchItemResult[] = this.builders.map(() => ({ + data: undefined, + error, + status: 0, + })); + return { + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type + results: results as any, + successCount: 0, + errorCount, + truncated: false, + firstErrorIndex: errorCount > 0 ? 0 : null, + }; + } + /** * Execute the batch operation. * @@ -146,41 +166,25 @@ export class BatchBuilder[]> { async execute( options?: ExecuteMethodOptions, ): Promise>> { - const baseUrl = this.context._getBaseUrl?.(); + const baseUrl = this.config.baseUrl; if (!baseUrl) { - // Return BatchResult with all operations marked as failed - const errorCount = this.builders.length; - // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type - const results: BatchItemResult[] = this.builders.map((_, _i) => ({ - data: undefined, - error: { - name: "ConfigurationError", - message: "Base URL not available - execution context must implement _getBaseUrl()", - timestamp: new Date(), - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object - } as any, - status: 0, - })); - - return { - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type - results: results as any, - successCount: 0, - errorCount, - truncated: false, - firstErrorIndex: 0, - }; + return this.failAllResults({ + name: "ConfigurationError", + message: "Base URL not available in ODataConfig", + timestamp: new Date(), + }); } - try { - // Convert builders to native Request objects + const pipeline = Effect.gen(this, function* () { + // Step 1: Convert builders to Request objects and format batch const requests: Request[] = this.builders.map((builder) => builder.toRequest(baseUrl, options)); + const { body, boundary } = yield* Effect.tryPromise({ + try: () => formatBatchRequestFromNative(requests, baseUrl), + catch: (e) => e as FMODataErrorType, + }); - // Format batch request (automatically groups mutations into changesets) - const { body, boundary } = await formatBatchRequestFromNative(requests, baseUrl); - - // Execute the batch request - const response = await this.context._makeRequest(`/${this.databaseName}/$batch`, { + // Step 2: Execute the batch HTTP request via DI + const responseData = yield* requestFromService(`/${this.config.databaseName}/$batch`, { ...options, method: "POST", headers: { @@ -191,39 +195,13 @@ export class BatchBuilder[]> { body, }); - if (response.error) { - // Return BatchResult with all operations marked as failed - const errorCount = this.builders.length; - // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type - const results: BatchItemResult[] = this.builders.map((_, _i) => ({ - data: undefined, - error: response.error, - status: 0, - })); - - return { - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type - results: results as any, - successCount: 0, - errorCount, - truncated: false, - firstErrorIndex: 0, - }; - } - - // Extract the actual boundary from the response - // FileMaker uses its own boundary, not the one we sent - const firstLine = response.data.split("\r\n")[0] || response.data.split("\n")[0] || ""; + // Step 3: Parse multipart response + const firstLine = responseData.split("\r\n")[0] || responseData.split("\n")[0] || ""; const actualBoundary = firstLine.startsWith("--") ? firstLine.substring(2) : boundary; - - // Parse the multipart response const contentTypeHeader = `multipart/mixed; boundary=${actualBoundary}`; - const parsedResponses = parseBatchResponse(response.data, contentTypeHeader); - - // Process each response using the corresponding builder - // Build BatchResult with per-item results - type _ResultTuple = ExtractTupleTypes; + const parsedResponses = parseBatchResponse(responseData, contentTypeHeader); + // Step 4: Process each response using the corresponding builder // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type const results: BatchItemResult[] = []; let successCount = 0; @@ -231,13 +209,11 @@ export class BatchBuilder[]> { let firstErrorIndex: number | null = null; const truncated = parsedResponses.length < this.builders.length; - // Process builders sequentially to preserve tuple order for (let i = 0; i < this.builders.length; i++) { const builder = this.builders[i]; const parsed = parsedResponses[i]; if (!parsed) { - // Truncated - operation never executed const failedAtIndex = firstErrorIndex ?? i; results.push({ data: undefined, @@ -249,7 +225,6 @@ export class BatchBuilder[]> { } if (!builder) { - // Should not happen, but handle gracefully results.push({ data: undefined, error: { @@ -267,28 +242,20 @@ export class BatchBuilder[]> { continue; } - // Convert parsed response to native Response const nativeResponse = parsedToResponse(parsed); - - // Let the builder process its own response - const result = await builder.processResponse(nativeResponse, options); + const result = yield* Effect.tryPromise({ + try: () => builder.processResponse(nativeResponse, options), + catch: (e) => e as FMODataErrorType, + }); if (result.error) { - results.push({ - data: undefined, - error: result.error, - status: parsed.status, - }); + results.push({ data: undefined, error: result.error, status: parsed.status }); errorCount++; if (firstErrorIndex === null) { firstErrorIndex = i; } } else { - results.push({ - data: result.data, - error: undefined, - status: parsed.status, - }); + results.push({ data: result.data, error: undefined, status: parsed.status }); successCount++; } } @@ -300,30 +267,14 @@ export class BatchBuilder[]> { errorCount, truncated, firstErrorIndex, - }; - } catch (err) { - // On exception, return a BatchResult with all operations marked as failed - const errorCount = this.builders.length; - // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type - const results: BatchItemResult[] = this.builders.map((_, _i) => ({ - data: undefined, - error: { - name: "BatchError", - message: err instanceof Error ? err.message : "Unknown error", - timestamp: new Date(), - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object - } as any, - status: 0, - })); + } as BatchResult>; + }); - return { - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type - results: results as any, - successCount: 0, - errorCount, - truncated: false, - firstErrorIndex: 0, - }; + // For batch, errors at the transport level fail all operations + const result = await runLayerResult(this.layer, pipeline, "fmodata.batch"); + if (result.error) { + return this.failAllResults(result.error); } + return result.data; } } diff --git a/packages/fmodata/src/client/builders/expand-builder.ts b/packages/fmodata/src/client/builders/expand-builder.ts index cce196c5..d89a4b6c 100644 --- a/packages/fmodata/src/client/builders/expand-builder.ts +++ b/packages/fmodata/src/client/builders/expand-builder.ts @@ -123,6 +123,15 @@ export class ExpandBuilder { ...(configuredBuilder as any).queryOptions, }; + // QueryBuilder stores typed filter expressions separately from queryOptions + // and serializes later. For nested expands, serialize immediately so + // where(eq(...)) inside expand callbacks is preserved. + // biome-ignore lint/suspicious/noExplicitAny: Internal builder state access + const filterExpression = (configuredBuilder as any).readState?.filterExpression; + if (filterExpression && !expandOptions.filter && typeof filterExpression.toODataFilter === "function") { + expandOptions.filter = filterExpression.toODataFilter(this.useEntityIds); + } + // If callback didn't provide select, apply defaultSelect from target table if (!expandOptions.select) { const defaultFields = getDefaultSelectFields(targetTable); @@ -220,10 +229,14 @@ export class ExpandBuilder { } if (opts.filter) { - const filterQuery = buildQuery({ filter: opts.filter }); - const match = filterQuery.match(FILTER_QUERY_REGEX); - if (match) { - parts.push(`$filter=${match[1]}`); + if (typeof opts.filter === "string") { + parts.push(`$filter=${opts.filter}`); + } else { + const filterQuery = buildQuery({ filter: opts.filter }); + const match = filterQuery.match(FILTER_QUERY_REGEX); + if (match) { + parts.push(`$filter=${match[1]}`); + } } } diff --git a/packages/fmodata/src/client/builders/index.ts b/packages/fmodata/src/client/builders/index.ts index 982da52f..d47b8a51 100644 --- a/packages/fmodata/src/client/builders/index.ts +++ b/packages/fmodata/src/client/builders/index.ts @@ -3,7 +3,9 @@ export * from "./default-select"; export * from "./expand-builder"; +export * from "./mutation-helpers"; export * from "./query-string-builder"; +export * from "./read-builder-state"; export * from "./response-processor"; export * from "./select-mixin"; export * from "./select-utils"; diff --git a/packages/fmodata/src/client/builders/mutation-helpers.ts b/packages/fmodata/src/client/builders/mutation-helpers.ts new file mode 100644 index 00000000..b1572705 --- /dev/null +++ b/packages/fmodata/src/client/builders/mutation-helpers.ts @@ -0,0 +1,144 @@ +import type { FFetchOptions } from "@fetchkit/ffetch"; +import { BuilderInvariantError, InvalidLocationHeaderError } from "../../errors"; +import type { FMTable } from "../../orm/table"; +import { getTableName } from "../../orm/table"; +import type { ExecuteOptions } from "../../types"; +import { resolveTableId } from "./table-utils"; + +const ROWID_MATCH_REGEX = /ROWID=(\d+)/; +const PAREN_VALUE_REGEX = /\(['"]?([^'"]+)['"]?\)/; + +type MutationMode = "byId" | "byFilter"; + +export interface FilterQueryBuilder { + getQueryString(options?: { useEntityIds?: boolean }): string; +} + +export function mergeMutationExecuteOptions( + options: (RequestInit & FFetchOptions & ExecuteOptions) | undefined, + databaseUseEntityIds: boolean, +): RequestInit & FFetchOptions & { useEntityIds?: boolean } { + return { + ...options, + useEntityIds: options?.useEntityIds ?? databaseUseEntityIds, + }; +} + +export function resolveMutationTableId( + // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration + table: FMTable | undefined, + useEntityIds: boolean, + builderName: string, +): string { + if (!table) { + throw new BuilderInvariantError(builderName, "table occurrence is required"); + } + return resolveTableId(table, getTableName(table), useEntityIds); +} + +export function buildMutationUrl(config: { + databaseName: string; + tableId: string; + tableName: string; + mode: MutationMode; + recordId?: string | number; + queryBuilder?: FilterQueryBuilder; + useEntityIds?: boolean; + builderName: string; +}): string { + const { databaseName, tableId, tableName, mode, recordId, queryBuilder, useEntityIds, builderName } = config; + + if (mode === "byId") { + if (recordId === undefined || recordId === null || recordId === "") { + throw new BuilderInvariantError(builderName, "recordId is required for byId mode"); + } + return `/${databaseName}/${tableId}('${recordId}')`; + } + + if (!queryBuilder) { + throw new BuilderInvariantError(builderName, "query builder is required for filter mode"); + } + + const queryString = queryBuilder.getQueryString({ useEntityIds }); + const queryParams = stripTablePathPrefix(queryString, tableId, tableName); + return `/${databaseName}/${tableId}${queryParams}`; +} + +export function stripTablePathPrefix(queryString: string, tableId: string, tableName: string): string { + if (queryString.startsWith(`/${tableId}`)) { + return queryString.slice(`/${tableId}`.length); + } + if (queryString.startsWith(`/${tableName}`)) { + return queryString.slice(`/${tableName}`.length); + } + return queryString; +} + +export function extractAffectedRows( + response: unknown, + headers?: Pick, + fallback = 0, + countKey?: "updatedCount" | "deletedCount", +): number { + const headerValue = headers?.get("fmodata.affected_rows"); + if (headerValue) { + const parsed = Number.parseInt(headerValue, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + if (typeof response === "number") { + return response; + } + + if (response && typeof response === "object") { + if (countKey && countKey in response) { + const count = Number((response as Record)[countKey]); + if (!Number.isNaN(count)) { + return count; + } + } + + const affected = Number((response as Record)["fmodata.affected_rows"]); + if (!Number.isNaN(affected)) { + return affected; + } + } + + return fallback; +} + +/** + * Parse ROWID from Location header. + * Expected formats: + * - contacts(ROWID=4583) + * - contacts('4583') + */ +export function parseRowIdFromLocationHeader(locationHeader: string | undefined): number { + if (!locationHeader) { + throw new InvalidLocationHeaderError("Location header is required but was not provided"); + } + + const rowidMatch = locationHeader.match(ROWID_MATCH_REGEX); + if (rowidMatch?.[1]) { + return Number.parseInt(rowidMatch[1], 10); + } + + const parenMatch = locationHeader.match(PAREN_VALUE_REGEX); + if (parenMatch?.[1]) { + const value = Number.parseInt(parenMatch[1], 10); + if (!Number.isNaN(value)) { + return value; + } + } + + throw new InvalidLocationHeaderError( + `Could not extract ROWID from Location header: ${locationHeader}`, + locationHeader, + ); +} + +export function getLocationHeader(headers: Pick): string | undefined { + return headers.get("Location") || headers.get("location") || undefined; +} diff --git a/packages/fmodata/src/client/builders/read-builder-state.ts b/packages/fmodata/src/client/builders/read-builder-state.ts new file mode 100644 index 00000000..c417adf0 --- /dev/null +++ b/packages/fmodata/src/client/builders/read-builder-state.ts @@ -0,0 +1,84 @@ +import type { QueryOptions } from "odata-query"; +import type { FilterExpression } from "../../orm/operators"; +import type { SystemColumnsOption } from "../query/types"; +import type { NavigationConfig } from "../query/url-builder"; +import type { ExpandConfig } from "./shared-types"; + +export interface QueryReadBuilderState { + queryOptions: Partial>; + filterExpression?: FilterExpression; + expandConfigs: ExpandConfig[]; + singleMode: "exact" | "maybe" | false; + isCountMode: boolean; + fieldMapping?: Record; + systemColumns?: SystemColumnsOption; + navigation?: NavigationConfig; +} + +export function createInitialQueryReadBuilderState(): QueryReadBuilderState { + return { + queryOptions: {}, + expandConfigs: [], + singleMode: false, + isCountMode: false, + }; +} + +export function cloneQueryReadBuilderState( + state: QueryReadBuilderState, + changes?: Partial> & { + queryOptions?: Partial>; + }, +): QueryReadBuilderState { + let fieldMapping = state.fieldMapping ? { ...state.fieldMapping } : undefined; + if ("fieldMapping" in (changes ?? {})) { + fieldMapping = changes?.fieldMapping ? { ...changes.fieldMapping } : undefined; + } + + return { + ...state, + ...changes, + queryOptions: { + ...state.queryOptions, + ...(changes?.queryOptions ?? {}), + }, + expandConfigs: changes?.expandConfigs ? [...changes.expandConfigs] : [...state.expandConfigs], + fieldMapping, + }; +} + +export interface RecordReadBuilderState { + selectedFields?: string[]; + expandConfigs: ExpandConfig[]; + fieldMapping?: Record; + systemColumns?: SystemColumnsOption; +} + +export function createInitialRecordReadBuilderState(): RecordReadBuilderState { + return { + expandConfigs: [], + }; +} + +export function cloneRecordReadBuilderState( + state: RecordReadBuilderState, + changes?: Partial, +): RecordReadBuilderState { + let selectedFields = state.selectedFields ? [...state.selectedFields] : undefined; + if ("selectedFields" in (changes ?? {})) { + selectedFields = changes?.selectedFields ? [...changes.selectedFields] : undefined; + } + + let fieldMapping = state.fieldMapping ? { ...state.fieldMapping } : undefined; + if ("fieldMapping" in (changes ?? {})) { + fieldMapping = changes?.fieldMapping ? { ...changes.fieldMapping } : undefined; + } + + return { + ...state, + ...changes, + selectedFields, + expandConfigs: changes?.expandConfigs ? [...changes.expandConfigs] : [...state.expandConfigs], + fieldMapping, + }; +} diff --git a/packages/fmodata/src/client/builders/response-processor.ts b/packages/fmodata/src/client/builders/response-processor.ts index 8269d652..ca6f13de 100644 --- a/packages/fmodata/src/client/builders/response-processor.ts +++ b/packages/fmodata/src/client/builders/response-processor.ts @@ -277,3 +277,37 @@ export async function processQueryResponse( return processedResponse; } + +/** + * Processes record response by delegating to the canonical query processor. + * Record reads are query reads with singleMode fixed to "exact". + */ +export async function processRecordResponse( + // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API + response: any, + config: { + // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration + table?: FMTable; + selectedFields?: string[]; + expandConfigs: ExpandConfig[]; + skipValidation?: boolean; + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + fieldMapping?: Record; + logger: InternalLogger; + }, +): Promise> { + const result = await processQueryResponse(response, { + occurrence: config.table, + singleMode: "exact", + queryOptions: { select: config.selectedFields }, + expandConfigs: config.expandConfigs, + skipValidation: config.skipValidation, + useEntityIds: config.useEntityIds, + includeSpecialColumns: config.includeSpecialColumns, + fieldMapping: config.fieldMapping, + logger: config.logger, + }); + + return result as Result; +} diff --git a/packages/fmodata/src/client/builders/shared-types.ts b/packages/fmodata/src/client/builders/shared-types.ts index 0b9f1085..9c6bceec 100644 --- a/packages/fmodata/src/client/builders/shared-types.ts +++ b/packages/fmodata/src/client/builders/shared-types.ts @@ -1,6 +1,5 @@ import type { QueryOptions } from "odata-query"; import type { FMTable } from "../../orm/table"; -import type { ExecutionContext } from "../../types"; /** * Expand configuration used by both QueryBuilder and RecordBuilder @@ -31,15 +30,3 @@ export interface NavigationContext { navigateBaseRelation?: string; navigateBasePath?: string; } - -/** - * Common builder configuration - */ -// biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration -export interface BuilderConfig | undefined> { - occurrence?: Occ; - tableName: string; - databaseName: string; - context: ExecutionContext; - databaseUseEntityIds?: boolean; -} diff --git a/packages/fmodata/src/client/builders/table-utils.ts b/packages/fmodata/src/client/builders/table-utils.ts index ba1e08e6..aedc86c7 100644 --- a/packages/fmodata/src/client/builders/table-utils.ts +++ b/packages/fmodata/src/client/builders/table-utils.ts @@ -1,7 +1,7 @@ import type { FFetchOptions } from "@fetchkit/ffetch"; import type { FMTable } from "../../orm/table"; import { getTableId as getTableIdHelper, getTableName, isUsingEntityIds } from "../../orm/table"; -import type { ExecuteOptions, ExecutionContext } from "../../types"; +import type { ExecuteOptions } from "../../types"; import { getAcceptHeader } from "../../types"; /** @@ -12,17 +12,13 @@ export function resolveTableId( // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration table: FMTable | undefined, fallbackTableName: string, - context: ExecutionContext, - useEntityIdsOverride?: boolean, + useEntityIds: boolean, ): string { if (!table) { return fallbackTableName; } - const contextDefault = context._getUseEntityIds?.() ?? false; - const shouldUseIds = useEntityIdsOverride ?? contextDefault; - - if (shouldUseIds) { + if (useEntityIds) { if (!isUsingEntityIds(table)) { throw new Error(`useEntityIds is true but table "${getTableName(table)}" does not have entity IDs configured`); } diff --git a/packages/fmodata/src/client/database.ts b/packages/fmodata/src/client/database.ts index 70e6e41d..64adafed 100644 --- a/packages/fmodata/src/client/database.ts +++ b/packages/fmodata/src/client/database.ts @@ -1,6 +1,9 @@ import type { FFetchOptions } from "@fetchkit/ffetch"; import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { requestFromService, runLayerOrThrow, runLayerResult } from "../effect"; +import { BuilderInvariantError, MetadataNotFoundError, SchemaValidationFailedError } from "../errors"; 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 { EntitySet } from "./entity-set"; @@ -26,9 +29,10 @@ export class Database { readonly schema: SchemaManager; readonly webhook: WebhookManager; private readonly databaseName: string; - private readonly context: ExecutionContext; private readonly _useEntityIds: boolean; private readonly _includeSpecialColumns: IncludeSpecialColumns; + /** @internal Database-scoped Effect Layer for dependency injection */ + readonly _layer: FMODataLayer; constructor( databaseName: string, @@ -48,12 +52,27 @@ export class Database { }, ) { this.databaseName = databaseName; - this.context = context; - // Initialize schema manager - this.schema = new SchemaManager(this.databaseName, this.context); - this.webhook = new WebhookManager(this.databaseName, this.context); this._useEntityIds = config?.useEntityIds ?? false; this._includeSpecialColumns = (config?.includeSpecialColumns ?? false) as IncludeSpecialColumns; + + // Create database-scoped layer from connection's base layer + const baseLayer = context._getLayer?.(); + if (baseLayer) { + this._layer = createDatabaseLayer(baseLayer, { + databaseName: this.databaseName, + useEntityIds: this._useEntityIds, + includeSpecialColumns: this._includeSpecialColumns, + }); + } else { + throw new BuilderInvariantError( + "Database", + "ExecutionContext must implement _getLayer() for dependency injection", + ); + } + + // Initialize schema and webhook managers with the database layer + this.schema = new SchemaManager(this._layer); + this.webhook = new WebhookManager(this._layer); } /** @@ -79,10 +98,11 @@ export class Database { /** * @internal Used by adapter packages for raw OData requests. - * Delegates to the connection's _makeRequest with the database name prepended. + * Makes requests through the Effect DI layer. */ _makeRequest(path: string, options?: RequestInit & FFetchOptions): Promise> { - return this.context._makeRequest(`/${this.databaseName}${path}`, options); + const pipeline = requestFromService(`/${this.databaseName}${path}`, options); + return runLayerResult(this._layer, pipeline); } // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration @@ -96,12 +116,21 @@ export class Database { useEntityIds = tableUseEntityIds; } } + + // If table overrides useEntityIds, create a new layer with the override + const layer = + useEntityIds !== this._useEntityIds + ? createDatabaseLayer(this._layer, { + databaseName: this.databaseName, + useEntityIds, + includeSpecialColumns: this._includeSpecialColumns, + }) + : this._layer; + return new EntitySet({ occurrence: table as T, - databaseName: this.databaseName, - context: this.context, + layer, database: this, - useEntityIds, }); } @@ -132,21 +161,17 @@ export class Database { headers.Prefer = 'include-annotations="-*"'; } - const result = await this.context._makeRequest | string>(url, { - headers, - }); - if (result.error) { - throw result.error; - } + const pipeline = requestFromService | string>(url, { headers }); + const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.metadata"); if (args?.format === "xml") { - return result.data as string; + return data as string; } - const data = result.data as Record; - const metadata = data[this.databaseName] ?? data[this.databaseName.replace(FMP12_EXT_REGEX, "")]; + const metadataMap = data as Record; + const metadata = metadataMap[this.databaseName] ?? metadataMap[this.databaseName.replace(FMP12_EXT_REGEX, "")]; if (!metadata) { - throw new Error(`Metadata for database "${this.databaseName}" not found in response`); + throw new MetadataNotFoundError(this.databaseName); } return metadata; } @@ -156,14 +181,13 @@ export class Database { * @returns Promise resolving to an array of table names */ async listTableNames(): Promise { - const result = await this.context._makeRequest<{ + const pipeline = requestFromService<{ value?: Array<{ name: string }>; }>(`/${this.databaseName}`); - if (result.error) { - throw result.error; - } - if (result.data.value && Array.isArray(result.data.value)) { - return result.data.value.map((item) => item.name); + + const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.listTableNames"); + if (data.value && Array.isArray(data.value)) { + return data.value.map((item) => item.name); } return []; } @@ -194,7 +218,7 @@ export class Database { body.scriptParameterValue = options.scriptParam; } - const result = await this.context._makeRequest<{ + const pipeline = requestFromService<{ scriptResult: { code: number; resultParameter?: string; @@ -204,25 +228,23 @@ export class Database { body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined, }); - if (result.error) { - throw result.error; - } - - const response = result.data; + const response = await runLayerOrThrow(this._layer, pipeline, "fmodata.runScript"); // If resultSchema is provided, validate the result through it if (options?.resultSchema && response.scriptResult !== undefined) { const validationResult = options.resultSchema["~standard"].validate(response.scriptResult.resultParameter); // Handle both sync and async validation - const result = validationResult instanceof Promise ? await validationResult : validationResult; + const validated = validationResult instanceof Promise ? await validationResult : validationResult; - if (result.issues) { - throw new Error(`Script result validation failed: ${JSON.stringify(result.issues)}`); + if (validated.issues) { + throw new SchemaValidationFailedError("Database.runScript", JSON.stringify(validated.issues), { + issues: validated.issues, + }); } return { resultCode: response.scriptResult.code, - result: result.value, + result: validated.value, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type } as any; } @@ -255,6 +277,6 @@ export class Database { */ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExecutableBuilder result type batch[]>(builders: Builders): BatchBuilder { - return new BatchBuilder(builders, this.databaseName, this.context); + return new BatchBuilder(builders, this._layer); } } diff --git a/packages/fmodata/src/client/delete-builder.ts b/packages/fmodata/src/client/delete-builder.ts index e312b0fc..8da749b4 100644 --- a/packages/fmodata/src/client/delete-builder.ts +++ b/packages/fmodata/src/client/delete-builder.ts @@ -1,10 +1,19 @@ -import type { FFetchOptions } from "@fetchkit/ffetch"; +import { Effect } from "effect"; +import { requestFromService, runLayerResult } from "../effect"; import type { FMTable } from "../orm/table"; -import { getTableId as getTableIdHelper, getTableName, isUsingEntityIds } from "../orm/table"; -import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, ExecutionContext, Result } from "../types"; +import { getTableName } from "../orm/table"; +import type { FMODataLayer, ODataConfig } from "../services"; +import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, Result } from "../types"; import { getAcceptHeader } from "../types"; +import { + buildMutationUrl, + extractAffectedRows, + mergeMutationExecuteOptions, + resolveMutationTableId, +} from "./builders/mutation-helpers"; import { parseErrorResponse } from "./error-parser"; import { QueryBuilder } from "./query-builder"; +import { createClientRuntime } from "./runtime"; /** * Initial delete builder returned from EntitySet.delete() @@ -12,24 +21,18 @@ import { QueryBuilder } from "./query-builder"; */ // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration export class DeleteBuilder> { - private readonly databaseName: string; - private readonly context: ExecutionContext; private readonly table: Occ; - private readonly databaseUseEntityIds: boolean; - private readonly databaseIncludeSpecialColumns: boolean; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; constructor(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; - databaseUseEntityIds?: boolean; - databaseIncludeSpecialColumns?: boolean; + layer: FMODataLayer; }) { this.table = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; - this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; - this.databaseIncludeSpecialColumns = config.databaseIncludeSpecialColumns ?? false; + const runtime = createClientRuntime(config.layer); + this.layer = runtime.layer; + this.config = runtime.config; } /** @@ -38,11 +41,9 @@ export class DeleteBuilder> { byId(id: string | number): ExecutableDeleteBuilder { return new ExecutableDeleteBuilder({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, mode: "byId", recordId: id, - databaseUseEntityIds: this.databaseUseEntityIds, }); } @@ -54,8 +55,7 @@ export class DeleteBuilder> { // Create a QueryBuilder for the user to configure const queryBuilder = new QueryBuilder({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, }); // Let the user configure it @@ -63,11 +63,9 @@ export class DeleteBuilder> { return new ExecutableDeleteBuilder({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, mode: "byFilter", queryBuilder: configuredBuilder, - databaseUseEntityIds: this.databaseUseEntityIds, }); } } @@ -80,154 +78,74 @@ export class DeleteBuilder> { export class ExecutableDeleteBuilder> implements ExecutableBuilder<{ deletedCount: number }> { - private readonly databaseName: string; - private readonly context: ExecutionContext; private readonly table: Occ; private readonly mode: "byId" | "byFilter"; private readonly recordId?: string | number; private readonly queryBuilder?: QueryBuilder; - private readonly databaseUseEntityIds: boolean; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; constructor(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; + layer: FMODataLayer; mode: "byId" | "byFilter"; recordId?: string | number; queryBuilder?: QueryBuilder; - databaseUseEntityIds?: boolean; }) { this.table = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; + this.layer = config.layer; this.mode = config.mode; this.recordId = config.recordId; this.queryBuilder = config.queryBuilder; - this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; + this.config = createClientRuntime(this.layer).config; } - /** - * Helper to merge database-level useEntityIds with per-request options - */ - private mergeExecuteOptions( - options?: RequestInit & FFetchOptions & ExecuteOptions, - ): RequestInit & FFetchOptions & { useEntityIds?: boolean } { - // If useEntityIds is not set in options, use the database-level setting - return { - ...options, - useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds, - }; - } - - /** - * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name - * @param useEntityIds - Optional override for entity ID usage - */ - private getTableId(useEntityIds?: boolean): string { - const contextDefault = this.context._getUseEntityIds?.() ?? false; - const shouldUseIds = useEntityIds ?? contextDefault; - - if (shouldUseIds) { - if (!isUsingEntityIds(this.table)) { - throw new Error( - `useEntityIds is true but table "${getTableName(this.table)}" does not have entity IDs configured`, - ); - } - return getTableIdHelper(this.table); - } - - return getTableName(this.table); - } - - async execute(options?: ExecuteMethodOptions): Promise> { - // Merge database-level useEntityIds with per-request options - const mergedOptions = this.mergeExecuteOptions(options); - - // Get table identifier with override support - const tableId = this.getTableId(mergedOptions.useEntityIds); - - let url: string; - - if (this.mode === "byId") { - // Delete single record by ID: DELETE /{database}/{table}('id') - url = `/${this.databaseName}/${tableId}('${this.recordId}')`; - } else { - // Delete by filter: DELETE /{database}/{table}?$filter=... - if (!this.queryBuilder) { - throw new Error("Query builder is required for filter-based delete"); - } - - // Get the query string from the configured QueryBuilder - const queryString = this.queryBuilder.getQueryString(); - // Remove the leading "/" and table name from the query string as we'll build our own URL - const tableName = getTableName(this.table); - let queryParams: string; - if (queryString.startsWith(`/${tableId}`)) { - queryParams = queryString.slice(`/${tableId}`.length); - } else if (queryString.startsWith(`/${tableName}`)) { - queryParams = queryString.slice(`/${tableName}`.length); - } else { - queryParams = queryString; - } - - url = `/${this.databaseName}/${tableId}${queryParams}`; - } - - // Make DELETE request - const result = await this.context._makeRequest(url, { - method: "DELETE", - ...mergedOptions, + execute(options?: ExecuteMethodOptions): Promise> { + const mergedOptions = mergeMutationExecuteOptions(options, this.config.useEntityIds); + // biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields + const { method: _method, body: _body, ...requestOptions } = mergedOptions as any; + const useEntityIds = mergedOptions.useEntityIds ?? this.config.useEntityIds; + const tableId = resolveMutationTableId(this.table, useEntityIds, "ExecutableDeleteBuilder"); + const url = buildMutationUrl({ + databaseName: this.config.databaseName, + tableId, + tableName: getTableName(this.table), + mode: this.mode, + recordId: this.recordId, + queryBuilder: this.queryBuilder, + useEntityIds, + builderName: "ExecutableDeleteBuilder", }); - if (result.error) { - return { data: undefined, error: result.error }; - } - - const response = result.data; + const pipeline = Effect.gen(this, function* () { + // Make DELETE request via DI + const response = yield* requestFromService(url, { + ...requestOptions, + method: "DELETE", + }); - // OData returns 204 No Content with fmodata.affected_rows header - // The _makeRequest should handle extracting the header value - // For now, we'll check if response contains the count - let deletedCount = 0; - - if (typeof response === "number") { - deletedCount = response; - } else if (response && typeof response === "object") { - // Check if the response has a count property (fallback) - // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - deletedCount = (response as any).deletedCount || 0; - } + const deletedCount = extractAffectedRows(response, undefined, 0, "deletedCount"); + return { deletedCount }; + }); - return { data: { deletedCount }, error: undefined }; + return runLayerResult(this.layer, pipeline, "fmodata.delete", { + "fmodata.table": getTableName(this.table), + }); } // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { - // For batch operations, use database-level setting (no per-request override available here) - const tableId = this.getTableId(this.databaseUseEntityIds); - - let url: string; - - if (this.mode === "byId") { - url = `/${this.databaseName}/${tableId}('${this.recordId}')`; - } else { - if (!this.queryBuilder) { - throw new Error("Query builder is required for filter-based delete"); - } - - const queryString = this.queryBuilder.getQueryString(); - const tableName = getTableName(this.table); - let queryParams: string; - if (queryString.startsWith(`/${tableId}`)) { - queryParams = queryString.slice(`/${tableId}`.length); - } else if (queryString.startsWith(`/${tableName}`)) { - queryParams = queryString.slice(`/${tableName}`.length); - } else { - queryParams = queryString; - } - - url = `/${this.databaseName}/${tableId}${queryParams}`; - } + const tableId = resolveMutationTableId(this.table, this.config.useEntityIds, "ExecutableDeleteBuilder"); + const url = buildMutationUrl({ + databaseName: this.config.databaseName, + tableId, + tableName: getTableName(this.table), + mode: this.mode, + recordId: this.recordId, + queryBuilder: this.queryBuilder, + useEntityIds: this.config.useEntityIds, + builderName: "ExecutableDeleteBuilder", + }); return { method: "DELETE", @@ -251,33 +169,20 @@ export class ExecutableDeleteBuilder> // Check for error responses (important for batch operations) if (!response.ok) { const tableName = getTableName(this.table); - const error = await parseErrorResponse(response, response.url || `/${this.databaseName}/${tableName}`); + const error = await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`); return { data: undefined, error }; } // Check for empty response (204 No Content) const text = await response.text(); if (!text || text.trim() === "") { - // For 204 No Content, check the fmodata.affected_rows header - const affectedRows = response.headers.get("fmodata.affected_rows"); - const deletedCount = affectedRows ? Number.parseInt(affectedRows, 10) : 1; + const deletedCount = extractAffectedRows(undefined, response.headers, 1, "deletedCount"); return { data: { deletedCount }, error: undefined }; } const rawResponse = JSON.parse(text); - // OData returns 204 No Content with fmodata.affected_rows header - // The _makeRequest should handle extracting the header value - // For now, we'll check if response contains the count - let deletedCount = 0; - - if (typeof rawResponse === "number") { - deletedCount = rawResponse; - } else if (rawResponse && typeof rawResponse === "object") { - // Check if the response has a count property (fallback) - // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - deletedCount = (rawResponse as any).deletedCount || 0; - } + const deletedCount = extractAffectedRows(rawResponse, response.headers, 0, "deletedCount"); return { data: { deletedCount }, error: undefined }; } diff --git a/packages/fmodata/src/client/entity-set.ts b/packages/fmodata/src/client/entity-set.ts index 727755b1..40d0e6ca 100644 --- a/packages/fmodata/src/client/entity-set.ts +++ b/packages/fmodata/src/client/entity-set.ts @@ -1,4 +1,4 @@ -import { createLogger, type InternalLogger } from "../logger"; +import type { InternalLogger } from "../logger"; import type { FieldBuilder } from "../orm/field-builders"; import type { ColumnMap, @@ -8,14 +8,22 @@ import type { UpdateDataFromFMTable, ValidExpandTarget, } from "../orm/table"; -import { FMTable as FMTableClass, getDefaultSelect, getTableColumns, getTableName, getTableSchema } from "../orm/table"; -import type { ExecutionContext } from "../types"; +import { + FMTable as FMTableClass, + getDefaultSelect, + getTableColumns, + getTableName, + getTableSchema, + isUsingEntityIds, +} from "../orm/table"; +import type { FMODataLayer, ODataConfig } from "../services"; import { resolveTableId } from "./builders/table-utils"; import type { Database } from "./database"; import { DeleteBuilder } from "./delete-builder"; import { InsertBuilder } from "./insert-builder"; import { QueryBuilder } from "./query/index"; import { RecordBuilder } from "./record-builder"; +import { createClientRuntime } from "./runtime"; import { UpdateBuilder } from "./update-builder"; // Helper type to extract defaultSelect from an FMTable @@ -41,53 +49,62 @@ type ExtractColumnsFromOcc = // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration export class EntitySet, DatabaseIncludeSpecialColumns extends boolean = false> { private readonly occurrence: Occ; - private readonly databaseName: string; - private readonly context: ExecutionContext; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; + private readonly logger: InternalLogger; private readonly database: Database; // Database instance for accessing occurrences private readonly isNavigateFromEntitySet?: boolean; private readonly navigateRelation?: string; + private readonly navigateRelationEntityId?: string; private readonly navigateSourceTableName?: string; + private readonly navigateSourceTableEntityId?: string; private readonly navigateBasePath?: string; // Full base path for chained navigations - private readonly databaseUseEntityIds: boolean; - private readonly databaseIncludeSpecialColumns: DatabaseIncludeSpecialColumns; - private readonly logger: InternalLogger; + private readonly navigateBasePathEntityId?: string; constructor(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; + layer: FMODataLayer; // biome-ignore lint/suspicious/noExplicitAny: Database type is optional and can be any Database instance database?: any; - useEntityIds?: boolean; }) { this.occurrence = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; this.database = config.database; - // Use explicit useEntityIds if provided, otherwise fall back to database setting - this.databaseUseEntityIds = config.useEntityIds ?? config.database?._getUseEntityIds ?? false; - // Get includeSpecialColumns from database if available, otherwise default to false - this.databaseIncludeSpecialColumns = (config.database?._getIncludeSpecialColumns ?? - false) as DatabaseIncludeSpecialColumns; - this.logger = config.context?._getLogger?.() ?? createLogger(); + // Extract config and logger from the layer for sync access + const runtime = createClientRuntime(config.layer); + this.layer = runtime.layer; + this.config = runtime.config; + this.logger = runtime.logger; } // Type-only method to help TypeScript infer the schema from table // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration static create, DatabaseIncludeSpecialColumns extends boolean = false>(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; + layer: FMODataLayer; database: Database; }): EntitySet { return new EntitySet({ occurrence: config.occurrence, - databaseName: config.databaseName, - context: config.context, + layer: config.layer, database: config.database, }); } + private applyNavigationContext(builder: T): T { + if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (builder as any).navigation = { + relation: this.navigateRelation, + relationEntityId: this.navigateRelationEntityId, + sourceTableName: this.navigateSourceTableName, + sourceTableEntityId: this.navigateSourceTableEntityId, + basePath: this.navigateBasePath, + basePathEntityId: this.navigateBasePathEntityId, + }; + } + return builder; + } + // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default list(): QueryBuilder, false, false, {}, DatabaseIncludeSpecialColumns> { const builder = new QueryBuilder< @@ -100,10 +117,7 @@ export class EntitySet, DatabaseIncludeSpecialColu DatabaseIncludeSpecialColumns >({ occurrence: this.occurrence as Occ, - databaseName: this.databaseName, - context: this.context, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, + layer: this.layer, }); // Apply defaultSelect if occurrence exists and select hasn't been called @@ -119,19 +133,11 @@ export class EntitySet, DatabaseIncludeSpecialColu // Cast to the declared return type - runtime behavior handles the actual selection const allColumns = getTableColumns(this.occurrence) as ExtractColumnsFromOcc; - // Include special columns if enabled at database level - const systemColumns = this.databaseIncludeSpecialColumns ? { ROWID: true, ROWMODID: true } : undefined; - - const selectedBuilder = builder.select(allColumns, systemColumns).top(1000); - // Propagate navigation context if present - if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { - // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (selectedBuilder as any).navigation = { - relation: this.navigateRelation, - sourceTableName: this.navigateSourceTableName, - basePath: this.navigateBasePath, - }; - } + const selectedBuilder = this.applyNavigationContext( + this.config.includeSpecialColumns + ? builder.select(allColumns, { ROWID: true, ROWMODID: true }) + : builder.select(allColumns), + ).top(1000); return selectedBuilder as QueryBuilder< Occ, keyof InferSchemaOutputFromFMTable, @@ -139,23 +145,15 @@ export class EntitySet, DatabaseIncludeSpecialColu false, // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default {}, - DatabaseIncludeSpecialColumns, - typeof systemColumns + DatabaseIncludeSpecialColumns >; } if (typeof defaultSelectValue === "object") { // defaultSelectValue is a select object (Record) // Cast to the declared return type - runtime behavior handles the actual selection - const selectedBuilder = builder.select(defaultSelectValue as ExtractColumnsFromOcc).top(1000); - // Propagate navigation context if present - if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { - // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (selectedBuilder as any).navigation = { - relation: this.navigateRelation, - sourceTableName: this.navigateSourceTableName, - basePath: this.navigateBasePath, - }; - } + const selectedBuilder = this.applyNavigationContext( + builder.select(defaultSelectValue as ExtractColumnsFromOcc), + ).top(1000); return selectedBuilder as QueryBuilder< Occ, keyof InferSchemaOutputFromFMTable, @@ -169,20 +167,9 @@ export class EntitySet, DatabaseIncludeSpecialColu // If defaultSelect is "all", no changes needed (current behavior) } - // Propagate navigation context if present - if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { - // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (builder as any).navigation = { - relation: this.navigateRelation, - sourceTableName: this.navigateSourceTableName, - basePath: this.navigateBasePath, - // recordId is intentionally not set (undefined) to indicate navigation from EntitySet - }; - } - // Apply default pagination limit of 1000 records to prevent stack overflow // with large datasets. Users can override with .top() if needed. - return builder.top(1000); + return this.applyNavigationContext(builder).top(1000); } get( @@ -199,11 +186,8 @@ export class EntitySet, DatabaseIncludeSpecialColu DatabaseIncludeSpecialColumns >({ occurrence: this.occurrence, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, recordId: id, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Apply defaultSelect if occurrence exists @@ -220,19 +204,11 @@ export class EntitySet, DatabaseIncludeSpecialColu // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter const allColumns = getTableColumns(this.occurrence as any) as ExtractColumnsFromOcc; - // Include special columns if enabled at database level - const systemColumns = this.databaseIncludeSpecialColumns ? { ROWID: true, ROWMODID: true } : undefined; - - const selectedBuilder = builder.select(allColumns, systemColumns); - // Propagate navigation context if present - if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { - // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (selectedBuilder as any).navigation = { - relation: this.navigateRelation, - sourceTableName: this.navigateSourceTableName, - basePath: this.navigateBasePath, - }; - } + const selectedBuilder = this.applyNavigationContext( + this.config.includeSpecialColumns + ? builder.select(allColumns, { ROWID: true, ROWMODID: true }) + : builder.select(allColumns), + ); // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type return selectedBuilder as any; } @@ -240,33 +216,17 @@ export class EntitySet, DatabaseIncludeSpecialColu // defaultSelectValue is a select object (Record) // Use it directly with select() // Use ExtractColumnsFromOcc to preserve the properly-typed column types - const selectedBuilder = builder.select(defaultSelectValue as ExtractColumnsFromOcc); - // Propagate navigation context if present - if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { - // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (selectedBuilder as any).navigation = { - relation: this.navigateRelation, - sourceTableName: this.navigateSourceTableName, - basePath: this.navigateBasePath, - }; - } + const selectedBuilder = this.applyNavigationContext( + builder.select(defaultSelectValue as ExtractColumnsFromOcc), + ); // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type return selectedBuilder as any; } // If defaultSelect is "all", no changes needed (current behavior) } - // Propagate navigation context if present - if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) { - // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (builder as any).navigation = { - relation: this.navigateRelation, - sourceTableName: this.navigateSourceTableName, - basePath: this.navigateBasePath, - }; - } // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type - return builder as any; + return this.applyNavigationContext(builder) as any; } // Overload: when returnFullRecord is false @@ -284,14 +244,11 @@ export class EntitySet, DatabaseIncludeSpecialColu return new InsertBuilder({ occurrence: this.occurrence, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, // biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime data: data as any, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter returnPreference: returnPreference as any, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); } @@ -310,24 +267,18 @@ export class EntitySet, DatabaseIncludeSpecialColu return new UpdateBuilder({ occurrence: this.occurrence, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, // biome-ignore lint/suspicious/noExplicitAny: Input type is validated/transformed at runtime data: data as any, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter returnPreference: returnPreference as any, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); } delete(): DeleteBuilder { return new DeleteBuilder({ occurrence: this.occurrence, - databaseName: this.databaseName, - context: this.context, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, + layer: this.layer, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type }) as any; } @@ -359,25 +310,25 @@ export class EntitySet, DatabaseIncludeSpecialColu // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any FMTable configuration const entitySet = new EntitySet({ occurrence: targetTable, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, database: this.database, - useEntityIds: this.databaseUseEntityIds, }); - // Resolve navigation names using entity IDs when appropriate - const resolvedRelation = resolveTableId(targetTable, relationName, this.context, this.databaseUseEntityIds); - const resolvedSourceName = resolveTableId( - this.occurrence, - getTableName(this.occurrence), - this.context, - this.databaseUseEntityIds, - ); + // Resolve entity IDs lazily at request time by storing both name and ID forms + const relationEntityId = isUsingEntityIds(targetTable) + ? resolveTableId(targetTable, relationName, true) + : relationName; + const sourceTableName = getTableName(this.occurrence); + const sourceTableEntityId = isUsingEntityIds(this.occurrence) + ? resolveTableId(this.occurrence, sourceTableName, true) + : sourceTableName; // Store the navigation info in the EntitySet // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern (entitySet as any).isNavigateFromEntitySet = true; // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (entitySet as any).navigateRelation = resolvedRelation; + (entitySet as any).navigateRelation = relationName; + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (entitySet as any).navigateRelationEntityId = relationEntityId; // Build the full base path for chained navigations if (this.isNavigateFromEntitySet && this.navigateBasePath) { @@ -385,17 +336,29 @@ export class EntitySet, DatabaseIncludeSpecialColu // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern (entitySet as any).navigateBasePath = `${this.navigateBasePath}/${this.navigateRelation}`; // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (entitySet as any).navigateBasePathEntityId = + `${this.navigateBasePathEntityId ?? this.navigateBasePath}/${this.navigateRelationEntityId ?? this.navigateRelation}`; + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern (entitySet as any).navigateSourceTableName = this.navigateSourceTableName; + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (entitySet as any).navigateSourceTableEntityId = this.navigateSourceTableEntityId; } else if (this.isNavigateFromEntitySet && this.navigateRelation) { // First chained navigation - create base path from source/relation // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern (entitySet as any).navigateBasePath = `${this.navigateSourceTableName}/${this.navigateRelation}`; // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (entitySet as any).navigateBasePathEntityId = + `${this.navigateSourceTableEntityId ?? this.navigateSourceTableName}/${this.navigateRelationEntityId ?? this.navigateRelation}`; + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern (entitySet as any).navigateSourceTableName = this.navigateSourceTableName; + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (entitySet as any).navigateSourceTableEntityId = this.navigateSourceTableEntityId; } else { // Initial navigation - source is just the table name (resolved to entity ID if needed) // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (entitySet as any).navigateSourceTableName = resolvedSourceName; + (entitySet as any).navigateSourceTableName = sourceTableName; + // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern + (entitySet as any).navigateSourceTableEntityId = sourceTableEntityId; } return entitySet; } diff --git a/packages/fmodata/src/client/filemaker-odata.ts b/packages/fmodata/src/client/filemaker-odata.ts index 45babb2e..ac722bf6 100644 --- a/packages/fmodata/src/client/filemaker-odata.ts +++ b/packages/fmodata/src/client/filemaker-odata.ts @@ -6,9 +6,13 @@ import createClient, { RetryLimitError, TimeoutError, } from "@fetchkit/ffetch"; +import { Effect, Layer } from "effect"; import { get } from "es-toolkit/compat"; +import { runAsResult, withRetryPolicy, withSpan } from "../effect"; +import type { FMODataErrorType } from "../errors"; import { HTTPError, ODataError, ResponseParseError, SchemaLockedError } from "../errors"; import { createLogger, type InternalLogger, type Logger } from "../logger"; +import { type FMODataLayer, HttpClient, ODataConfig, ODataLogger } from "../services"; import type { Auth, ExecutionContext, Result } from "../types"; import { getAcceptHeader } from "../types"; import { Database } from "./database"; @@ -98,15 +102,102 @@ export class FMServerConnection implements ExecutionContext { /** * @internal + * Returns the Effect Layer for this connection, composing HttpClient, ODataConfig, and ODataLogger services. */ - async _makeRequest( + _getLayer(): FMODataLayer { + const httpLayer = Layer.succeed(HttpClient, { + request: ( + url: string, + options?: RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean }, + ) => this._makeRequestEffect(url, options), + }); + + const configLayer = Layer.succeed(ODataConfig, { + baseUrl: this._getBaseUrl(), + databaseName: "", + useEntityIds: this.useEntityIds, + includeSpecialColumns: this.includeSpecialColumns, + }); + + const loggerLayer = Layer.succeed(ODataLogger, { + logger: this.logger, + }); + + return Layer.mergeAll(httpLayer, configLayer, loggerLayer); + } + + /** + * @internal + * Classifies a caught error into a typed FMODataErrorType. + */ + private _classifyError(err: unknown, fullUrl: string): FMODataErrorType { + if ( + err instanceof TimeoutError || + err instanceof AbortError || + err instanceof NetworkError || + err instanceof RetryLimitError || + err instanceof CircuitOpenError + ) { + return err; + } + if (err instanceof ResponseParseError) { + return err; + } + return new NetworkError(fullUrl, err); + } + + /** + * @internal + * Parses an HTTP error response into a typed FMODataErrorType. + */ + private _parseHttpError( + resp: Response, + fullUrl: string, + errorBody: { error?: { code?: string | number; message?: string } } | undefined, + ): FMODataErrorType { + if (errorBody?.error) { + const errorCode = errorBody.error.code; + const errorMessage = errorBody.error.message || resp.statusText; + if (errorCode === "303" || errorCode === 303) { + return new SchemaLockedError(fullUrl, errorMessage, errorBody.error); + } + return new ODataError(fullUrl, errorMessage, String(errorCode), errorBody.error); + } + return new HTTPError(fullUrl, resp.status, resp.statusText, errorBody); + } + + /** + * @internal + * Checks parsed JSON data for embedded OData errors. + */ + private _checkEmbeddedODataError( + data: T & { error?: { code?: string | number; message?: string } }, + fullUrl: string, + ): FMODataErrorType | undefined { + if (get(data, "error", null)) { + const errorCode = get(data, "error.code", null); + const errorMessage = String(get(data, "error.message", "Unknown OData error")); + if (errorCode === "303" || errorCode === 303) { + return new SchemaLockedError(fullUrl, errorMessage, data.error); + } + return new ODataError(fullUrl, errorMessage, String(errorCode), data.error); + } + return undefined; + } + + /** + * @internal + * Builds the Effect pipeline for an HTTP request. + * Each step in the pipeline is a discrete Effect, enabling composable error handling. + */ + private _makeRequestEffect( url: string, options?: RequestInit & FFetchOptions & { useEntityIds?: boolean; includeSpecialColumns?: boolean; }, - ): Promise> { + ): Effect.Effect { const logger = this._getLogger(); const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`; const fullUrl = baseUrl + url; @@ -152,121 +243,106 @@ export class FMServerConnection implements ExecutionContext { // Otherwise use the existing client const clientToUse = fetchHandler ? createClient({ retries: 0, fetchHandler }) : this.fetchClient; - try { - const finalOptions = { - ...restOptions, - headers, - }; - - const resp = await clientToUse(fullUrl, finalOptions); - logger.debug(`${finalOptions.method ?? "GET"} ${resp.status} ${fullUrl}`); - - // Handle HTTP errors - if (!resp.ok) { - // Try to parse error body if it's JSON - let errorBody: { error?: { code?: string | number; message?: string } } | undefined; - try { - if (resp.headers.get("content-type")?.includes("application/json")) { - errorBody = await safeJsonParse(resp); - } - } catch { - // Ignore JSON parse errors - } + const finalOptions = { + ...restOptions, + headers, + }; - // Check if it's an OData error response - if (errorBody?.error) { - const errorCode = errorBody.error.code; - const errorMessage = errorBody.error.message || resp.statusText; - - // Check for schema locked error (code 303) - if (errorCode === "303" || errorCode === 303) { - return { - data: undefined, - error: new SchemaLockedError(fullUrl, errorMessage, errorBody.error), - }; - } + // Step 1: Execute the HTTP request + const fetchEffect = Effect.tryPromise({ + try: () => clientToUse(fullUrl, finalOptions), + catch: (err) => this._classifyError(err, fullUrl), + }); - return { - data: undefined, - error: new ODataError(fullUrl, errorMessage, String(errorCode), errorBody.error), - }; + // Step 2: Process the response + const pipeline = fetchEffect.pipe( + Effect.tap((resp) => + Effect.sync(() => logger.debug(`${finalOptions.method ?? "GET"} ${resp.status} ${fullUrl}`)), + ), + Effect.flatMap((resp) => { + // Handle HTTP errors + if (!resp.ok) { + return Effect.tryPromise({ + try: async () => { + let errorBody: { error?: { code?: string | number; message?: string } } | undefined; + try { + if (resp.headers.get("content-type")?.includes("application/json")) { + errorBody = await safeJsonParse(resp); + } + } catch { + // Ignore JSON parse errors + } + return errorBody; + }, + catch: () => new HTTPError(fullUrl, resp.status, resp.statusText) as FMODataErrorType, + }).pipe(Effect.flatMap((errorBody) => Effect.fail(this._parseHttpError(resp, fullUrl, errorBody)))); } - return { - data: undefined, - error: new HTTPError(fullUrl, resp.status, resp.statusText, errorBody), - }; - } - - // Check for affected rows header (for DELETE and bulk PATCH operations) - // FileMaker may return this with 204 No Content or 200 OK - const affectedRows = resp.headers.get("fmodata.affected_rows"); - if (affectedRows !== null) { - return { data: Number.parseInt(affectedRows, 10) as T, error: undefined }; - } - - // Handle 204 No Content with no body - if (resp.status === 204) { - // Check for Location header (used for insert with return=minimal) - // Use optional chaining for safety with mocks that might not have proper headers - const locationHeader = resp.headers?.get?.("Location") || resp.headers?.get?.("location"); - if (locationHeader) { - // Return the location header so InsertBuilder can extract ROWID - return { data: { _location: locationHeader } as T, error: undefined }; + // Check for affected rows header (for DELETE and bulk PATCH operations) + const affectedRows = resp.headers.get("fmodata.affected_rows"); + if (affectedRows !== null) { + return Effect.succeed(Number.parseInt(affectedRows, 10) as T); } - return { data: 0 as T, error: undefined }; - } - // Parse response - if (resp.headers.get("content-type")?.includes("application/json")) { - const data = await safeJsonParse(resp); - - // Check for embedded OData errors - if (get(data, "error", null)) { - const errorCode = get(data, "error.code", null); - const errorMessage = get(data, "error.message", "Unknown OData error"); - - // Check for schema locked error (code 303) - if (errorCode === "303" || errorCode === 303) { - return { - data: undefined, - error: new SchemaLockedError(fullUrl, errorMessage, data.error), - }; + // Handle 204 No Content with no body + if (resp.status === 204) { + const locationHeader = resp.headers?.get?.("Location") || resp.headers?.get?.("location"); + if (locationHeader) { + return Effect.succeed({ _location: locationHeader } as T); } + return Effect.succeed(0 as T); + } - return { - data: undefined, - error: new ODataError(fullUrl, errorMessage, String(errorCode), data.error), - }; + // Parse JSON response + if (resp.headers.get("content-type")?.includes("application/json")) { + return Effect.tryPromise({ + try: () => safeJsonParse(resp), + catch: (err) => this._classifyError(err, fullUrl), + }).pipe( + Effect.flatMap((data) => { + const embeddedError = this._checkEmbeddedODataError(data, fullUrl); + if (embeddedError) { + return Effect.fail(embeddedError); + } + return Effect.succeed(data as T); + }), + ); } - return { data: data as T, error: undefined }; - } + // Plain text response + return Effect.tryPromise({ + try: () => resp.text(), + catch: (err) => this._classifyError(err, fullUrl), + }).pipe(Effect.map((text) => text as T)); + }), + ); - return { data: (await resp.text()) as T, error: undefined }; - } catch (err) { - // Map ffetch errors - return them directly (no re-wrapping) - if ( - err instanceof TimeoutError || - err instanceof AbortError || - err instanceof NetworkError || - err instanceof RetryLimitError || - err instanceof CircuitOpenError - ) { - return { data: undefined, error: err }; - } + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for optional property access + const retryPolicy = (options as any)?.retryPolicy; + const method = (finalOptions.method ?? "GET").toUpperCase(); + const isRetrySafeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS" || method === "PUT"; - // Handle JSON parse errors (ResponseParseError from safeJsonParse) - if (err instanceof ResponseParseError) { - return { data: undefined, error: err }; - } + const requestEffect = retryPolicy && isRetrySafeMethod ? withRetryPolicy(pipeline, retryPolicy) : pipeline; - // Unknown error - wrap it as NetworkError - return { - data: undefined, - error: new NetworkError(fullUrl, err), - }; - } + // Apply retry policy and tracing span + return withSpan(requestEffect, "fmodata.request", { + "fmodata.url": url, + "fmodata.method": method, + }); + } + + /** + * @internal + */ + _makeRequest( + url: string, + options?: RequestInit & + FFetchOptions & { + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + }, + ): Promise> { + return runAsResult(this._makeRequestEffect(url, options)); } database( diff --git a/packages/fmodata/src/client/insert-builder.ts b/packages/fmodata/src/client/insert-builder.ts index df07d19a..5d75291b 100644 --- a/packages/fmodata/src/client/insert-builder.ts +++ b/packages/fmodata/src/client/insert-builder.ts @@ -1,24 +1,31 @@ import type { FFetchOptions } from "@fetchkit/ffetch"; -import { InvalidLocationHeaderError } from "../errors"; +import { Effect } from "effect"; +import { fromValidation, requestFromService, runLayerResult, tryEffect } from "../effect"; +import type { FMODataErrorType } from "../errors"; +import { BuilderInvariantError, InvalidLocationHeaderError } from "../errors"; import type { FMTable } from "../orm/table"; -import { getBaseTableConfig, getTableId as getTableIdHelper, getTableName, isUsingEntityIds } from "../orm/table"; +import { getBaseTableConfig, getTableName } from "../orm/table"; +import type { FMODataLayer, ODataConfig } from "../services"; import { transformFieldNamesToIds, transformResponseFields } from "../transform"; import type { ConditionallyWithODataAnnotations, ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, - ExecutionContext, Result, } from "../types"; import { getAcceptHeader } from "../types"; import { validateAndTransformInput, validateSingleResponse } from "../validation"; +import { + getLocationHeader, + mergeMutationExecuteOptions, + parseRowIdFromLocationHeader, + resolveMutationTableId, +} from "./builders/mutation-helpers"; import { parseErrorResponse } from "./error-parser"; +import { createClientRuntime } from "./runtime"; import { safeJsonParse } from "./sanitize-json"; -const ROWID_MATCH_REGEX = /ROWID=(\d+)/; -const PAREN_VALUE_REGEX = /\(['"]?([^'"]+)['"]?\)/; - export interface InsertOptions { return?: "minimal" | "representation"; } @@ -35,30 +42,24 @@ export class InsertBuilder< > { private readonly table?: Occ; - private readonly databaseName: string; - private readonly context: ExecutionContext; private readonly data: Partial>>; private readonly returnPreference: ReturnPreference; - - private readonly databaseUseEntityIds: boolean; - private readonly databaseIncludeSpecialColumns: boolean; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; constructor(config: { occurrence?: Occ; - databaseName: string; - context: ExecutionContext; + layer: FMODataLayer; data: Partial>>; returnPreference?: ReturnPreference; - databaseUseEntityIds?: boolean; - databaseIncludeSpecialColumns?: boolean; }) { this.table = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; + this.layer = config.layer; this.data = config.data; this.returnPreference = (config.returnPreference || "representation") as ReturnPreference; - this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; - this.databaseIncludeSpecialColumns = config.databaseIncludeSpecialColumns ?? false; + // Extract config from layer for sync method access + const runtime = createClientRuntime(this.layer); + this.config = runtime.config; } /** @@ -67,11 +68,7 @@ export class InsertBuilder< private mergeExecuteOptions( options?: RequestInit & FFetchOptions & ExecuteOptions, ): RequestInit & FFetchOptions & { useEntityIds?: boolean } { - // If useEntityIds is not set in options, use the database-level setting - return { - ...options, - useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds, - }; + return mergeMutationExecuteOptions(options, this.config.useEntityIds); } /** @@ -81,30 +78,7 @@ export class InsertBuilder< * - contacts('some-uuid') */ private parseLocationHeader(locationHeader: string | undefined): number { - if (!locationHeader) { - throw new InvalidLocationHeaderError("Location header is required but was not provided"); - } - - // Try to match ROWID=number pattern - const rowidMatch = locationHeader.match(ROWID_MATCH_REGEX); - if (rowidMatch?.[1]) { - return Number.parseInt(rowidMatch[1], 10); - } - - // Try to extract value from parentheses and parse as number - const parenMatch = locationHeader.match(PAREN_VALUE_REGEX); - if (parenMatch?.[1]) { - const value = parenMatch[1]; - const numValue = Number.parseInt(value, 10); - if (!Number.isNaN(numValue)) { - return numValue; - } - } - - throw new InvalidLocationHeaderError( - `Could not extract ROWID from Location header: ${locationHeader}`, - locationHeader, - ); + return parseRowIdFromLocationHeader(locationHeader); } /** @@ -113,25 +87,30 @@ export class InsertBuilder< */ private getTableId(useEntityIds?: boolean): string { if (!this.table) { - throw new Error("Table occurrence is required"); + throw new BuilderInvariantError("InsertBuilder", "table occurrence is required"); } + return resolveMutationTableId(this.table, useEntityIds ?? this.config.useEntityIds, "InsertBuilder"); + } - const contextDefault = this.context._getUseEntityIds?.() ?? false; - const shouldUseIds = useEntityIds ?? contextDefault; - - if (shouldUseIds) { - if (!isUsingEntityIds(this.table)) { - throw new Error( - `useEntityIds is true but table "${getTableName(this.table)}" does not have entity IDs configured`, - ); - } - return getTableIdHelper(this.table); + /** + * Builds the schema for validation, excluding container fields. + */ + // biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration + private getValidationSchema(): Record | undefined { + if (!this.table) { + return undefined; } - - return getTableName(this.table); + const baseTableConfig = getBaseTableConfig(this.table); + const containerFields = baseTableConfig.containerFields || []; + // biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration + const schema: Record = { ...baseTableConfig.schema }; + for (const containerField of containerFields) { + delete schema[containerField as string]; + } + return schema; } - async execute( + execute( options?: ExecuteMethodOptions, ): Promise< Result< @@ -143,141 +122,118 @@ export class InsertBuilder< > > > { - // Merge database-level useEntityIds with per-request options const mergedOptions = this.mergeExecuteOptions(options); - - // Get table identifier with override support + // Prevent caller options from overriding required request shape + // biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields + const { method: _method, headers: callerHeaders, body: _body, ...requestOptions } = mergedOptions as any; const tableId = this.getTableId(mergedOptions.useEntityIds); - const url = `/${this.databaseName}/${tableId}`; - - // Validate and transform input data using input validators (writeValidators) - let validatedData = this.data; - if (this.table) { - const baseTableConfig = getBaseTableConfig(this.table); - const inputSchema = baseTableConfig.inputSchema; - - try { - validatedData = await validateAndTransformInput(this.data, inputSchema); - } catch (error) { - // If validation fails, return error immediately - return { - data: undefined, - error: error instanceof Error ? error : new Error(String(error)), - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - } as any; - } - } - - // Transform field names to FMFIDs if using entity IDs - // Only transform if useEntityIds resolves to true (respects per-request override) + const url = `/${this.config.databaseName}/${tableId}`; const shouldUseIds = mergedOptions.useEntityIds ?? false; - - const transformedData = - this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData; - - // Set Prefer header based on return preference const preferHeader = this.returnPreference === "minimal" ? "return=minimal" : "return=representation"; - // Make POST request with JSON body - // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - const result = await this.context._makeRequest(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Prefer: preferHeader, - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for headers object - ...((mergedOptions as any)?.headers || {}), - }, - body: JSON.stringify(transformedData), - ...mergedOptions, - }); + const pipeline = Effect.gen(this, function* () { + // Step 1: Validate input + let validatedData = this.data; + if (this.table) { + const baseTableConfig = getBaseTableConfig(this.table); + validatedData = yield* tryEffect( + () => validateAndTransformInput(this.data, baseTableConfig.inputSchema), + (e) => + (e instanceof Error + ? e + : new BuilderInvariantError("InsertBuilder.execute", String(e))) as FMODataErrorType, + ); + } - if (result.error) { - return { data: undefined, error: result.error }; - } + // Step 2: Transform field names to entity IDs if needed + const transformedData = + this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData; - // Handle return=minimal case - if (this.returnPreference === "minimal") { - // The response should be empty (204 No Content) - // _makeRequest will return { _location: string } when there's a Location header + // Step 3: Make HTTP request via DI // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - const responseData = result.data as any; - - if (!responseData?._location) { - throw new InvalidLocationHeaderError( - "Location header is required when using return=minimal but was not found in response", - ); + const responseData = yield* requestFromService(url, { + ...requestOptions, + method: "POST", + headers: { + ...(callerHeaders || {}), + "Content-Type": "application/json", + Prefer: preferHeader, + }, + body: JSON.stringify(transformedData), + }); + + // Step 4: Handle return=minimal case + if (this.returnPreference === "minimal") { + if (!responseData?._location) { + return yield* Effect.fail( + new InvalidLocationHeaderError( + "Location header is required when using return=minimal but was not found in response", + ) as FMODataErrorType, + ); + } + const rowid = this.parseLocationHeader(responseData._location); + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type + return { ROWID: rowid } as any; } - const rowid = this.parseLocationHeader(responseData._location); - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: { ROWID: rowid } as any, error: undefined }; - } - - let response = result.data; + // Step 5: Transform response field IDs back to names + let response = responseData; + if (this.table && shouldUseIds) { + response = transformResponseFields(response, this.table, undefined); + } - // Transform response field IDs back to names if using entity IDs - // Only transform if useEntityIds resolves to true (respects per-request override) - if (this.table && shouldUseIds) { - response = transformResponseFields( - response, - this.table, - undefined, // No expand configs for insert + // Step 6: Validate response + const schema = this.getValidationSchema(); + const validated = yield* fromValidation(() => + validateSingleResponse>>( + response, + schema, + undefined, + undefined, + "exact", + ), ); - } - // Get schema from table if available, excluding container fields - // biome-ignore lint/suspicious/noExplicitAny: Dynamic schema shape from table configuration - let schema: Record | undefined; - if (this.table) { - const baseTableConfig = getBaseTableConfig(this.table); - const containerFields = baseTableConfig.containerFields || []; - - // Filter out container fields from schema - schema = { ...baseTableConfig.schema }; - for (const containerField of containerFields) { - delete schema[containerField as string]; + if (validated === null) { + return yield* Effect.fail( + new BuilderInvariantError( + "InsertBuilder.execute", + "insert operation returned null response", + ) as FMODataErrorType, + ); } - } - // Validate the response (FileMaker returns the created record) - const validation = await validateSingleResponse>>( - response, - schema, - undefined, // No selected fields for insert - undefined, // No expand configs - "exact", // Expect exactly one record - ); - - if (!validation.valid) { - return { data: undefined, error: validation.error }; - } - - // Handle null response (shouldn't happen for insert, but handle it) - if (validation.data === null) { - return { - data: undefined, - error: new Error("Insert operation returned null response"), - }; - } + return validated; + }); - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: validation.data as any, error: undefined }; + return runLayerResult( + this.layer, + pipeline, + "fmodata.insert", + this.table ? { "fmodata.table": getTableName(this.table) } : undefined, + ) as Promise< + Result< + ReturnPreference extends "minimal" + ? { ROWID: number } + : ConditionallyWithODataAnnotations< + InferSchemaOutputFromFMTable>, + EO["includeODataAnnotations"] extends true ? true : false + > + > + >; } // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { - // For batch operations, use database-level setting (no per-request override available here) - // Note: Input validation happens in execute() and processResponse() for batch operations - const tableId = this.getTableId(this.databaseUseEntityIds); + const tableId = this.getTableId(this.config.useEntityIds); // Transform field names to FMFIDs if using entity IDs const transformedData = - this.table && this.databaseUseEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data; + this.table && this.config.useEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data; return { method: "POST", - url: `/${this.databaseName}/${tableId}`, + url: `/${this.config.databaseName}/${tableId}`, body: JSON.stringify(transformedData), }; } @@ -309,7 +265,7 @@ export class InsertBuilder< // Check for error responses (important for batch operations) if (!response.ok) { const tableName = this.table ? getTableName(this.table) : "unknown"; - const error = await parseErrorResponse(response, response.url || `/${this.databaseName}/${tableName}`); + const error = await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`); return { data: undefined, error }; } @@ -318,7 +274,7 @@ export class InsertBuilder< if (response.status === 204) { // Check for Location header (for return=minimal) if (this.returnPreference === "minimal") { - const locationHeader = response.headers.get("Location") || response.headers.get("location"); + const locationHeader = getLocationHeader(response.headers); if (locationHeader) { const rowid = this.parseLocationHeader(locationHeader); // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type @@ -382,15 +338,15 @@ export class InsertBuilder< } catch (error) { return { data: undefined, - error: error instanceof Error ? error : new Error(String(error)), + error: + error instanceof Error ? error : new BuilderInvariantError("InsertBuilder.processResponse", String(error)), // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type } as any; } } // Transform response field IDs back to names if using entity IDs - // Only transform if useEntityIds resolves to true (respects per-request override) - const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds; + const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds; let transformedResponse = rawResponse; if (this.table && shouldUseIds) { @@ -432,7 +388,7 @@ export class InsertBuilder< if (validation.data === null) { return { data: undefined, - error: new Error("Insert operation returned null response"), + error: new BuilderInvariantError("InsertBuilder.processResponse", "insert operation returned null response"), }; } diff --git a/packages/fmodata/src/client/query/expand-builder.ts b/packages/fmodata/src/client/query/expand-builder.ts deleted file mode 100644 index f4871b95..00000000 --- a/packages/fmodata/src/client/query/expand-builder.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import buildQuery, { type QueryOptions } from "odata-query"; -import { FMTable } from "../../orm/table"; -import type { ExpandValidationConfig } from "../../validation"; -import { formatSelectFields } from "../builders/select-utils"; - -const FILTER_QUERY_REGEX = /\$filter=([^&]+)/; - -/** - * Internal type for expand configuration - */ -export interface ExpandConfig { - relation: string; - // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryOptions configuration - options?: Partial>; - // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration - targetTable?: FMTable; -} - -/** - * Builds OData expand query strings and validation configs. - * Handles nested expands recursively and transforms relation names to FMTIDs - * when using entity IDs. - */ -export class ExpandBuilder { - private readonly useEntityIds: boolean; - - constructor(useEntityIds: boolean) { - this.useEntityIds = useEntityIds; - } - - /** - * Builds OData expand query string from expand configurations. - * Handles nested expands recursively. - * Transforms relation names to FMTIDs if using entity IDs. - */ - buildExpandString(configs: ExpandConfig[]): string { - if (configs.length === 0) { - return ""; - } - - return configs.map((config) => this.buildSingleExpand(config)).join(","); - } - - /** - * Builds a single expand string with its options. - */ - private buildSingleExpand(config: ExpandConfig): string { - // Get target table/occurrence from config (stored during expand call) - const targetTable = config.targetTable; - - // When using entity IDs, use the target table's FMTID in the expand parameter - // FileMaker expects FMTID in $expand when Prefer header is set - // Only use FMTID if databaseUseEntityIds is enabled - let relationName = config.relation; - if (this.useEntityIds && targetTable && FMTable.Symbol.EntityId in targetTable) { - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access - const tableId = (targetTable as any)[FMTable.Symbol.EntityId] as `FMTID:${string}` | undefined; - if (tableId) { - relationName = tableId; - } - } - - if (!config.options || Object.keys(config.options).length === 0) { - // Simple expand without options - return relationName; - } - - // Build query options for this expand - const parts: string[] = []; - - if (config.options.select) { - // Use shared formatSelectFields function for consistent id field quoting - const selectArray = Array.isArray(config.options.select) - ? config.options.select.map(String) - : [String(config.options.select)]; - const selectFields = formatSelectFields(selectArray, targetTable, this.useEntityIds); - parts.push(`$select=${selectFields}`); - } - - if (config.options.filter) { - // Filter should already be transformed by the nested builder - // Use odata-query to build filter string - const filterQuery = buildQuery({ filter: config.options.filter }); - const filterMatch = filterQuery.match(FILTER_QUERY_REGEX); - if (filterMatch) { - parts.push(`$filter=${filterMatch[1]}`); - } - } - - if (config.options.orderBy) { - // OrderBy should already be transformed by the nested builder - const orderByValue = Array.isArray(config.options.orderBy) - ? config.options.orderBy.join(",") - : config.options.orderBy; - parts.push(`$orderby=${String(orderByValue)}`); - } - - if (config.options.top !== undefined) { - parts.push(`$top=${config.options.top}`); - } - - if (config.options.skip !== undefined) { - parts.push(`$skip=${config.options.skip}`); - } - - // Handle nested expands (from expand configs) - if (config.options.expand && typeof config.options.expand === "string") { - // If expand is a string, it's already been built - parts.push(`$expand=${config.options.expand}`); - } - - if (parts.length === 0) { - return relationName; - } - - return `${relationName}(${parts.join(";")})`; - } - - /** - * Builds expand validation configs from internal expand configurations. - * These are used to validate expanded navigation properties. - */ - buildValidationConfigs(configs: ExpandConfig[]): ExpandValidationConfig[] { - return configs.map((config) => { - // Get target table/occurrence from config (stored during expand call) - const targetTable = config.targetTable; - - // Extract schema from target table/occurrence - let targetSchema: Record | undefined; - if (targetTable) { - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access - const tableSchema = (targetTable as any)[FMTable.Symbol.Schema]; - if (tableSchema) { - const zodSchema = tableSchema["~standard"]?.schema; - if (zodSchema && typeof zodSchema === "object" && "shape" in zodSchema) { - targetSchema = zodSchema.shape as Record; - } - } - } - - // Extract selected fields from options - let selectedFields: string[] | undefined; - if (config.options?.select) { - selectedFields = Array.isArray(config.options.select) - ? config.options.select.map((f) => String(f)) - : [String(config.options.select)]; - } - - return { - relation: config.relation, - targetSchema, - targetTable, - table: targetTable, // For transformation - selectedFields, - nestedExpands: undefined, // TODO: Handle nested expands if needed - }; - }); - } -} diff --git a/packages/fmodata/src/client/query/index.ts b/packages/fmodata/src/client/query/index.ts index 9d427b37..037c4cb6 100644 --- a/packages/fmodata/src/client/query/index.ts +++ b/packages/fmodata/src/client/query/index.ts @@ -2,8 +2,8 @@ // Re-export QueryBuilder as the main export -// Export ExpandConfig from expand-builder -export type { ExpandConfig } from "./expand-builder"; +// Export ExpandConfig from canonical shared builder types +export type { ExpandConfig } from "../builders/shared-types"; // Export types export type { diff --git a/packages/fmodata/src/client/query/query-builder.ts b/packages/fmodata/src/client/query/query-builder.ts index 48baef6b..2c03db40 100644 --- a/packages/fmodata/src/client/query/query-builder.ts +++ b/packages/fmodata/src/client/query/query-builder.ts @@ -1,8 +1,10 @@ /** biome-ignore-all lint/style/useReadonlyClassProperties: properties are reassigned in cloneWithChanges() */ import type { FFetchOptions } from "@fetchkit/ffetch"; +import { Effect } from "effect"; import buildQuery, { type QueryOptions } from "odata-query"; +import { requestFromService, runLayerResult } from "../../effect"; import { RecordCountMismatchError } from "../../errors"; -import { createLogger, type InternalLogger } from "../../logger"; +import type { InternalLogger } from "../../logger"; import { type Column, isColumn } from "../../orm/column"; import { type FilterExpression, isOrderByExpression, type OrderByExpression } from "../../orm/operators"; import { @@ -12,6 +14,7 @@ import { type InferSchemaOutputFromFMTable, type ValidExpandTarget, } from "../../orm/table"; +import type { FMODataLayer, ODataConfig } from "../../services"; import { transformOrderByField } from "../../transform"; import type { ConditionallyWithODataAnnotations, @@ -19,12 +22,13 @@ import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, - ExecutionContext, NormalizeIncludeSpecialColumns, Result, } from "../../types"; import { buildSelectExpandQueryString, + cloneQueryReadBuilderState, + createInitialQueryReadBuilderState, createODataRequest, ExpandBuilder, type ExpandConfig, @@ -34,6 +38,7 @@ import { processSelectWithRenames, } from "../builders/index"; import { parseErrorResponse } from "../error-parser"; +import { createClientRuntime } from "../runtime"; import { safeJsonParse } from "../sanitize-json"; import type { QueryReturnType, SystemColumnsOption, TypeSafeOrderBy } from "./types"; import { type NavigationConfig, QueryUrlBuilder } from "./url-builder"; @@ -67,44 +72,94 @@ export class QueryBuilder< QueryReturnType, Selected, SingleMode, IsCount, Expands, SystemCols> > { - // These properties are reassigned in cloneWithChanges() on new instances, not on this - private queryOptions: Partial>>; - private expandConfigs: ExpandConfig[]; - private singleMode: SingleMode; - private isCountMode: IsCount; + private readState = createInitialQueryReadBuilderState>(); private readonly occurrence: Occ; - private readonly databaseName: string; - private readonly context: ExecutionContext; - private navigation?: NavigationConfig; - private readonly databaseUseEntityIds: boolean; - private readonly databaseIncludeSpecialColumns: boolean; private readonly expandBuilder: ExpandBuilder; private urlBuilder: QueryUrlBuilder; - // Mapping from field names to output keys (for renamed fields in select) - private fieldMapping?: Record; - // System columns requested via select() second argument - private systemColumns?: SystemColumnsOption; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; private readonly logger: InternalLogger; + // Compatibility accessors for internal modules that inspect builder internals via `as any`. + private get queryOptions(): Partial>> { + return this.readState.queryOptions; + } + + private set queryOptions(queryOptions: Partial>>) { + this.readState = cloneQueryReadBuilderState(this.readState, { + queryOptions, + }); + } + + private get expandConfigs(): ExpandConfig[] { + return this.readState.expandConfigs; + } + + private set expandConfigs(expandConfigs: ExpandConfig[]) { + this.readState = cloneQueryReadBuilderState(this.readState, { + expandConfigs, + }); + } + + private get singleMode(): SingleMode { + return this.readState.singleMode as SingleMode; + } + + private set singleMode(singleMode: SingleMode) { + this.readState = cloneQueryReadBuilderState(this.readState, { + singleMode, + }); + } + + private get isCountMode(): IsCount { + return this.readState.isCountMode as IsCount; + } + + private set isCountMode(isCountMode: IsCount) { + this.readState = cloneQueryReadBuilderState(this.readState, { + isCountMode, + }); + } + + private get fieldMapping(): Record | undefined { + return this.readState.fieldMapping; + } + + private set fieldMapping(fieldMapping: Record | undefined) { + this.readState = cloneQueryReadBuilderState(this.readState, { + fieldMapping, + }); + } + + private get systemColumns(): SystemColumnsOption | undefined { + return this.readState.systemColumns; + } + + private set systemColumns(systemColumns: SystemColumnsOption | undefined) { + this.readState = cloneQueryReadBuilderState(this.readState, { + systemColumns, + }); + } + + private get navigation(): NavigationConfig | undefined { + return this.readState.navigation; + } + + private set navigation(navigation: NavigationConfig | undefined) { + this.setNavigation(navigation); + } + constructor(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; - databaseUseEntityIds?: boolean; - databaseIncludeSpecialColumns?: boolean; + layer: FMODataLayer; }) { this.occurrence = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; - this.logger = config.context?._getLogger?.() ?? createLogger(); - this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; - this.databaseIncludeSpecialColumns = config.databaseIncludeSpecialColumns ?? false; - this.expandBuilder = new ExpandBuilder(this.databaseUseEntityIds, this.logger); - this.urlBuilder = new QueryUrlBuilder(this.databaseName, this.occurrence, this.context); - this.queryOptions = {}; - this.expandConfigs = []; - this.singleMode = false as SingleMode; - this.isCountMode = false as IsCount; + const runtime = createClientRuntime(config.layer); + this.layer = runtime.layer; + this.config = runtime.config; + this.logger = runtime.logger; + this.expandBuilder = new ExpandBuilder(this.config.useEntityIds, this.logger); + this.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds); } /** @@ -115,13 +170,31 @@ export class QueryBuilder< useEntityIds?: boolean; includeSpecialColumns?: boolean; } { - const merged = mergeExecuteOptions(options, this.databaseUseEntityIds); + const merged = mergeExecuteOptions(options, this.config.useEntityIds); return { ...merged, - includeSpecialColumns: options?.includeSpecialColumns ?? this.databaseIncludeSpecialColumns, + includeSpecialColumns: options?.includeSpecialColumns ?? this.config.includeSpecialColumns, }; } + private patchQueryOptions(patch: Partial>>): void { + this.readState = cloneQueryReadBuilderState(this.readState, { + queryOptions: patch, + }); + } + + private setFilterExpression(expression: FilterExpression | undefined): void { + this.readState = cloneQueryReadBuilderState(this.readState, { + filterExpression: expression, + }); + } + + private setNavigation(navigation: NavigationConfig | undefined): void { + this.readState = cloneQueryReadBuilderState(this.readState, { + navigation, + }); + } + /** * Creates a new QueryBuilder with modified configuration. * Used by single(), maybeSingle(), count(), and select() to create new instances. @@ -152,25 +225,20 @@ export class QueryBuilder< NewSystemCols >({ occurrence: this.occurrence, - databaseName: this.databaseName, - context: this.context, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, + layer: this.layer, }); - newBuilder.queryOptions = { - ...this.queryOptions, - ...changes.queryOptions, - }; - newBuilder.expandConfigs = [...this.expandConfigs]; - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter - newBuilder.singleMode = (changes.singleMode ?? this.singleMode) as any; - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter - newBuilder.isCountMode = (changes.isCountMode ?? this.isCountMode) as any; - newBuilder.fieldMapping = "fieldMapping" in changes ? changes.fieldMapping : this.fieldMapping; - newBuilder.systemColumns = changes.systemColumns !== undefined ? changes.systemColumns : this.systemColumns; - // Copy navigation metadata - newBuilder.navigation = this.navigation; - newBuilder.urlBuilder = new QueryUrlBuilder(this.databaseName, this.occurrence, this.context); + newBuilder.readState = cloneQueryReadBuilderState(this.readState, { + queryOptions: changes.queryOptions, + expandConfigs: this.readState.expandConfigs, + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter + singleMode: (changes.singleMode ?? this.readState.singleMode) as any, + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter + isCountMode: (changes.isCountMode ?? this.readState.isCountMode) as any, + fieldMapping: "fieldMapping" in changes ? changes.fieldMapping : this.readState.fieldMapping, + systemColumns: changes.systemColumns !== undefined ? changes.systemColumns : this.readState.systemColumns, + navigation: this.readState.navigation, + }); + newBuilder.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds); return newBuilder; } @@ -272,12 +340,14 @@ export class QueryBuilder< ): QueryBuilder { // Handle raw string filters (escape hatch) if (typeof expression === "string") { - this.queryOptions.filter = expression; + this.setFilterExpression(undefined); + this.patchQueryOptions({ filter: expression }); return this; } - // Convert FilterExpression to OData filter string - const filterString = expression.toODataFilter(this.databaseUseEntityIds); - this.queryOptions.filter = filterString; + + // Defer serialization until execute/getQueryString so per-request useEntityIds is honored + this.setFilterExpression(expression); + this.patchQueryOptions({ filter: undefined }); return this; } @@ -349,7 +419,7 @@ export class QueryBuilder< } throw new Error("Variadic orderBy() only accepts Column or OrderByExpression arguments"); }); - this.queryOptions.orderBy = orderByParts; + this.patchQueryOptions({ orderBy: orderByParts }); return this; } @@ -366,7 +436,7 @@ export class QueryBuilder< } const fieldName = orderBy.column.fieldName; const transformedField = this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName; - this.queryOptions.orderBy = `${transformedField} ${orderBy.direction}`; + this.patchQueryOptions({ orderBy: `${transformedField} ${orderBy.direction}` }); return this; } @@ -380,7 +450,9 @@ export class QueryBuilder< } // Single Column reference without direction (defaults to ascending) const fieldName = orderBy.fieldName; - this.queryOptions.orderBy = this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName; + this.patchQueryOptions({ + orderBy: this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName, + }); return this; } // Transform field names to FMFIDs if using entity IDs @@ -395,19 +467,20 @@ export class QueryBuilder< // Single tuple: [field, direction] or [column, direction] const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0]; const direction = orderBy[1] as "asc" | "desc"; - this.queryOptions.orderBy = `${transformOrderByField(field, this.occurrence)} ${direction}`; + this.patchQueryOptions({ orderBy: `${transformOrderByField(field, this.occurrence)} ${direction}` }); } else { // Array of tuples: [[field, dir], [field, dir], ...] - // biome-ignore lint/suspicious/noExplicitAny: Dynamic orderBy tuple type from user input - this.queryOptions.orderBy = (orderBy as [any, "asc" | "desc"][]).map(([fieldOrCol, direction]) => { - const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); - const transformedField = this.occurrence ? transformOrderByField(field, this.occurrence) : field; - return `${transformedField} ${direction}`; + this.patchQueryOptions({ + orderBy: (orderBy as [unknown, "asc" | "desc"][]).map(([fieldOrCol, direction]) => { + const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); + const transformedField = this.occurrence ? transformOrderByField(field, this.occurrence) : field; + return `${transformedField} ${direction}`; + }), }); } } else { // Single field name (string) - this.queryOptions.orderBy = transformOrderByField(String(orderBy), this.occurrence); + this.patchQueryOptions({ orderBy: transformOrderByField(String(orderBy), this.occurrence) }); } } else if (Array.isArray(orderBy)) { if ( @@ -418,17 +491,18 @@ export class QueryBuilder< // Single tuple: [field, direction] or [column, direction] const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0]; const direction = orderBy[1] as "asc" | "desc"; - this.queryOptions.orderBy = `${field} ${direction}`; + this.patchQueryOptions({ orderBy: `${field} ${direction}` }); } else { // Array of tuples - // biome-ignore lint/suspicious/noExplicitAny: Dynamic orderBy tuple type from user input - this.queryOptions.orderBy = (orderBy as [any, "asc" | "desc"][]).map(([fieldOrCol, direction]) => { - const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); - return `${field} ${direction}`; + this.patchQueryOptions({ + orderBy: (orderBy as [unknown, "asc" | "desc"][]).map(([fieldOrCol, direction]) => { + const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol); + return `${field} ${direction}`; + }), }); } } else { - this.queryOptions.orderBy = orderBy; + this.patchQueryOptions({ orderBy }); } return this; } @@ -436,14 +510,14 @@ export class QueryBuilder< top( count: number, ): QueryBuilder { - this.queryOptions.top = count; + this.patchQueryOptions({ top: count }); return this; } skip( count: number, ): QueryBuilder { - this.queryOptions.skip = count; + this.patchQueryOptions({ skip: count }); return this; } @@ -499,14 +573,13 @@ export class QueryBuilder< // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration new QueryBuilder({ occurrence: targetTable, - databaseName: this.databaseName, - context: this.context, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, + layer: this.layer, }), ); - this.expandConfigs.push(expandConfig); + this.readState = cloneQueryReadBuilderState(this.readState, { + expandConfigs: [...this.readState.expandConfigs, expandConfig], + }); // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type return this as any; } @@ -530,8 +603,14 @@ export class QueryBuilder< * Builds the OData query string from current query options and expand configs. */ private buildQueryString(includeSpecialColumns?: boolean, useEntityIds?: boolean): string { + // Use provided useEntityIds if provided, otherwise use database-level default + const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds; + // Build query without expand and select (we'll add them manually if using entity IDs) - const queryOptionsWithoutExpandAndSelect = { ...this.queryOptions }; + const queryOptionsWithoutExpandAndSelect = { ...this.readState.queryOptions }; + if (this.readState.filterExpression) { + queryOptionsWithoutExpandAndSelect.filter = this.readState.filterExpression.toODataFilter(finalUseEntityIds); + } const originalSelect = queryOptionsWithoutExpandAndSelect.select; queryOptionsWithoutExpandAndSelect.expand = undefined; queryOptionsWithoutExpandAndSelect.select = undefined; @@ -545,14 +624,11 @@ export class QueryBuilder< } // Use merged includeSpecialColumns if provided, otherwise use database-level default - const finalIncludeSpecialColumns = includeSpecialColumns ?? this.databaseIncludeSpecialColumns; - - // Use provided useEntityIds if provided, otherwise use database-level default - const finalUseEntityIds = useEntityIds ?? this.databaseUseEntityIds; + const finalIncludeSpecialColumns = includeSpecialColumns ?? this.config.includeSpecialColumns; const selectExpandString = buildSelectExpandQueryString({ selectedFields: selectArray, - expandConfigs: this.expandConfigs, + expandConfigs: this.readState.expandConfigs, table: this.occurrence, useEntityIds: finalUseEntityIds, logger: this.logger, @@ -570,7 +646,7 @@ export class QueryBuilder< return queryString; } - async execute( + execute( options?: ExecuteMethodOptions, ): Promise< Result< @@ -591,63 +667,87 @@ export class QueryBuilder< > > > { + type ExecuteResponse = ConditionallyWithODataAnnotations< + ConditionallyWithSpecialColumns< + QueryReturnType, Selected, SingleMode, IsCount, Expands, SystemCols>, + NormalizeIncludeSpecialColumns, + // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration + Selected extends Record> + ? true + : Selected extends keyof InferSchemaOutputFromFMTable + ? false + : true + >, + EO["includeODataAnnotations"] extends true ? true : false + >; + const mergedOptions = this.mergeExecuteOptions(options); const queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds); // Handle $count endpoint - if (this.isCountMode) { + if (this.readState.isCountMode) { const url = this.urlBuilder.build(queryString, { isCount: true, useEntityIds: mergedOptions.useEntityIds, - navigation: this.navigation, + navigation: this.readState.navigation, }); - const result = await this.context._makeRequest(url, mergedOptions); - if (result.error) { - return { data: undefined, error: result.error }; - } + const pipeline = requestFromService(url, mergedOptions).pipe( + Effect.map((data) => { + const count = typeof data === "string" ? Number(data) : data; + return count as number; + }), + ); - // OData returns count as a string, convert to number - const count = typeof result.data === "string" ? Number(result.data) : result.data; - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: count as number, error: undefined } as any; + return runLayerResult(this.layer, pipeline, "fmodata.query.count", { + "fmodata.table": getTableName(this.occurrence), + }) as Promise>; } const url = this.urlBuilder.build(queryString, { - isCount: this.isCountMode, + isCount: this.readState.isCountMode, useEntityIds: mergedOptions.useEntityIds, - navigation: this.navigation, + navigation: this.readState.navigation, }); - const result = await this.context._makeRequest(url, mergedOptions); - - if (result.error) { - return { data: undefined, error: result.error }; - } - - // Check if select was applied (runtime check) - const _hasSelect = this.queryOptions.select !== undefined; + const pipeline = requestFromService(url, mergedOptions).pipe( + Effect.flatMap((data) => + Effect.tryPromise({ + try: () => + processQueryResponse(data, { + occurrence: this.occurrence, + singleMode: this.readState.singleMode as SingleMode, + queryOptions: this.readState.queryOptions as { + select?: (keyof InferSchemaOutputFromFMTable)[] | string[]; + }, + expandConfigs: this.readState.expandConfigs, + skipValidation: options?.skipValidation, + useEntityIds: mergedOptions.useEntityIds, + includeSpecialColumns: mergedOptions.includeSpecialColumns, + fieldMapping: this.readState.fieldMapping, + logger: this.logger, + }), + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }), + ), + // processQueryResponse returns a Result, so we need to unwrap it + Effect.flatMap((result) => (result.error ? Effect.fail(result.error) : Effect.succeed(result.data))), + ); - return processQueryResponse(result.data, { - occurrence: this.occurrence, - singleMode: this.singleMode, - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter - queryOptions: this.queryOptions as any, - expandConfigs: this.expandConfigs, - skipValidation: options?.skipValidation, - useEntityIds: mergedOptions.useEntityIds, - includeSpecialColumns: mergedOptions.includeSpecialColumns, - fieldMapping: this.fieldMapping, - logger: this.logger, - }); + return runLayerResult( + this.layer, + pipeline, + this.readState.singleMode ? "fmodata.query.single" : "fmodata.query.list", + { "fmodata.table": getTableName(this.occurrence) }, + ) as Promise>; } getQueryString(options?: { useEntityIds?: boolean }): string { - const useEntityIds = options?.useEntityIds ?? this.databaseUseEntityIds; + const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds; const queryString = this.buildQueryString(undefined, useEntityIds); return this.urlBuilder.buildPath(queryString, { useEntityIds, - navigation: this.navigation, + navigation: this.readState.navigation, }); } @@ -655,9 +755,9 @@ export class QueryBuilder< getRequestConfig(): { method: string; url: string; body?: any } { const queryString = this.buildQueryString(); const url = this.urlBuilder.build(queryString, { - isCount: this.isCountMode, - useEntityIds: this.databaseUseEntityIds, - navigation: this.navigation, + isCount: this.readState.isCountMode, + useEntityIds: this.config.useEntityIds, + navigation: this.readState.navigation, }); return { @@ -681,7 +781,7 @@ export class QueryBuilder< if (!response.ok) { const error = await parseErrorResponse( response, - response.url || `/${this.databaseName}/${getTableName(this.occurrence)}`, + response.url || `/${this.config.databaseName}/${getTableName(this.occurrence)}`, ); return { data: undefined, error }; } @@ -689,8 +789,8 @@ export class QueryBuilder< // Handle 204 No Content (shouldn't happen for queries, but handle it gracefully) if (response.status === 204) { // Return empty list for list queries, null for single queries - if (this.singleMode !== false) { - if (this.singleMode === "maybe") { + if (this.readState.singleMode !== false) { + if (this.readState.singleMode === "maybe") { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type return { data: null as any, error: undefined }; } @@ -739,18 +839,18 @@ export class QueryBuilder< const mergedOptions = this.mergeExecuteOptions(options); // Check if select was applied (runtime check) - const _hasSelect = this.queryOptions.select !== undefined; + const _hasSelect = this.readState.queryOptions.select !== undefined; return processQueryResponse(rawData, { occurrence: this.occurrence, - singleMode: this.singleMode, + singleMode: this.readState.singleMode as SingleMode, // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type parameter - queryOptions: this.queryOptions as any, - expandConfigs: this.expandConfigs, + queryOptions: this.readState.queryOptions as any, + expandConfigs: this.readState.expandConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, includeSpecialColumns: mergedOptions.includeSpecialColumns, - fieldMapping: this.fieldMapping, + fieldMapping: this.readState.fieldMapping, logger: this.logger, }); } diff --git a/packages/fmodata/src/client/query/response-processor.ts b/packages/fmodata/src/client/query/response-processor.ts deleted file mode 100644 index fac6f380..00000000 --- a/packages/fmodata/src/client/query/response-processor.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import type { QueryOptions } from "odata-query"; -import { RecordCountMismatchError } from "../../errors"; -import type { InternalLogger } from "../../logger"; -import type { FMTable } from "../../orm/table"; -import { getTableSchema } from "../../orm/table"; -import { transformResponseFields } from "../../transform"; -import type { Result } from "../../types"; -import type { ExpandValidationConfig } from "../../validation"; -import { validateListResponse, validateSingleResponse } from "../../validation"; -import type { ExpandConfig } from "./expand-builder"; - -/** - * Configuration for processing query responses - */ -export interface ProcessQueryResponseConfig { - // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration - occurrence?: FMTable; - singleMode: "exact" | "maybe" | false; - queryOptions: Partial>; - expandConfigs: ExpandConfig[]; - skipValidation?: boolean; - useEntityIds?: boolean; - includeSpecialColumns?: boolean; - // Mapping from field names to output keys (for renamed fields in select) - fieldMapping?: Record; - logger: InternalLogger; -} - -/** - * Builds expand validation configs from internal expand configurations. - * These are used to validate expanded navigation properties. - */ -function buildExpandValidationConfigs(configs: ExpandConfig[]): ExpandValidationConfig[] { - return configs.map((config) => { - // Get target table/occurrence from config (stored during expand call) - const targetTable = config.targetTable; - - // Extract schema from target table/occurrence - // Schema is stored directly as Partial> - const targetSchema = targetTable - ? (getTableSchema(targetTable) as Record | undefined) - : undefined; - - // Extract selected fields from options - let selectedFields: string[] | undefined; - if (config.options?.select) { - selectedFields = Array.isArray(config.options.select) - ? config.options.select.map((f) => String(f)) - : [String(config.options.select)]; - } - - return { - relation: config.relation, - targetSchema, - targetTable, - table: targetTable, // For transformation - selectedFields, - nestedExpands: undefined, // TODO: Handle nested expands if needed - }; - }); -} - -/** - * Extracts records from response data without validation. - * Handles both single and list responses. - */ -// biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API, generic return type -function extractRecords(data: any, singleMode: "exact" | "maybe" | false): Result { - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for response structure - const resp = data as any; - if (singleMode !== false) { - const records = resp.value ?? [resp]; - const count = Array.isArray(records) ? records.length : 1; - - if (count > 1) { - return { - data: undefined, - error: new RecordCountMismatchError(singleMode === "exact" ? "one" : "at-most-one", count), - }; - } - - if (count === 0) { - if (singleMode === "exact") { - return { - data: undefined, - error: new RecordCountMismatchError("one", 0), - }; - } - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: null as any, error: undefined }; - } - - const record = Array.isArray(records) ? records[0] : records; - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: record as any, error: undefined }; - } - // Handle list response structure - const records = resp.value ?? []; - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: records as any, error: undefined }; -} - -/** - * Renames fields in response data according to the field mapping. - * Used when select() is called with renamed fields (e.g., { userEmail: users.email }). - */ -// biome-ignore lint/suspicious/noExplicitAny: Dynamic response data transformation -function renameFieldsInResponse(data: any, fieldMapping: Record): any { - if (!data || typeof data !== "object") { - return data; - } - - // Handle array responses - if (Array.isArray(data)) { - return data.map((item) => renameFieldsInResponse(item, fieldMapping)); - } - - // Handle OData list response structure - if ("value" in data && Array.isArray(data.value)) { - return { - ...data, - // biome-ignore lint/suspicious/noExplicitAny: Dynamic record transformation - value: data.value.map((item: any) => renameFieldsInResponse(item, fieldMapping)), - }; - } - - // Handle single record - // biome-ignore lint/suspicious/noExplicitAny: Dynamic field transformation - const renamed: Record = {}; - for (const [key, value] of Object.entries(data)) { - // Check if this field should be renamed - const outputKey = fieldMapping[key]; - if (outputKey) { - renamed[outputKey] = value; - } else { - renamed[key] = value; - } - } - return renamed; -} - -/** - * Processes a query response by transforming field IDs and validating the data. - * This function consolidates the response processing logic that was duplicated - * across multiple navigation branches in QueryBuilder.execute(). - */ -export async function processQueryResponse( - // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - response: any, - config: ProcessQueryResponseConfig, - // biome-ignore lint/suspicious/noExplicitAny: Generic return type for interface compliance -): Promise> { - const { occurrence, singleMode, skipValidation, useEntityIds, fieldMapping } = config; - - // Transform response if needed - let data = response; - if (occurrence && useEntityIds) { - const expandValidationConfigs = buildExpandValidationConfigs(config.expandConfigs); - data = transformResponseFields(response, occurrence, expandValidationConfigs); - } - - // Skip validation path - if (skipValidation) { - const result = extractRecords(data, singleMode); - // Rename fields AFTER extraction (but before returning) - if (result.data && fieldMapping && Object.keys(fieldMapping).length > 0) { - return { - ...result, - data: renameFieldsInResponse(result.data, fieldMapping), - }; - } - return result; - } - - // Validation path - // Get schema from occurrence if available - // Schema is stored directly as Partial> - const schema = occurrence ? getTableSchema(occurrence) : undefined; - - const selectedFields = config.queryOptions.select - ? ((Array.isArray(config.queryOptions.select) - ? config.queryOptions.select.map((f) => String(f)) - : [String(config.queryOptions.select)]) as (keyof T)[]) - : undefined; - const expandValidationConfigs = buildExpandValidationConfigs(config.expandConfigs); - - // Validate with original field names - // Special columns are excluded when using single() method (per OData spec behavior) - // Note: While FileMaker may return special columns in single mode if requested via header, - // we exclude them here to maintain OData spec compliance. The types will also not include - // special columns for single mode to match this runtime behavior. - const shouldIncludeSpecialColumns = singleMode === false ? (config.includeSpecialColumns ?? false) : false; - const validationResult = - singleMode !== false - ? await validateSingleResponse( - data, - schema, - selectedFields as string[] | undefined, - expandValidationConfigs, - singleMode, - shouldIncludeSpecialColumns, - ) - : await validateListResponse( - data, - schema, - selectedFields as string[] | undefined, - expandValidationConfigs, - shouldIncludeSpecialColumns, - ); - - if (!validationResult.valid) { - return { data: undefined, error: validationResult.error }; - } - - // Rename fields AFTER validation completes - if (fieldMapping && Object.keys(fieldMapping).length > 0) { - return { - data: renameFieldsInResponse(validationResult.data, fieldMapping), - error: undefined, - }; - } - - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: validationResult.data as any, error: undefined }; -} diff --git a/packages/fmodata/src/client/query/types.ts b/packages/fmodata/src/client/query/types.ts index 85ffa4ae..c8fc9af4 100644 --- a/packages/fmodata/src/client/query/types.ts +++ b/packages/fmodata/src/client/query/types.ts @@ -17,13 +17,7 @@ export type TypeSafeOrderBy = | [keyof T & string, "asc" | "desc"][]; // Multiple fields with directions // Internal type for expand configuration -export interface ExpandConfig { - relation: string; - // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryOptions configuration - options?: Partial>; - // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration - targetTable?: import("../../orm/table").FMTable; -} +export type { ExpandConfig } from "../builders/shared-types"; // Type to represent expanded relations // biome-ignore lint/suspicious/noExplicitAny: Dynamic schema and selected types from user input diff --git a/packages/fmodata/src/client/query/url-builder.ts b/packages/fmodata/src/client/query/url-builder.ts index 0cc47c1a..e3f5c9e3 100644 --- a/packages/fmodata/src/client/query/url-builder.ts +++ b/packages/fmodata/src/client/query/url-builder.ts @@ -1,6 +1,5 @@ import type { FMTable } from "../../orm/table"; import { getTableName } from "../../orm/table"; -import type { ExecutionContext } from "../../types"; import { resolveTableId } from "../builders/table-utils"; /** @@ -9,9 +8,13 @@ import { resolveTableId } from "../builders/table-utils"; export interface NavigationConfig { recordId?: string | number; relation: string; + relationEntityId?: string; sourceTableName: string; + sourceTableEntityId?: string; baseRelation?: string; // For chained navigations from navigated EntitySets + baseRelationEntityId?: string; basePath?: string; // Full base path for chained entity set navigations + basePathEntityId?: string; } /** @@ -26,13 +29,13 @@ export class QueryUrlBuilder { private readonly databaseName: string; // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration private readonly occurrence: FMTable; - private readonly context: ExecutionContext; + private readonly useEntityIds: boolean; // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration - constructor(databaseName: string, occurrence: FMTable, context: ExecutionContext) { + constructor(databaseName: string, occurrence: FMTable, useEntityIds: boolean) { this.databaseName = databaseName; this.occurrence = occurrence; - this.context = context; + this.useEntityIds = useEntityIds; } /** @@ -49,14 +52,15 @@ export class QueryUrlBuilder { navigation?: NavigationConfig; }, ): string { - const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), this.context, options.useEntityIds); + const effectiveUseEntityIds = options.useEntityIds ?? this.useEntityIds; + const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds); const navigation = options.navigation; if (navigation?.recordId && navigation?.relation) { - return this.buildRecordNavigation(queryString, tableId, navigation); + return this.buildRecordNavigation(queryString, tableId, navigation, effectiveUseEntityIds); } if (navigation?.relation) { - return this.buildEntitySetNavigation(queryString, tableId, navigation); + return this.buildEntitySetNavigation(queryString, tableId, navigation, effectiveUseEntityIds); } if (options.isCount) { return `/${this.databaseName}/${tableId}/$count${queryString}`; @@ -68,11 +72,21 @@ export class QueryUrlBuilder { * Builds URL for record navigation: /database/sourceTable('recordId')/relation * or /database/sourceTable/baseRelation('recordId')/relation for chained navigations */ - private buildRecordNavigation(queryString: string, _tableId: string, navigation: NavigationConfig): string { - const { sourceTableName, baseRelation, recordId, relation } = navigation; - const base = baseRelation - ? `${sourceTableName}/${baseRelation}('${recordId}')` - : `${sourceTableName}('${recordId}')`; + private buildRecordNavigation( + queryString: string, + _tableId: string, + navigation: NavigationConfig, + useEntityIds: boolean, + ): string { + const sourceTable = useEntityIds + ? (navigation.sourceTableEntityId ?? navigation.sourceTableName) + : navigation.sourceTableName; + const baseRelation = useEntityIds + ? (navigation.baseRelationEntityId ?? navigation.baseRelation) + : navigation.baseRelation; + const relation = useEntityIds ? (navigation.relationEntityId ?? navigation.relation) : navigation.relation; + const { recordId } = navigation; + const base = baseRelation ? `${sourceTable}/${baseRelation}('${recordId}')` : `${sourceTable}('${recordId}')`; return `/${this.databaseName}/${base}/${relation}${queryString}`; } @@ -80,9 +94,18 @@ export class QueryUrlBuilder { * Builds URL for entity set navigation: /database/sourceTable/relation * or /database/basePath/relation for chained navigations */ - private buildEntitySetNavigation(queryString: string, _tableId: string, navigation: NavigationConfig): string { - const { sourceTableName, basePath, relation } = navigation; - const base = basePath || sourceTableName; + private buildEntitySetNavigation( + queryString: string, + _tableId: string, + navigation: NavigationConfig, + useEntityIds: boolean, + ): string { + const sourceTable = useEntityIds + ? (navigation.sourceTableEntityId ?? navigation.sourceTableName) + : navigation.sourceTableName; + const basePath = useEntityIds ? (navigation.basePathEntityId ?? navigation.basePath) : navigation.basePath; + const relation = useEntityIds ? (navigation.relationEntityId ?? navigation.relation) : navigation.relation; + const base = basePath || sourceTable; return `/${this.databaseName}/${base}/${relation}${queryString}`; } @@ -91,20 +114,35 @@ export class QueryUrlBuilder { * Used when the full URL is not needed. */ buildPath(queryString: string, options?: { useEntityIds?: boolean; navigation?: NavigationConfig }): string { - const useEntityIds = options?.useEntityIds; + const effectiveUseEntityIds = options?.useEntityIds ?? this.useEntityIds; const navigation = options?.navigation; - const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), this.context, useEntityIds); + const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds); if (navigation?.recordId && navigation?.relation) { - const { sourceTableName, baseRelation, recordId, relation } = navigation; - const base = baseRelation - ? `${sourceTableName}/${baseRelation}('${recordId}')` - : `${sourceTableName}('${recordId}')`; + const sourceTable = effectiveUseEntityIds + ? (navigation.sourceTableEntityId ?? navigation.sourceTableName) + : navigation.sourceTableName; + const baseRelation = effectiveUseEntityIds + ? (navigation.baseRelationEntityId ?? navigation.baseRelation) + : navigation.baseRelation; + const relation = effectiveUseEntityIds + ? (navigation.relationEntityId ?? navigation.relation) + : navigation.relation; + const { recordId } = navigation; + const base = baseRelation ? `${sourceTable}/${baseRelation}('${recordId}')` : `${sourceTable}('${recordId}')`; return queryString ? `/${base}/${relation}${queryString}` : `/${base}/${relation}`; } if (navigation?.relation) { - const { sourceTableName, basePath, relation } = navigation; - const base = basePath || sourceTableName; + const sourceTable = effectiveUseEntityIds + ? (navigation.sourceTableEntityId ?? navigation.sourceTableName) + : navigation.sourceTableName; + const basePath = effectiveUseEntityIds + ? (navigation.basePathEntityId ?? navigation.basePath) + : navigation.basePath; + const relation = effectiveUseEntityIds + ? (navigation.relationEntityId ?? navigation.relation) + : navigation.relation; + const base = basePath || sourceTable; return queryString ? `/${base}/${relation}${queryString}` : `/${base}/${relation}`; } return queryString ? `/${tableId}${queryString}` : `/${tableId}`; @@ -130,7 +168,8 @@ export class QueryUrlBuilder { navigateRelation?: string; }, ): string { - const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), this.context, options?.useEntityIds); + const effectiveUseEntityIds = options?.useEntityIds ?? this.useEntityIds; + const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds); // Build the base URL depending on whether this came from a navigated EntitySet let url: string; diff --git a/packages/fmodata/src/client/record-builder.ts b/packages/fmodata/src/client/record-builder.ts index b61049cd..4ceeb278 100644 --- a/packages/fmodata/src/client/record-builder.ts +++ b/packages/fmodata/src/client/record-builder.ts @@ -1,35 +1,40 @@ /** biome-ignore-all lint/complexity/noBannedTypes: Empty object type represents no expands by default */ import type { FFetchOptions } from "@fetchkit/ffetch"; -import { createLogger, type InternalLogger } from "../logger"; +import { Effect } from "effect"; +import { requestFromService, runLayerResult } from "../effect"; +import { BuilderInvariantError } from "../errors"; +import type { InternalLogger } from "../logger"; import type { Column } from "../orm/column"; import type { ExtractTableName, FMTable, InferSchemaOutputFromFMTable, ValidExpandTarget } from "../orm/table"; -import { getNavigationPaths, getTableName } from "../orm/table"; +import { getNavigationPaths, getTableName, isUsingEntityIds } from "../orm/table"; +import type { FMODataLayer, ODataConfig } from "../services"; import type { ConditionallyWithODataAnnotations, ConditionallyWithSpecialColumns, ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, - ExecutionContext, NormalizeIncludeSpecialColumns, ODataFieldResponse, Result, } from "../types"; import { buildSelectExpandQueryString, + cloneRecordReadBuilderState, + createInitialRecordReadBuilderState, createODataRequest, ExpandBuilder, type ExpandConfig, type ExpandedRelations, - getSchemaFromTable, mergeExecuteOptions, - processODataResponse, + processRecordResponse, processSelectWithRenames, resolveTableId, } from "./builders/index"; import { parseErrorResponse } from "./error-parser"; import type { ResolveExpandedRelations, SystemColumnsFromOption, SystemColumnsOption } from "./query/types"; import { QueryBuilder } from "./query-builder"; +import { createClientRuntime } from "./runtime"; import { safeJsonParse } from "./sanitize-json"; /** @@ -105,8 +110,6 @@ export class RecordBuilder< > { private readonly table: Occ; - private readonly databaseName: string; - private readonly context: ExecutionContext; private readonly recordId: string | number; private readonly operation?: "getSingleField" | "navigate"; private readonly operationParam?: string; @@ -114,36 +117,68 @@ export class RecordBuilder< private readonly operationColumn?: Column; private readonly isNavigateFromEntitySet?: boolean; private readonly navigateRelation?: string; + private readonly navigateRelationEntityId?: string; private readonly navigateSourceTableName?: string; + private readonly navigateSourceTableEntityId?: string; - private readonly databaseUseEntityIds: boolean; - private readonly databaseIncludeSpecialColumns: boolean; - - // Properties for select/expand support - private readonly selectedFields?: string[]; - private readonly expandConfigs: ExpandConfig[] = []; - // Mapping from field names to output keys (for renamed fields in select) - private readonly fieldMapping?: Record; - // System columns requested via select() second argument - private readonly systemColumns?: SystemColumnsOption; + private readState = createInitialRecordReadBuilderState(); + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; private readonly logger: InternalLogger; + // Compatibility accessors for internal modules that inspect builder internals via `as any`. + private get selectedFields(): string[] | undefined { + return this.readState.selectedFields; + } + + private set selectedFields(selectedFields: string[] | undefined) { + this.readState = cloneRecordReadBuilderState(this.readState, { + selectedFields, + }); + } + + private get expandConfigs(): ExpandConfig[] { + return this.readState.expandConfigs; + } + + private set expandConfigs(expandConfigs: ExpandConfig[]) { + this.readState = cloneRecordReadBuilderState(this.readState, { + expandConfigs, + }); + } + + private get fieldMapping(): Record | undefined { + return this.readState.fieldMapping; + } + + private set fieldMapping(fieldMapping: Record | undefined) { + this.readState = cloneRecordReadBuilderState(this.readState, { + fieldMapping, + }); + } + + private get systemColumns(): SystemColumnsOption | undefined { + return this.readState.systemColumns; + } + + private set systemColumns(systemColumns: SystemColumnsOption | undefined) { + this.readState = cloneRecordReadBuilderState(this.readState, { + systemColumns, + }); + } + constructor(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; + layer: FMODataLayer; recordId: string | number; - databaseUseEntityIds?: boolean; - databaseIncludeSpecialColumns?: boolean; }) { this.table = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; this.recordId = config.recordId; - this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; - this.databaseIncludeSpecialColumns = config.databaseIncludeSpecialColumns ?? false; - this.logger = config.context?._getLogger?.() ?? createLogger(); + const runtime = createClientRuntime(config.layer); + this.layer = runtime.layer; + this.config = runtime.config; + this.logger = runtime.logger; } /** @@ -154,10 +189,10 @@ export class RecordBuilder< useEntityIds?: boolean; includeSpecialColumns?: boolean; } { - const merged = mergeExecuteOptions(options, this.databaseUseEntityIds); + const merged = mergeExecuteOptions(options, this.config.useEntityIds); return { ...merged, - includeSpecialColumns: options?.includeSpecialColumns ?? this.databaseIncludeSpecialColumns, + includeSpecialColumns: options?.includeSpecialColumns ?? this.config.includeSpecialColumns, }; } @@ -167,9 +202,9 @@ export class RecordBuilder< */ private getTableId(useEntityIds?: boolean): string { if (!this.table) { - throw new Error("Table occurrence is required"); + throw new BuilderInvariantError("RecordBuilder", "table occurrence is required"); } - return resolveTableId(this.table, getTableName(this.table), this.context, useEntityIds); + return resolveTableId(this.table, getTableName(this.table), useEntityIds ?? this.config.useEntityIds); } /** @@ -197,24 +232,25 @@ export class RecordBuilder< NewSystemCols >({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, recordId: this.recordId, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Use type assertion to allow assignment to readonly properties on new instance // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern const mutableBuilder = newBuilder as any; - mutableBuilder.selectedFields = "selectedFields" in changes ? changes.selectedFields : this.selectedFields; - mutableBuilder.fieldMapping = "fieldMapping" in changes ? changes.fieldMapping : this.fieldMapping; - mutableBuilder.systemColumns = changes.systemColumns !== undefined ? changes.systemColumns : this.systemColumns; - mutableBuilder.expandConfigs = [...this.expandConfigs]; + mutableBuilder.readState = cloneRecordReadBuilderState(this.readState, { + selectedFields: "selectedFields" in changes ? changes.selectedFields : this.selectedFields, + fieldMapping: "fieldMapping" in changes ? changes.fieldMapping : this.fieldMapping, + systemColumns: changes.systemColumns !== undefined ? changes.systemColumns : this.systemColumns, + expandConfigs: this.expandConfigs, + }); // Preserve navigation context mutableBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet; mutableBuilder.navigateRelation = this.navigateRelation; + mutableBuilder.navigateRelationEntityId = this.navigateRelationEntityId; mutableBuilder.navigateSourceTableName = this.navigateSourceTableName; + mutableBuilder.navigateSourceTableEntityId = this.navigateSourceTableEntityId; mutableBuilder.operationColumn = this.operationColumn; return newBuilder; } @@ -233,7 +269,10 @@ export class RecordBuilder< // Runtime validation: ensure column is from the correct table const tableName = getTableName(this.table); if (!column.isFromTable(tableName)) { - throw new Error(`Column ${column.toString()} is not from table ${tableName}`); + throw new BuilderInvariantError( + "RecordBuilder.getSingleField", + `column ${column.toString()} is not from table ${tableName}`, + ); } const newBuilder = new RecordBuilder< @@ -245,11 +284,8 @@ export class RecordBuilder< DatabaseIncludeSpecialColumns >({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, recordId: this.recordId, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Use type assertion to allow assignment to readonly properties on new instance @@ -257,7 +293,6 @@ export class RecordBuilder< const mutableBuilder = newBuilder as any; mutableBuilder.operation = "getSingleField"; mutableBuilder.operationColumn = column; - mutableBuilder.operationParam = column.getFieldIdentifier(this.databaseUseEntityIds); // Preserve navigation context mutableBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet; mutableBuilder.navigateRelation = this.navigateRelation; @@ -393,28 +428,29 @@ export class RecordBuilder< // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExpandedRelations const newBuilder = new RecordBuilder({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, recordId: this.recordId, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, }); // Use type assertion to allow assignment to readonly properties on new instance // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern const mutableBuilder = newBuilder as any; // Copy existing state - mutableBuilder.selectedFields = this.selectedFields; - mutableBuilder.fieldMapping = this.fieldMapping; - mutableBuilder.systemColumns = this.systemColumns; - mutableBuilder.expandConfigs = [...this.expandConfigs]; + mutableBuilder.readState = cloneRecordReadBuilderState(this.readState, { + selectedFields: this.selectedFields, + fieldMapping: this.fieldMapping, + systemColumns: this.systemColumns, + expandConfigs: this.expandConfigs, + }); mutableBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet; mutableBuilder.navigateRelation = this.navigateRelation; + mutableBuilder.navigateRelationEntityId = this.navigateRelationEntityId; mutableBuilder.navigateSourceTableName = this.navigateSourceTableName; + mutableBuilder.navigateSourceTableEntityId = this.navigateSourceTableEntityId; mutableBuilder.operationColumn = this.operationColumn; // Use ExpandBuilder.processExpand to handle the expand logic - const expandBuilder = new ExpandBuilder(this.databaseUseEntityIds, this.logger); + const expandBuilder = new ExpandBuilder(this.config.useEntityIds, this.logger); type TargetBuilder = QueryBuilder, false, false, {}>; const expandConfig = expandBuilder.processExpand( targetTable, @@ -424,14 +460,13 @@ export class RecordBuilder< // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration new QueryBuilder({ occurrence: targetTable, - databaseName: this.databaseName, - context: this.context, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, + layer: this.layer, }), ); - mutableBuilder.expandConfigs.push(expandConfig); + mutableBuilder.readState = cloneRecordReadBuilderState(mutableBuilder.readState, { + expandConfigs: [...this.expandConfigs, expandConfig], + }); // biome-ignore lint/suspicious/noExplicitAny: Type assertion needed as expand changes generic parameters in complex way that TypeScript cannot infer return newBuilder as any; } @@ -465,44 +500,46 @@ export class RecordBuilder< // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any QueryBuilder configuration const builder = new QueryBuilder({ occurrence: targetTable, - databaseName: this.databaseName, - context: this.context, - databaseUseEntityIds: this.databaseUseEntityIds, - databaseIncludeSpecialColumns: this.databaseIncludeSpecialColumns, + layer: this.layer, }); // Store the navigation info - resolve entity ID for relation if needed - const relationId = resolveTableId(targetTable, relationName, this.context, this.databaseUseEntityIds); + const relationEntityId = isUsingEntityIds(targetTable) + ? resolveTableId(targetTable, relationName, true) + : relationName; // If this RecordBuilder came from a navigated EntitySet, we need to preserve that base path let sourceTableName: string; + let sourceTableEntityId: string; let baseRelation: string | undefined; + let baseRelationEntityId: string | undefined; if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { // Build the base path: /sourceTable/relation('recordId')/newRelation sourceTableName = this.navigateSourceTableName; + sourceTableEntityId = this.navigateSourceTableEntityId ?? sourceTableName; baseRelation = this.navigateRelation; + baseRelationEntityId = this.navigateRelationEntityId ?? baseRelation; } else { // Normal record navigation: /tableName('recordId')/relation // Use table ID if available, otherwise table name if (!this.table) { - throw new Error("Table occurrence is required for navigation"); + throw new BuilderInvariantError("RecordBuilder.navigate", "table occurrence is required for navigation"); } - sourceTableName = resolveTableId(this.table, getTableName(this.table), this.context, this.databaseUseEntityIds); + sourceTableName = getTableName(this.table); + sourceTableEntityId = isUsingEntityIds(this.table) + ? resolveTableId(this.table, sourceTableName, true) + : sourceTableName; } // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern (builder as any).navigation = { recordId: this.recordId, - relation: relationId, - sourceTableName, - baseRelation, - }; - // biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern - (builder as any).navigation = { - recordId: this.recordId, - relation: relationId, + relation: relationName, + relationEntityId, sourceTableName, + sourceTableEntityId, baseRelation, + baseRelationEntityId, }; return builder; @@ -513,9 +550,9 @@ export class RecordBuilder< */ private buildQueryString(includeSpecialColumns?: boolean, useEntityIds?: boolean): string { // Use merged includeSpecialColumns if provided, otherwise use database-level default - const finalIncludeSpecialColumns = includeSpecialColumns ?? this.databaseIncludeSpecialColumns; + const finalIncludeSpecialColumns = includeSpecialColumns ?? this.config.includeSpecialColumns; // Use merged useEntityIds if provided, otherwise use database-level default - const finalUseEntityIds = useEntityIds ?? this.databaseUseEntityIds; + const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds; return buildSelectExpandQueryString({ selectedFields: this.selectedFields, @@ -527,7 +564,7 @@ export class RecordBuilder< }); } - async execute( + execute( options?: ExecuteMethodOptions, ): Promise< Result< @@ -557,60 +594,79 @@ export class RecordBuilder< > > > { + const mergedOptions = this.mergeExecuteOptions(options); let url: string; // Build the base URL depending on whether this came from a navigated EntitySet if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { + const sourceSegment = mergedOptions.useEntityIds + ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName) + : this.navigateSourceTableName; + const relationSegment = mergedOptions.useEntityIds + ? (this.navigateRelationEntityId ?? this.navigateRelation) + : this.navigateRelation; // From navigated EntitySet: /sourceTable/relation('recordId') - url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`; + url = `/${this.config.databaseName}/${sourceSegment}/${relationSegment}('${this.recordId}')`; } else { // Normal record: /tableName('recordId') - use FMTID if configured - const tableId = this.getTableId(options?.useEntityIds ?? this.databaseUseEntityIds); - url = `/${this.databaseName}/${tableId}('${this.recordId}')`; + const tableId = this.getTableId(mergedOptions.useEntityIds); + url = `/${this.config.databaseName}/${tableId}('${this.recordId}')`; } - const mergedOptions = this.mergeExecuteOptions(options); - - if (this.operation === "getSingleField" && this.operationParam) { + if (this.operation === "getSingleField" && this.operationColumn) { + url += `/${this.operationColumn.getFieldIdentifier(mergedOptions.useEntityIds)}`; + } else if (this.operation === "getSingleField" && this.operationParam) { url += `/${this.operationParam}`; } else { // Add query string for select/expand (only when not getting a single field) const queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds); url += queryString; } - const result = await this.context._makeRequest(url, mergedOptions); + const pipeline = Effect.gen(this, function* () { + // Make GET request via DI + // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API + const response = yield* requestFromService(url, { + method: "GET", + ...mergedOptions, + }); - if (result.error) { - return { data: undefined, error: result.error }; - } + // Handle single field operation + if (this.operation === "getSingleField") { + // Single field returns a JSON object with @context and value + // The type is extracted from the Column stored in FieldColumn generic + // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API + const fieldResponse = response as ODataFieldResponse; + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type extraction + return fieldResponse.value as any; + } - const response = result.data; + const result = yield* Effect.tryPromise({ + try: () => + processRecordResponse(response, { + table: this.table, + selectedFields: this.selectedFields, + expandConfigs: this.expandConfigs, + skipValidation: options?.skipValidation, + useEntityIds: mergedOptions.useEntityIds, + includeSpecialColumns: mergedOptions.includeSpecialColumns, + fieldMapping: this.fieldMapping, + logger: this.logger, + }), + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }); - // Handle single field operation - if (this.operation === "getSingleField") { - // Single field returns a JSON object with @context and value - // The type is extracted from the Column stored in FieldColumn generic - // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - const fieldResponse = response as ODataFieldResponse; - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic type extraction - return { data: fieldResponse.value as any, error: undefined }; - } + if (result.error) { + return yield* Effect.fail(result.error); + } - // Use shared response processor - const expandBuilder = new ExpandBuilder(mergedOptions.useEntityIds ?? false, this.logger); - const expandValidationConfigs = expandBuilder.buildValidationConfigs(this.expandConfigs); + return result.data; + }); - return processODataResponse(response, { - table: this.table, - schema: getSchemaFromTable(this.table), - singleMode: "exact", - selectedFields: this.selectedFields, - expandValidationConfigs, - skipValidation: options?.skipValidation, - useEntityIds: mergedOptions.useEntityIds, - includeSpecialColumns: mergedOptions.includeSpecialColumns, - fieldMapping: this.fieldMapping, + const result = runLayerResult(this.layer, pipeline, "fmodata.record.get", { + "fmodata.table": getTableName(this.table), }); + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type + return result as Promise>; } // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value @@ -619,17 +675,23 @@ export class RecordBuilder< // Build the base URL depending on whether this came from a navigated EntitySet if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { + const sourceSegment = this.config.useEntityIds + ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName) + : this.navigateSourceTableName; + const relationSegment = this.config.useEntityIds + ? (this.navigateRelationEntityId ?? this.navigateRelation) + : this.navigateRelation; // From navigated EntitySet: /sourceTable/relation('recordId') - url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`; + url = `/${this.config.databaseName}/${sourceSegment}/${relationSegment}('${this.recordId}')`; } else { // For batch operations, use database-level setting (no per-request override available here) - const tableId = this.getTableId(this.databaseUseEntityIds); - url = `/${this.databaseName}/${tableId}('${this.recordId}')`; + const tableId = this.getTableId(this.config.useEntityIds); + url = `/${this.config.databaseName}/${tableId}('${this.recordId}')`; } if (this.operation === "getSingleField" && this.operationColumn) { // Use the column's getFieldIdentifier to support entity IDs - url += `/${this.operationColumn.getFieldIdentifier(this.databaseUseEntityIds)}`; + url += `/${this.operationColumn.getFieldIdentifier(this.config.useEntityIds)}`; } else if (this.operation === "getSingleField" && this.operationParam) { // Fallback for backwards compatibility (shouldn't happen in normal flow) url += `/${this.operationParam}`; @@ -649,12 +711,18 @@ export class RecordBuilder< * Returns the query string for this record builder (for testing purposes). */ getQueryString(options?: { useEntityIds?: boolean }): string { - const useEntityIds = options?.useEntityIds ?? this.databaseUseEntityIds; + const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds; let path: string; // Build the path depending on navigation context if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) { - path = `/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`; + const sourceSegment = useEntityIds + ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName) + : this.navigateSourceTableName; + const relationSegment = useEntityIds + ? (this.navigateRelationEntityId ?? this.navigateRelation) + : this.navigateRelation; + path = `/${sourceSegment}/${relationSegment}('${this.recordId}')`; } else { // Use getTableId to respect entity ID settings (same as getRequestConfig) const tableId = this.getTableId(useEntityIds); @@ -696,7 +764,7 @@ export class RecordBuilder< // Check for error responses (important for batch operations) if (!response.ok) { const tableName = this.table ? getTableName(this.table) : "unknown"; - const error = await parseErrorResponse(response, response.url || `/${this.databaseName}/${tableName}`); + const error = await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`); return { data: undefined, error }; } @@ -715,19 +783,15 @@ export class RecordBuilder< // Use shared response processor const mergedOptions = this.mergeExecuteOptions(options); - const expandBuilder = new ExpandBuilder(mergedOptions.useEntityIds ?? false, this.logger); - const expandValidationConfigs = expandBuilder.buildValidationConfigs(this.expandConfigs); - - return processODataResponse(rawResponse, { + return processRecordResponse(rawResponse, { table: this.table, - schema: getSchemaFromTable(this.table), - singleMode: "exact", selectedFields: this.selectedFields, - expandValidationConfigs, + expandConfigs: this.expandConfigs, skipValidation: options?.skipValidation, useEntityIds: mergedOptions.useEntityIds, includeSpecialColumns: mergedOptions.includeSpecialColumns, fieldMapping: this.fieldMapping, + logger: this.logger, }); } } diff --git a/packages/fmodata/src/client/response-processor.ts b/packages/fmodata/src/client/response-processor.ts deleted file mode 100644 index 4fb37c44..00000000 --- a/packages/fmodata/src/client/response-processor.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import type { ResponseStructureError, ValidationError } from "../errors"; -import type { FMTable } from "../orm/table"; -import { transformResponseFields } from "../transform"; -import type { ExpandValidationConfig } from "../validation"; -import { validateListResponse, validateRecord } from "../validation"; - -// Type for raw OData responses -export type ODataResponse = T & { - "@odata.context"?: string; - "@odata.count"?: number; -}; - -export type ODataListResponse = ODataResponse<{ - value: T[]; -}>; - -export type ODataRecordResponse = ODataResponse< - T & { - "@id"?: string; - "@editLink"?: string; - } ->; - -/** - * Transform field IDs back to names using the table configuration - */ -export function applyFieldTransformation>( - response: ODataResponse | ODataListResponse, - // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration - table: FMTable, - expandConfigs?: ExpandValidationConfig[], -): ODataResponse | ODataListResponse { - return transformResponseFields(response, table, expandConfigs) as ODataResponse | ODataListResponse; -} - -/** - * Apply schema validation and transformation to data - */ -export async function applyValidation>( - data: T | T[], - schema?: Record, - selectedFields?: (keyof T)[], - expandConfigs?: ExpandValidationConfig[], -): Promise<{ valid: true; data: T | T[] } | { valid: false; error: ValidationError | ResponseStructureError }> { - if (Array.isArray(data)) { - // Validate as a list - const validation = await validateListResponse( - { value: data }, - schema, - selectedFields as string[] | undefined, - expandConfigs, - ); - if (!validation.valid) { - return { valid: false, error: validation.error }; - } - return { valid: true, data: validation.data }; - } - // Validate as a single record - const validation = await validateRecord(data, schema, selectedFields, expandConfigs); - if (!validation.valid) { - return { valid: false, error: validation.error }; - } - return { valid: true, data: validation.data }; -} - -/** - * Extract value array from OData list response, or wrap single record in array - */ -export function extractListValue(response: ODataListResponse | ODataRecordResponse): T[] { - if ("value" in response && Array.isArray(response.value)) { - return response.value; - } - // Single record responses return the record directly - return [response as T]; -} diff --git a/packages/fmodata/src/client/runtime.ts b/packages/fmodata/src/client/runtime.ts new file mode 100644 index 00000000..9fdd234c --- /dev/null +++ b/packages/fmodata/src/client/runtime.ts @@ -0,0 +1,21 @@ +import type { InternalLogger } from "../logger"; +import { extractConfigFromLayer, type FMODataLayer, type ODataConfig } from "../services"; + +export interface ClientRuntime { + layer: FMODataLayer; + config: ODataConfig; + logger: InternalLogger; +} + +/** + * Single boundary for synchronous extraction of config/logger from the DI layer. + * Builder/manager constructors should call this once and pass runtime around. + */ +export function createClientRuntime(layer: FMODataLayer): ClientRuntime { + const { config, logger } = extractConfigFromLayer(layer); + return { + layer, + config, + logger, + }; +} diff --git a/packages/fmodata/src/client/schema-manager.ts b/packages/fmodata/src/client/schema-manager.ts index 7636cde3..3e5a424c 100644 --- a/packages/fmodata/src/client/schema-manager.ts +++ b/packages/fmodata/src/client/schema-manager.ts @@ -1,5 +1,8 @@ import type { FFetchOptions } from "@fetchkit/ffetch"; -import type { ExecutionContext } from "../types"; +import { Effect } from "effect"; +import { requestFromService, runLayerOrThrow } from "../effect"; +import type { FMODataLayer, ODataConfig } from "../services"; +import { createClientRuntime } from "./runtime"; interface GenericField { name: string; @@ -54,109 +57,94 @@ interface TableDefinition { } export class SchemaManager { - private readonly databaseName: string; - private readonly context: ExecutionContext; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; - constructor(databaseName: string, context: ExecutionContext) { - this.databaseName = databaseName; - this.context = context; + constructor(layer: FMODataLayer) { + const runtime = createClientRuntime(layer); + this.layer = runtime.layer; + this.config = runtime.config; } - async createTable( - tableName: string, - fields: Field[], - options?: RequestInit & FFetchOptions, - ): Promise { - const result = await this.context._makeRequest(`/${this.databaseName}/FileMaker_Tables`, { - method: "POST", - body: JSON.stringify({ - tableName, - fields: fields.map(SchemaManager.compileFieldDefinition), - }), - ...options, + createTable(tableName: string, fields: Field[], options?: RequestInit & FFetchOptions): Promise { + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables`, { + method: "POST", + body: JSON.stringify({ + tableName, + fields: fields.map(SchemaManager.compileFieldDefinition), + }), + ...options, + }); }); - if (result.error) { - throw result.error; - } - - return result.data; + return runLayerOrThrow(this.layer, pipeline, "fmodata.schema.createTable"); } - async addFields(tableName: string, fields: Field[], options?: RequestInit & FFetchOptions): Promise { - const result = await this.context._makeRequest( - `/${this.databaseName}/FileMaker_Tables/${tableName}`, - { + addFields(tableName: string, fields: Field[], options?: RequestInit & FFetchOptions): Promise { + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}`, { method: "PATCH", body: JSON.stringify({ fields: fields.map(SchemaManager.compileFieldDefinition), }), ...options, - }, - ); - - if (result.error) { - throw result.error; - } + }); + }); - return result.data; + return runLayerOrThrow(this.layer, pipeline, "fmodata.schema.addFields"); } async deleteTable(tableName: string, options?: RequestInit & FFetchOptions): Promise { - const result = await this.context._makeRequest(`/${this.databaseName}/FileMaker_Tables/${tableName}`, { - method: "DELETE", - ...options, + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}`, { + method: "DELETE", + ...options, + }); }); - if (result.error) { - throw result.error; - } + await runLayerOrThrow(this.layer, pipeline, "fmodata.schema.deleteTable"); } async deleteField(tableName: string, fieldName: string, options?: RequestInit & FFetchOptions): Promise { - const result = await this.context._makeRequest(`/${this.databaseName}/FileMaker_Tables/${tableName}/${fieldName}`, { - method: "DELETE", - ...options, + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}/${fieldName}`, { + method: "DELETE", + ...options, + }); }); - if (result.error) { - throw result.error; - } + await runLayerOrThrow(this.layer, pipeline, "fmodata.schema.deleteField"); } - async createIndex( + createIndex( tableName: string, fieldName: string, options?: RequestInit & FFetchOptions, ): Promise<{ indexName: string }> { - const result = await this.context._makeRequest<{ indexName: string }>( - `/${this.databaseName}/FileMaker_Indexes/${tableName}`, - { - method: "POST", - body: JSON.stringify({ indexName: fieldName }), - ...options, - }, - ); - - if (result.error) { - throw result.error; - } + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService<{ indexName: string }>( + `/${this.config.databaseName}/FileMaker_Indexes/${tableName}`, + { + method: "POST", + body: JSON.stringify({ indexName: fieldName }), + ...options, + }, + ); + }); - return result.data; + return runLayerOrThrow(this.layer, pipeline, "fmodata.schema.createIndex"); } async deleteIndex(tableName: string, fieldName: string, options?: RequestInit & FFetchOptions): Promise { - const result = await this.context._makeRequest( - `/${this.databaseName}/FileMaker_Indexes/${tableName}/${fieldName}`, - { + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Indexes/${tableName}/${fieldName}`, { method: "DELETE", ...options, - }, - ); + }); + }); - if (result.error) { - throw result.error; - } + await runLayerOrThrow(this.layer, pipeline, "fmodata.schema.deleteIndex"); } private static compileFieldDefinition(field: Field): FileMakerField { diff --git a/packages/fmodata/src/client/update-builder.ts b/packages/fmodata/src/client/update-builder.ts index f11bdfb1..fb614e81 100644 --- a/packages/fmodata/src/client/update-builder.ts +++ b/packages/fmodata/src/client/update-builder.ts @@ -1,12 +1,23 @@ -import type { FFetchOptions } from "@fetchkit/ffetch"; +import { Effect } from "effect"; +import { requestFromService, runLayerResult, tryEffect } from "../effect"; +import type { FMODataErrorType } from "../errors"; +import { BuilderInvariantError } from "../errors"; import type { FMTable, InferSchemaOutputFromFMTable } from "../orm/table"; -import { getBaseTableConfig, getTableId as getTableIdHelper, getTableName, isUsingEntityIds } from "../orm/table"; +import { getBaseTableConfig, getTableName } from "../orm/table"; +import type { FMODataLayer, ODataConfig } from "../services"; import { transformFieldNamesToIds } from "../transform"; -import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, ExecutionContext, Result } from "../types"; +import type { ExecutableBuilder, ExecuteMethodOptions, ExecuteOptions, Result } from "../types"; import { getAcceptHeader } from "../types"; import { validateAndTransformInput } from "../validation"; +import { + buildMutationUrl, + extractAffectedRows, + mergeMutationExecuteOptions, + resolveMutationTableId, +} from "./builders/mutation-helpers"; import { parseErrorResponse } from "./error-parser"; import { QueryBuilder } from "./query-builder"; +import { createClientRuntime } from "./runtime"; /** * Initial update builder returned from EntitySet.update(data) @@ -17,31 +28,24 @@ export class UpdateBuilder< Occ extends FMTable, ReturnPreference extends "minimal" | "representation" = "minimal", > { - private readonly databaseName: string; - private readonly context: ExecutionContext; private readonly table: Occ; private readonly data: Partial>; private readonly returnPreference: ReturnPreference; - - private readonly databaseUseEntityIds: boolean; - private readonly databaseIncludeSpecialColumns: boolean; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; constructor(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; + layer: FMODataLayer; data: Partial>; returnPreference: ReturnPreference; - databaseUseEntityIds?: boolean; - databaseIncludeSpecialColumns?: boolean; }) { this.table = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; + const runtime = createClientRuntime(config.layer); + this.layer = runtime.layer; this.data = config.data; this.returnPreference = config.returnPreference; - this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; - this.databaseIncludeSpecialColumns = config.databaseIncludeSpecialColumns ?? false; + this.config = runtime.config; } /** @@ -51,13 +55,11 @@ export class UpdateBuilder< byId(id: string | number): ExecutableUpdateBuilder { return new ExecutableUpdateBuilder({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, data: this.data, mode: "byId", recordId: id, returnPreference: this.returnPreference, - databaseUseEntityIds: this.databaseUseEntityIds, }); } @@ -70,8 +72,7 @@ export class UpdateBuilder< // Create a QueryBuilder for the user to configure const queryBuilder = new QueryBuilder({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, }); // Let the user configure it @@ -79,13 +80,11 @@ export class UpdateBuilder< return new ExecutableUpdateBuilder({ occurrence: this.table, - databaseName: this.databaseName, - context: this.context, + layer: this.layer, data: this.data, mode: "byFilter", queryBuilder: configuredBuilder, returnPreference: this.returnPreference, - databaseUseEntityIds: this.databaseUseEntityIds, }); } } @@ -103,219 +102,126 @@ export class ExecutableUpdateBuilder< > implements ExecutableBuilder> { - private readonly databaseName: string; - private readonly context: ExecutionContext; private readonly table: Occ; private readonly data: Partial>; private readonly mode: "byId" | "byFilter"; private readonly recordId?: string | number; private readonly queryBuilder?: QueryBuilder; private readonly returnPreference: ReturnPreference; - private readonly databaseUseEntityIds: boolean; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; constructor(config: { occurrence: Occ; - databaseName: string; - context: ExecutionContext; + layer: FMODataLayer; data: Partial>; mode: "byId" | "byFilter"; recordId?: string | number; queryBuilder?: QueryBuilder; returnPreference: ReturnPreference; - databaseUseEntityIds?: boolean; }) { this.table = config.occurrence; - this.databaseName = config.databaseName; - this.context = config.context; + this.layer = config.layer; this.data = config.data; this.mode = config.mode; this.recordId = config.recordId; this.queryBuilder = config.queryBuilder; this.returnPreference = config.returnPreference; - this.databaseUseEntityIds = config.databaseUseEntityIds ?? false; + const runtime = createClientRuntime(this.layer); + this.config = runtime.config; } - /** - * Helper to merge database-level useEntityIds with per-request options - */ - private mergeExecuteOptions( - options?: RequestInit & FFetchOptions & ExecuteOptions, - ): RequestInit & FFetchOptions & { useEntityIds?: boolean } { - // If useEntityIds is not set in options, use the database-level setting - return { - ...options, - useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds, - }; - } - - /** - * Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name - * @param useEntityIds - Optional override for entity ID usage - */ - private getTableId(useEntityIds?: boolean): string { - const contextDefault = this.context._getUseEntityIds?.() ?? false; - const shouldUseIds = useEntityIds ?? contextDefault; - - if (shouldUseIds) { - if (!isUsingEntityIds(this.table)) { - throw new Error( - `useEntityIds is true but table "${getTableName(this.table)}" does not have entity IDs configured`, - ); - } - return getTableIdHelper(this.table); - } - - return getTableName(this.table); - } - - async execute( + execute( options?: ExecuteMethodOptions, ): Promise< Result> > { - // Merge database-level useEntityIds with per-request options - const mergedOptions = this.mergeExecuteOptions(options); - - // Get table identifier with override support - const tableId = this.getTableId(mergedOptions.useEntityIds); - - // Validate and transform input data using input validators (writeValidators) - let validatedData = this.data; - if (this.table) { - const baseTableConfig = getBaseTableConfig(this.table); - const inputSchema = baseTableConfig.inputSchema; + const mergedOptions = mergeMutationExecuteOptions(options, this.config.useEntityIds); + // biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields + const { method: _method, body: _body, headers: callerHeaders, ...requestOptions } = mergedOptions as any; + const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds; + const tableId = resolveMutationTableId(this.table, shouldUseIds, "ExecutableUpdateBuilder"); + const url = buildMutationUrl({ + databaseName: this.config.databaseName, + tableId, + tableName: getTableName(this.table), + mode: this.mode, + recordId: this.recordId, + queryBuilder: this.queryBuilder, + useEntityIds: shouldUseIds, + builderName: "ExecutableUpdateBuilder", + }); - try { - validatedData = await validateAndTransformInput(this.data, inputSchema); - } catch (error) { - // If validation fails, return error immediately - return { - data: undefined, - error: error instanceof Error ? error : new Error(String(error)), - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - } as any; - } + const headers: Record = { "Content-Type": "application/json" }; + if (this.returnPreference === "representation") { + headers.Prefer = "return=representation"; } - // Transform field names to FMFIDs if using entity IDs - // Only transform if useEntityIds resolves to true (respects per-request override) - const shouldUseIds = mergedOptions.useEntityIds ?? false; - - const transformedData = - this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData; - - let url: string; - - if (this.mode === "byId") { - // Update single record by ID: PATCH /{database}/{table}('id') - url = `/${this.databaseName}/${tableId}('${this.recordId}')`; - } else { - // Update by filter: PATCH /{database}/{table}?$filter=... - if (!this.queryBuilder) { - throw new Error("Query builder is required for filter-based update"); + const pipeline = Effect.gen(this, function* () { + // Step 1: Validate input + let validatedData = this.data; + if (this.table) { + const baseTableConfig = getBaseTableConfig(this.table); + validatedData = yield* tryEffect( + () => validateAndTransformInput(this.data, baseTableConfig.inputSchema), + (e) => + (e instanceof Error + ? e + : new BuilderInvariantError("ExecutableUpdateBuilder.execute", String(e))) as FMODataErrorType, + ); } - // Get the query string from the configured QueryBuilder - const queryString = this.queryBuilder.getQueryString(); - // The query string will have the tableId already transformed by QueryBuilder - // Remove the leading "/" and table name from the query string as we'll build our own URL - const tableName = getTableName(this.table); - let queryParams: string; - if (queryString.startsWith(`/${tableId}`)) { - queryParams = queryString.slice(`/${tableId}`.length); - } else if (queryString.startsWith(`/${tableName}`)) { - queryParams = queryString.slice(`/${tableName}`.length); - } else { - queryParams = queryString; - } + // Step 2: Transform field names + const transformedData = + this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData; - url = `/${this.databaseName}/${tableId}${queryParams}`; - } + // Step 3: Make PATCH request via DI + const requestHeaders = new Headers(callerHeaders); + for (const [key, value] of Object.entries(headers)) { + requestHeaders.set(key, value); + } - // Set Prefer header based on returnPreference - const headers: Record = { - "Content-Type": "application/json", - }; + const response = yield* requestFromService(url, { + ...requestOptions, + method: "PATCH", + headers: requestHeaders, + body: JSON.stringify(transformedData), + }); - if (this.returnPreference === "representation") { - headers.Prefer = "return=representation"; - } + // Step 4: Handle response based on return preference + if (this.returnPreference === "representation") { + return response; + } - // Make PATCH request with JSON body - const result = await this.context._makeRequest(url, { - method: "PATCH", - headers, - body: JSON.stringify(transformedData), - ...mergedOptions, + const updatedCount = extractAffectedRows(response, undefined, 0, "updatedCount"); + return { updatedCount }; }); - if (result.error) { - return { data: undefined, error: result.error }; - } - - const response = result.data; - - // Handle based on return preference - if (this.returnPreference === "representation") { - // Return the full updated record - return { - data: response as ReturnPreference extends "minimal" - ? { updatedCount: number } - : InferSchemaOutputFromFMTable, - error: undefined, - }; - } - // Return updated count (minimal) - let updatedCount = 0; - - if (typeof response === "number") { - updatedCount = response; - } else if (response && typeof response === "object") { - // Check if the response has a count property (fallback) - // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - updatedCount = (response as any).updatedCount || 0; - } - - return { - data: { updatedCount } as ReturnPreference extends "minimal" - ? { updatedCount: number } - : InferSchemaOutputFromFMTable, - error: undefined, - }; + return runLayerResult(this.layer, pipeline, "fmodata.update", { + "fmodata.table": getTableName(this.table), + }) as Promise< + Result> + >; } // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value getRequestConfig(): { method: string; url: string; body?: any } { - // For batch operations, use database-level setting (no per-request override available here) - // Note: Input validation happens in execute() and processResponse() for batch operations - const tableId = this.getTableId(this.databaseUseEntityIds); + const tableId = resolveMutationTableId(this.table, this.config.useEntityIds, "ExecutableUpdateBuilder"); // Transform field names to FMFIDs if using entity IDs const transformedData = - this.table && this.databaseUseEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data; - - let url: string; - - if (this.mode === "byId") { - url = `/${this.databaseName}/${tableId}('${this.recordId}')`; - } else { - if (!this.queryBuilder) { - throw new Error("Query builder is required for filter-based update"); - } - - const queryString = this.queryBuilder.getQueryString(); - const tableName = getTableName(this.table); - let queryParams: string; - if (queryString.startsWith(`/${tableId}`)) { - queryParams = queryString.slice(`/${tableId}`.length); - } else if (queryString.startsWith(`/${tableName}`)) { - queryParams = queryString.slice(`/${tableName}`.length); - } else { - queryParams = queryString; - } - - url = `/${this.databaseName}/${tableId}${queryParams}`; - } + this.table && this.config.useEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data; + + const url = buildMutationUrl({ + databaseName: this.config.databaseName, + tableId, + tableName: getTableName(this.table), + mode: this.mode, + recordId: this.recordId, + queryBuilder: this.queryBuilder, + useEntityIds: this.config.useEntityIds, + builderName: "ExecutableUpdateBuilder", + }); return { method: "PATCH", @@ -347,16 +253,14 @@ export class ExecutableUpdateBuilder< // Check for error responses (important for batch operations) if (!response.ok) { const tableName = getTableName(this.table); - const error = await parseErrorResponse(response, response.url || `/${this.databaseName}/${tableName}`); + const error = await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`); return { data: undefined, error }; } // Check for empty response (204 No Content) const text = await response.text(); if (!text || text.trim() === "") { - // For 204 No Content, check the fmodata.affected_rows header - const affectedRows = response.headers.get("fmodata.affected_rows"); - const updatedCount = affectedRows ? Number.parseInt(affectedRows, 10) : 1; + const updatedCount = extractAffectedRows(undefined, response.headers, 1, "updatedCount"); return { data: { updatedCount } as ReturnPreference extends "minimal" ? { updatedCount: number } @@ -379,7 +283,10 @@ export class ExecutableUpdateBuilder< } catch (error) { return { data: undefined, - error: error instanceof Error ? error : new Error(String(error)), + error: + error instanceof Error + ? error + : new BuilderInvariantError("ExecutableUpdateBuilder.processResponse", String(error)), // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type } as any; } @@ -396,15 +303,7 @@ export class ExecutableUpdateBuilder< }; } // Return updated count (minimal) - let updatedCount = 0; - - if (typeof rawResponse === "number") { - updatedCount = rawResponse; - } else if (rawResponse && typeof rawResponse === "object") { - // Check if the response has a count property (fallback) - // biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API - updatedCount = (rawResponse as any).updatedCount || 0; - } + const updatedCount = extractAffectedRows(rawResponse, response.headers, 0, "updatedCount"); return { data: { updatedCount } as ReturnPreference extends "minimal" diff --git a/packages/fmodata/src/client/webhook-builder.ts b/packages/fmodata/src/client/webhook-builder.ts index d102543f..3234fcd6 100644 --- a/packages/fmodata/src/client/webhook-builder.ts +++ b/packages/fmodata/src/client/webhook-builder.ts @@ -1,8 +1,12 @@ +import { Effect } from "effect"; +import { requestFromService, runLayerOrThrow } from "../effect"; import { type FMTable, getTableName } from "../orm"; import { type Column, isColumn } from "../orm/column"; import { FilterExpression } from "../orm/operators"; -import type { ExecuteMethodOptions, ExecutionContext } from "../types"; +import type { FMODataLayer, ODataConfig } from "../services"; +import type { ExecuteMethodOptions } from "../types"; import { formatSelectFields } from "./builders/select-utils"; +import { createClientRuntime } from "./runtime"; export interface Webhook { webhook: string; @@ -46,12 +50,13 @@ export interface WebhookAddResponse { } export class WebhookManager { - private readonly databaseName: string; - private readonly context: ExecutionContext; + private readonly layer: FMODataLayer; + private readonly config: ODataConfig; - constructor(databaseName: string, context: ExecutionContext) { - this.databaseName = databaseName; - this.context = context; + constructor(layer: FMODataLayer) { + const runtime = createClientRuntime(layer); + this.layer = runtime.layer; + this.config = runtime.config; } /** @@ -84,12 +89,12 @@ export class WebhookManager { * }); * ``` */ - async add(webhook: Webhook, options?: ExecuteMethodOptions): Promise { + add(webhook: Webhook, options?: ExecuteMethodOptions): Promise { // Extract the string table name from the FMTable instance const tableName = getTableName(webhook.tableName); - // Get useEntityIds setting (check options first, then context, default to false) - const useEntityIds = options?.useEntityIds ?? this.context._getUseEntityIds?.() ?? false; + // Get useEntityIds setting (check options first, then config, default to false) + const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds ?? false; // Transform filter if it's a FilterExpression let filter: string | undefined; @@ -146,17 +151,15 @@ export class WebhookManager { requestBody.filter = filter; } - const result = await this.context._makeRequest(`/${this.databaseName}/Webhook.Add`, { - method: "POST", - body: JSON.stringify(requestBody), - ...options, + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/Webhook.Add`, { + method: "POST", + body: JSON.stringify(requestBody), + ...options, + }); }); - if (result.error) { - throw result.error; - } - - return result.data; + return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.add"); } /** @@ -169,14 +172,14 @@ export class WebhookManager { * ``` */ async remove(webhookId: number, options?: ExecuteMethodOptions): Promise { - const result = await this.context._makeRequest(`/${this.databaseName}/Webhook.Delete(${webhookId})`, { - method: "POST", - ...options, + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/Webhook.Delete(${webhookId})`, { + method: "POST", + ...options, + }); }); - if (result.error) { - throw result.error; - } + await runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.remove"); } /** @@ -189,17 +192,12 @@ export class WebhookManager { * // webhook.webhookID, webhook.tableName, webhook.webhook, etc. * ``` */ - async get(webhookId: number, options?: ExecuteMethodOptions): Promise { - const result = await this.context._makeRequest( - `/${this.databaseName}/Webhook.Get(${webhookId})`, - options, - ); - - if (result.error) { - throw result.error; - } + get(webhookId: number, options?: ExecuteMethodOptions): Promise { + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/Webhook.Get(${webhookId})`, options); + }); - return result.data; + return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.get"); } /** @@ -212,17 +210,12 @@ export class WebhookManager { * // result.webhooks contains the array of webhooks * ``` */ - async list(options?: ExecuteMethodOptions): Promise { - const result = await this.context._makeRequest( - `/${this.databaseName}/Webhook.GetAll`, - options, - ); - - if (result.error) { - throw result.error; - } + list(options?: ExecuteMethodOptions): Promise { + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/Webhook.GetAll`, options); + }); - return result.data; + return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.list"); } /** @@ -240,26 +233,20 @@ export class WebhookManager { * await db.webhook.invoke(1, { rowIDs: [63, 61] }); * ``` */ - async invoke( - webhookId: number, - options?: { rowIDs?: number[] }, - executeOptions?: ExecuteMethodOptions, - ): Promise { + invoke(webhookId: number, options?: { rowIDs?: number[] }, executeOptions?: ExecuteMethodOptions): Promise { const body: { rowIDs?: number[] } = {}; if (options?.rowIDs !== undefined) { body.rowIDs = options.rowIDs; } - const result = await this.context._makeRequest(`/${this.databaseName}/Webhook.Invoke(${webhookId})`, { - method: "POST", - body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined, - ...executeOptions, + const pipeline = Effect.gen(this, function* () { + return yield* requestFromService(`/${this.config.databaseName}/Webhook.Invoke(${webhookId})`, { + method: "POST", + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined, + ...executeOptions, + }); }); - if (result.error) { - throw result.error; - } - - return result.data; + return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.invoke"); } } diff --git a/packages/fmodata/src/effect.ts b/packages/fmodata/src/effect.ts new file mode 100644 index 00000000..1cead7a3 --- /dev/null +++ b/packages/fmodata/src/effect.ts @@ -0,0 +1,195 @@ +/** + * Effect.ts integration for fmodata. + * + * Provides Effect-based wrappers around the core fmodata operations, + * enabling composable error handling, retry policies, and typed error channels. + * + * This module is used internally by builders to reduce error-threading boilerplate. + * The public API surface (Result) remains unchanged. + */ + +import type { FFetchOptions } from "@fetchkit/ffetch"; +import { Effect, Schedule } from "effect"; +import type { FMODataErrorType } from "./errors"; +import { BuilderInvariantError, isFMODataError, isTransientError } from "./errors"; +import type { + FMODataLayer, + HttpClient as HttpClientService, + ODataConfig as ODataConfigService, + ODataLogger as ODataLoggerService, +} from "./services"; +import { HttpClient } from "./services"; +import type { Result, RetryPolicy } from "./types"; + +type FMODataServices = HttpClientService | ODataConfigService | ODataLoggerService; + +/** + * Converts a Promise> into an Effect with typed error channel. + * This is the bridge between the existing Result pattern and Effect pipelines. + */ +export function fromResult(promise: Promise>): Effect.Effect { + return Effect.tryPromise({ + try: () => promise, + catch: (e) => e as FMODataErrorType, + }).pipe(Effect.flatMap((result) => (result.error ? Effect.fail(result.error) : Effect.succeed(result.data)))); +} + +/** + * Creates an Effect that yields the HttpClient service and makes a request. + * This is the primary way builders should make HTTP requests. + */ +export function requestFromService( + url: string, + options?: RequestInit & + FFetchOptions & { + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + includeODataAnnotations?: boolean; + retryPolicy?: RetryPolicy; + }, +): Effect.Effect { + return Effect.flatMap(HttpClient, (client) => client.request(url, options)); +} + +/** + * Runs an Effect pipeline and converts the result back to the fmodata Result type. + * This is the exit point from Effect back to the public API. + */ +export function runAsResult(effect: Effect.Effect): Promise> { + return Effect.runPromise( + effect.pipe( + Effect.map((data): Result => ({ data, error: undefined })), + Effect.catchAll((error) => Effect.succeed>({ data: undefined, error })), + ), + ).catch((defect) => ({ + data: undefined, + error: isFMODataError(defect) ? defect : new BuilderInvariantError("runAsResult", String(defect)), + })); +} + +function withOptionalSpan( + effect: Effect.Effect, + spanName?: string, + attributes?: Record, +): Effect.Effect { + if (!spanName) { + return effect; + } + return withSpan(effect, spanName, attributes); +} + +/** + * Runs an Effect by providing the shared DI layer and returns fmodata Result. + */ +export function runLayerResult( + layer: FMODataLayer, + effect: Effect.Effect, + spanName?: string, + attributes?: Record, +): Promise> { + const provided = Effect.provide(withOptionalSpan(effect, spanName, attributes), layer); + return runAsResult(provided); +} + +/** + * Runs an Effect by providing the shared DI layer and throws on fmodata errors. + */ +export async function runLayerOrThrow( + layer: FMODataLayer, + effect: Effect.Effect, + spanName?: string, + attributes?: Record, +): Promise { + const result = await runLayerResult(layer, effect, spanName, attributes); + if (result.error) { + throw result.error; + } + return result.data; +} + +/** + * Convenience wrapper for request-like effects where span instrumentation is always desired. + */ +export function requestWithSpan( + layer: FMODataLayer, + spanName: string, + requestEffect: Effect.Effect, + attributes?: Record, +): Promise> { + return runLayerResult(layer, requestEffect, spanName, attributes); +} + +/** + * Wraps a sync/async function that may throw into an Effect that captures + * the error as a typed FMODataErrorType. + */ +export function tryEffect( + fn: () => T | Promise, + mapError: (e: unknown) => FMODataErrorType, +): Effect.Effect { + return Effect.tryPromise({ + try: () => Promise.resolve(fn()), + catch: mapError, + }); +} + +/** + * Wraps a function that returns a validation-style result + * ({ valid: true, data } | { valid: false, error }) into an Effect. + */ +export function fromValidation( + fn: () => Promise<{ valid: true; data: T } | { valid: false; error: FMODataErrorType }>, +): Effect.Effect { + return Effect.tryPromise({ + try: fn, + catch: (e) => e as FMODataErrorType, + }).pipe(Effect.flatMap((result) => (result.valid ? Effect.succeed(result.data) : Effect.fail(result.error)))); +} + +/** + * Builds an Effect Schedule from a RetryPolicy configuration. + * Uses exponential backoff with optional jitter, only retrying transient errors. + */ +export function buildRetrySchedule(policy: RetryPolicy) { + const maxRetries = policy.maxRetries ?? 3; + const baseDelay = `${policy.baseDelay ?? 500} millis` as const; + const useJitter = policy.jitter !== false; + + const base = Schedule.exponential(baseDelay); + const withJitter = useJitter ? Schedule.jittered(base) : base; + + return withJitter.pipe( + Schedule.intersect(Schedule.recurs(maxRetries)), + Schedule.whileInput((error: FMODataErrorType) => isTransientError(error)), + ); +} + +/** + * Applies a retry policy to an Effect if the policy is defined. + * Only retries transient errors (SchemaLockedError, NetworkError, TimeoutError, HTTP 5xx). + */ +export function withRetryPolicy( + effect: Effect.Effect, + retryPolicy?: RetryPolicy, +): Effect.Effect { + if (!retryPolicy) { + return effect; + } + return effect.pipe(Effect.retry(buildRetrySchedule(retryPolicy))); +} + +/** + * Wraps an Effect with a tracing span for observability. + * Zero overhead when no OpenTelemetry tracer is configured. + */ +export function withSpan( + effect: Effect.Effect, + name: string, + attributes?: Record, +): Effect.Effect { + return effect.pipe( + Effect.withSpan(name, { + attributes: attributes ? attributes : undefined, + }), + ); +} diff --git a/packages/fmodata/src/errors.ts b/packages/fmodata/src/errors.ts index 6084f56a..5c5359cb 100644 --- a/packages/fmodata/src/errors.ts +++ b/packages/fmodata/src/errors.ts @@ -112,7 +112,8 @@ export class ValidationError extends FMODataError { cause?: Error["cause"]; }, ) { - super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); + const cause = options?.cause ?? issues; + super(message, { cause }); this.field = options?.field; this.issues = issues; this.value = options?.value; @@ -180,6 +181,59 @@ export class BatchTruncatedError extends FMODataError { } } +// ============================================ +// Internal Runtime/Invariant Errors +// ============================================ + +export class MissingLayerServiceError extends FMODataError { + readonly kind = "MissingLayerServiceError" as const; + readonly service: string; + + constructor(service: string, options?: { cause?: Error }) { + super( + `Required layer service "${service}" is not available`, + options?.cause ? { cause: options.cause } : undefined, + ); + this.service = service; + } +} + +export class MetadataNotFoundError extends FMODataError { + readonly kind = "MetadataNotFoundError" as const; + readonly databaseName: string; + + constructor(databaseName: string) { + super(`Metadata for database "${databaseName}" not found in response`); + this.databaseName = databaseName; + } +} + +export class BuilderInvariantError extends FMODataError { + readonly kind = "BuilderInvariantError" as const; + readonly builder: string; + + constructor(builder: string, message: string, options?: { cause?: Error }) { + super(`${builder} invariant violation: ${message}`, options?.cause ? { cause: options.cause } : undefined); + this.builder = builder; + } +} + +export class SchemaValidationFailedError extends FMODataError { + readonly kind = "SchemaValidationFailedError" as const; + readonly operation: string; + readonly issues?: readonly StandardSchemaV1.Issue[]; + + constructor( + operation: string, + message: string, + options?: { issues?: readonly StandardSchemaV1.Issue[]; cause?: Error }, + ) { + super(`${operation} schema validation failed: ${message}`, options?.cause ? { cause: options.cause } : undefined); + this.operation = operation; + this.issues = options?.issues; + } +} + // ============================================ // Type Guards // ============================================ @@ -216,10 +270,51 @@ export function isBatchTruncatedError(error: unknown): error is BatchTruncatedEr return error instanceof BatchTruncatedError; } +export function isMissingLayerServiceError(error: unknown): error is MissingLayerServiceError { + return error instanceof MissingLayerServiceError; +} + +export function isMetadataNotFoundError(error: unknown): error is MetadataNotFoundError { + return error instanceof MetadataNotFoundError; +} + +export function isBuilderInvariantError(error: unknown): error is BuilderInvariantError { + return error instanceof BuilderInvariantError; +} + +export function isSchemaValidationFailedError(error: unknown): error is SchemaValidationFailedError { + return error instanceof SchemaValidationFailedError; +} + export function isFMODataError(error: unknown): error is FMODataError { return error instanceof FMODataError; } +/** + * Determines whether an error is transient and safe to retry. + * Transient errors include: + * - SchemaLockedError (FM code 303 — file locked temporarily) + * - NetworkError (connection issues) + * - TimeoutError (request timed out) + * - HTTP 5xx errors (server-side failures) + */ +export function isTransientError(error: unknown): boolean { + if (error instanceof SchemaLockedError) { + return true; + } + // Check ffetch error types by name since they aren't subclasses of FMODataError + if (error && typeof error === "object") { + const name = Reflect.get(error, "name"); + if (typeof name === "string" && (name === "NetworkError" || name === "TimeoutError")) { + return true; + } + } + if (error instanceof HTTPError && error.is5xx()) { + return true; + } + return false; +} + // ============================================ // Union type for all possible errors // ============================================ @@ -247,4 +342,8 @@ export type FMODataErrorType = | RecordCountMismatchError | InvalidLocationHeaderError | ResponseParseError - | BatchTruncatedError; + | BatchTruncatedError + | MissingLayerServiceError + | MetadataNotFoundError + | BuilderInvariantError + | SchemaValidationFailedError; diff --git a/packages/fmodata/src/index.ts b/packages/fmodata/src/index.ts index 709a31d7..fbc454c9 100644 --- a/packages/fmodata/src/index.ts +++ b/packages/fmodata/src/index.ts @@ -46,6 +46,7 @@ export { isResponseParseError, isResponseStructureError, isSchemaLockedError, + isTransientError, isValidationError, ODataError, RecordCountMismatchError, @@ -124,4 +125,5 @@ export type { Metadata, ODataRecordMetadata, Result, + RetryPolicy, } from "./types"; diff --git a/packages/fmodata/src/services.ts b/packages/fmodata/src/services.ts new file mode 100644 index 00000000..2fe9e1cd --- /dev/null +++ b/packages/fmodata/src/services.ts @@ -0,0 +1,103 @@ +/** + * Effect Service definitions for fmodata. + * + * These services replace the monolithic ExecutionContext interface with + * composable, mockable Effect services. Each service has a single responsibility: + * - HttpClient: HTTP request execution + * - ODataConfig: Connection and database configuration + * - ODataLogger: Logging + * + * Services are combined into Layers provided by FMServerConnection (production) + * or MockFMServerConnection (testing). + */ + +import type { FFetchOptions } from "@fetchkit/ffetch"; +import { Context, Effect, Layer } from "effect"; +import type { FMODataErrorType } from "./errors"; +import { MissingLayerServiceError } from "./errors"; +import type { InternalLogger } from "./logger"; + +// --- HttpClient Service --- + +export interface HttpClient { + readonly request: ( + url: string, + options?: RequestInit & + FFetchOptions & { + useEntityIds?: boolean; + includeSpecialColumns?: boolean; + includeODataAnnotations?: boolean; + retryPolicy?: import("./types").RetryPolicy; + }, + ) => Effect.Effect; +} + +export const HttpClient = Context.GenericTag("@proofkit/fmodata/HttpClient"); + +// --- ODataConfig Service --- + +export interface ODataConfig { + readonly baseUrl: string; + readonly databaseName: string; + readonly useEntityIds: boolean; + readonly includeSpecialColumns: boolean; +} + +export const ODataConfig = Context.GenericTag("@proofkit/fmodata/ODataConfig"); + +// --- ODataLogger Service --- + +export interface ODataLogger { + readonly logger: InternalLogger; +} + +export const ODataLogger = Context.GenericTag("@proofkit/fmodata/ODataLogger"); + +// --- Combined layer type --- + +export type FMODataLayer = Layer.Layer; + +// --- Layer utilities --- + +/** + * Extracts ODataConfig and ODataLogger values from a Layer synchronously. + * Used by builders to access config in non-Effect methods (getRequestConfig, toRequest, etc.) + */ +export function extractConfigFromLayer(layer: FMODataLayer): { config: ODataConfig; logger: InternalLogger } { + const effect = Effect.gen(function* () { + const config = yield* ODataConfig.pipe( + Effect.mapError((error) => new MissingLayerServiceError("ODataConfig", { cause: error as Error })), + ); + const { logger } = yield* ODataLogger.pipe( + Effect.mapError((error) => new MissingLayerServiceError("ODataLogger", { cause: error as Error })), + ); + return { config, logger }; + }); + return Effect.runSync(Effect.provide(effect, layer)); +} + +/** + * Creates a database-scoped Layer by overriding ODataConfig with database-specific values. + * The HttpClient and ODataLogger services are preserved from the base layer. + */ +export function createDatabaseLayer( + baseLayer: FMODataLayer, + overrides: { + databaseName: string; + useEntityIds: boolean; + includeSpecialColumns: boolean; + }, +): FMODataLayer { + // Extract base config to get baseUrl + const { config: baseConfig } = extractConfigFromLayer(baseLayer); + + const dbConfigLayer = Layer.succeed(ODataConfig, { + baseUrl: baseConfig.baseUrl, + databaseName: overrides.databaseName, + useEntityIds: overrides.useEntityIds, + includeSpecialColumns: overrides.includeSpecialColumns, + }); + + // Merge: dbConfigLayer overrides ODataConfig, but HttpClient and ODataLogger come from baseLayer + return Layer.merge(baseLayer, dbConfigLayer); +} diff --git a/packages/fmodata/src/testing.ts b/packages/fmodata/src/testing.ts new file mode 100644 index 00000000..46d30f1e --- /dev/null +++ b/packages/fmodata/src/testing.ts @@ -0,0 +1,299 @@ +/** + * Testing utilities for fmodata. + * + * Provides MockFMServerConnection and helper functions for writing tests + * without per-request fetchHandler overrides. + * + * @example + * ```ts + * import { MockFMServerConnection } from "@proofkit/fmodata/testing"; + * + * const mock = new MockFMServerConnection(); + * mock.addRoute({ + * urlPattern: "/testdb/contacts", + * response: { value: [{ id: "1", name: "Alice" }] }, + * }); + * const db = mock.database("testdb"); + * const result = await db.from(contacts).list().execute(); + * ``` + */ + +import type { Database } from "./client/database"; +import { FMServerConnection } from "./client/filemaker-odata"; + +// --- MockRoute type --- + +export interface MockRoute { + /** URL pattern to match against. String matches with `includes()`, RegExp tests the full URL. */ + urlPattern: string | RegExp; + /** HTTP method to match (case-insensitive). If omitted, matches any method. */ + method?: string; + /** Response data. Arrays are wrapped in OData `{ value: [...] }` format. Objects are sent as-is. */ + response: unknown; + /** HTTP status code (default: 200) */ + status?: number; + /** Response headers */ + headers?: Record; + /** If set, the fetch handler rejects with this error (simulates network failure). */ + throwError?: Error; +} + +// --- RequestSpy type --- + +export interface RequestSpy { + /** All recorded requests */ + readonly calls: ReadonlyArray<{ url: string; method: string; body?: string; headers?: Record }>; + /** Clear recorded calls */ + clear(): void; + /** Get calls matching a URL pattern */ + forUrl(pattern: string | RegExp): ReadonlyArray<{ url: string; method: string; body?: string }>; +} + +/** + * Strips @id and @editLink fields from response data when Accept header requests no metadata. + */ +function stripODataAnnotations(data: unknown): unknown { + if (Array.isArray(data)) { + return data.map(stripODataAnnotations); + } + if (data && typeof data === "object") { + const { "@id": _id, "@editLink": _editLink, ...rest } = data as Record; + const result: Record = {}; + for (const [key, value] of Object.entries(rest)) { + result[key] = stripODataAnnotations(value); + } + return result; + } + return data; +} + +/** + * Creates a router-style fetch handler that matches requests against a list of MockRoutes. + * The routes array is captured by reference, so routes added later are picked up automatically. + */ +function createRouterFetch( + routes: MockRoute[], + spy?: { calls: Array<{ url: string; method: string; body?: string; headers?: Record }> }, +): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else { + url = input.url; + } + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + + // Record the call if spy is active + if (spy) { + let body: string | undefined; + if (typeof init?.body === "string") { + body = init.body; + } else if (input instanceof Request) { + // ffetch wraps everything in a Request object, so body/headers may only be on `input` + try { + body = await input.clone().text(); + if (body === "") { + body = undefined; + } + } catch { + // body may not be readable + } + } + + const headers: Record = {}; + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((v, k) => { + headers[k] = v; + }); + } else if (!Array.isArray(init.headers)) { + Object.assign(headers, init.headers); + } + } else if (input instanceof Request) { + input.headers.forEach((v, k) => { + headers[k] = v; + }); + } + spy.calls.push({ url, method, body, headers }); + } + + // 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 methodMatch = !r.method || r.method.toUpperCase() === method.toUpperCase(); + return urlMatch && methodMatch; + }); + + if (route?.throwError) { + throw route.throwError; + } + + if (!route) { + return new Response( + JSON.stringify({ error: { message: `No mock route for ${method} ${url}`, code: "MOCK_NOT_FOUND" } }), + { + status: 404, + statusText: "Not Found (No Mock Route)", + headers: { "content-type": "application/json" }, + }, + ); + } + + const status = route.status ?? 200; + const contentType = route.headers?.["content-type"] ?? "application/json"; + const responseHeaders = new Headers({ "content-type": contentType }); + + // Add custom headers + if (route.headers) { + for (const [key, value] of Object.entries(route.headers)) { + if (key !== "content-type" && value) { + responseHeaders.set(key, value); + } + } + } + + // Handle 204 No Content + if (status === 204) { + return new Response(null, { status, statusText: "No Content", headers: responseHeaders }); + } + + // Determine if annotations should be stripped + let acceptHeader = ""; + if (input instanceof Request) { + acceptHeader = input.headers.get("Accept") ?? ""; + } else if (init?.headers) { + if (init.headers instanceof Headers) { + acceptHeader = init.headers.get("Accept") ?? ""; + } else if (Array.isArray(init.headers)) { + const acceptEntry = init.headers.find(([key]) => key.toLowerCase() === "accept"); + acceptHeader = acceptEntry?.[1] ?? ""; + } else { + acceptHeader = + (init.headers as Record).Accept ?? (init.headers as Record).accept ?? ""; + } + } + const shouldStripAnnotations = acceptHeader.includes("odata.metadata=none"); + + // Build response body + let responseData = route.response; + if (Array.isArray(responseData)) { + responseData = { value: responseData }; + } + if (shouldStripAnnotations && responseData) { + responseData = stripODataAnnotations(responseData); + } + + let body: string | null; + if (responseData === null || responseData === undefined) { + body = null; + } else if (typeof responseData === "string") { + body = responseData; + } else { + body = JSON.stringify(responseData); + } + + return new Response(body, { + status, + statusText: status >= 200 && status < 300 ? "OK" : "Error", + headers: responseHeaders, + }); + }; +} + +/** + * A mock FMServerConnection for testing. + * + * Wraps a real FMServerConnection with a router-style fetch handler, + * so the full HTTP parsing pipeline (error classification, OData handling, etc.) + * is exercised in tests. + * + * Routes can be added at construction time or dynamically via `.addRoute()`. + */ +export class MockFMServerConnection { + private readonly routes: MockRoute[]; + private readonly connection: FMServerConnection; + private readonly _spy?: { + calls: Array<{ url: string; method: string; body?: string; headers?: Record }>; + }; + + constructor(config?: { + routes?: MockRoute[]; + baseUrl?: string; + enableSpy?: boolean; + }) { + this.routes = config?.routes ? [...config.routes] : []; + this._spy = config?.enableSpy ? { calls: [] } : undefined; + + this.connection = new FMServerConnection({ + serverUrl: config?.baseUrl ?? "https://test.example.com", + auth: { apiKey: "test-api-key" }, + fetchClientOptions: { + retries: 0, + fetchHandler: createRouterFetch(this.routes, this._spy), + }, + }); + } + + /** + * Add a route to the mock. Routes added after construction are picked up + * automatically by subsequent requests. Routes are matched in order, + * and the first matching route wins. + */ + addRoute(route: MockRoute): this { + this.routes.push(route); + return this; + } + + /** + * Set multiple routes, replacing any existing routes. + */ + setRoutes(routes: MockRoute[]): this { + this.routes.length = 0; + this.routes.push(...routes); + return this; + } + + /** + * Get the request spy (only available if `enableSpy: true` was passed to constructor). + */ + get spy(): RequestSpy | undefined { + if (!this._spy) { + return undefined; + } + const spy = this._spy; + return { + get calls() { + return spy.calls; + }, + clear() { + spy.calls.length = 0; + }, + forUrl(pattern: string | RegExp) { + return spy.calls.filter((c) => (typeof pattern === "string" ? c.url.includes(pattern) : pattern.test(c.url))); + }, + }; + } + + /** + * Create a Database instance, same API as FMServerConnection.database(). + */ + database( + name: string, + config?: { + useEntityIds?: boolean; + includeSpecialColumns?: IncludeSpecialColumns; + }, + ): Database { + return this.connection.database(name, config); + } + + /** + * Get the underlying FMServerConnection (for cases that need the real type). + */ + get asConnection(): FMServerConnection { + return this.connection; + } +} diff --git a/packages/fmodata/src/types.ts b/packages/fmodata/src/types.ts index 93aab60c..3cc0e063 100644 --- a/packages/fmodata/src/types.ts +++ b/packages/fmodata/src/types.ts @@ -1,6 +1,5 @@ import type { FFetchOptions } from "@fetchkit/ffetch"; import type { StandardSchemaV1 } from "@standard-schema/spec"; -import type { InternalLogger } from "./logger"; export type Auth = { username: string; password: string } | { apiKey: string }; @@ -28,20 +27,12 @@ export interface ExecutableBuilder { } export interface ExecutionContext { - _makeRequest( - url: string, - options?: RequestInit & - FFetchOptions & { - useEntityIds?: boolean; - includeSpecialColumns?: boolean; - }, - ): Promise>; - _setUseEntityIds?(useEntityIds: boolean): void; - _getUseEntityIds?(): boolean; - _setIncludeSpecialColumns?(includeSpecialColumns: boolean): void; - _getIncludeSpecialColumns?(): boolean; - _getBaseUrl?(): string; - _getLogger?(): InternalLogger; + /** + * @internal + * Returns the Effect Layer for this context, enabling service-based Effect pipelines. + * All HTTP requests are made through the Layer's HttpClient service. + */ + _getLayer?(): import("./services").FMODataLayer; } export type InferSchemaType> = { @@ -146,6 +137,25 @@ type _ComputeInsertData< Exclude, ExcludedFields> >; +/** + * Configuration for automatic retry of transient errors. + * Uses exponential backoff with optional jitter. + */ +export interface RetryPolicy { + /** + * Maximum number of retry attempts (default: 3) + */ + maxRetries?: number; + /** + * Base delay in milliseconds for exponential backoff (default: 500) + */ + baseDelay?: number; + /** + * Whether to add random jitter to delay to prevent thundering herd (default: true) + */ + jitter?: boolean; +} + export interface ExecuteOptions { includeODataAnnotations?: boolean; skipValidation?: boolean; @@ -158,6 +168,12 @@ export interface ExecuteOptions { * Note: Special columns are only included when there is no $select query. */ includeSpecialColumns?: boolean; + /** + * Optional retry policy for transient errors (SchemaLockedError, NetworkError, TimeoutError, HTTP 5xx). + * When set, failed requests matching transient error conditions will be retried + * with exponential backoff. + */ + retryPolicy?: RetryPolicy; } /** diff --git a/packages/fmodata/src/validation.ts b/packages/fmodata/src/validation.ts index d2f5dac5..bb27ab53 100644 --- a/packages/fmodata/src/validation.ts +++ b/packages/fmodata/src/validation.ts @@ -26,6 +26,8 @@ export async function validateAndTransformInput>( // biome-ignore lint/suspicious/noExplicitAny: Dynamic field transformation const transformedData: Record = { ...data }; + const allIssues: StandardSchemaV1.Issue[] = []; + const failedFields: string[] = []; // Process each field that has an input validator for (const [fieldName, fieldSchema] of Object.entries(inputSchema)) { @@ -45,33 +47,51 @@ export async function validateAndTransformInput>( result = await result; } - // Check for validation errors + // Check for validation errors — accumulate instead of throwing immediately if (result.issues) { - throw new ValidationError(`Input validation failed for field '${fieldName}'`, result.issues, { - field: fieldName, - value: inputValue, - cause: result.issues, - }); + for (const issue of result.issues) { + allIssues.push({ + ...issue, + path: issue.path ? [fieldName, ...issue.path] : [fieldName], + }); + } + failedFields.push(fieldName); + continue; } // Store the transformed value transformedData[fieldName] = result.value; } catch (error) { - // If it's already a ValidationError, re-throw it + // Accumulate wrapped errors if (error instanceof ValidationError) { - throw error; + for (const issue of error.issues) { + allIssues.push({ + ...issue, + path: issue.path ? [fieldName, ...issue.path] : [fieldName], + }); + } + } else { + allIssues.push({ + message: error instanceof Error ? error.message : String(error), + path: [fieldName], + }); } - - // Otherwise, wrap the error - throw new ValidationError(`Input validation failed for field '${fieldName}'`, [], { - field: fieldName, - value: inputValue, - cause: error, - }); + failedFields.push(fieldName); } } } + // If any fields failed validation, throw a single error with all issues + if (allIssues.length > 0) { + throw new ValidationError( + `Input validation failed for field${failedFields.length > 1 ? "s" : ""} '${failedFields.join("', '")}'`, + allIssues, + { + field: failedFields[0], + }, + ); + } + // Fields without input validators are already in transformedData (passed through) return transformedData as Partial; } @@ -154,6 +174,8 @@ export async function validateRecord>( if (selectedFields && selectedFields.length > 0) { // biome-ignore lint/suspicious/noExplicitAny: Dynamic field validation const validatedRecord: Record = {}; + const allIssues: StandardSchemaV1.Issue[] = []; + const failedFields: string[] = []; for (const field of selectedFields) { const fieldName = String(field); @@ -167,29 +189,35 @@ export async function validateRecord>( result = await result; } - // if the `issues` field exists, the validation failed + // if the `issues` field exists, accumulate and continue if (result.issues) { - return { - valid: false, - error: new ValidationError(`Validation failed for field '${fieldName}'`, result.issues, { - field: fieldName, - value: input, - cause: result.issues, - }), - }; + for (const issue of result.issues) { + allIssues.push({ + ...issue, + path: issue.path ? [fieldName, ...issue.path] : [fieldName], + }); + } + failedFields.push(fieldName); + continue; } validatedRecord[fieldName] = result.value; } catch (originalError) { - // If the validator throws directly, wrap it - return { - valid: false, - error: new ValidationError(`Validation failed for field '${fieldName}'`, [], { - field: fieldName, - value: input, - cause: originalError, - }), - }; + if (originalError instanceof ValidationError) { + for (const issue of originalError.issues) { + allIssues.push({ + ...issue, + path: issue.path ? [fieldName, ...issue.path] : [fieldName], + }); + } + } else { + // Accumulate thrown errors + allIssues.push({ + message: originalError instanceof Error ? originalError.message : String(originalError), + path: [fieldName], + }); + } + failedFields.push(fieldName); } } else { // For fields not in schema (like when explicitly selecting ROWID/ROWMODID) @@ -208,6 +236,18 @@ export async function validateRecord>( } } + // If any field validations failed, return accumulated error + if (allIssues.length > 0) { + return { + valid: false, + error: new ValidationError( + `Validation failed for field${failedFields.length > 1 ? "s" : ""} '${failedFields.join("', '")}'`, + allIssues, + { field: failedFields[0], value: record }, + ), + }; + } + // Validate expanded relations if (expandConfigs && expandConfigs.length > 0) { for (const expandConfig of expandConfigs) { @@ -315,6 +355,8 @@ export async function validateRecord>( // Validate all fields in schema, but exclude ROWID/ROWMODID by default (unless includeSpecialColumns is enabled) // biome-ignore lint/suspicious/noExplicitAny: Dynamic field validation const validatedRecord: Record = { ...restWithoutSystemFields }; + const allIssues: StandardSchemaV1.Issue[] = []; + const failedFields: string[] = []; for (const [fieldName, fieldSchema] of Object.entries(schema)) { // Skip if no schema for this field @@ -329,33 +371,50 @@ export async function validateRecord>( result = await result; } - // if the `issues` field exists, the validation failed + // if the `issues` field exists, accumulate and continue if (result.issues) { - return { - valid: false, - error: new ValidationError(`Validation failed for field '${fieldName}'`, result.issues, { - field: fieldName, - value: input, - cause: result.issues, - }), - }; + for (const issue of result.issues) { + allIssues.push({ + ...issue, + path: issue.path ? [fieldName, ...issue.path] : [fieldName], + }); + } + failedFields.push(fieldName); + continue; } validatedRecord[fieldName] = result.value; } catch (originalError) { - // If the validator throws an error directly, catch and wrap it - // This preserves the original error instance for instanceof checks - return { - valid: false, - error: new ValidationError(`Validation failed for field '${fieldName}'`, [], { - field: fieldName, - value: input, - cause: originalError, - }), - }; + if (originalError instanceof ValidationError) { + for (const issue of originalError.issues) { + allIssues.push({ + ...issue, + path: issue.path ? [fieldName, ...issue.path] : [fieldName], + }); + } + } else { + // Accumulate thrown errors + allIssues.push({ + message: originalError instanceof Error ? originalError.message : String(originalError), + path: [fieldName], + }); + } + failedFields.push(fieldName); } } + // If any field validations failed, return accumulated error + if (allIssues.length > 0) { + return { + valid: false, + error: new ValidationError( + `Validation failed for field${failedFields.length > 1 ? "s" : ""} '${failedFields.join("', '")}'`, + allIssues, + { field: failedFields[0], value: record }, + ), + }; + } + // Validate expanded relations even when not using selected fields if (expandConfigs && expandConfigs.length > 0) { for (const expandConfig of expandConfigs) { diff --git a/packages/fmodata/tests/batch-error-messages.test.ts b/packages/fmodata/tests/batch-error-messages.test.ts index 108fdb26..7fd03877 100644 --- a/packages/fmodata/tests/batch-error-messages.test.ts +++ b/packages/fmodata/tests/batch-error-messages.test.ts @@ -9,8 +9,8 @@ */ import { fmTableOccurrence, isODataError, isResponseStructureError, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, it } from "vitest"; -import { createMockClient } from "./utils/test-setup"; /** * Creates a mock fetch handler that returns a multipart batch response @@ -34,7 +34,9 @@ function createBatchMockFetch(batchResponseBody: string): typeof fetch { } describe("Batch Error Messages - Improved Error Parsing", () => { - const client = createMockClient(); + // Batch tests use a custom fetchHandler because multipart responses are easier + // to model directly than via per-route JSON response helpers. + const mock = new MockFMServerConnection(); // Define simple schemas for batch testing const addressesTO = fmTableOccurrence("addresses", { @@ -42,7 +44,7 @@ describe("Batch Error Messages - Improved Error Parsing", () => { street: textField(), }); - const db = client.database("test_db"); + const db = mock.database("test_db"); it("should return ODataError with helpful message instead of vague ResponseStructureError", async () => { // This simulates the exact scenario from the user's error: @@ -123,13 +125,6 @@ describe("Batch Error Messages - Improved Error Parsing", () => { expect(r2.error.code).toBe("-1020"); expect(r2.error.message).toContain("Table 'Purchase_Orders' not defined"); expect(r2.error.kind).toBe("ODataError"); - - // The error message is now helpful instead of: - // "Invalid response structure: expected 'value' property to be an array" - console.log("\n✅ Fixed Error Message:"); - console.log(` Code: ${r2.error.code}`); - console.log(` Message: ${r2.error.message}`); - console.log(` Kind: ${r2.error.kind}\n`); } // Third query succeeded (not truncated in this mock) diff --git a/packages/fmodata/tests/batch.test.ts b/packages/fmodata/tests/batch.test.ts index 3f19cf8f..7108780c 100644 --- a/packages/fmodata/tests/batch.test.ts +++ b/packages/fmodata/tests/batch.test.ts @@ -6,8 +6,8 @@ */ import { eq, fmTableOccurrence, isBatchTruncatedError, isNotNull, isODataError, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, it } from "vitest"; -import { createMockClient } from "./utils/test-setup"; /** * Creates a mock fetch handler that returns a multipart batch response @@ -31,7 +31,7 @@ function createBatchMockFetch(batchResponseBody: string): typeof fetch { } describe("Batch Operations - Mock Tests", () => { - const client = createMockClient(); + const mock = new MockFMServerConnection(); // Define simple schemas for batch testing const contactsTO = fmTableOccurrence("contacts", { @@ -45,7 +45,7 @@ describe("Batch Operations - Mock Tests", () => { name: textField(), }); - const db = client.database("test_db"); + const db = mock.database("test_db"); describe("Mixed success/failure responses", () => { it("should handle batch response where first succeeds, second fails (404), and third is truncated", async () => { diff --git a/packages/fmodata/tests/delete.test.ts b/packages/fmodata/tests/delete.test.ts index 11e09d5d..56ac18a6 100644 --- a/packages/fmodata/tests/delete.test.ts +++ b/packages/fmodata/tests/delete.test.ts @@ -7,14 +7,11 @@ import { and, eq, fmTableOccurrence, type InferTableSchema, lt, numberField, textField } from "@proofkit/fmodata"; import { DeleteBuilder, ExecutableDeleteBuilder } from "@proofkit/fmodata/client/delete-builder"; -import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; -import { simpleMock } from "./utils/mock-fetch"; -import { createMockClient } from "./utils/test-setup"; describe("delete method", () => { - const client = createMockClient(); - const usersTO = fmTableOccurrence("users", { id: textField().primaryKey(), username: textField().notNull(), @@ -27,14 +24,16 @@ describe("delete method", () => { describe("builder pattern", () => { it("should return DeleteBuilder when delete() is called", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const result = db.from(usersTO).delete(); expect(result).toBeInstanceOf(DeleteBuilder); }); it("should not have execute() on initial DeleteBuilder", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const deleteBuilder = db.from(usersTO).delete(); @@ -43,14 +42,16 @@ describe("delete method", () => { }); it("should return ExecutableDeleteBuilder after byId()", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const result = db.from(usersTO).delete().byId("user-123"); expect(result).toBeInstanceOf(ExecutableDeleteBuilder); }); it("should return ExecutableDeleteBuilder after where()", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const result = db .from(usersTO) @@ -60,7 +61,8 @@ describe("delete method", () => { }); it("should have execute() on ExecutableDeleteBuilder", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const executableBuilder = db.from(usersTO).delete().byId("user-123"); @@ -71,7 +73,8 @@ describe("delete method", () => { describe("delete by ID", () => { it("should generate correct URL for delete by ID", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const deleteBuilder = db.from(usersTO).delete().byId("user-123"); const config = deleteBuilder.getRequestConfig(); @@ -81,22 +84,24 @@ describe("delete method", () => { }); it("should return deletedCount result type", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); db.from(usersTO).delete().byId("user-123"); }); it("should execute delete by ID and return count", async () => { - // Mock the fetch to return a count - const mockFetch = simpleMock({ + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/users", + method: "DELETE", status: 204, headers: { "fmodata.affected_rows": "1" }, - body: null, + response: null, }); + const db = mock.database("test_db"); - const db = client.database("test_db"); - - const result = await db.from(usersTO).delete().byId("user-123").execute({ fetchHandler: mockFetch }); + const result = await db.from(usersTO).delete().byId("user-123").execute(); expect(result.error).toBeUndefined(); expect(result.data).toEqual({ deletedCount: 1 }); @@ -105,7 +110,8 @@ describe("delete method", () => { describe("delete by filter", () => { it("should generate correct URL for delete by filter", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const deleteBuilder = db .from(usersTO) @@ -121,7 +127,8 @@ describe("delete method", () => { }); it("should support complex filters with QueryBuilder", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const deleteBuilder = db .from(usersTO) @@ -135,7 +142,8 @@ describe("delete method", () => { }); it("should support QueryBuilder chaining in where callback", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const deleteBuilder = db .from(usersTO) @@ -150,8 +158,8 @@ describe("delete method", () => { }); it("should return deletedCount result type for filter-based delete", () => { - const db = client.database("test_db"); - db.from(usersTO); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); db.from(usersTO) .delete() @@ -159,20 +167,21 @@ describe("delete method", () => { }); it("should execute delete by filter and return count", async () => { - // Mock the fetch to return a count - const mockFetch = simpleMock({ + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/users", + method: "DELETE", status: 204, headers: { "fmodata.affected_rows": "5" }, - body: null, + response: null, }); - - const db = client.database("test_db"); + const db = mock.database("test_db"); const result = await db .from(usersTO) .delete() .where((q) => q.where(eq(usersTO.active, 0))) - .execute({ fetchHandler: mockFetch }); + .execute(); expect(result.error).toBeUndefined(); expect(result.data).toEqual({ deletedCount: 5 }); @@ -181,7 +190,8 @@ describe("delete method", () => { describe("type safety", () => { it("should enforce type-safe filter properties", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // This should work - valid property db.from(usersTO) @@ -190,7 +200,8 @@ describe("delete method", () => { }); it("should provide type-safe QueryBuilder in where callback", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); db.from(usersTO) .delete() @@ -208,15 +219,16 @@ describe("delete method", () => { describe("error handling", () => { it("should return error on failed delete", async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error("Network error")); - - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/users", + method: "DELETE", + throwError: new Error("Network error"), + response: null, + }); + const db = mock.database("test_db"); - const result = await db - .from(usersTO) - .delete() - .byId("user-123") - .execute({ fetchHandler: mockFetch as any }); + const result = await db.from(usersTO).delete().byId("user-123").execute(); expect(result.data).toBeUndefined(); expect(result.error).toBeInstanceOf(Error); diff --git a/packages/fmodata/tests/effect-layer-execution.test.ts b/packages/fmodata/tests/effect-layer-execution.test.ts new file mode 100644 index 00000000..ff0efc3a --- /dev/null +++ b/packages/fmodata/tests/effect-layer-execution.test.ts @@ -0,0 +1,53 @@ +import { HTTPError } from "@proofkit/fmodata"; +import { requestFromService, runLayerOrThrow, runLayerResult } from "@proofkit/fmodata/effect"; +import { HttpClient, ODataConfig, ODataLogger } from "@proofkit/fmodata/services"; +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +const logger = { + debug: () => undefined, + info: () => undefined, + success: () => undefined, + warn: () => undefined, + error: () => undefined, + get level() { + return "error" as const; + }, +}; + +const baseConfig = { + baseUrl: "https://example.com", + databaseName: "test_db", + useEntityIds: false, + includeSpecialColumns: false, +}; + +describe("effect layer execution helpers", () => { + it("maps successful layered execution to Result", async () => { + const layer = Layer.mergeAll( + Layer.succeed(HttpClient, { + request: () => Effect.succeed({ ok: true } as T), + }), + Layer.succeed(ODataConfig, baseConfig), + Layer.succeed(ODataLogger, { logger }), + ); + + const result = await runLayerResult(layer, requestFromService<{ ok: boolean }>("/health"), "fmodata.test.success"); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ ok: true }); + }); + + it("throws when using runLayerOrThrow on failure", async () => { + const layer = Layer.mergeAll( + Layer.succeed(HttpClient, { + request: () => Effect.fail(new HTTPError("/broken", 500, "Server Error")), + }), + Layer.succeed(ODataConfig, baseConfig), + Layer.succeed(ODataLogger, { logger }), + ); + + await expect(runLayerOrThrow(layer, requestFromService("/broken"), "fmodata.test.failure")).rejects.toBeInstanceOf( + HTTPError, + ); + }); +}); diff --git a/packages/fmodata/tests/errors.test.ts b/packages/fmodata/tests/errors.test.ts index 43b306c6..8c6b4b23 100644 --- a/packages/fmodata/tests/errors.test.ts +++ b/packages/fmodata/tests/errors.test.ts @@ -27,14 +27,11 @@ import { textField, ValidationError, } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod/v4"; -import { createMockFetch, simpleMock } from "./utils/mock-fetch"; -import { createMockClient } from "./utils/test-setup"; describe("Error Handling", () => { - const client = createMockClient(); - const users = fmTableOccurrence("users", { id: textField().primaryKey(), username: textField(), @@ -45,13 +42,14 @@ describe("Error Handling", () => { describe("HTTP Errors", () => { it("should return HTTPError for 404 Not Found", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 404 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 404, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.data).toBeUndefined(); @@ -65,13 +63,14 @@ describe("Error Handling", () => { }); it("should return HTTPError for 401 Unauthorized", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 401 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 401, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(HTTPError); @@ -82,13 +81,14 @@ describe("Error Handling", () => { }); it("should return HTTPError for 500 Server Error", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 500 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 500, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(HTTPError); @@ -101,16 +101,14 @@ describe("Error Handling", () => { it("should include response body in HTTPError", async () => { const errorBody = { message: "Custom error message" }; - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ - status: 400, - body: errorBody, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: errorBody, + status: 400, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeInstanceOf(HTTPError); const httpError = result.error as HTTPError; @@ -128,19 +126,14 @@ describe("Error Handling", () => { }, }; - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 400, - response: odataError, - headers: { "content-type": "application/json" }, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: odataError, + status: 400, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(ODataError); @@ -158,19 +151,14 @@ describe("Error Handling", () => { }, }; - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 400, - response: schemaLockedError, - headers: { "content-type": "application/json" }, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: schemaLockedError, + status: 400, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(SchemaLockedError); @@ -190,19 +178,14 @@ describe("Error Handling", () => { }, }; - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 400, - response: schemaLockedError, - headers: { "content-type": "application/json" }, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: schemaLockedError, + status: 400, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(SchemaLockedError); @@ -211,8 +194,6 @@ describe("Error Handling", () => { describe("Validation Errors", () => { it("should return ValidationError when schema validation fails", async () => { - const db = client.database("testdb"); - // Return data that doesn't match schema (email is invalid, age is out of range) const invalidData = [ { @@ -224,12 +205,13 @@ describe("Error Handling", () => { }, ]; - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch(invalidData), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: invalidData, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(ValidationError); @@ -242,8 +224,6 @@ describe("Error Handling", () => { }); it("should preserve Standard Schema issues in cause property", async () => { - const db = client.database("testdb"); - const invalidData = [ { id: "1", @@ -254,12 +234,13 @@ describe("Error Handling", () => { }, ]; - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch(invalidData), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: invalidData, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeInstanceOf(ValidationError); const validationError = result.error as ValidationError; @@ -282,8 +263,6 @@ describe("Error Handling", () => { }); it("should include field name in ValidationError", async () => { - const db = client.database("testdb"); - const invalidData = [ { id: "1", @@ -294,12 +273,13 @@ describe("Error Handling", () => { }, ]; - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch(invalidData), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: invalidData, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeInstanceOf(ValidationError); const validationError = result.error as ValidationError; @@ -311,21 +291,16 @@ describe("Error Handling", () => { describe("Response Structure Errors", () => { it("should return ResponseStructureError for invalid response structure", async () => { - const db = client.database("testdb"); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: JSON.stringify("not an object"), + status: 200, + }); + const db = mock.database("testdb"); // Return invalid structure (not an object) - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 200, - response: "not an object", // Invalid - should be object with value array - headers: { "content-type": "application/json" }, - }), - }); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(ResponseStructureError); @@ -335,20 +310,14 @@ describe("Error Handling", () => { }); it("should return ResponseStructureError when value is not an array", async () => { - const db = client.database("testdb"); - - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 200, - response: { value: "not an array" }, // Invalid - value should be array - headers: { "content-type": "application/json" }, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: { value: "not an array" }, + status: 200, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(ResponseStructureError); @@ -357,8 +326,6 @@ describe("Error Handling", () => { describe("Record Count Mismatch Errors", () => { it("should return RecordCountMismatchError for single() when multiple records found", async () => { - const db = client.database("testdb"); - const multipleRecords = [ { id: "1", @@ -376,13 +343,13 @@ describe("Error Handling", () => { }, ]; - const result = await db - .from(users) - .list() - .single() - .execute({ - fetchHandler: createMockFetch(multipleRecords), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: multipleRecords, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().single().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(RecordCountMismatchError); @@ -393,15 +360,10 @@ describe("Error Handling", () => { }); it("should return RecordCountMismatchError for single() when no records found", async () => { - const db = client.database("testdb"); - - const result = await db - .from(users) - .list() - .single() - .execute({ - fetchHandler: createMockFetch([]), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ urlPattern: "/testdb/users", response: [] }); + const db = mock.database("testdb"); + const result = await db.from(users).list().single().execute(); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(RecordCountMismatchError); @@ -414,13 +376,14 @@ describe("Error Handling", () => { describe("Type Guards", () => { it("should correctly identify HTTPError using type guard", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 404 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 404, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(isHTTPError(result.error)).toBe(true); @@ -432,21 +395,21 @@ describe("Error Handling", () => { }); it("should correctly identify ValidationError using type guard", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch([ - { - id: "1", - username: "test", - email: "invalid-email", - active: true, - age: 25, - }, - ]), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: [ + { + id: "1", + username: "test", + email: "invalid-email", + active: true, + age: 25, + }, + ], + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(isValidationError(result.error)).toBe(true); @@ -458,19 +421,14 @@ describe("Error Handling", () => { }); it("should correctly identify ODataError using type guard", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 400, - response: { error: { code: "ERROR", message: "Test" } }, - headers: { "content-type": "application/json" }, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: { error: { code: "ERROR", message: "Test" } }, + status: 400, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(isODataError(result.error)).toBe(true); @@ -482,21 +440,16 @@ describe("Error Handling", () => { }); it("should correctly identify SchemaLockedError using type guard", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 400, - response: { - error: { code: "303", message: "Database schema is locked" }, - }, - headers: { "content-type": "application/json" }, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: { + error: { code: "303", message: "Database schema is locked" }, + }, + status: 400, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(isSchemaLockedError(result.error)).toBe(true); @@ -509,48 +462,42 @@ describe("Error Handling", () => { }); it("should correctly identify ResponseStructureError using type guard", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: createMockFetch({ - url: "https://api.example.com", - method: "GET", - status: 200, - response: "invalid", - headers: { "content-type": "application/json" }, - }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: JSON.stringify("invalid"), + status: 200, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); expect(isResponseStructureError(result.error)).toBe(true); }); it("should correctly identify RecordCountMismatchError using type guard", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .single() - .execute({ - fetchHandler: createMockFetch([ - { - id: "1", - username: "user1", - email: "user1@test.com", - active: true, - age: 25, - }, - { - id: "2", - username: "user2", - email: "user2@test.com", - active: true, - age: 30, - }, - ]), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: [ + { + id: "1", + username: "user1", + email: "user1@test.com", + active: true, + age: 25, + }, + { + id: "2", + username: "user2", + email: "user2@test.com", + active: true, + age: 30, + }, + ], + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().single().execute(); expect(result.error).toBeDefined(); expect(isRecordCountMismatchError(result.error)).toBe(true); @@ -559,13 +506,14 @@ describe("Error Handling", () => { describe("Error Properties", () => { it("should include timestamp in all errors", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 404 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 404, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); if (result.error && "timestamp" in result.error) { @@ -574,13 +522,14 @@ describe("Error Handling", () => { }); it("should include kind property for discriminated unions", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 404 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 404, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); expect(result.error).toBeDefined(); if (result.error && "kind" in result.error) { @@ -591,13 +540,14 @@ describe("Error Handling", () => { describe("Error Handling Patterns", () => { it("should allow instanceof checks (like ffetch pattern)", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 404 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 404, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); if (result.error) { if (result.error instanceof HTTPError) { @@ -609,13 +559,14 @@ describe("Error Handling", () => { }); it("should allow switch statement on kind property", async () => { - const db = client.database("testdb"); - const result = await db - .from(users) - .list() - .execute({ - fetchHandler: simpleMock({ status: 404 }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/testdb/users", + response: null, + status: 404, + }); + const db = mock.database("testdb"); + const result = await db.from(users).list().execute(); if (result.error && "kind" in result.error) { switch (result.error.kind) { diff --git a/packages/fmodata/tests/expands.test.ts b/packages/fmodata/tests/expands.test.ts index 8e150753..b721e1c8 100644 --- a/packages/fmodata/tests/expands.test.ts +++ b/packages/fmodata/tests/expands.test.ts @@ -8,11 +8,10 @@ */ import { eq, FMServerConnection, fmTableOccurrence, numberField, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { assert, describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; import { mockResponses } from "./fixtures/responses"; -import { simpleMock } from "./utils/mock-fetch"; -import { createMockClient } from "./utils/test-setup"; describe("Expand API Specification", () => { // Spec test table definitions (simplified for type testing) @@ -109,12 +108,7 @@ describe("Expand API Specification", () => { }, ); - const client = createMockClient(); - - // type UserFieldNames = keyof InferTableSchema; - // type CustomerFieldNames = keyof InferTableSchema; - - const db = client.database("test_db"); + const db = new MockFMServerConnection().database("test_db"); describe("Simple expand (no callback)", () => { it("should generate query string for simple expand", () => { @@ -325,7 +319,15 @@ describe("Expand API Specification", () => { it("should validate nested expands on single record", async () => { // This test uses real server schema (contactsReal, usersReal) to match captured responses const mockData = mockResponses["deep nested expand"]; - const result = await db + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/contacts", + response: mockData.response, + status: mockData.status, + headers: mockData.headers, + }); + const mockDb = mock.database("test_db"); + const result = await mockDb .from(contactsReal) .get("B5BFBC89-03E0-47FC-ABB6-D51401730227") .expand(usersReal, (usersBuilder) => { @@ -335,13 +337,7 @@ describe("Expand API Specification", () => { return customerBuilder.select({ name: userCustomer.name }); }); }) - .execute({ - fetchHandler: simpleMock({ - status: mockData.status, - body: mockData.response, - headers: mockData.headers, - }), - }); + .execute(); assert(result.data, "Result data should be defined"); expect(result.data.name).toBe("Eric"); @@ -382,7 +378,15 @@ describe("Expand API Specification", () => { it("should validate nested expands on list query", async () => { // This test uses real server schema (contactsReal, usersReal) to match captured responses const mockData = mockResponses["list with nested expand"]; - const result = await db + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/contacts", + response: mockData.response, + status: mockData.status, + headers: mockData.headers, + }); + const mockDb = mock.database("test_db"); + const result = await mockDb .from(contactsReal) .list() .expand(usersReal, (usersBuilder) => { @@ -391,13 +395,7 @@ describe("Expand API Specification", () => { return customerBuilder.select({ name: userCustomer.name }); }); }) - .execute({ - fetchHandler: simpleMock({ - status: mockData.status, - body: mockData.response, - headers: mockData.headers, - }), - }); + .execute(); expect(result.data).toBeDefined(); expect(Array.isArray(result.data)).toBe(true); diff --git a/packages/fmodata/tests/field-id-transforms.test.ts b/packages/fmodata/tests/field-id-transforms.test.ts index 17ead77b..997dde67 100644 --- a/packages/fmodata/tests/field-id-transforms.test.ts +++ b/packages/fmodata/tests/field-id-transforms.test.ts @@ -4,31 +4,20 @@ * Tests that field names are transparently transformed to/from FileMaker field IDs (FMFIDs) * and table occurrence IDs (FMTIDs) when using BaseTableWithIds and TableOccurrenceWithIds. * - * Uses mock responses to verify: + * Uses MockFMServerConnection with spy to verify: * 1. Requests are sent with FMFIDs and FMTIDs * 2. Responses with FMFID keys are transformed back to field names * 3. User experience remains unchanged (uses field names throughout) */ import { eq } from "@proofkit/fmodata"; -import { beforeEach, describe, expect, it } from "vitest"; -import { simpleMock } from "./utils/mock-fetch"; -import { contactsTOWithIds, createMockClient, usersTOWithIds } from "./utils/test-setup"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; +import { describe, expect, it } from "vitest"; +import { contactsTOWithIds, usersTOWithIds } from "./utils/test-setup"; describe("Field ID Transformation", () => { - let capturedRequests: Array<{ url: string; options: any }> = []; - - beforeEach(() => { - capturedRequests = []; - }); - describe("Query with Select", () => { it("should send request with FMFIDs and FMTID", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { "@context": "https://api.example.com/$metadata#users", value: [ @@ -42,6 +31,14 @@ describe("Field ID Transformation", () => { ], }; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + await db .from(usersTOWithIds) .list() @@ -50,17 +47,16 @@ describe("Field ID Transformation", () => { name: usersTOWithIds.name, active: usersTOWithIds.active, }) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + .execute(); // Verify the request used FMTIDs for table and FMFIDs for fields - expect(capturedRequests).toHaveLength(1); - const request = capturedRequests[0]; + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.forUrl("test.fmp12"); + expect(spyCalls).toHaveLength(1); + const request = spyCalls[0]; if (!request) { throw new Error("Expected request to be defined"); } @@ -72,9 +68,6 @@ describe("Field ID Transformation", () => { }); it("should transform FMFID response keys back to field names", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12"); - const mockResponse = { "@context": "https://api.example.com/$metadata#users", value: [ @@ -95,6 +88,14 @@ describe("Field ID Transformation", () => { ], }; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12"); + const result = await db .from(usersTOWithIds) .list() @@ -103,13 +104,7 @@ describe("Field ID Transformation", () => { name: usersTOWithIds.name, active: usersTOWithIds.active, }) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + .execute(); // User should receive data with field names, not FMFIDs expect(result.data).toHaveLength(2); @@ -131,28 +126,30 @@ describe("Field ID Transformation", () => { describe("Filter Operations", () => { it("should transform field names to FMFIDs in filter", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { value: [] }; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + await db .from(usersTOWithIds) .list() .select({ id: usersTOWithIds.id, name: usersTOWithIds.name }) .where(eq(usersTOWithIds.active, true)) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + .execute(); // Verify filter uses FMFID for the field name - const request = capturedRequests[0]; + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.forUrl("test.fmp12"); + const request = spyCalls[0]; if (!request) { throw new Error("Expected request to be defined"); } @@ -163,28 +160,30 @@ describe("Field ID Transformation", () => { describe("OrderBy Operations", () => { it("should transform field names to FMFIDs in orderBy", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { value: [] }; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + await db .from(usersTOWithIds) .list() .select({ id: usersTOWithIds.id, name: usersTOWithIds.name }) .orderBy(["name", "desc"]) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + .execute(); // Verify orderBy uses FMFID - const request = capturedRequests[0]; + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.forUrl("test.fmp12"); + const request = spyCalls[0]; if (!request) { throw new Error("Expected request to be defined"); } @@ -194,11 +193,6 @@ describe("Field ID Transformation", () => { describe("Get by ID", () => { it("should use FMTID in URL", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { "@id": "https://api.example.com/users('550e8400-e29b-41d4-a716-446655440001')", "@editLink": "users('550e8400-e29b-41d4-a716-446655440001')", @@ -206,18 +200,22 @@ describe("Field ID Transformation", () => { "FMFID:6": "Alice", }; - await db - .from(usersTOWithIds) - .get("550e8400-e29b-41d4-a716-446655440001") - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + + await db.from(usersTOWithIds).get("550e8400-e29b-41d4-a716-446655440001").execute(); - const request = capturedRequests[0]; + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.forUrl("test.fmp12"); + const request = spyCalls[0]; if (!request) { throw new Error("Expected request to be defined"); } @@ -226,11 +224,6 @@ describe("Field ID Transformation", () => { }); it("should transform response field IDs back to names", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { "@id": "https://api.example.com/users('550e8400-e29b-41d4-a716-446655440001')", "@editLink": "users('550e8400-e29b-41d4-a716-446655440001')", @@ -245,16 +238,15 @@ describe("Field ID Transformation", () => { "FMFID:9": "customer-1", }; - const result = await db - .from(usersTOWithIds) - .get("550e8400-e29b-41d4-a716-446655440001") - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + + const result = await db.from(usersTOWithIds).get("550e8400-e29b-41d4-a716-446655440001").execute(); expect(result.data).toMatchObject({ id: "550e8400-e29b-41d4-a716-446655440001", @@ -267,11 +259,6 @@ describe("Field ID Transformation", () => { describe("Insert Operations", () => { it("should transform field names to FMFIDs in request body", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { "@id": "https://api.example.com/users('new-user')", "@editLink": "users('new-user')", @@ -286,7 +273,14 @@ describe("Field ID Transformation", () => { "FMFID:9": null, }; - let capturedBody: any; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 201, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + const _result = await db .from(usersTOWithIds) .insert({ @@ -294,30 +288,22 @@ describe("Field ID Transformation", () => { active: true, fake_field: "test", }) - .execute({ - fetchHandler: async (input, init) => { - const url = input instanceof Request ? input.url : input.toString(); - // Capture body - it might be in the Request object itself - let bodyText: string | null = null; - if (input instanceof Request && input.body) { - bodyText = await input.text(); - } else if (init?.body) { - bodyText = init.body as string; - } - capturedBody = bodyText ? JSON.parse(bodyText) : {}; - capturedRequests.push({ url, options: init || {} }); - return simpleMock({ body: mockResponse, status: 201 })(url, init); - }, - }); + .execute(); - expect(capturedRequests).toHaveLength(1); - const request = capturedRequests[0]; + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.forUrl("test.fmp12"); + expect(spyCalls).toHaveLength(1); + const request = spyCalls[0]; if (!request) { throw new Error("Expected request to be defined"); } expect(request.url).toContain("FMTID:1065093"); // Table ID // Check that the body has FMFIDs (not field names) + const capturedBody = request.body ? JSON.parse(request.body) : {}; expect(capturedBody).toMatchObject({ "FMFID:6": "Charlie", // name "FMFID:7": 1, // active (number field, 1 = true) @@ -326,11 +312,6 @@ describe("Field ID Transformation", () => { }); it("should transform response field IDs back to names", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { "@id": "https://api.example.com/users('550e8400-e29b-41d4-a716-446655440003')", "@editLink": "users('550e8400-e29b-41d4-a716-446655440003')", @@ -345,6 +326,14 @@ describe("Field ID Transformation", () => { "FMFID:9": null, }; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 201, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + const result = await db .from(usersTOWithIds) .insert({ @@ -352,13 +341,7 @@ describe("Field ID Transformation", () => { active: true, fake_field: "test", }) - .execute({ - fetchHandler: (input, init) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init || {} }); - return simpleMock({ body: mockResponse, status: 201 })(input, init); - }, - }); + .execute(); expect(result.data).toMatchObject({ id: "550e8400-e29b-41d4-a716-446655440003", @@ -370,12 +353,14 @@ describe("Field ID Transformation", () => { describe("Update Operations", () => { it("should transform field names to FMFIDs in update body", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: 1, + status: 200, }); + const db = mock.database("test.fmp12", { useEntityIds: true }); - let capturedBody: any; await db .from(usersTOWithIds) .update({ @@ -383,30 +368,22 @@ describe("Field ID Transformation", () => { active: false, }) .byId("550e8400-e29b-41d4-a716-446655440001") - .execute({ - fetchHandler: async (input, init) => { - const url = input instanceof Request ? input.url : input.toString(); - // Capture body - it might be in the Request object itself - let bodyText: string | null = null; - if (input instanceof Request && input.body) { - bodyText = await input.text(); - } else if (init?.body) { - bodyText = init.body as string; - } - capturedBody = bodyText ? JSON.parse(bodyText) : {}; - capturedRequests.push({ url, options: init || {} }); - return simpleMock({ body: 1, status: 200 })(url, init); - }, - }); + .execute(); - expect(capturedRequests).toHaveLength(1); - const request = capturedRequests[0]; + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.forUrl("test.fmp12"); + expect(spyCalls).toHaveLength(1); + const request = spyCalls[0]; if (!request) { throw new Error("Expected request to be defined"); } expect(request.url).toContain("FMTID:1065093"); // Table ID // Check that the body has FMFIDs (not field names) + const capturedBody = request.body ? JSON.parse(request.body) : {}; expect(capturedBody).toMatchObject({ "FMFID:6": "Alice Updated", // name "FMFID:7": 0, // active (number field, 0 = false) @@ -416,26 +393,28 @@ describe("Field ID Transformation", () => { describe("Expand Operations", () => { it("should use FMFIDs for expanded relation fields", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { value: [] }; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + await db .from(contactsTOWithIds) .list() .expand(usersTOWithIds, (b: any) => b.select({ id: usersTOWithIds.id, name: usersTOWithIds.name })) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + .execute(); - const request = capturedRequests[0]; + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.forUrl("test.fmp12"); + const request = spyCalls[0]; if (!request) { throw new Error("Expected request to be defined"); } @@ -446,11 +425,6 @@ describe("Field ID Transformation", () => { }); it("should transform expanded relation response fields back to names", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { "@context": "https://api.example.com/$metadata#contacts", value: [ @@ -484,23 +458,24 @@ describe("Field ID Transformation", () => { ], }; + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); + const result = await db .from(contactsTOWithIds) .list() .expand(usersTOWithIds, (b: any) => b.select({ id: usersTOWithIds.id, name: usersTOWithIds.name })) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - capturedRequests.push({ url, options: init }); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + .execute(); // For this test, we'll skip full validation since expanded relations // add dynamic fields not in the schema. Just verify the transformation happened. if (result.error) { - // If validation failed, check raw response to ensure transformation occurred - console.log("Note: Validation failed for expanded data (expected - dynamic fields)"); + expect(result.error).toBeDefined(); } else { expect(result.data).toBeDefined(); expect(result.data).toHaveLength(1); @@ -524,30 +499,32 @@ describe("Field ID Transformation", () => { describe("Prefer Header", () => { it("should include 'Prefer: fmodata.entity-ids' header when using entity IDs", async () => { - const connection = createMockClient(); - const db = connection.database("test.fmp12", { - useEntityIds: true, - }); - const mockResponse = { value: [] }; - await db - .from(usersTOWithIds) - .list() - .select({ id: usersTOWithIds.id, name: usersTOWithIds.name }) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - const headers = (init as RequestInit)?.headers as Record; - capturedRequests.push({ url, options: { ...init, headers } }); + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "test.fmp12", + response: mockResponse, + status: 200, + }); + const db = mock.database("test.fmp12", { useEntityIds: true }); - // Verify the Prefer header is present - expect(headers).toBeDefined(); - expect(headers.Prefer).toBe("fmodata.entity-ids"); + await db.from(usersTOWithIds).list().select({ id: usersTOWithIds.id, name: usersTOWithIds.name }).execute(); - return simpleMock({ body: mockResponse, status: 200 })(input, init); - }, - }); + // Verify the Prefer header is present + const spy = mock.spy; + if (!spy) { + throw new Error("Expected spy to be enabled"); + } + const spyCalls = spy.calls; + expect(spyCalls).toHaveLength(1); + const request = spyCalls[0]; + if (!request) { + throw new Error("Expected request to be defined"); + } + expect(request.headers).toBeDefined(); + // Headers from Request objects are normalized to lowercase by the Headers API + expect(request.headers?.prefer).toBe("fmodata.entity-ids"); }); }); }); diff --git a/packages/fmodata/tests/filters.test.ts b/packages/fmodata/tests/filters.test.ts index 7b5feae8..b056ea9c 100644 --- a/packages/fmodata/tests/filters.test.ts +++ b/packages/fmodata/tests/filters.test.ts @@ -38,12 +38,13 @@ import { toupper, trim, } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, it } from "vitest"; import { z } from "zod/v4"; -import { contacts, createMockClient, users, usersTOWithIds } from "./utils/test-setup"; +import { contacts, users, usersTOWithIds } from "./utils/test-setup"; describe("Filter Tests", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("fmdapi_test.fmp12"); it("should enforce correct operator types for each field type", () => { @@ -271,7 +272,7 @@ describe("Filter Tests", () => { expect(queryString).toContain("$filter"); expect(queryString).toContain("FMFID"); - const dbWithIds = createMockClient().database("fmdapi_test.fmp12", { + const dbWithIds = new MockFMServerConnection().database("fmdapi_test.fmp12", { useEntityIds: true, }); @@ -573,7 +574,7 @@ describe("Filter Tests", () => { createdAt: timestampField(), }); // Fresh db to avoid state pollution from prior tests mutating useEntityIds - const freshDb = createMockClient().database("test.fmp12"); + const freshDb = new MockFMServerConnection().database("test.fmp12"); const gtQuery = freshDb.from(dateTable).list().where(gt(dateTable.invoiceDate, "2024-01-01")); expect(gtQuery.getQueryString()).toContain("invoiceDate gt 2024-01-01"); @@ -601,7 +602,7 @@ describe("Filter Tests", () => { dueDate: dateField(), createdAt: timestampField(), }); - const freshDb = createMockClient().database("test.fmp12"); + const freshDb = new MockFMServerConnection().database("test.fmp12"); const dateStart = new Date("2024-01-01T00:00:00.000Z"); const dateEnd = new Date("2024-12-31T00:00:00.000Z"); diff --git a/packages/fmodata/tests/fmids-validation.test.ts b/packages/fmodata/tests/fmids-validation.test.ts index 9064b5d6..9ce2bfca 100644 --- a/packages/fmodata/tests/fmids-validation.test.ts +++ b/packages/fmodata/tests/fmids-validation.test.ts @@ -8,8 +8,8 @@ */ import { FMTable, fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, it } from "vitest"; -import { createMockClient } from "./utils/test-setup"; describe("BaseTable with entity IDs", () => { it("should create a table with fmfIds using fmTableOccurrence", () => { @@ -148,12 +148,12 @@ describe("Type enforcement (compile-time)", () => { // Note: The new ORM pattern doesn't have the same mixing restriction // Both tables can be used together regardless of entity IDs expect(() => { - createMockClient().database("test"); + new MockFMServerConnection().database("test"); }).not.toThrow(); // Should not throw when mixed if useEntityIds is set to false expect(() => { - createMockClient().database("test", { + new MockFMServerConnection().database("test", { useEntityIds: false, }); }).not.toThrow(); diff --git a/packages/fmodata/tests/include-special-columns.test.ts b/packages/fmodata/tests/include-special-columns.test.ts index cbbc1a52..51855d1f 100644 --- a/packages/fmodata/tests/include-special-columns.test.ts +++ b/packages/fmodata/tests/include-special-columns.test.ts @@ -7,9 +7,8 @@ */ import { fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { assert, describe, expect, expectTypeOf, it } from "vitest"; -import { simpleMock } from "./utils/mock-fetch"; -import { createMockClient } from "./utils/test-setup"; // Create a simple table occurrence for testing const contactsTO = fmTableOccurrence("contacts", { @@ -17,11 +16,17 @@ const contactsTO = fmTableOccurrence("contacts", { name: textField(), }); -const connection = createMockClient(); - describe("includeSpecialColumns feature", () => { it("should include special columns header when enabled at database level", async () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: true, }); @@ -38,12 +43,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { - value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], - }, - status: 200, - }), }); expect(preferHeader).toBe("fmodata.include-specialcolumns"); if (!reqUrl) { @@ -75,7 +74,15 @@ describe("includeSpecialColumns feature", () => { }); it("should not add $select parameter when defaultSelect is not 'schema'", async () => { - const db = connection.database("TestDB", { includeSpecialColumns: true }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: true }); const contactsAll = fmTableOccurrence( "contacts", @@ -99,12 +106,6 @@ describe("includeSpecialColumns feature", () => { reqUrl = req.url; }, }, - fetchHandler: simpleMock({ - body: { - value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], - }, - status: 200, - }), }); if (!reqUrl) { throw new Error("Expected reqUrl to be defined"); @@ -134,7 +135,13 @@ describe("includeSpecialColumns feature", () => { }); it("should not include special columns header when disabled at database level", async () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { value: [{ id: "1", name: "John" }] }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: false, }); @@ -149,10 +156,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { value: [{ id: "1", name: "John" }] }, - status: 200, - }), }); expect(preferHeader).toBeNull(); @@ -178,7 +181,13 @@ describe("includeSpecialColumns feature", () => { }); it("should be disabled by default at database level", async () => { - const db = connection.database("TestDB"); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { value: [{ id: "1", name: "John" }] }, + status: 200, + }); + const db = mock.database("TestDB"); let preferHeader: string | null = null; const { data } = await db @@ -191,10 +200,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { value: [{ id: "1", name: "John" }] }, - status: 200, - }), }); expect(preferHeader).toBeNull(); @@ -220,7 +225,13 @@ describe("includeSpecialColumns feature", () => { }); it("should allow overriding includeSpecialColumns at request level", async () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { value: [{ id: "1", name: "John" }] }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: false, }); @@ -236,10 +247,6 @@ describe("includeSpecialColumns feature", () => { preferHeader1 = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { value: [{ id: "1", name: "John" }] }, - status: 200, - }), }); if (!data1 || data1.length === 0) { @@ -263,8 +270,20 @@ describe("includeSpecialColumns feature", () => { expect(firstRecord1).not.toHaveProperty("ROWMODID"); // Second request: explicitly enable for this request only + const mock2 = new MockFMServerConnection(); + mock2.addRoute({ + urlPattern: "/TestDB/contacts", + response: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }); + const db2 = mock2.database("TestDB", { + includeSpecialColumns: false, + }); + let preferHeader2: string | null = null; - const { data: data2 } = await db + const { data: data2 } = await db2 .from(contactsTO) .list() .execute({ @@ -275,12 +294,6 @@ describe("includeSpecialColumns feature", () => { preferHeader2 = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { - value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], - }, - status: 200, - }), }); if (!data2 || data2.length === 0) { @@ -302,8 +315,18 @@ describe("includeSpecialColumns feature", () => { expect(firstRecord2).toHaveProperty("ROWMODID"); // Third request: explicitly disable for this request + const mock3 = new MockFMServerConnection(); + mock3.addRoute({ + urlPattern: "/TestDB/contacts", + response: { value: [] }, + status: 200, + }); + const db3 = mock3.database("TestDB", { + includeSpecialColumns: false, + }); + let preferHeader3: string | null = null; - await db + await db3 .from(contactsTO) .list() .execute({ @@ -314,7 +337,6 @@ describe("includeSpecialColumns feature", () => { preferHeader3 = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ body: { value: [] }, status: 200 }), }); expect(preferHeader1).toBeNull(); @@ -334,7 +356,15 @@ describe("includeSpecialColumns feature", () => { }, ); - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/", + response: { + value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }); + const db = mock.database("TestDB", { useEntityIds: true, includeSpecialColumns: true, }); @@ -350,12 +380,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { - value: [{ id: "1", name: "John", ROWID: 123, ROWMODID: 456 }], - }, - status: 200, - }), }); // Should be comma-separated expect(preferHeader).not.toBeNull(); @@ -391,7 +415,18 @@ describe("includeSpecialColumns feature", () => { }); it("should work with get() method for single records", async () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { + id: "123", + name: "John", + ROWID: 123, + ROWMODID: 456, + }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: true, }); @@ -406,15 +441,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { - id: "123", - name: "John", - ROWID: 123, - ROWMODID: 456, - }, - status: 200, - }), }); expect(preferHeader).toBe("fmodata.include-specialcolumns"); @@ -432,7 +458,15 @@ describe("includeSpecialColumns feature", () => { }); it("should not include special columns when $select is applied", async () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { + value: [{ name: "John" }], // No ROWID or ROWMODID + }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: true, }); @@ -451,12 +485,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { - value: [{ name: "John" }], // No ROWID or ROWMODID - }, - status: 200, - }), }); expect(preferHeader).toBe("fmodata.include-specialcolumns"); @@ -482,7 +510,8 @@ describe("includeSpecialColumns feature", () => { }); it("should not append ROWID/ROWMODID to explicit $select unless requested via systemColumns", () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + const db = mock.database("TestDB", { includeSpecialColumns: true, }); @@ -506,7 +535,18 @@ describe("includeSpecialColumns feature", () => { }); it("should work with single() method", async () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { + id: "123", + name: "John", + ROWID: 123, + ROWMODID: 456, + }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: true, }); @@ -522,15 +562,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ - body: { - id: "123", - name: "John", - ROWID: 123, - ROWMODID: 456, - }, - status: 200, - }), }); expect(preferHeader).toBe("fmodata.include-specialcolumns"); @@ -548,7 +579,13 @@ describe("includeSpecialColumns feature", () => { }); it("should not include special columns if getSingleField() is used", async () => { - const db = connection.database("TestDB", { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { value: "John" }, + status: 200, + }); + const db = mock.database("TestDB", { includeSpecialColumns: true, }); @@ -564,7 +601,6 @@ describe("includeSpecialColumns feature", () => { preferHeader = headers.get("Prefer"); }, }, - fetchHandler: simpleMock({ body: { value: "John" }, status: 200 }), }); expect(preferHeader).toBe("fmodata.include-specialcolumns"); @@ -577,7 +613,15 @@ describe("includeSpecialColumns feature", () => { }); it("should still allow you to select ROWID or ROWMODID in select()", async () => { - const db = connection.database("TestDB"); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/contacts", + response: { + value: [{ id: "1", ROWID: 123, ROWMODID: 456 }], + }, + status: 200, + }); + const db = mock.database("TestDB"); const { data } = await db .from(contactsTO) @@ -588,14 +632,7 @@ describe("includeSpecialColumns feature", () => { }, { ROWID: true, ROWMODID: true }, ) - .execute({ - fetchHandler: simpleMock({ - body: { - value: [{ id: "1", ROWID: 123, ROWMODID: 456 }], - }, - status: 200, - }), - }); + .execute(); if (!data || data.length === 0) { throw new Error("Expected data to be defined and non-empty"); } diff --git a/packages/fmodata/tests/insert.test.ts b/packages/fmodata/tests/insert.test.ts index 27407438..ba1ea306 100644 --- a/packages/fmodata/tests/insert.test.ts +++ b/packages/fmodata/tests/insert.test.ts @@ -4,27 +4,30 @@ * Tests for the insert() and update() methods with returnFullRecord option. */ +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, expectTypeOf, it } from "vitest"; import { mockResponses } from "./fixtures/responses"; -import { createMockFetch } from "./utils/mock-fetch"; -import { contacts, createMockClient } from "./utils/test-setup"; +import { contacts } from "./utils/test-setup"; const UUID_REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i; describe("insert and update operations with returnFullRecord", () => { - const client = createMockClient(); - it("should insert a record and return the created record with metadata", async () => { - const db = client.database("fmdapi_test.fmp12", {}); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses.insert?.response ?? {}, + status: mockResponses.insert?.status ?? 200, + headers: mockResponses.insert?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); const result = await db .from(contacts) .insert({ name: "Capture test", }) - .execute({ - fetchHandler: createMockFetch(mockResponses.insert ?? {}), - }); + .execute(); // Verify no errors expect(result.error).toBeUndefined(); @@ -52,7 +55,14 @@ describe("insert and update operations with returnFullRecord", () => { }); it("should allow returnFullRecord=false to get just ROWID", async () => { - const db = client.database("fmdapi_test.fmp12"); + const insertMinimalMock = new MockFMServerConnection(); + insertMinimalMock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["insert-return-minimal"]?.response ?? {}, + status: mockResponses["insert-return-minimal"]?.status ?? 200, + headers: mockResponses["insert-return-minimal"]?.headers, + }); + const db = insertMinimalMock.database("fmdapi_test.fmp12"); const result = await db .from(contacts) @@ -63,15 +73,22 @@ describe("insert and update operations with returnFullRecord", () => { // Set returnFullRecord to false to get just the ROWID { returnFullRecord: false }, ) - .execute({ - fetchHandler: createMockFetch(mockResponses["insert-return-minimal"] ?? {}), - }); + .execute(); // Type check: when returnFullRecord is false, result should only have ROWID expectTypeOf(result.data).toEqualTypeOf<{ ROWID: number } | undefined>(); // Type check: when returnFullRecord is true or omitted, result should have full record - const fullResult = await db + const insertFullMock = new MockFMServerConnection(); + insertFullMock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses.insert?.response ?? {}, + status: mockResponses.insert?.status ?? 200, + headers: mockResponses.insert?.headers, + }); + const db2 = insertFullMock.database("fmdapi_test.fmp12"); + + const fullResult = await db2 .from(contacts) .insert( { @@ -79,9 +96,7 @@ describe("insert and update operations with returnFullRecord", () => { }, { returnFullRecord: true }, ) - .execute({ - fetchHandler: createMockFetch(mockResponses.insert ?? {}), - }); + .execute(); expectTypeOf(fullResult.data).not.toEqualTypeOf<{ ROWID: number } | undefined>(); @@ -95,31 +110,35 @@ describe("insert and update operations with returnFullRecord", () => { }); it("should allow returnFullRecord=true for update to get full record", async () => { - const db = client.database("fmdapi_test.fmp12"); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses.insert?.response ?? {}, + status: mockResponses.insert?.status ?? 200, + headers: mockResponses.insert?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); // Test with returnFullRecord=true const result = await db .from(contacts) .update({ name: "Updated name" }, { returnFullRecord: true }) .byId("331F5862-2ABF-4FB6-AA24-A00F7359BDDA") - .execute({ - fetchHandler: createMockFetch(mockResponses.insert ?? {}), // Reuse insert mock, same structure - }); + .execute(); // Type check: when returnFullRecord is true, result should have full record expectTypeOf(result.data).not.toEqualTypeOf<{ updatedCount: number } | undefined>(); // Test without returnFullRecord (default - returns count) - const countResult = await db + const countBuilder = db .from(contacts) .update({ name: "Updated name" }) - .byId("331F5862-2ABF-4FB6-AA24-A00F7359BDDA") - .execute({ - fetchHandler: createMockFetch(mockResponses.insert ?? {}), - }); + .byId("331F5862-2ABF-4FB6-AA24-A00F7359BDDA"); // Type check: default should return count - expectTypeOf(countResult.data).toEqualTypeOf<{ updatedCount: number } | undefined>(); + expectTypeOf(countBuilder.execute).returns.resolves.toMatchTypeOf<{ + data: { updatedCount: number } | undefined; + }>(); expect(result.error).toBeUndefined(); expect(result.data).toBeDefined(); diff --git a/packages/fmodata/tests/list-methods.test.ts b/packages/fmodata/tests/list-methods.test.ts index 4293ea1c..73c8da7a 100644 --- a/packages/fmodata/tests/list-methods.test.ts +++ b/packages/fmodata/tests/list-methods.test.ts @@ -1,8 +1,15 @@ -import { describe, it } from "vitest"; -import { createMockClient, users } from "./utils/test-setup"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; +import { beforeEach, describe, it } from "vitest"; +import { users } from "./utils/test-setup"; -const client = createMockClient(); -const db = client.database("test_db"); +const DB_NAME = "test_db"; +let db: ReturnType; + +beforeEach(() => { + const mock = new MockFMServerConnection(); + mock.addRoute({ urlPattern: DB_NAME, response: { value: [] } }); + db = mock.database(DB_NAME); +}); describe("list methods", () => { it("should not run query unless you await the method", async () => { diff --git a/packages/fmodata/tests/metadata.test.ts b/packages/fmodata/tests/metadata.test.ts index 9443542e..3e03c4df 100644 --- a/packages/fmodata/tests/metadata.test.ts +++ b/packages/fmodata/tests/metadata.test.ts @@ -10,19 +10,9 @@ * the extension. */ -import { FMServerConnection } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, it } from "vitest"; -function makeMetadataFetch(responseBody: unknown, status = 200): typeof fetch { - return (_input: RequestInfo | URL, _init?: RequestInit): Promise => - Promise.resolve( - new Response(JSON.stringify(responseBody), { - status, - headers: { "content-type": "application/json" }, - }), - ); -} - const SAMPLE_METADATA = { "@SchemaVersion": "1.0", someTable: { $Kind: "EntityType" }, @@ -35,13 +25,10 @@ describe("Database.getMetadata() key lookup", () => { GMT_Web: SAMPLE_METADATA, }; - const client = new FMServerConnection({ - serverUrl: "https://api.example.com", - auth: { apiKey: "test" }, - fetchClientOptions: { fetchHandler: makeMetadataFetch(responseBody) }, - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ urlPattern: "/$metadata", response: responseBody }); - const db = client.database("GMT_Web.fmp12"); + const db = mock.database("GMT_Web.fmp12"); const metadata = await db.getMetadata(); expect(metadata).toEqual(SAMPLE_METADATA); @@ -53,13 +40,10 @@ describe("Database.getMetadata() key lookup", () => { "GMT_Web.fmp12": SAMPLE_METADATA, }; - const client = new FMServerConnection({ - serverUrl: "https://api.example.com", - auth: { apiKey: "test" }, - fetchClientOptions: { fetchHandler: makeMetadataFetch(responseBody) }, - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ urlPattern: "/$metadata", response: responseBody }); - const db = client.database("GMT_Web.fmp12"); + const db = mock.database("GMT_Web.fmp12"); const metadata = await db.getMetadata(); expect(metadata).toEqual(SAMPLE_METADATA); @@ -74,13 +58,10 @@ describe("Database.getMetadata() key lookup", () => { GMT_Web: metadataWithoutExt, }; - const client = new FMServerConnection({ - serverUrl: "https://api.example.com", - auth: { apiKey: "test" }, - fetchClientOptions: { fetchHandler: makeMetadataFetch(responseBody) }, - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ urlPattern: "/$metadata", response: responseBody }); - const db = client.database("GMT_Web.fmp12"); + const db = mock.database("GMT_Web.fmp12"); const metadata = await db.getMetadata(); expect(metadata).toEqual(metadataWithExt); @@ -92,13 +73,10 @@ describe("Database.getMetadata() key lookup", () => { some_other_db: SAMPLE_METADATA, }; - const client = new FMServerConnection({ - serverUrl: "https://api.example.com", - auth: { apiKey: "test" }, - fetchClientOptions: { fetchHandler: makeMetadataFetch(responseBody) }, - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ urlPattern: "/$metadata", response: responseBody }); - const db = client.database("GMT_Web.fmp12"); + const db = mock.database("GMT_Web.fmp12"); await expect(db.getMetadata()).rejects.toThrow('Metadata for database "GMT_Web.fmp12" not found in response'); }); @@ -108,13 +86,10 @@ describe("Database.getMetadata() key lookup", () => { GMT_Web: SAMPLE_METADATA, }; - const client = new FMServerConnection({ - serverUrl: "https://api.example.com", - auth: { apiKey: "test" }, - fetchClientOptions: { fetchHandler: makeMetadataFetch(responseBody) }, - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ urlPattern: "/$metadata", response: responseBody }); - const db = client.database("GMT_Web.fmp12"); + const db = mock.database("GMT_Web.fmp12"); const metadata = await db.getMetadata({ format: "json" }); expect(metadata).toEqual(SAMPLE_METADATA); diff --git a/packages/fmodata/tests/mock.test.ts b/packages/fmodata/tests/mock.test.ts index e551738e..4a8d928f 100644 --- a/packages/fmodata/tests/mock.test.ts +++ b/packages/fmodata/tests/mock.test.ts @@ -13,25 +13,25 @@ * 3. The mock fetch will automatically match the request URL to the stored response */ -import { assert } from "node:console"; import { eq } from "@proofkit/fmodata"; -import { describe, expect, expectTypeOf, it } from "vitest"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; +import { assert, describe, expect, expectTypeOf, it } from "vitest"; import { mockResponses } from "./fixtures/responses"; -import { createMockFetch, simpleMock } from "./utils/mock-fetch"; -import { contacts, createMockClient } from "./utils/test-setup"; +import { contacts } from "./utils/test-setup"; describe("Mock Fetch Tests", () => { - const client = createMockClient(); - const db = client.database("fmdapi_test.fmp12"); - describe("List queries", () => { it("should execute a basic list query using mocked response", async () => { - const result = await db - .from(contacts) - .list() - .execute({ - fetchHandler: createMockFetch(mockResponses["list-with-pagination"] ?? {}), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["list-with-pagination"]?.response, + status: mockResponses["list-with-pagination"]?.status ?? 200, + headers: mockResponses["list-with-pagination"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().execute(); expect(result).toBeDefined(); expect(result.error).toBeUndefined(); @@ -47,13 +47,16 @@ describe("Mock Fetch Tests", () => { }); it("should return odata annotations if requested", async () => { - const result = await db - .from(contacts) - .list() - .execute({ - fetchHandler: createMockFetch(mockResponses["list-with-pagination"] ?? {}), - includeODataAnnotations: true, - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["list-with-pagination"]?.response, + status: mockResponses["list-with-pagination"]?.status ?? 200, + headers: mockResponses["list-with-pagination"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().execute({ includeODataAnnotations: true }); expect(result).toBeDefined(); expect(result.error).toBeUndefined(); @@ -69,18 +72,22 @@ describe("Mock Fetch Tests", () => { }); it("should execute a list query with $select using mocked response", async () => { + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["list-with-pagination"]?.response, + status: mockResponses["list-with-pagination"]?.status ?? 200, + headers: mockResponses["list-with-pagination"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + const result = await db .from(contacts) .list() .select({ name: contacts.name, PrimaryKey: contacts.PrimaryKey }) - .execute({ - fetchHandler: createMockFetch(mockResponses["list-with-pagination"] ?? {}), - }); + .execute(); expect(result).toBeDefined(); - if (result.error) { - console.log(result.error); - } expect(result.error).toBeUndefined(); expect(result.data).toBeDefined(); if (!result.data) { @@ -94,13 +101,16 @@ describe("Mock Fetch Tests", () => { }); it("should execute a list query with $top using mocked response", async () => { - const result = await db - .from(contacts) - .list() - .top(5) - .execute({ - fetchHandler: createMockFetch(mockResponses["list-with-orderby"] ?? {}), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["list-with-orderby"]?.response, + status: mockResponses["list-with-orderby"]?.status ?? 200, + headers: mockResponses["list-with-orderby"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().top(5).execute(); expect(result).toBeDefined(); expect(result.error).toBeUndefined(); @@ -115,14 +125,16 @@ describe("Mock Fetch Tests", () => { }); it("should execute a list query with $orderby using mocked response", async () => { - const result = await db - .from(contacts) - .list() - .orderBy("name") - .top(5) - .execute({ - fetchHandler: createMockFetch(mockResponses["list-with-orderby"] ?? {}), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["list-with-orderby"]?.response, + status: mockResponses["list-with-orderby"]?.status ?? 200, + headers: mockResponses["list-with-orderby"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().orderBy("name").top(5).execute(); expect(result).toBeDefined(); expect(result.data).toBeDefined(); @@ -131,26 +143,31 @@ describe("Mock Fetch Tests", () => { }); it("should error if more than 1 record is returned in single mode", async () => { - const result = await db - .from(contacts) - .list() - .single() - .execute({ - fetchHandler: createMockFetch(mockResponses["list-with-orderby"] ?? {}), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["list-with-orderby"]?.response, + status: mockResponses["list-with-orderby"]?.status ?? 200, + headers: mockResponses["list-with-orderby"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().single().execute(); expect(result).toBeDefined(); expect(result.data).toBeUndefined(); expect(result.error).toBeDefined(); }); it("should not error if no records are returned in maybeSingle mode", async () => { - const result = await db - .from(contacts) - .list() - .maybeSingle() - .execute({ - fetchHandler: simpleMock({ status: 200, body: { value: [] } }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: { value: [] }, + status: 200, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().maybeSingle().execute(); expect(result.data).toBeNull(); expect(result.error).toBeUndefined(); @@ -159,28 +176,31 @@ describe("Mock Fetch Tests", () => { expectTypeOf(result.data).toBeNullable(); }); it("should error if more than 1 record is returned in maybeSingle mode", async () => { - const result = await db - .from(contacts) - .list() - .maybeSingle() - .execute({ - // TODO: add better mock data - fetchHandler: simpleMock({ status: 200, body: { value: [{}, {}] } }), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: { value: [{}, {}] }, + status: 200, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().maybeSingle().execute(); expect(result.data).toBeUndefined(); expect(result.error).toBeDefined(); }); it("should execute a list query with pagination using mocked response", async () => { - const result = await db - .from(contacts) - .list() - .top(2) - .skip(2) - .execute({ - fetchHandler: createMockFetch(mockResponses["list-with-pagination"] ?? {}), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["list-with-pagination"]?.response, + status: mockResponses["list-with-pagination"]?.status ?? 200, + headers: mockResponses["list-with-pagination"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().top(2).skip(2).execute(); expect(result).toBeDefined(); expect(result.data).toBeDefined(); @@ -191,12 +211,16 @@ describe("Mock Fetch Tests", () => { describe("Single record queries", () => { it("should execute a single record query using mocked response", async () => { - const result = await db - .from(contacts) - .get("B5BFBC89-03E0-47FC-ABB6-D51401730227") - .execute({ - fetchHandler: createMockFetch(mockResponses["single-record"] ?? {}), - }); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["single-record"]?.response, + status: mockResponses["single-record"]?.status ?? 200, + headers: mockResponses["single-record"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).get("B5BFBC89-03E0-47FC-ABB6-D51401730227").execute(); expect(result).toBeDefined(); expect(result.data).toBeDefined(); @@ -210,13 +234,20 @@ describe("Mock Fetch Tests", () => { // Note: Type errors for wrong columns are now caught at compile time // We can't easily test this with @ts-expect-error since we'd need a wrong table's column + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: mockResponses["single-field"]?.response, + status: mockResponses["single-field"]?.status ?? 200, + headers: mockResponses["single-field"]?.headers, + }); + const db = mock.database("fmdapi_test.fmp12"); + const result = await db .from(contacts) .get("B5BFBC89-03E0-47FC-ABB6-D51401730227") .getSingleField(contacts.name) - .execute({ - fetchHandler: createMockFetch(mockResponses["single-field"] ?? {}), - }); + .execute(); expect(result).toBeDefined(); expect(result.data).toBeDefined(); @@ -233,6 +264,9 @@ describe("Mock Fetch Tests", () => { describe("Query builder methods", () => { it("should generate correct query strings even with mocks", () => { + const mock = new MockFMServerConnection(); + const db = mock.database("fmdapi_test.fmp12"); + const queryString = db .from(contacts) .list() diff --git a/packages/fmodata/tests/mutation-helpers.test.ts b/packages/fmodata/tests/mutation-helpers.test.ts new file mode 100644 index 00000000..200be0ff --- /dev/null +++ b/packages/fmodata/tests/mutation-helpers.test.ts @@ -0,0 +1,59 @@ +import { + buildMutationUrl, + extractAffectedRows, + parseRowIdFromLocationHeader, + stripTablePathPrefix, +} from "@proofkit/fmodata/client/builders/mutation-helpers"; +import { InvalidLocationHeaderError } from "@proofkit/fmodata/errors"; +import { describe, expect, it } from "vitest"; + +describe("mutation helpers", () => { + it("builds byId mutation URLs", () => { + const url = buildMutationUrl({ + databaseName: "test_db", + tableId: "users", + tableName: "users", + mode: "byId", + recordId: "abc-123", + builderName: "TestBuilder", + }); + + expect(url).toBe("/test_db/users('abc-123')"); + }); + + it("builds byFilter mutation URLs and rewrites table prefix", () => { + const queryBuilder = { + getQueryString: () => "/users?$filter=name eq 'John'&$top=10", + }; + + const url = buildMutationUrl({ + databaseName: "test_db", + tableId: "FMTID:555", + tableName: "users", + mode: "byFilter", + queryBuilder, + builderName: "TestBuilder", + }); + + expect(url).toBe("/test_db/FMTID:555?$filter=name eq 'John'&$top=10"); + expect(stripTablePathPrefix("/FMTID:555?$filter=active eq true", "FMTID:555", "users")).toBe( + "?$filter=active eq true", + ); + }); + + it("extracts affected rows from headers and body fallbacks", () => { + const headers = new Headers({ "fmodata.affected_rows": "7" }); + expect(extractAffectedRows(undefined, headers, 0, "updatedCount")).toBe(7); + expect(extractAffectedRows({ updatedCount: 3 }, undefined, 0, "updatedCount")).toBe(3); + expect(extractAffectedRows({ deletedCount: 2 }, undefined, 0, "deletedCount")).toBe(2); + expect(extractAffectedRows(9, undefined, 0, "deletedCount")).toBe(9); + expect(extractAffectedRows(undefined, undefined, 11, "deletedCount")).toBe(11); + }); + + it("parses row id from location header", () => { + expect(parseRowIdFromLocationHeader("contacts(ROWID=4583)")).toBe(4583); + expect(parseRowIdFromLocationHeader("contacts('42')")).toBe(42); + expect(() => parseRowIdFromLocationHeader(undefined)).toThrow(InvalidLocationHeaderError); + expect(() => parseRowIdFromLocationHeader("contacts('abc')")).toThrow(InvalidLocationHeaderError); + }); +}); diff --git a/packages/fmodata/tests/navigate.test.ts b/packages/fmodata/tests/navigate.test.ts index 4e9447be..cad75add 100644 --- a/packages/fmodata/tests/navigate.test.ts +++ b/packages/fmodata/tests/navigate.test.ts @@ -6,12 +6,12 @@ */ import { dateField, fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, expectTypeOf, it } from "vitest"; import { arbitraryTable, contacts, contactsTOWithIds, - createMockClient, invoices, lineItems, users, @@ -47,7 +47,7 @@ const usersWithSchema = fmTableOccurrence( ); describe("navigate", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); it("should not allow navigation to an invalid relation", () => { const db = client.database("test_db"); diff --git a/packages/fmodata/tests/query-strings.test.ts b/packages/fmodata/tests/query-strings.test.ts index 7dfc34f2..6079cc6a 100644 --- a/packages/fmodata/tests/query-strings.test.ts +++ b/packages/fmodata/tests/query-strings.test.ts @@ -22,8 +22,8 @@ const SELECT_QUERY_REGEX = /\$select=([^&]+)/; import { and, asc, desc, eq, fmTableOccurrence, gt, isNull, numberField, or, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, it } from "vitest"; -import { createMockClient } from "./utils/test-setup"; const users = fmTableOccurrence( "users", @@ -47,7 +47,7 @@ const contacts = fmTableOccurrence("contacts", { }); describe("OData Query String Generation", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("TestDB"); describe("$select", () => { diff --git a/packages/fmodata/tests/record-builder-select-expand.test.ts b/packages/fmodata/tests/record-builder-select-expand.test.ts index 69a70c8e..f76f44a9 100644 --- a/packages/fmodata/tests/record-builder-select-expand.test.ts +++ b/packages/fmodata/tests/record-builder-select-expand.test.ts @@ -10,17 +10,17 @@ */ import { eq, fmTableOccurrence, numberField, textField, timestampField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; -import { createMockFetch } from "./utils/mock-fetch"; -import { arbitraryTable, contacts, createMockClient, invoices, users } from "./utils/test-setup"; +import { arbitraryTable, contacts, invoices, users } from "./utils/test-setup"; const EXPAND_WITH_ID_REGEX = /\$expand=users\([^)]*id[^)]*\)/; const SELECT_ID_REGEX = /\$select=["']?id["']?\)/; describe("RecordBuilder Select/Expand", () => { - const client = createMockClient(); - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // Create occurrences with different defaultSelect values for testing const contactsWithSchemaSelect = fmTableOccurrence( @@ -493,13 +493,20 @@ describe("RecordBuilder Select/Expand", () => { }, }; - const result = await db + const execMock = new MockFMServerConnection(); + execMock.addRoute({ + urlPattern: "/test_db/contacts", + response: mockResponse.response, + status: mockResponse.status, + headers: mockResponse.headers, + }); + const execDb = execMock.database("test_db"); + + const result = await execDb .from(contacts) .get("test-uuid") .select({ name: contacts.name, hobby: contacts.hobby }) - .execute({ - fetchHandler: createMockFetch(mockResponse), - }); + .execute(); expect(result.error).toBeUndefined(); expect(result.data).toBeDefined(); @@ -536,13 +543,20 @@ describe("RecordBuilder Select/Expand", () => { }, }; - const result = await db + const execMock = new MockFMServerConnection(); + execMock.addRoute({ + urlPattern: "/test_db/contacts", + response: mockResponse.response, + status: mockResponse.status, + headers: mockResponse.headers, + }); + const execDb = execMock.database("test_db"); + + const result = await execDb .from(contacts) .get("test-uuid") .expand(users, (b: any) => b.select({ name: users.name, active: users.active })) - .execute({ - fetchHandler: createMockFetch(mockResponse), - }); + .execute(); expect(result.error).toBeUndefined(); expect(result.data).toBeDefined(); @@ -567,13 +581,20 @@ describe("RecordBuilder Select/Expand", () => { }, }; - const result = await db + const execMock = new MockFMServerConnection(); + execMock.addRoute({ + urlPattern: "/test_db/contacts", + response: mockResponse.response, + status: mockResponse.status, + headers: mockResponse.headers, + }); + const execDb = execMock.database("test_db"); + + const result = await execDb .from(contacts) .get("test-uuid") .select({ name: contacts.name, hobby: contacts.hobby }) - .execute({ - fetchHandler: createMockFetch(mockResponse), - }); + .execute(); expect(result.data).toBeDefined(); // OData annotations should be stripped @@ -596,12 +617,20 @@ describe("RecordBuilder Select/Expand", () => { }, }; - const result = await db + const execMock = new MockFMServerConnection(); + execMock.addRoute({ + urlPattern: "/test_db/contacts", + response: mockResponse.response, + status: mockResponse.status, + headers: mockResponse.headers, + }); + const execDb = execMock.database("test_db"); + + const result = await execDb .from(contacts) .get("test-uuid") .select({ name: contacts.name, hobby: contacts.hobby }) .execute({ - fetchHandler: createMockFetch(mockResponse), includeODataAnnotations: true, }); @@ -698,12 +727,16 @@ describe("RecordBuilder Select/Expand", () => { }, }; - const result = await db - .from(contactsWithSchemaSelect) - .get("test-uuid") - .execute({ - fetchHandler: createMockFetch(mockResponse), - }); + const execMock = new MockFMServerConnection(); + execMock.addRoute({ + urlPattern: "/test_db/contacts", + response: mockResponse.response, + status: mockResponse.status, + headers: mockResponse.headers, + }); + const execDb = execMock.database("test_db"); + + const result = await execDb.from(contactsWithSchemaSelect).get("test-uuid").execute(); expect(result.data).toBeDefined(); expect(result.error).toBeUndefined(); diff --git a/packages/fmodata/tests/response-processor.test.ts b/packages/fmodata/tests/response-processor.test.ts new file mode 100644 index 00000000..9aac4d8c --- /dev/null +++ b/packages/fmodata/tests/response-processor.test.ts @@ -0,0 +1,66 @@ +import { RecordCountMismatchError } from "@proofkit/fmodata"; +import { + processODataResponse, + processQueryResponse, + processRecordResponse, +} from "@proofkit/fmodata/client/builders/response-processor"; +import { describe, expect, it } from "vitest"; + +const logger = { + debug: () => undefined, + info: () => undefined, + success: () => undefined, + warn: () => undefined, + error: () => undefined, + get level() { + return "error" as const; + }, +}; + +describe("response processor", () => { + it("renames fields when skipValidation is enabled", async () => { + const result = await processODataResponse<{ userEmail: string }>( + { value: [{ email: "john@example.com" }] }, + { + singleMode: false, + skipValidation: true, + fieldMapping: { email: "userEmail" }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.data).toEqual([{ userEmail: "john@example.com" }]); + }); + + it("returns record count mismatch in exact single mode", async () => { + const result = await processQueryResponse<{ id: string }>( + { value: [{ id: "a" }, { id: "b" }] }, + { + singleMode: "exact", + queryOptions: {}, + expandConfigs: [], + skipValidation: true, + logger, + }, + ); + + expect(result.data).toBeUndefined(); + expect(result.error).toBeInstanceOf(RecordCountMismatchError); + }); + + it("processes record responses through the canonical query flow", async () => { + const result = await processRecordResponse<{ id: string; alias: string }>( + { id: "abc", name: "Taylor" }, + { + selectedFields: ["id", "name"], + expandConfigs: [], + skipValidation: true, + fieldMapping: { name: "alias" }, + logger, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ id: "abc", alias: "Taylor" }); + }); +}); diff --git a/packages/fmodata/tests/scripts.test.ts b/packages/fmodata/tests/scripts.test.ts index a5ea8a9d..69b71f26 100644 --- a/packages/fmodata/tests/scripts.test.ts +++ b/packages/fmodata/tests/scripts.test.ts @@ -4,16 +4,16 @@ * Tests for running FileMaker scripts via the OData API. */ +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; import { jsonCodec } from "./utils/helpers"; -import { createMockClient } from "./utils/test-setup"; describe("scripts", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); it("should handle expands", () => { - expectTypeOf(client.listDatabaseNames).returns.resolves.toBeArray(); + expectTypeOf(client.asConnection.listDatabaseNames).returns.resolves.toBeArray(); const db = client.database("test_db"); expectTypeOf(db.listTableNames).returns.resolves.toBeArray(); diff --git a/packages/fmodata/tests/typescript.test.ts b/packages/fmodata/tests/typescript.test.ts index 4df4392d..49d1bc48 100644 --- a/packages/fmodata/tests/typescript.test.ts +++ b/packages/fmodata/tests/typescript.test.ts @@ -20,7 +20,6 @@ import { eq, - FMServerConnection, FMTable, fmTableOccurrence, getTableColumns, @@ -29,14 +28,14 @@ import { numberField, textField, } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; -import { createMockFetch } from "./utils/mock-fetch"; -import { contacts, createMockClient, users } from "./utils/test-setup"; +import { contacts, users } from "./utils/test-setup"; describe("fmodata", () => { describe("API ergonomics", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("TestDB"); it("should support list() with query chaining", () => { @@ -245,7 +244,7 @@ describe("fmodata", () => { }); describe("BaseTable and TableOccurrence", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); it("should create BaseTable and TableOccurrence", () => { const tableOcc = fmTableOccurrence("Users", { @@ -285,8 +284,8 @@ describe("fmodata", () => { name: textField(), }); - const client1 = createMockClient(); - const client2 = createMockClient(); + const client1 = new MockFMServerConnection(); + const client2 = new MockFMServerConnection(); const db1 = client1.database("DB1"); const db2 = client2.database("DB2"); @@ -343,22 +342,21 @@ describe("fmodata", () => { describe("Type safety and result parsing", () => { it("should properly type the result of a query", async () => { - const client = new FMServerConnection({ - serverUrl: "https://api.example.com", - auth: { apiKey: "test-api-key" }, - fetchClientOptions: { - fetchHandler: createMockFetch([ - { - "@id": "1", - "@editLink": "https://api.example.com/Users/1", - id: 1, - name: "John Doe", - active: 0, // should coerce to boolean false - activeHuman: "active", - }, - ]), - }, + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/TestDB/Users", + response: [ + { + "@id": "1", + "@editLink": "https://api.example.com/Users/1", + id: 1, + name: "John Doe", + active: 0, // should coerce to boolean false + activeHuman: "active", + }, + ], }); + const client = mock; const usersTO = fmTableOccurrence("Users", { id: numberField().primaryKey(), @@ -421,7 +419,7 @@ describe("fmodata", () => { */ it("should support single field orderBy with default ascending", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("fmdapi_test.fmp12"); // ✅ Single field name - defaults to ascending @@ -437,7 +435,7 @@ describe("fmodata", () => { }); it("should support tuple syntax for single field with explicit direction", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("fmdapi_test.fmp12"); // ✅ Tuple syntax: [fieldName, direction] @@ -456,7 +454,7 @@ describe("fmodata", () => { }); it("should support tuple syntax with entity IDs and transform field names to FMFIDs", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("test.fmp12"); // ✅ Tuple syntax: [fieldName, direction] @@ -476,7 +474,7 @@ describe("fmodata", () => { }); it("should support array of tuples for multiple fields", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("fmdapi_test.fmp12"); // ✅ Array of tuples for multiple fields with explicit directions @@ -493,7 +491,7 @@ describe("fmodata", () => { }); it("should chain orderBy with other query methods", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("fmdapi_test.fmp12"); const query = db @@ -523,7 +521,7 @@ describe("fmodata", () => { * - Multiple fields: Array<[keyof T, 'asc' | 'desc']> - array of tuples */ it("should reject invalid usage at compile time", () => { - const client = createMockClient(); + const client = new MockFMServerConnection(); const db = client.database("fmdapi_test.fmp12"); const _typeChecks = () => { diff --git a/packages/fmodata/tests/update.test.ts b/packages/fmodata/tests/update.test.ts index d4db1bf1..0ac9c89c 100644 --- a/packages/fmodata/tests/update.test.ts +++ b/packages/fmodata/tests/update.test.ts @@ -17,14 +17,11 @@ import { } from "@proofkit/fmodata"; import { InsertBuilder } from "@proofkit/fmodata/client/insert-builder"; import { ExecutableUpdateBuilder, UpdateBuilder } from "@proofkit/fmodata/client/update-builder"; -import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; -import { simpleMock } from "./utils/mock-fetch"; -import { createMockClient } from "./utils/test-setup"; describe("insert and update methods", () => { - const client = createMockClient(); - const _contactsTO = fmTableOccurrence( "contacts", { @@ -71,14 +68,16 @@ describe("insert and update methods", () => { describe("insert method", () => { it("should return InsertBuilder when called", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const result = db.from(users).insert({ username: "test", active: true }); expect(result).toBeInstanceOf(InsertBuilder); }); it("should accept all fields as optional when no required specified", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // @ts-expect-error - some fields are required, no empty object is allowed db.from(users).insert({}); @@ -95,7 +94,8 @@ describe("insert and update methods", () => { }); it("should require specified fields when required is set", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // These should work - required fields are username and email db.from(usersWithRequired).insert({ @@ -114,7 +114,8 @@ describe("insert and update methods", () => { }); it("should have execute() that returns Result without ODataRecordMetadata by default", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const builder = db.from(users).insert({ username: "test", active: true }); @@ -127,14 +128,16 @@ describe("insert and update methods", () => { describe("update method with builder pattern", () => { it("should return UpdateBuilder when update() is called", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const result = db.from(users).update({ username: "newname" }); expect(result).toBeInstanceOf(UpdateBuilder); }); it("should not have execute() on initial UpdateBuilder", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const updateBuilder = db.from(users).update({ username: "newname" }); @@ -143,14 +146,16 @@ describe("insert and update methods", () => { }); it("should return ExecutableUpdateBuilder after byId()", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const result = db.from(users).update({ username: "newname" }).byId("user-123"); expect(result).toBeInstanceOf(ExecutableUpdateBuilder); }); it("should return ExecutableUpdateBuilder after where()", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const result = db .from(users) @@ -162,7 +167,8 @@ describe("insert and update methods", () => { describe("update by ID", () => { it("should generate correct URL for update by ID", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const updateBuilder = db.from(users).update({ username: "newname" }).byId("user-123"); const config = updateBuilder.getRequestConfig(); @@ -173,7 +179,8 @@ describe("insert and update methods", () => { }); it("should return updatedCount type for update by ID", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const updateBuilder = db.from(users).update({ username: "newname" }).byId("user-123"); @@ -182,19 +189,18 @@ describe("insert and update methods", () => { }); it("should execute update by ID and return count", async () => { - const mockFetch = simpleMock({ + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/users", + method: "PATCH", status: 200, headers: { "fmodata.affected_rows": "1" }, - body: null, + response: null, }); - const db = client.database("test_db"); + const db = mock.database("test_db"); - const result = await db - .from(users) - .update({ username: "newname" }) - .byId("user-123") - .execute({ fetchHandler: mockFetch }); + const result = await db.from(users).update({ username: "newname" }).byId("user-123").execute(); expect(result.error).toBeUndefined(); expect(result.data).toBeDefined(); @@ -204,7 +210,8 @@ describe("insert and update methods", () => { describe("update by filter", () => { it("should generate correct URL for update by filter", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const updateBuilder = db .from(users) @@ -220,7 +227,8 @@ describe("insert and update methods", () => { }); it("should support complex filters with QueryBuilder", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const updateBuilder = db .from(users) @@ -234,7 +242,8 @@ describe("insert and update methods", () => { }); it("should support QueryBuilder chaining in where callback", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const updateBuilder = db .from(users) @@ -249,7 +258,8 @@ describe("insert and update methods", () => { }); it("should return updatedCount result type for filter-based update", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); const updateBuilder = db .from(users) @@ -264,19 +274,22 @@ describe("insert and update methods", () => { }); it("should execute update by filter and return count", async () => { - const mockFetch = simpleMock({ + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/users", + method: "PATCH", status: 204, headers: { "fmodata.affected_rows": "3" }, - body: null, + response: null, }); - const db = client.database("test_db"); + const db = mock.database("test_db"); const result = await db .from(users) .update({ active: false }) .where((q) => q.where(eq(users.active, true))) - .execute({ fetchHandler: mockFetch }); + .execute(); expect(result.error).toBeUndefined(); expect(result.data).toEqual({ updatedCount: 3 }); @@ -285,7 +298,8 @@ describe("insert and update methods", () => { describe("update with optional fields", () => { it("should allow all fields to be optional for updates", () => { - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // All fields should be optional for updates (updateRequired removed) db.from(usersWithRequired).update({ @@ -308,7 +322,8 @@ describe("insert and update methods", () => { status: textField(), }); - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // All fields are optional for update, even those required for insert db.from(usersForUpdate).update({ @@ -333,7 +348,8 @@ describe("insert and update methods", () => { email: textField(), }); - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // id, createdAt, and modifiedAt should not be available for insert db.from(usersWithReadOnly).insert({ @@ -375,7 +391,8 @@ describe("insert and update methods", () => { email: textField(), }); - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // id, createdAt, and modifiedAt should not be available for update db.from(usersWithReadOnlyTO).update({ @@ -402,7 +419,8 @@ describe("insert and update methods", () => { email: textField(), // nullable by default }); - const db = client.database("test_db"); + const mock = new MockFMServerConnection(); + const db = mock.database("test_db"); // Should work - id and createdAt are excluded automatically db.from(usersWithReadOnlyTO).insert({ @@ -419,15 +437,16 @@ describe("insert and update methods", () => { describe("error handling", () => { it("should return error on failed update by ID", async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error("Network error")); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/users", + throwError: new Error("Network error"), + response: null, + }); - const db = client.database("test_db"); + const db = mock.database("test_db"); - const result = await db - .from(users) - .update({ username: "newname" }) - .byId("user-123") - .execute({ fetchHandler: mockFetch as any }); + const result = await db.from(users).update({ username: "newname" }).byId("user-123").execute(); expect(result.data).toBeUndefined(); expect(result.error).toBeInstanceOf(Error); @@ -435,15 +454,20 @@ describe("insert and update methods", () => { }); it("should return error on failed update by filter", async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error("Network error")); + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/test_db/users", + throwError: new Error("Network error"), + response: null, + }); - const db = client.database("test_db"); + const db = mock.database("test_db"); const result = await db .from(users) .update({ active: false }) .where((q) => q.where(eq(users.active, true))) - .execute({ fetchHandler: mockFetch as any }); + .execute(); expect(result.data).toBeUndefined(); expect(result.error).toBeInstanceOf(Error); diff --git a/packages/fmodata/tests/use-entity-ids-override.test.ts b/packages/fmodata/tests/use-entity-ids-override.test.ts index 36d57642..cbb2313a 100644 --- a/packages/fmodata/tests/use-entity-ids-override.test.ts +++ b/packages/fmodata/tests/use-entity-ids-override.test.ts @@ -4,13 +4,17 @@ * These tests verify that the useEntityIds option can be overridden at the request level * using ExecuteOptions, allowing users to disable entity IDs for specific requests even * when the database is configured to use them by default. + * + * Note: The spy captures headers from the Request object (via Headers API), which + * normalizes header names to lowercase. Use lowercase keys (e.g. "prefer") when + * checking spy headers. */ -import { FMServerConnection, fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { describe, expect, it } from "vitest"; -import { simpleMock } from "./utils/mock-fetch"; -// Create database with entity IDs +// Create table occurrence with entity IDs configured const contactsTO = fmTableOccurrence( "contacts", { @@ -23,257 +27,148 @@ const contactsTO = fmTableOccurrence( ); describe("Per-request useEntityIds override", () => { + const makeLocalContactsTO = () => + fmTableOccurrence( + "contacts", + { + id: textField().primaryKey().entityId("FMFID:1"), + name: textField().entityId("FMFID:2"), + }, + { + entityId: "FMTID:100", + }, + ); + it("should allow disabling entity IDs for a specific request", async () => { - // Create connection with entity IDs enabled by default - const connection = new FMServerConnection({ - serverUrl: "https://test.com", - auth: { username: "test", password: "test" }, + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB", + response: { value: [] }, + status: 200, }); - - const db = connection.database("TestDB"); + const db = mock.database("TestDB", { useEntityIds: true }); // First request: use default (should have entity ID header) - await db - .from(contactsTO) - .list() - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBe("fmodata.entity-ids"); - return simpleMock({ body: { value: [] }, status: 200 })(input, init); - }, - }); + await db.from(contactsTO).list().execute(); + + const call0 = mock.spy?.calls[0]; + expect(call0?.headers?.prefer).toBe("fmodata.entity-ids"); // Second request: explicitly disable entity IDs for this request only - await db - .from(contactsTO) - .list() - .execute({ - useEntityIds: false, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBeUndefined(); - return simpleMock({ body: { value: [] }, status: 200 })(input, init); - }, - }); + await db.from(contactsTO).list().execute({ useEntityIds: false }); + + const call1 = mock.spy?.calls[1]; + expect(call1?.headers?.prefer).toBeUndefined(); // Third request: explicitly enable entity IDs for this request - await db - .from(contactsTO) - .list() - .execute({ - useEntityIds: true, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBe("fmodata.entity-ids"); - return simpleMock({ body: { value: [] }, status: 200 })(input, init); - }, - }); + await db.from(contactsTO).list().execute({ useEntityIds: true }); + + const call2 = mock.spy?.calls[2]; + expect(call2?.headers?.prefer).toBe("fmodata.entity-ids"); }); it("should allow enabling entity IDs for a specific request when disabled by default", async () => { - // Create connection without entity IDs by default - const connection = new FMServerConnection({ - serverUrl: "https://test.com", - auth: { username: "test", password: "test" }, - }); - - const db = connection.database("TestDB", { - useEntityIds: false, + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB", + response: { value: [] }, + status: 200, }); + const db = mock.database("TestDB", { useEntityIds: false }); // First request: use default (should NOT have entity ID header) - await db - .from(contactsTO) - .list() - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBeUndefined(); - return simpleMock({ body: { value: [] }, status: 200 })(input, init); - }, - }); + await db.from(contactsTO).list().execute(); + + const call0 = mock.spy?.calls[0]; + expect(call0?.headers?.prefer).toBeUndefined(); // Second request: explicitly enable entity IDs for this request only - await db - .from(contactsTO) - .list() - .execute({ - useEntityIds: true, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBe("fmodata.entity-ids"); - return simpleMock({ body: { value: [] }, status: 200 })(input, init); - }, - }); + await db.from(contactsTO).list().execute({ useEntityIds: true }); + + const call1 = mock.spy?.calls[1]; + expect(call1?.headers?.prefer).toBe("fmodata.entity-ids"); // Third request: confirm default is still disabled - await db - .from(contactsTO) - .list() - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBeUndefined(); - return simpleMock({ body: { value: [] }, status: 200 })(input, init); - }, - }); + await db.from(contactsTO).list().execute(); + + const call2 = mock.spy?.calls[2]; + expect(call2?.headers?.prefer).toBeUndefined(); }); it("should work with insert operations", async () => { - const connection = new FMServerConnection({ - serverUrl: "https://test.com", - auth: { username: "test", password: "test" }, + const localContactsTO = makeLocalContactsTO(); + + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB", + response: { id: "1", name: "Test" }, + status: 200, }); + const db = mock.database("TestDB", { useEntityIds: true }); - const contactsTO = fmTableOccurrence( - "contacts", - { - id: textField().primaryKey().entityId("FMFID:1"), - name: textField().entityId("FMFID:2"), - }, - { - entityId: "FMTID:100", - }, - ); + // Insert with entity IDs enabled — verify via URL (uses FMTID) + // Note: The insert builder sets its own Prefer header ("return=representation") + // which overwrites the entity-ids Prefer value. Entity ID usage is verified via URL. + await db.from(localContactsTO).insert({ name: "Test" }).execute(); + + const call0 = mock.spy?.calls[0]; + expect(call0?.url).toContain("FMTID:100"); - const db = connection.database("TestDB"); - - // Insert with default settings (entity IDs enabled) - await db - .from(contactsTO) - .insert({ name: "Test" }) - .execute({ - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toContain("fmodata.entity-ids"); - return simpleMock({ body: { id: "1", name: "Test" }, status: 200 })(input, init); - }, - }); - - // Insert with entity IDs disabled for this request - await db - .from(contactsTO) - .insert({ name: "Test" }) - .execute({ - useEntityIds: false, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).not.toContain("fmodata.entity-ids"); - return simpleMock({ body: { id: "1", name: "Test" }, status: 200 })(input, init); - }, - }); + // Insert with entity IDs disabled — URL should use table name + await db.from(localContactsTO).insert({ name: "Test" }).execute({ useEntityIds: false }); + + const call1 = mock.spy?.calls[1]; + expect(call1?.url).toContain("/contacts"); + expect(call1?.url).not.toContain("FMTID:"); }); it("should work with update operations", async () => { - const connection = new FMServerConnection({ - serverUrl: "https://test.com", - auth: { username: "test", password: "test" }, + const localContactsTO = makeLocalContactsTO(); + + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB", + response: "1", + status: 200, + headers: { "fmodata.affected_rows": "1" }, }); - - const contactsTO = fmTableOccurrence( - "contacts", - { - id: textField().primaryKey().entityId("FMFID:1"), - name: textField().entityId("FMFID:2"), - }, - { - entityId: "FMTID:100", - }, - ); - - const db = connection.database("TestDB"); + const db = mock.database("TestDB", { useEntityIds: true }); // Update with entity IDs disabled - await db - .from(contactsTO) - .update({ name: "Updated" }) - .byId("123") - .execute({ - useEntityIds: false, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBeUndefined(); - return simpleMock({ - body: "1", - status: 200, - headers: { "fmodata.affected_rows": "1" }, - })(input, init); - }, - }); + await db.from(localContactsTO).update({ name: "Updated" }).byId("123").execute({ useEntityIds: false }); + + const call0 = mock.spy?.calls[0]; + expect(call0?.headers?.prefer).toBeUndefined(); // Update with entity IDs enabled - await db - .from(contactsTO) - .update({ name: "Updated" }) - .byId("123") - .execute({ - useEntityIds: true, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBe("fmodata.entity-ids"); - return simpleMock({ - body: "1", - status: 200, - headers: { "fmodata.affected_rows": "1" }, - })(input, init); - }, - }); + await db.from(localContactsTO).update({ name: "Updated" }).byId("123").execute({ useEntityIds: true }); + + const call1 = mock.spy?.calls[1]; + expect(call1?.headers?.prefer).toBe("fmodata.entity-ids"); }); it("should work with delete operations", async () => { - const connection = new FMServerConnection({ - serverUrl: "https://test.com", - auth: { username: "test", password: "test" }, + const localContactsTO = makeLocalContactsTO(); + + const mock = new MockFMServerConnection({ enableSpy: true }); + mock.addRoute({ + urlPattern: "/TestDB", + response: null, + status: 204, + headers: { "fmodata.affected_rows": "1" }, }); - - const contactsTO = fmTableOccurrence( - "contacts", - { - id: textField().primaryKey().entityId("FMFID:1"), - name: textField().entityId("FMFID:2"), - }, - { - entityId: "FMTID:100", - }, - ); - - const db = connection.database("TestDB"); + const db = mock.database("TestDB", { useEntityIds: true }); // Delete with entity IDs enabled - await db - .from(contactsTO) - .delete() - .byId("123") - .execute({ - useEntityIds: true, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBe("fmodata.entity-ids"); - return simpleMock({ - body: "1", - status: 204, - headers: { "fmodata.affected_rows": "1" }, - })(input, init); - }, - }); + await db.from(localContactsTO).delete().byId("123").execute({ useEntityIds: true }); + + const call0 = mock.spy?.calls[0]; + expect(call0?.headers?.prefer).toBe("fmodata.entity-ids"); // Delete with entity IDs disabled - await db - .from(contactsTO) - .delete() - .byId("123") - .execute({ - useEntityIds: false, - fetchHandler: (input: RequestInfo | URL, init?: RequestInit) => { - const headers = (init as RequestInit)?.headers as Record; - expect(headers?.Prefer).toBeUndefined(); - return simpleMock({ - body: "1", - status: 204, - headers: { "fmodata.affected_rows": "1" }, - })(input, init); - }, - }); + await db.from(localContactsTO).delete().byId("123").execute({ useEntityIds: false }); + + const call1 = mock.spy?.calls[1]; + expect(call1?.headers?.prefer).toBeUndefined(); }); }); diff --git a/packages/fmodata/tests/validation.test.ts b/packages/fmodata/tests/validation.test.ts index b479185f..0446567b 100644 --- a/packages/fmodata/tests/validation.test.ts +++ b/packages/fmodata/tests/validation.test.ts @@ -14,35 +14,30 @@ */ import { fmTableOccurrence, textField } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { assert, describe, expect, expectTypeOf, it } from "vitest"; import type { z } from "zod/v4"; -import { simpleMock } from "./utils/mock-fetch"; -import { contacts, createMockClient, type hobbyEnum, users } from "./utils/test-setup"; +import { contacts, type hobbyEnum, users } from "./utils/test-setup"; describe("Validation Tests", () => { - const client = createMockClient(); - const db = client.database("fmdapi_test.fmp12"); - const simpleDb = client.database("fmdapi_test.fmp12"); - describe("validateRecord", () => { it("should validate a single record", async () => { - const result = await db - .from(contacts) - .list() - .select({ hobby: contacts.hobby }) - .execute({ - fetchHandler: simpleMock({ - status: 200, - body: { - "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", - value: [ - { - hobby: "Invalid Hobby", - }, - ], + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: { + "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", + value: [ + { + hobby: "Invalid Hobby", }, - }), - }); + ], + }, + status: 200, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().select({ hobby: contacts.hobby }).execute(); assert(result.data, "Result data should be defined"); const firstRecord = result.data?.[0]; @@ -53,35 +48,38 @@ describe("Validation Tests", () => { }); it("should validate records within an expand expression", async () => { - const result = await db - .from(contacts) - .list() - .expand(users, (b: any) => b.select({ name: users.name, fake_field: users.fake_field })) - .execute({ - fetchHandler: simpleMock({ - status: 200, - body: { - "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", - value: [ + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: { + "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", + value: [ + { + PrimaryKey: "B5BFBC89-03E0-47FC-ABB6-D51401730227", + CreationTimestamp: "2025-10-31T10:03:27Z", + CreatedBy: "admin", + ModificationTimestamp: "2025-10-31T15:55:53Z", + ModifiedBy: "admin", + name: "Eric", + hobby: "Board games", + id_user: "1A269FA3-82E6-465A-94FA-39EE3F2F9B5D", + users: [ { - PrimaryKey: "B5BFBC89-03E0-47FC-ABB6-D51401730227", - CreationTimestamp: "2025-10-31T10:03:27Z", - CreatedBy: "admin", - ModificationTimestamp: "2025-10-31T15:55:53Z", - ModifiedBy: "admin", - name: "Eric", - hobby: "Board games", - id_user: "1A269FA3-82E6-465A-94FA-39EE3F2F9B5D", - users: [ - { - name: "Test User", - }, - ], + name: "Test User", }, ], }, - }), - }); + ], + }, + status: 200, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db + .from(contacts) + .list() + .expand(users, (b) => b.select({ name: users.name, fake_field: users.fake_field })) + .execute(); assert(result.data, "Result data should be defined"); expect(result.error).toBeUndefined(); @@ -120,7 +118,9 @@ describe("Validation Tests", () => { id: textField().primaryKey().notNull(), name: textField().notNull(), }); - const query = simpleDb.from(simpleUsers).list(); + const mock = new MockFMServerConnection(); + const db = mock.database("fmdapi_test.fmp12"); + const query = db.from(simpleUsers).list(); const queryString = query.getQueryString(); @@ -131,24 +131,25 @@ describe("Validation Tests", () => { }); it("should skip validation if requested", async () => { - const result = await db - .from(contacts) - .list() - .execute({ - skipValidation: true, - fetchHandler: simpleMock({ - status: 200, - body: { - "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", - value: [ - { - PrimaryKey: "B5BFBC89-03E0-47FC-ABB6-D51401730227", - hobby: "not a valid hobby", - }, - ], + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: { + "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", + value: [ + { + PrimaryKey: "B5BFBC89-03E0-47FC-ABB6-D51401730227", + hobby: "not a valid hobby", }, - }), - }); + ], + }, + status: 200, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().execute({ + skipValidation: true, + }); expect(result).toBeDefined(); expect(result.error).toBeUndefined(); @@ -169,29 +170,30 @@ describe("Validation Tests", () => { }); it("should return odata annotations if requested, even if skipValidation is true", async () => { - const result = await db - .from(contacts) - .list() - .execute({ - skipValidation: true, - includeODataAnnotations: true, - fetchHandler: simpleMock({ - status: 200, - body: { - "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", - value: [ - { - "@id": - "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/contacts(B5BFBC89-03E0-47FC-ABB6-D51401730227)", - "@editLink": - "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/contacts(B5BFBC89-03E0-47FC-ABB6-D51401730227)", - PrimaryKey: "B5BFBC89-03E0-47FC-ABB6-D51401730227", - hobby: "not a valid hobby", - }, - ], + const mock = new MockFMServerConnection(); + mock.addRoute({ + urlPattern: "/fmdapi_test.fmp12/contacts", + response: { + "@context": "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/$metadata#contacts", + value: [ + { + "@id": + "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/contacts(B5BFBC89-03E0-47FC-ABB6-D51401730227)", + "@editLink": + "https://api.example.com/fmi/odata/v4/fmdapi_test.fmp12/contacts(B5BFBC89-03E0-47FC-ABB6-D51401730227)", + PrimaryKey: "B5BFBC89-03E0-47FC-ABB6-D51401730227", + hobby: "not a valid hobby", }, - }), - }); + ], + }, + status: 200, + }); + const db = mock.database("fmdapi_test.fmp12"); + + const result = await db.from(contacts).list().execute({ + skipValidation: true, + includeODataAnnotations: true, + }); expect(result).toBeDefined(); expect(result.error).toBeUndefined(); diff --git a/packages/fmodata/tests/webhooks.test.ts b/packages/fmodata/tests/webhooks.test.ts index f0967648..5d991c0a 100644 --- a/packages/fmodata/tests/webhooks.test.ts +++ b/packages/fmodata/tests/webhooks.test.ts @@ -14,13 +14,13 @@ import { type WebhookInfo, type WebhookListResponse, } from "@proofkit/fmodata"; +import { MockFMServerConnection } from "@proofkit/fmodata/testing"; import { assert, describe, expect, it } from "vitest"; import { mockResponses } from "./fixtures/responses"; import { createMockFetch } from "./utils/mock-fetch"; -import { createMockClient } from "./utils/test-setup"; describe("WebhookManager", () => { - const connection = createMockClient(); + const connection = new MockFMServerConnection(); const db = connection.database("fmdapi_test.fmp12"); // Create a simple table occurrence for testing diff --git a/packages/fmodata/vite.config.ts b/packages/fmodata/vite.config.ts index 5436c9ba..746359ff 100644 --- a/packages/fmodata/vite.config.ts +++ b/packages/fmodata/vite.config.ts @@ -8,7 +8,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: "./src/index.ts", + entry: ["./src/index.ts", "./src/testing.ts"], srcDir: "./src", cjs: false, outDir: "./dist", diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index 6c8456fb..6c1abcac 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -242,7 +242,7 @@ export function createClientFromConfig(config: FmdapiConfig): Omit=18'} - next-auth@4.24.13: resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==} peerDependencies: @@ -11076,7 +11069,7 @@ snapshots: dependencies: '@trpc/server': 11.0.0-rc.441 - '@trpc/next@11.0.0-rc.441(@tanstack/react-query@5.90.16(react@19.2.3))(@trpc/client@11.0.0-rc.441(@trpc/server@11.0.0-rc.441))(@trpc/react-query@11.0.0-rc.441(@tanstack/react-query@5.90.16(react@19.2.3))(@trpc/client@11.0.0-rc.441(@trpc/server@11.0.0-rc.441))(@trpc/server@11.0.0-rc.441)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@trpc/server@11.0.0-rc.441)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@trpc/next@11.0.0-rc.441(@tanstack/react-query@5.90.16(react@19.2.3))(@trpc/client@11.0.0-rc.441(@trpc/server@11.0.0-rc.441))(@trpc/react-query@11.0.0-rc.441(@tanstack/react-query@5.90.16(react@19.2.3))(@trpc/client@11.0.0-rc.441(@trpc/server@11.0.0-rc.441))(@trpc/server@11.0.0-rc.441)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@trpc/server@11.0.0-rc.441)(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@trpc/client': 11.0.0-rc.441(@trpc/server@11.0.0-rc.441) '@trpc/server': 11.0.0-rc.441 @@ -14135,11 +14128,7 @@ snapshots: neo-async@2.6.2: {} - neverthrow@8.2.0: - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.55.1 - - next-auth@4.24.13(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next-auth@4.24.13(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.28.4 '@panva/hkdf': 1.2.1