@proofkit/fmodata
Version:
FileMaker OData API client
5,265 lines • 198 kB
JavaScript
#!/usr/bin/env node
import { createRequire } from "node:module";
import { Command } from "commander";
import createClient, { AbortError, CircuitOpenError, NetworkError, RetryLimitError, TimeoutError } from "@fetchkit/ffetch";
import { Context, Effect, Layer, Schedule } from "effect";
import { get } from "es-toolkit/compat";
import { AuthenticationDetails, CognitoUser, CognitoUserPool } from "amazon-cognito-identity-js";
import buildQuery from "odata-query";
import Table from "cli-table3";
//#region src/errors.ts
/**
* Base class for all fmodata errors
*/
var FMODataError = class extends Error {
timestamp;
constructor(message, options) {
super(message, options);
this.name = this.constructor.name;
this.timestamp = /* @__PURE__ */ new Date();
}
};
var HTTPError = class extends FMODataError {
kind = "HTTPError";
url;
status;
statusText;
response;
constructor(url, status, statusText, response) {
super(`HTTP ${status} ${statusText} for ${url}`);
this.url = url;
this.status = status;
this.statusText = statusText;
this.response = response;
}
is4xx() {
return this.status >= 400 && this.status < 500;
}
is5xx() {
return this.status >= 500 && this.status < 600;
}
isNotFound() {
return this.status === 404;
}
isUnauthorized() {
return this.status === 401;
}
isForbidden() {
return this.status === 403;
}
};
var ODataError = class extends FMODataError {
kind = "ODataError";
url;
code;
details;
constructor(url, message, code, details) {
super(`OData error: ${message}`);
this.url = url;
this.code = code;
this.details = details;
}
};
var SchemaLockedError = class extends FMODataError {
kind = "SchemaLockedError";
url;
code;
details;
constructor(url, message, details) {
super(`OData error: ${message}`);
this.url = url;
this.code = "303";
this.details = details;
}
};
var ValidationError = class extends FMODataError {
kind = "ValidationError";
field;
issues;
value;
constructor(message, issues, options) {
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
this.field = options?.field;
this.issues = issues;
this.value = options?.value;
}
};
var ResponseStructureError = class extends FMODataError {
kind = "ResponseStructureError";
expected;
received;
constructor(expected, received) {
super(`Invalid response structure: expected ${expected}`);
this.expected = expected;
this.received = received;
}
};
var RecordCountMismatchError = class extends FMODataError {
kind = "RecordCountMismatchError";
expected;
received;
constructor(expected, received) {
super(`Expected ${typeof expected === "number" ? expected : expected} record(s), but received ${received}`);
this.expected = expected;
this.received = received;
}
};
var InvalidLocationHeaderError = class extends FMODataError {
kind = "InvalidLocationHeaderError";
locationHeader;
constructor(message, locationHeader) {
super(message);
this.locationHeader = locationHeader;
}
};
var ResponseParseError = class extends FMODataError {
kind = "ResponseParseError";
url;
rawText;
constructor(url, message, options) {
super(message, options?.cause ? { cause: options.cause } : void 0);
this.url = url;
this.rawText = options?.rawText;
}
};
var BatchTruncatedError = class extends FMODataError {
kind = "BatchTruncatedError";
operationIndex;
failedAtIndex;
constructor(operationIndex, failedAtIndex) {
super(`Operation ${operationIndex} was not executed because operation ${failedAtIndex} failed`);
this.operationIndex = operationIndex;
this.failedAtIndex = failedAtIndex;
}
};
var MissingLayerServiceError = class extends FMODataError {
kind = "MissingLayerServiceError";
service;
constructor(service, options) {
super(`Required layer service "${service}" is not available`, options?.cause ? { cause: options.cause } : void 0);
this.service = service;
}
};
var MetadataNotFoundError = class extends FMODataError {
kind = "MetadataNotFoundError";
databaseName;
constructor(databaseName) {
super(`Metadata for database "${databaseName}" not found in response`);
this.databaseName = databaseName;
}
};
var BuilderInvariantError = class extends FMODataError {
kind = "BuilderInvariantError";
builder;
constructor(builder, message, options) {
super(`${builder} invariant violation: ${message}`, options?.cause ? { cause: options.cause } : void 0);
this.builder = builder;
}
};
var SchemaValidationFailedError = class extends FMODataError {
kind = "SchemaValidationFailedError";
operation;
issues;
constructor(operation, message, options) {
super(`${operation} schema validation failed: ${message}`, options?.cause ? { cause: options.cause } : void 0);
this.operation = operation;
this.issues = options?.issues;
}
};
function isFMODataError(error) {
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)
*/
function isTransientError(error) {
if (error instanceof SchemaLockedError) return true;
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;
}
//#endregion
//#region src/services.ts
const HttpClient = Context.GenericTag("@proofkit/fmodata/HttpClient");
const ODataConfig = Context.GenericTag("@proofkit/fmodata/ODataConfig");
const ODataLogger = Context.GenericTag("@proofkit/fmodata/ODataLogger");
/**
* Extracts ODataConfig and ODataLogger values from a Layer synchronously.
* Used by builders to access config in non-Effect methods (getRequestConfig, toRequest, etc.)
*/
function extractConfigFromLayer(layer) {
const effect = Effect.gen(function* () {
const config = yield* ODataConfig.pipe(Effect.mapError((error) => new MissingLayerServiceError("ODataConfig", { cause: error })));
const { logger } = yield* ODataLogger.pipe(Effect.mapError((error) => new MissingLayerServiceError("ODataLogger", { cause: 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.
*/
function createDatabaseLayer(baseLayer, overrides) {
const { config: baseConfig } = extractConfigFromLayer(baseLayer);
const dbConfigLayer = Layer.succeed(ODataConfig, {
baseUrl: baseConfig.baseUrl,
databaseName: overrides.databaseName,
normalizeDatabaseName: overrides.normalizeDatabaseName,
useEntityIds: overrides.useEntityIds,
includeSpecialColumns: overrides.includeSpecialColumns
});
return Layer.merge(baseLayer, dbConfigLayer);
}
//#endregion
//#region src/effect.ts
/**
* Creates an Effect that yields the HttpClient service and makes a request.
* This is the primary way builders should make HTTP requests.
*/
function requestFromService(url, options) {
return Effect.gen(function* () {
const client = yield* HttpClient;
const config = yield* ODataConfig;
return yield* client.request(url, {
...options,
normalizeDatabaseName: options?.normalizeDatabaseName ?? config.normalizeDatabaseName
});
});
}
/**
* 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.
*/
function runAsResult(effect) {
return Effect.runPromise(effect.pipe(Effect.map((data) => ({
data,
error: void 0
})), Effect.catchAll((error) => Effect.succeed({
data: void 0,
error
})))).catch((defect) => ({
data: void 0,
error: isFMODataError(defect) ? defect : new BuilderInvariantError("runAsResult", String(defect))
}));
}
function withOptionalSpan(effect, spanName, attributes) {
if (!spanName) return effect;
return withSpan(effect, spanName, attributes);
}
/**
* Runs an Effect by providing the shared DI layer and returns fmodata Result<T>.
*/
function runLayerResult(layer, effect, spanName, attributes) {
return runAsResult(Effect.provide(withOptionalSpan(effect, spanName, attributes), layer));
}
/**
* Runs an Effect by providing the shared DI layer and throws on fmodata errors.
*/
async function runLayerOrThrow(layer, effect, spanName, attributes) {
const result = await runLayerResult(layer, effect, spanName, attributes);
if (result.error) throw result.error;
return result.data;
}
/**
* Wraps a sync/async function that may throw into an Effect that captures
* the error as a typed FMODataErrorType.
*/
function tryEffect(fn, mapError) {
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.
*/
function fromValidation(fn) {
return Effect.tryPromise({
try: fn,
catch: (e) => e
}).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.
*/
function buildRetrySchedule(policy) {
const maxRetries = policy.maxRetries ?? 3;
const baseDelay = `${policy.baseDelay ?? 500} millis`;
const useJitter = policy.jitter !== false;
const base = Schedule.exponential(baseDelay);
return (useJitter ? Schedule.jittered(base) : base).pipe(Schedule.intersect(Schedule.recurs(maxRetries)), Schedule.whileInput((error) => isTransientError(error)));
}
/**
* Applies a retry policy to an Effect if the policy is defined.
* Only retries transient errors (SchemaLockedError, NetworkError, TimeoutError, HTTP 5xx).
*/
function withRetryPolicy(effect, retryPolicy) {
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.
*/
function withSpan(effect, name, attributes) {
return effect.pipe(Effect.withSpan(name, { attributes: attributes ? attributes : void 0 }));
}
//#endregion
//#region src/logger.ts
const TTY_COLORS = {
reset: "\x1B[0m",
bright: "\x1B[1m",
dim: "\x1B[2m",
undim: "\x1B[22m",
underscore: "\x1B[4m",
blink: "\x1B[5m",
reverse: "\x1B[7m",
hidden: "\x1B[8m",
fg: {
black: "\x1B[30m",
red: "\x1B[31m",
green: "\x1B[32m",
yellow: "\x1B[33m",
blue: "\x1B[34m",
magenta: "\x1B[35m",
cyan: "\x1B[36m",
white: "\x1B[37m"
},
bg: {
black: "\x1B[40m",
red: "\x1B[41m",
green: "\x1B[42m",
yellow: "\x1B[43m",
blue: "\x1B[44m",
magenta: "\x1B[45m",
cyan: "\x1B[46m",
white: "\x1B[47m"
}
};
const levels = [
"debug",
"info",
"success",
"warn",
"error"
];
function shouldPublishLog(currentLogLevel, logLevel) {
return levels.indexOf(logLevel) >= levels.indexOf(currentLogLevel);
}
const levelColors = {
info: TTY_COLORS.fg.blue,
success: TTY_COLORS.fg.green,
warn: TTY_COLORS.fg.yellow,
error: TTY_COLORS.fg.red,
debug: TTY_COLORS.fg.magenta
};
const formatMessage = (level, message, colorsEnabled) => {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
if (colorsEnabled) return `${TTY_COLORS.dim}${timestamp}${TTY_COLORS.reset} ${levelColors[level]}${level.toUpperCase()}${TTY_COLORS.reset} ${TTY_COLORS.bright}[FMODATA]:${TTY_COLORS.reset} ${message}`;
return `${timestamp} ${level.toUpperCase()} [FMODATA]: ${message}`;
};
const createLogger = (options) => {
const enabled = options?.disabled !== true;
const logLevel = options?.level ?? "error";
const colorsEnabled = options?.disableColors !== true;
const LogFunc = (level, message, args = []) => {
if (!(enabled && shouldPublishLog(logLevel, level))) return;
const formattedMessage = formatMessage(level, message, colorsEnabled);
if (!options || typeof options.log !== "function") {
if (level === "error") console.error(formattedMessage, ...args);
else if (level === "warn") console.warn(formattedMessage, ...args);
else console.log(formattedMessage, ...args);
return;
}
options.log(level === "success" ? "info" : level, message, ...args);
};
return {
...Object.fromEntries(levels.map((level) => [level, (...[message, ...args]) => LogFunc(level, message, args)])),
get level() {
return logLevel;
}
};
};
createLogger();
//#endregion
//#region src/types.ts
/**
* Get the Accept header value based on includeODataAnnotations option
* @param includeODataAnnotations - Whether to include OData annotations
* @returns Accept header value
*/
function getAcceptHeader(includeODataAnnotations) {
return includeODataAnnotations === true ? "application/json" : "application/json;odata.metadata=none";
}
//#endregion
//#region src/transform.ts
const WHITESPACE_SPLIT_REGEX = /\s+/;
/**
* Transforms field names to FileMaker field IDs (FMFID) in an object
* @param data - Object with field names as keys
* @param table - FMTable instance to get field IDs from
* @returns Object with FMFID keys instead of field names
*/
function transformFieldNamesToIds(data, table) {
if (!getBaseTableConfig(table).fmfIds) return data;
const transformed = {};
for (const [fieldName, value] of Object.entries(data)) {
const fieldId = getFieldId(table, fieldName);
transformed[fieldId] = value;
}
return transformed;
}
/**
* Transforms response data by converting field IDs back to field names recursively.
* Handles both single records and arrays of records, as well as nested expand relationships.
*
* @param data - Response data from FileMaker (can be single record, array, or wrapped in value property)
* @param table - FMTable instance for the main table
* @param expandConfigs - Configuration for expanded relations (optional)
* @returns Transformed data with field names instead of IDs
*/
function transformResponseFields(data, table, expandConfigs) {
if (!getBaseTableConfig(table).fmfIds) return data;
if (data === null || data === void 0) return data;
if (data.value && Array.isArray(data.value)) return {
...data,
value: data.value.map((record) => transformSingleRecord(record, table, expandConfigs))
};
if (Array.isArray(data)) return data.map((record) => transformSingleRecord(record, table, expandConfigs));
return transformSingleRecord(data, table, expandConfigs);
}
/**
* Transforms a single record, converting field IDs to names and handling nested expands
*/
function transformSingleRecord(record, table, expandConfigs) {
if (!record || typeof record !== "object") return record;
const transformed = {};
for (const [key, value] of Object.entries(record)) {
if (key.startsWith("@")) {
transformed[key] = value;
continue;
}
let expandConfig = expandConfigs?.find((ec) => ec.relation === key);
if (!expandConfig && key.startsWith("FMTID:")) expandConfig = expandConfigs?.find((ec) => ec.table && isUsingEntityIds(ec.table) && getTableId(ec.table) === key);
if (expandConfig?.table) {
const relationKey = expandConfig.relation;
if (Array.isArray(value)) {
if (!expandConfig.table) {
transformed[relationKey] = value;
continue;
}
const nestedTable = expandConfig.table;
transformed[relationKey] = value.map((nestedRecord) => transformSingleRecord(nestedRecord, nestedTable, void 0));
} else if (value && typeof value === "object") {
if (!expandConfig.table) {
transformed[relationKey] = value;
continue;
}
transformed[relationKey] = transformSingleRecord(value, expandConfig.table, void 0);
} else transformed[relationKey] = value;
continue;
}
const fieldName = getFieldName(table, key);
transformed[fieldName] = value;
}
return transformed;
}
/**
* Transforms an array of field names to FMFIDs
* @param fieldNames - Array of field names
* @param table - FMTable instance to get field IDs from
* @returns Array of FMFIDs or field names
*/
function transformFieldNamesArray(fieldNames, table) {
if (!getBaseTableConfig(table).fmfIds) return fieldNames;
return fieldNames.map((fieldName) => getFieldId(table, fieldName));
}
/**
* Transforms a field name in an orderBy string (e.g., "name desc" -> "FMFID:1 desc")
* @param orderByString - The orderBy string (field name with optional asc/desc)
* @param table - FMTable instance to get field ID from
* @returns Transformed orderBy string with FMFID
*/
function transformOrderByField(orderByString, table) {
if (!table) return orderByString;
if (!getBaseTableConfig(table)?.fmfIds) return orderByString;
const parts = orderByString.trim().split(WHITESPACE_SPLIT_REGEX);
const fieldName = parts[0];
if (!fieldName) return orderByString;
const direction = parts[1];
const fieldId = getFieldId(table, fieldName);
return direction ? `${fieldId} ${direction}` : fieldId;
}
//#endregion
//#region src/client/builders/select-utils.ts
const VALID_FIELD_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9]*$/;
/**
* Determines if a field name needs to be quoted in OData queries.
* Per FileMaker docs: field names with special characters (spaces, underscores, etc.) must be quoted.
* Also quotes "id" case-insensitively as it's an OData reserved word.
* Entity IDs (FMFID:*, FMTID:*) are not quoted as they're identifiers, not field names.
*
* @param fieldName - The field name or identifier to check
* @returns true if the field name should be quoted in OData queries
*/
function needsFieldQuoting(fieldName) {
if (fieldName.startsWith("FMFID:") || fieldName.startsWith("FMTID:")) return false;
if (fieldName.toLowerCase() === "id") return true;
return fieldName.includes(" ") || fieldName.includes("_") || !VALID_FIELD_NAME_REGEX.test(fieldName);
}
/**
* Formats select fields for use in OData query strings.
* - Transforms field names to FMFIDs if using entity IDs
* - Wraps "id" fields in double quotes (OData reserved)
* - URL-encodes special characters but preserves spaces
*/
function formatSelectFields(select, table, useEntityIds) {
if (!select || select.length === 0) return "";
const selectArray = Array.isArray(select) ? select : [select];
return (table && useEntityIds ? transformFieldNamesArray(selectArray.map(String), table) : selectArray.map(String)).map((field) => {
if (needsFieldQuoting(field)) return `"${field}"`;
return encodeURIComponent(field).replace(/%20/g, " ");
}).join(",");
}
//#endregion
//#region src/orm/column.ts
/**
* Column represents a type-safe reference to a table field.
* Used in queries, filters, and operators to provide autocomplete and type checking.
*
* @template TOutput - The TypeScript type when reading from the database (output type)
* @template TInput - The TypeScript type when writing to the database (input type, for filters)
* @template TableName - The table name as a string literal type (for validation)
* @template IsContainer - Whether this column represents a container field (cannot be selected)
*/
var Column = class {
fieldName;
entityId;
tableName;
tableEntityId;
inputValidator;
fieldType;
_phantomOutput;
_phantomInput;
_isContainer;
constructor(config) {
this.fieldName = config.fieldName;
this.entityId = config.entityId;
this.tableName = config.tableName;
this.tableEntityId = config.tableEntityId;
this.inputValidator = config.inputValidator;
this.fieldType = config.fieldType;
}
/**
* Get the field identifier (entity ID if available, otherwise field name).
* Used when building OData queries.
*/
getFieldIdentifier(useEntityIds) {
if (useEntityIds && this.entityId) return this.entityId;
return this.fieldName;
}
/**
* Get the table identifier (entity ID if available, otherwise table name).
* Used when building OData queries.
*/
getTableIdentifier(useEntityIds) {
if (useEntityIds && this.tableEntityId) return this.tableEntityId;
return this.tableName;
}
/**
* Check if this column is from a specific table.
* Useful for validation in cross-table operations.
*/
isFromTable(tableName) {
return this.tableName === tableName;
}
/**
* Create a string representation for debugging.
*/
toString() {
return `${this.tableName}.${this.fieldName}`;
}
};
/**
* Type guard to check if a value is a Column instance.
*/
function isColumn(value) {
return value instanceof Column;
}
/**
* ColumnFunction wraps a Column with an OData string function (tolower, toupper, trim).
* Since it extends Column, it passes `isColumn()` checks and works with all existing operators.
* Supports nesting: `tolower(trim(col))` → `tolower(trim(name))`.
*/
var ColumnFunction = class extends Column {
fnName;
innerColumn;
constructor(fnName, innerColumn) {
super({
fieldName: innerColumn.fieldName,
entityId: innerColumn.entityId,
tableName: innerColumn.tableName,
tableEntityId: innerColumn.tableEntityId,
inputValidator: innerColumn.inputValidator,
fieldType: innerColumn.fieldType
});
this.fnName = fnName;
this.innerColumn = innerColumn;
}
toFilterString(useEntityIds) {
if (isColumnFunction(this.innerColumn)) return `${this.fnName}(${this.innerColumn.toFilterString(useEntityIds)})`;
const fieldIdentifier = this.innerColumn.getFieldIdentifier(useEntityIds);
const quoted = needsFieldQuoting(fieldIdentifier) ? `"${fieldIdentifier}"` : fieldIdentifier;
return `${this.fnName}(${quoted})`;
}
};
/**
* Type guard to check if a value is a ColumnFunction instance.
*/
function isColumnFunction(value) {
return value instanceof ColumnFunction;
}
//#endregion
//#region src/orm/table.ts
/**
* Internal Symbols for table properties (hidden from IDE autocomplete).
* These are used to store internal configuration that shouldn't be visible
* when users access table columns.
* @internal - Not exported from public API, only accessible via FMTable.Symbol
*/
const FMTableName = Symbol.for("fmodata:FMTableName");
const FMTableEntityId = Symbol.for("fmodata:FMTableEntityId");
const FMTableSchema = Symbol.for("fmodata:FMTableSchema");
const FMTableFields = Symbol.for("fmodata:FMTableFields");
const FMTableNavigationPaths = Symbol.for("fmodata:FMTableNavigationPaths");
const FMTableDefaultSelect = Symbol.for("fmodata:FMTableDefaultSelect");
const FMTableBaseTableConfig = Symbol.for("fmodata:FMTableBaseTableConfig");
const FMTableUseEntityIds = Symbol.for("fmodata:FMTableUseEntityIds");
const FMTableComment = Symbol.for("fmodata:FMTableComment");
/**
* Base table class with Symbol-based internal properties.
* This follows the Drizzle ORM pattern where internal configuration
* is stored via Symbols, keeping it hidden from IDE autocomplete.
*/
var FMTable = class {
/**
* Internal Symbols for accessing table metadata.
* @internal - Not intended for public use. Access table properties via columns instead.
*/
static Symbol = {
Name: FMTableName,
EntityId: FMTableEntityId,
UseEntityIds: FMTableUseEntityIds,
Schema: FMTableSchema,
Fields: FMTableFields,
NavigationPaths: FMTableNavigationPaths,
DefaultSelect: FMTableDefaultSelect,
BaseTableConfig: FMTableBaseTableConfig,
Comment: FMTableComment
};
/** @internal */
[FMTableName];
/** @internal */
[FMTableEntityId];
/** @internal */
[FMTableUseEntityIds];
/** @internal */
[FMTableComment];
/** @internal */
[FMTableSchema];
/** @internal */
[FMTableFields];
/** @internal */
[FMTableNavigationPaths];
/** @internal */
[FMTableDefaultSelect];
/** @internal */
[FMTableBaseTableConfig];
constructor(config) {
this[FMTableName] = config.name;
this[FMTableEntityId] = config.entityId;
this[FMTableUseEntityIds] = config.useEntityIds;
this[FMTableComment] = config.comment;
this[FMTableSchema] = config.schema;
this[FMTableFields] = config.fields;
this[FMTableNavigationPaths] = config.navigationPaths;
this[FMTableDefaultSelect] = config.defaultSelect;
this[FMTableBaseTableConfig] = config.baseTableConfig;
}
};
/**
* Get the table name from an FMTable instance.
* @param table - FMTable instance
* @returns The table name
*/
function getTableName(table) {
return table[FMTableName];
}
/**
* Get the schema validator from an FMTable instance.
* @param table - FMTable instance
* @returns The StandardSchemaV1 validator record (partial - only fields with validators)
*/
function getTableSchema(table) {
return table[FMTableSchema];
}
/**
* Get the navigation paths from an FMTable instance.
* @param table - FMTable instance
* @returns Array of navigation path names
*/
function getNavigationPaths(table) {
return table[FMTableNavigationPaths];
}
/**
* Get the default select configuration from an FMTable instance.
* @param table - FMTable instance
* @returns Default select configuration
*/
function getDefaultSelect(table) {
return table[FMTableDefaultSelect];
}
/**
* Get the base table configuration from an FMTable instance.
* This provides access to schema, idField, required fields, readOnly fields, and field IDs.
* @param table - FMTable instance
* @returns Base table configuration object
*/
function getBaseTableConfig(table) {
return table[FMTableBaseTableConfig];
}
/**
* Check if an FMTable instance is using entity IDs (both FMTID and FMFIDs).
* @param table - FMTable instance
* @returns True if using entity IDs, false otherwise
*/
function isUsingEntityIds(table) {
return table[FMTableEntityId] !== void 0 && table[FMTableBaseTableConfig].fmfIds !== void 0;
}
/**
* Get the field ID (FMFID) for a given field name, or the field name itself if not using IDs.
* @param table - FMTable instance
* @param fieldName - Field name to get the ID for
* @returns The FMFID string or the original field name
*/
function getFieldId(table, fieldName) {
const config = table[FMTableBaseTableConfig];
if (config.fmfIds && fieldName in config.fmfIds) {
const fieldId = config.fmfIds[fieldName];
if (fieldId) return fieldId;
}
return fieldName;
}
/**
* Get the field name for a given field ID (FMFID), or the ID itself if not found.
* @param table - FMTable instance
* @param fieldId - The FMFID to get the field name for
* @returns The field name or the original ID
*/
function getFieldName(table, fieldId) {
const config = table[FMTableBaseTableConfig];
if (config.fmfIds) {
for (const [fieldName, fmfId] of Object.entries(config.fmfIds)) if (fmfId === fieldId) return fieldName;
}
return fieldId;
}
/**
* Get the table ID (FMTID or name) from an FMTable instance.
* Returns the FMTID if available, otherwise returns the table name.
* @param table - FMTable instance
* @returns The FMTID string or the table name
*/
function getTableId(table) {
return table[FMTableEntityId] ?? table[FMTableName];
}
/**
* Get all columns from a table as an object.
* Useful for selecting all fields except some using destructuring.
*
* @example
* const { password, ...cols } = getTableColumns(users)
* db.from(users).list().select(cols)
*
* @param table - FMTable instance
* @returns Object with all columns from the table
*/
function getTableColumns(table) {
const fields = table[FMTableFields];
const tableName = table[FMTableName];
const tableEntityId = table[FMTableEntityId];
const baseConfig = table[FMTableBaseTableConfig];
const columns = {};
for (const [fieldName, builder] of Object.entries(fields)) {
const config = builder._getConfig();
columns[fieldName] = new Column({
fieldName: String(fieldName),
entityId: baseConfig.fmfIds?.[fieldName],
tableName,
tableEntityId,
inputValidator: config.inputValidator,
fieldType: config.fieldType
});
}
return columns;
}
//#endregion
//#region src/client/database-name.ts
const FMP12_EXT_REGEX = /\.fmp12$/i;
function stripFmp12Extension(databaseName) {
return databaseName.replace(FMP12_EXT_REGEX, "");
}
function ensureFmp12Extension(databaseName) {
return FMP12_EXT_REGEX.test(databaseName) ? databaseName : `${databaseName}.fmp12`;
}
function normalizeDatabaseSegment(databaseName, normalizeDatabaseName, mode = "default") {
if (!normalizeDatabaseName) return databaseName;
return mode === "ensureExtension" ? ensureFmp12Extension(databaseName) : stripFmp12Extension(databaseName);
}
function normalizeDatabasePath(path, options) {
if (!path.startsWith("/")) return path;
const secondSlashIndex = path.indexOf("/", 1);
const hasDatabaseSegment = secondSlashIndex !== -1;
const databaseSegment = hasDatabaseSegment ? path.slice(1, secondSlashIndex) : path.slice(1);
if (!databaseSegment || databaseSegment.startsWith("$")) return path;
let normalizedInput = databaseSegment;
try {
normalizedInput = decodeURIComponent(databaseSegment);
} catch {
normalizedInput = databaseSegment;
}
const normalizedDatabaseSegment = normalizeDatabaseSegment(normalizedInput, options.normalizeDatabaseName, options.mode);
const encodedDatabaseSegment = encodeURIComponent(normalizedDatabaseSegment);
return hasDatabaseSegment ? `/${encodedDatabaseSegment}${path.slice(secondSlashIndex)}` : `/${encodedDatabaseSegment}`;
}
//#endregion
//#region src/client/builders/table-utils.ts
/**
* Resolves table identifier based on entity ID settings.
* Used by both QueryBuilder and RecordBuilder.
*/
function resolveTableId(table, fallbackTableName, useEntityIds) {
if (!table) return fallbackTableName;
if (useEntityIds) {
if (!isUsingEntityIds(table)) throw new Error(`useEntityIds is true but table "${getTableName(table)}" does not have entity IDs configured`);
return getTableId(table);
}
return getTableName(table);
}
/**
* Merges database-level useEntityIds with per-request options.
*/
function mergeEntityIdOptions(options, databaseDefault) {
return {
...options,
useEntityIds: options?.useEntityIds ?? databaseDefault
};
}
/**
* Type-safe helper for merging execute options with entity ID settings
*/
function mergeExecuteOptions(options, databaseUseEntityIds) {
return mergeEntityIdOptions(options, databaseUseEntityIds);
}
/**
* Creates an OData Request object with proper headers.
* Used by both QueryBuilder and RecordBuilder to eliminate duplication.
*
* @param baseUrl - Base URL for the request
* @param config - Request configuration with method and url
* @param options - Optional execution options
* @returns Request object ready to use
*/
function createODataRequest(baseUrl, config, options) {
const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { normalizeDatabaseName: options?.normalizeDatabaseName ?? true })}`;
return new Request(fullUrl, {
method: config.method,
headers: {
"Content-Type": "application/json",
Accept: getAcceptHeader(options?.includeODataAnnotations)
}
});
}
//#endregion
//#region src/client/builders/mutation-helpers.ts
const ROWID_MATCH_REGEX = /ROWID=(\d+)/;
const PAREN_VALUE_REGEX = /\(['"]?([^'"]+)['"]?\)/;
function isRowIdRecordLocator(recordLocator) {
return typeof recordLocator === "object" && recordLocator !== null && "ROWID" in recordLocator;
}
function escapeODataStringLiteral(value) {
return value.replaceAll("'", "''");
}
function buildRecordLocatorSegment(recordLocator) {
if (isRowIdRecordLocator(recordLocator)) return `(ROWID=${recordLocator.ROWID})`;
return `('${escapeODataStringLiteral(String(recordLocator))}')`;
}
function buildRecordPath(pathPrefix, recordLocator) {
return `${pathPrefix}${buildRecordLocatorSegment(recordLocator)}`;
}
function mergeMutationExecuteOptions(options, databaseUseEntityIds, databaseIncludeSpecialColumns) {
return {
...options,
useEntityIds: options?.useEntityIds ?? databaseUseEntityIds,
includeSpecialColumns: options?.includeSpecialColumns ?? databaseIncludeSpecialColumns
};
}
function mergePreferHeaderValues(...values) {
const merged = [];
const seen = /* @__PURE__ */ new Set();
for (const value of values) {
if (!value) continue;
for (const part of value.split(",")) {
const normalized = part.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
merged.push(normalized);
}
}
return merged.length > 0 ? merged.join(", ") : void 0;
}
function resolveMutationTableId(table, useEntityIds, builderName) {
if (!table) throw new BuilderInvariantError(builderName, "table occurrence is required");
return resolveTableId(table, getTableName(table), useEntityIds);
}
function buildMutationUrl(config) {
const { databaseName, tableId, tableName, mode, recordLocator, queryBuilder, useEntityIds, builderName } = config;
if (mode === "byId") {
if (recordLocator === void 0 || recordLocator === null || recordLocator === "") throw new BuilderInvariantError(builderName, "recordLocator is required for byId mode");
return `/${databaseName}/${buildRecordPath(tableId, recordLocator)}`;
}
if (!queryBuilder) throw new BuilderInvariantError(builderName, "query builder is required for filter mode");
return `/${databaseName}/${tableId}${stripTablePathPrefix(queryBuilder.getQueryString({ useEntityIds }), tableId, tableName)}`;
}
function stripTablePathPrefix(queryString, tableId, tableName) {
if (queryString.startsWith(`/${tableId}`)) return queryString.slice(`/${tableId}`.length);
if (queryString.startsWith(`/${tableName}`)) return queryString.slice(`/${tableName}`.length);
return queryString;
}
function extractAffectedRows(response, headers, fallback = 0, countKey) {
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[countKey]);
if (!Number.isNaN(count)) return count;
}
const affected = Number(response["fmodata.affected_rows"]);
if (!Number.isNaN(affected)) return affected;
}
return fallback;
}
/**
* Parse ROWID from Location header.
* Expected formats:
* - contacts(ROWID=4583)
* - contacts('4583')
*/
function parseRowIdFromLocationHeader(locationHeader) {
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);
}
function getLocationHeader(headers) {
return headers.get("Location") || headers.get("location") || void 0;
}
//#endregion
//#region src/client/claris-id.ts
const CLARIS_USER_POOL_URL = "https://www.ifmcloud.com/endpoint/userpool/2.2.0.my.claris.com.json";
const UNSUPPORTED_MFA_ERROR = "Claris ID MFA is not supported by @proofkit/fmodata yet. Use a non-MFA Claris ID account for now.";
var ClarisIdAuthManager = class {
username;
password;
authenticationDetails;
userPool = null;
cognitoUser = null;
userSession = null;
idTokenPromise = null;
constructor(config) {
this.username = config.username;
this.password = config.password;
}
async getAuthorizationHeader(fetchLike) {
if (!this.idTokenPromise) this.idTokenPromise = this.getIdToken(fetchLike).finally(() => {
this.idTokenPromise = null;
});
return `FMID ${await this.idTokenPromise}`;
}
getAuthenticationDetails() {
if (!this.authenticationDetails) this.authenticationDetails = new AuthenticationDetails({
Username: this.username,
Password: this.password
});
return this.authenticationDetails;
}
async getIdToken(fetchLike) {
if (this.userSession) return this.getStoredIdToken(this.userSession);
return (await this.retrieveNewSession(fetchLike)).getIdToken().getJwtToken();
}
async getStoredIdToken(userSession) {
return (userSession.isValid() ? userSession : await this.refreshSession(userSession)).getIdToken().getJwtToken();
}
async refreshSession(userSession) {
const cognitoUser = await this.getCognitoUser();
this.userSession = await new Promise((resolve, reject) => {
cognitoUser.refreshSession(userSession.getRefreshToken(), async (error, session) => {
if (error || !session) try {
resolve(await this.retrieveNewSession());
return;
} catch (reauthError) {
reject(reauthError);
return;
}
resolve(session);
});
});
return this.userSession;
}
async retrieveNewSession(fetchLike) {
const cognitoUser = await this.getCognitoUser(fetchLike);
const authenticationDetails = this.getAuthenticationDetails();
this.userSession = await new Promise((resolve, reject) => {
cognitoUser.authenticateUser(authenticationDetails, {
onSuccess: (result) => {
resolve(result);
},
onFailure: (error) => {
reject(error);
},
mfaRequired: () => reject(/* @__PURE__ */ new Error(UNSUPPORTED_MFA_ERROR)),
totpRequired: () => reject(/* @__PURE__ */ new Error(UNSUPPORTED_MFA_ERROR)),
selectMFAType: () => reject(/* @__PURE__ */ new Error(UNSUPPORTED_MFA_ERROR)),
mfaSetup: () => reject(/* @__PURE__ */ new Error(UNSUPPORTED_MFA_ERROR))
});
});
return this.userSession;
}
async getCognitoUser(fetchLike) {
if (this.cognitoUser) return this.cognitoUser;
this.cognitoUser = new CognitoUser({
Username: this.getAuthenticationDetails().getUsername(),
Pool: await this.getUserPool(fetchLike)
});
return this.cognitoUser;
}
async getUserPool(fetchLike) {
if (this.userPool) return this.userPool;
const response = await (fetchLike ?? fetch)(CLARIS_USER_POOL_URL);
if (!response.ok) throw new Error("Could not fetch Claris ID user pool config");
const config = await response.json();
const userPoolId = config.data?.UserPool_ID;
const clientId = config.data?.Client_ID;
if (!(userPoolId && clientId)) throw new Error("Invalid Claris ID user pool config response");
this.userPool = new CognitoUserPool({
UserPoolId: userPoolId,
ClientId: clientId
});
return this.userPool;
}
};
//#endregion
//#region src/client/batch-request.ts
/**
* Batch Request Utilities
*
* Utilities for formatting and parsing OData batch requests using multipart/mixed format.
* OData batch requests allow bundling multiple operations into a single HTTP request,
* with support for transactional changesets.
*/
const BOUNDARY_REGEX = /boundary=([^;]+)/;
const HTTP_STATUS_LINE_REGEX = /HTTP\/\d\.\d\s+(\d+)\s*(.*)/;
const CRLF_REGEX = /\r\n/;
const CHANGESET_CONTENT_TYPE_REGEX = /Content-Type: multipart\/mixed;\s*boundary=([^\r\n]+)/;
const OTTO_PREFIX_REGEX = /^\/otto/;
const FMPRO_EXT_REGEX = /\.fmp12/;
/**
* Generates a random boundary string for multipart requests
* @param prefix - Prefix for the boundary (e.g., "batch_" or "changeset_")
* @returns A boundary string with the prefix and 32 random hex characters
*/
function generateBoundary(prefix = "batch_") {
return `${prefix}${Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("")}`;
}
/**
* Converts a native Request object to RequestConfig
* @param request - Native Request object
* @returns RequestConfig object
*/
async function requestToConfig(request) {
const headers = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
let body;
if (request.body) body = await request.clone().text();
return {
method: request.method,
url: request.url,
body,
headers
};
}
/**
* Transforms a full URL into the canonical path format required by FileMaker's
* OData batch processor. Strips proxy prefixes (e.g. /otto/) and the .fmp12
* file extension from the database name segment.
*/
function toBatchSubRequestUrl(fullUrl) {
const url = new URL(fullUrl);
return `${url.pathname.replace(OTTO_PREFIX_REGEX, "").replace(FMPRO_EXT_REGEX, "")}${url.search}`;
}
/**
* Formats a single HTTP request for inclusion in a batch
* @param request - The request configuration
* @param baseUrl - The base URL to prepend to relative URLs
* @returns Formatted request string with CRLF line endings
*
* Formatting rules for FileMaker OData:
* - GET (no body): request line → blank → blank
* - POST/PATCH (with body): request line → headers → blank → body (NO blank after!)
*/
function formatSubRequest(request, baseUrl) {
const lines = [];
lines.push("Content-Type: application/http");
lines.push("Content-Transfer-Encoding: binary");
lines.push("");
const subRequestUrl = toBatchSubRequestUrl(request.url.startsWith("http") ? request.url : `${baseUrl}${request.url}`);
lines.push(`${request.method} ${subRequestUrl} HTTP/1.1`);
if (request.body) {
if (request.headers) {
for (const [key, value] of Object.entries(request.headers)) if (key.toLowerCase() !== "authorization") lines.push(`${key}: ${value}`);
}
if (!(request.headers && Object.keys(request.headers).some((k) => k.toLowerCase() === "content-type"))) lines.push("Content-Type: application/json");
if (!(request.headers && Object.keys(request.headers).some((k) => k.toLowerCase() === "content-length"))) lines.push(`Content-Length: ${request.body.length}`);
lines.push("");
lines.push(request.body);
} else {
lines.push("");
lines.push("");
}
return lines.join("\r\n");
}
/**
* Formats a changeset containing multiple non-GET operations
* @param requests - Array of request configurations (should be non-GET)
* @param baseUrl - The base URL to prepend to relative URLs
* @param changesetBoundary - Boundary string for the changeset
* @returns Formatted changeset string with CRLF line endings
*/
function formatChangeset(requests, baseUrl, changesetBoundary) {
const lines = [];
lines.push(`Content-Type: multipart/mixed; boundary=${changesetBoundary}`);
lines.push("");
for (const request of requests) {
lines.push(`--${changesetBoundary}`);
lines.push(formatSubRequest(request, baseUrl));
}
lines.push(`--${changesetBoundary}--`);
return lines.join("\r\n");
}
/**
* Formats multiple Request objects into a batch request body
* Supports explicit changesets via Request arrays
* @param requests - Array of Request objects or Request arrays (for explicit changesets)
* @param baseUrl - The base URL to prepend to relative URLs
* @param batchBoundary - Optional boundary string for the batch (generated if not provided)
* @returns Promise resolving to object containing the formatted body and boundary
*/
async function formatBatchRequestFromNative(requests, baseUrl, batchBoundary) {
const boundary = batchBoundary || generateBoundary("batch_");
const lines = [];
for (const item of requests) if (Array.isArray(item)) {
const changesetBoundary = generateBoundary("changeset_");
const changesetConfigs = [];
for (const request of item) changesetConfigs.push(await requestToConfig(request));
lines.push(`--${boundary}`);
lines.push(formatChangeset(changesetConfigs, baseUrl, changesetBoundary));
} else {
const config = await requestToConfig(item);
if (config.method === "GET") {
lines.push(`--${boundary}`);
lines.push(formatSubRequest(config, baseUrl));
} else {
const changesetBoundary = generateBoundary("changeset_");
lines.push(`--${boundary}`);
lines.push(formatChangeset([config], baseUrl, changesetBoundary));
}
}
lines.push(`--${boundary}--`);
return {
body: lines.join("\r\n"),
boundary
};
}
/**
* Extracts the boundary from a Content-Type header
* @param contentType - The Content-Type header value
* @returns The boundary string, or null if not found
*/
function extractBoundary(contentType) {
const match = contentType.match(BOUNDARY_REGEX);
return match?.[1] ? match[1].trim() : null;
}
/**
* Parses an HTTP response line (status line)
* @param line - The HTTP status line (e.g., "HTTP/1.1 200 OK")
* @returns Object containing status code and status text
*/
function parseStatusLine(line) {
const match = line.match(HTTP_STATUS_LINE_REGEX);
if (!match?.[1]) return {
status: 0,
statusText: ""
};
return {
status: Number.parseInt(match[1], 10),
statusText: match[2]?.trim() || ""
};
}
/**
* Parses headers from an array of header lines
* @param lines - Array of header lines
* @returns Object containing parsed headers
*/
function parseHeaders(lines) {
const headers = {};
for (const line of lines) {
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
headers[key.toLowerCase()] = value;
}
}
return headers;
}
/**
* Parses a single HTTP response from a batch part
* @param part - The raw HTTP response string
* @returns Parsed response object
*/
function parseHttpResponse(part) {
const lines = part.split(CRLF_REGEX);
let statusLineIndex = -1;
for (let i = 0; i < lines.length; i++) if (lines[i]?.startsWith("HTTP/")) {
statusLineIndex = i;
break;
}
if (statusLineIndex === -1) return {
status: 0,
statusText: "Invalid response",
headers: {},
body: null
};
const statusLine = lines[statusLineIndex];
if (!statusLine) return {
status: 0,
statusText: "Invalid response",
headers: {},
body: null
};
const { status, statusText } = parseStatusLine(statusLine);
const headerLines = [];
let bodyStartIndex = lines.length;
let foundEmptyLine = false;
for (let i = statusLineIndex + 1; i < lines.length; i++) {
const line = lines[i];
if (line === "") {
bodyStartIndex = i + 1;
foundEmptyLine = true;
break;
}
if (line?.startsWith("--")) break;
if (line) headerLines.push(line);
}
const headers = parseHeaders(headerLines);
let bodyText = "";
if (foundEmptyLine && bodyStartIndex < lines.length) {
const bodyLines = lines.slice(bodyStartIndex);
const bodyLinesFiltered = [];
for (const line of bodyLines) {
if (line.startsWith("--")) break;
bodyLinesFiltered.push(line);
}
bodyText = bodyLinesFiltered.join("\r\n").trim();
}
let body = null;
if (bodyText) try {
body = JSON.parse(bodyText);
} catch {
body = bodyText;
}
return {
status,
statusText,
headers,
body
};
}
/**
* Parses a batch response into individual responses
* @param responseText - The raw batch response text
* @param contentType - The Content-Type header from the response
* @returns Array of parsed responses in the same order as the request
*/
function parseBatchResponse(responseText, contentType) {
const boundary = extractBoundary(contentType);
if (!boundary) throw new Error("Could not extract boundary from Content-Type header");
const results = [];
const boundaryPattern = `--${boundary}`;
const parts = responseText.split(boundaryPattern);
for (const part of parts) {
const trimmedPart = part.trim();
if (!trimmedPart || trimmedPart === "--") continue;
if (trimmedPart.includes("Content-Type: multipart/mixed")) {
const changesetContentTypeMatch = trimmedPart.match(CHANGESET_CONTENT_TYPE_REGEX);
if (changesetContentTypeMatch) {
const changesetPattern = `--${changesetContentTypeMatch?.[1]?.trim()}`;
const changesetParts = trimmedPart.split(changesetPattern);
for (const changesetPart of changesetParts) {
const trimmedChangesetPart = changesetPart.trim();
if (!trimmedChangesetPart || trimmedChangesetPart === "--") continue;
if (trimmedChangesetPart.startsWith("Content-Type: multipart/mixed")) continue;
const response = parseHttpResponse(trimmedChangesetPart);
if (response.status > 0) results.push(response);
}
}
} else {
const response = parseHttpResponse(trimmedPart);
if (response.status > 0) results.push(response);
}
}
return results;
}
//#endregion
//#region src/client/runtime.ts
/**
* Single boundary for synchronous extraction of config/logger from the DI layer.
* Builder/manager constructors should call this once and pass runtime around.
*/
function createClientRuntime(layer) {
const { config, logger } = extractConfigFromLayer(layer);
return {
layer,
config,
logger
};
}
//#endregion
//#region src/client/batch-builder.ts
/**
* Converts a ParsedBatchResponse to a native Response object
* @param parsed - The parsed batch response
* @returns A native Response object
*/
function parsedToResponse(parsed) {
const headers = new Headers(parsed.headers);
if (parsed.body === null || parsed.body === void 0) return new Response(null, {
status: parsed.status,
statusText: parsed.statusText,
headers
});
const bodyString = typeof parsed.body === "string" ? parsed.body : JSON.stringify(parsed.body);
let status = parsed.status;
if (status === 204 && bodyString && bodyString.trim() !== "") status = 200;
return new Response(status === 204 ? null : bodyString, {
status,
statusText: parsed.statusText,
headers
});
}
/**
* Builder for batch operations that allows multiple queries to be executed together
* in a single transactional request.
*
* Note: BatchBuilder does not implement ExecutableBuilder because execute() returns
* BatchResult instead of Result, which is a different return type structure.
*/
var BatchBuilder = class {
builders;
layer;
config;
constructor(builders, layer) {
this.builders = [...builders];
const runtime = createClientRuntime(layer);
this.layer = runtime.layer;
this.config = runtime.config;
}
/**
* Add a request to the batch dynamically.
* This allows building up batch operations programmatically.
*
* @param builder - An executable builder to add to the batch
* @returns A BatchBuilder typed with the appended request result
* @example
* ```ts
* const batch = db.batch([]);
* batch.addRequest(db.from('contacts').list());
* batch.addRequest(db.from('users').list());
* const result = await batch.execute();
* ```
*/
addRequest(builder) {
this.builders.push(builder);
return this;
}
/**
* Get the request configuration for this batch operation.
* This is used internally by the execution system.
*/
getRequestConfig() {
return {
method: "POST",
url: `/${this.config.databaseName}/$batch`,
body: void 0
};
}
toRequest(baseUrl, _options) {
const fullUrl = `${baseUrl}${normalizeDatabasePath(`/${this.config.databaseName}/$batch`, { normalizeDatabaseName: _options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName })}`;
return new Request(fullUrl, {
method: "POST",
headers: {
"Content-Type": "multipart/mixed",
"OData-Version": "4.0"
}
});
}
processResponse(_response, _options) {
return Promise.resolve({
data: void 0,
error: {
name: "NotImplementedError",
message: "Batch operations handle response processing internally",
timestamp: /* @__PURE__ */ new Date()
}
});
}
/**
* Creates a failed BatchResult where all operations are marked as failed with the given error.
*/
failAllResults(error) {
const errorCount = this.builders.length;
return {
results: this.builders.map(() => ({
data: void 0,
error,
status: 0
})),
successCount: 0,
errorCount,
truncated: false,
firstErrorIndex: errorCount > 0 ? 0 : null
};
}
/**
* Execute the batch operation.
*
* @param options - Optional fetch options and batch-specific options (includes beforeRequest hook)
* @returns A BatchResult containing individual results for each operation
*/
async execute(options) {
const baseUrl = this.config.baseUrl;
if (!baseUrl) return this.failAllResults({
name: "ConfigurationError",
message: "Base URL not available in ODataConfig",
timestamp: /* @__PURE__ */ new Date()
});
const pipeline = Effect.gen(this, function* () {
const requests = this.builders.map((builder) => builder.toRequest(baseUrl, options));
const { body, boundary } = yield* Effect.tryPromise({
try: () => formatBatchRequestFromNative(requests, baseUrl),
catch: (e) => e
});
const responseData = yield* requestFromService(`/${this.config.databaseName}/$batch`, {
...options,
method: "POST",
headers: {
...options?.headers,
"Content-Type": `multipart/mixed; boundary=${boundary}`,
"OData-Version": "4.0"
},
body
});
const firstLine = responseData.split("\r\n")[0] || responseData.split("\n")[0] || "";
const parsedResponses = parseBatchResponse(responseData, `multipart/mixed; boundary=${firstLine.startsWith("--") ? firstLine.substring(2) : boundary}`);
const results = [];
let successCount = 0;
let errorCount = 0;
let firstErrorIndex = null;
const truncated = parsedResponses.length < this.builders.length;
for (let i = 0; i < this.builders.length; i++) {
const builder = this.builders[i];
const parsed = parsedResponses[i];
if (!parsed) {
const failedAtIndex = firstErrorIndex ?? i;
results.push({
data: void 0,
error: new BatchTruncatedError(i, failedAtIndex),
status: 0
});
errorCount++;
continue;
}
if (!builder) {
results.push({
data: void 0,
error: {
name: "BatchError",
message: `Builder at index ${i} is undefined`,
timestamp: /* @__PURE__ */ new Date()
},
status: parsed.status
});
errorCount++;
if (firstErrorIndex === null) firstErrorIndex = i;
continue;
}
const nativeResponse = parsedToResponse(parsed);
const result = yield* Effect.tryPromise({
try: () => builder.processResponse(nativeResponse, options),
catch: (e) => e
});
if (result.error) {
results.push({
data: void 0,
error: result.error,
status: parsed.status
});
errorCount++;
if (firstErrorIndex === null) firstErrorIndex = i;
} else {
results.push({
data: result.data,
error: void 0,
status: parsed.status
});
successCount++;
}
}
return {
results,
successCount,
errorCount,
truncated,
firstErrorIndex
};
});
const result = await runLayerResult(this.layer, pipeline, "fmodata.batch");
if (result.error) return this.failAllResults(result.error);
return result.data;
}
};
//#endregion
//#region src/client/builders/default-select.ts
/**
* Helper function to get container field names from a table.
* Container fields cannot be selected via $select in FileMaker OData API.
*/
function getContainerFieldNames(table) {
const baseTableConfig = getBaseTableConfig(table);
if (!baseTableConfig?.containerFields) return [];
return baseTableConfig.containerFields;
}
/**
* Gets default select fields from a table definition.
* Returns undefined if defaultSelect is "all".
* Automatically filters out container fields since they cannot be selected via $select.
*
* @param table - The table occurrence
* @param includeSpecialColumns - If true, includes ROWID and ROWMODID when defaultSelect is "schema"
*/
function getDefaultSelectFields(table, includeSpecialColumns) {
if (!table) return;
const defaultSelect = table[FMTable.Symbol.DefaultSelect];
const containerFields = getContainerFieldNames(table);
if (defaultSelect === "schema") {
const baseTableConfig = getBaseTableConfig(table);
const allFields = Object.keys(baseTableConfig.schema);
const fields = [...new Set(allFields.filter((f) => !containerFields.includes(f)))];
if (includeSpecialColumns) fields.push("ROWID", "ROWMODID");
return fields.length > 0 ? fields : void 0;
}
if (Array.isArray(defaultSelect)) return [...new Set(defaultSelect.filter((f) => !containerFields.includes(f)))];
if (typeof defaultSelect === "object" && defaultSelect !== null && !Array.isArray(defaultSelect)) {
const fieldNames = [];
for (const value of Object.values(defaultSelect)) if (isColumn(value)) fieldNames.push(value.fieldName);
if (fieldNames.length > 0) return [...new Set(fieldNames.filter((f) => !containerFields.includes(f)))];
}
}
//#endregion
//#region src/client/builders/expand-builder.ts
const FILTER_QUERY_REGEX = /\$filter=([^&]+)/;
/**
* Builds OData expand query strings and validation configs.
* Handles nested expands recursively and transforms relation names to FMTIDs
* when using entity IDs.
*/
var ExpandBuilder = class {
useEntityIds;
logger;
constructor(useEntityIds, logger) {
this.useEntityIds = useEntityIds;
this.logger = logger;
}
/**
* Builds OData $expand query string from expand configurations.
*/
buildExpandString(configs) {
if (configs.length === 0) return "";
return configs.map((config) => this.buildSingleExpand(config)).join(",");
}
/**
* Builds validation configs for expanded navigation properties.
*/
buildValidationConfigs(configs) {
return configs.map((config) => {
const targetTable = config.targetTable;
let targetSchema;
if (targetTable) {
const baseTableConfig = getBaseTableConfig(targetTable);
const containerFields = baseTableConfig.containerFields || [];
const schema = { ...baseTableConfig.schema };
for (const containerField of containerFields) delete schema[containerField];
targetSchema = schema;
}
let selectedFields;
if (config.options?.select) selectedFields = Array.isArray(config.options.select) ? config.options.select.map(String) : [String(config.options.select)];
const nestedExpands = config.nestedExpandConfigs ? this.buildValidationConfigs(config.nestedExpandConfigs) : void 0;
return {
relation: config.relation,
targetSchema,
targetTable,
table: targetTable,
selectedFields,
nestedExpands
};
});
}
/**
* Process an expand() call and return the expand config.
* Used by both QueryBuilder and RecordBuilder to eliminate duplication.
*
* @param targetTable - The target table to expand to
* @param sourceTable - The source table (for validation)
* @param callback - Optional callback to configure the expand query
* @param builderFactory - Function that creates a QueryBuilder for the target table
* @returns ExpandConfig to add to the builder's expandConfigs array
*/
processExpand(targetTable, sourceTable, callback, builderFactory) {
const relationName = getTableName(targetTable);
if (sourceTable) {
const navigationPaths = getNavigationPaths(sourceTable);
if (navigationPaths && !navigationPaths.includes(relationName)) this.logger.warn(`Cannot expand to "${relationName}". Valid navigation paths: ${navigationPaths.length > 0 ? navigationPaths.join(", ") : "none"}`);
}
if (callback && builderFactory) {
const configuredBuilder = callback(builderFactory());
const expandOptions = { ...configuredBuilder.queryOptions };
const filterExpression = configuredBuilder.readState?.filterExpression;
if (filterExpression && !expandOptions.filter && typeof filterExpression.toODataFilter === "function") expandOptions.filter = filterExpression.toODataFilter(this.useEntityIds);
if (!expandOptions.select) {
const defaultFields = getDefaultSelectFields(targetTable);
if (defaultFields) expandOptions.select = defaultFields;
}
const nestedExpandConfigs = configuredBuilder.expandConfigs;
if (nestedExpandConfigs?.length > 0) {
const nestedExpandString = this.buildExpandString(nestedExpandConfigs);
if (nestedExpandString) expandOptions.expand = nestedExpandString;
}
return {
relation: relationName,
options: expandOptions,
targetTable,
nestedExpandConfigs: nestedExpandConfigs?.length > 0 ? nestedExpandConfigs : void 0
};
}
const defaultFields = getDefaultSelectFields(targetTable);
if (defaultFields) return {
relation: relationName,
options: { select: defaultFields },
targetTable
};
return {
relation: relationName,
targetTable
};
}
/**
* Builds a single expand string with its options.
*/
buildSingleExpand(config) {
const relationName = this.resolveRelationName(config);
const parts = this.buildExpandParts(config);
if (parts.length === 0) return relationName;
return `${relationName}(${parts.join(";")})`;
}
/**
* Resolves relation name, using FMTID if entity IDs are enabled.
*/
resolveRelationName(config) {
if (!this.useEntityIds) return config.relation;
const targetTable = config.targetTable;
if (targetTable && FMTable.Symbol.EntityId in targetTable) {
const tableId = targetTable[FMTable.Symbol.EntityId];
if (tableId) return tableId;
}
return config.relation;
}
/**
* Builds expand parts (select, filter, orderBy, etc.) for a single expand.
*/
buildExpandParts(config) {
if (!config.options || Object.keys(config.options).length === 0) return [];
const parts = [];
const opts = config.options;
if (opts.select) {
const selectFields = formatSelectFields(Array.isArray(opts.select) ? opts.select.map(String) : [String(opts.select)], config.targetTable, this.useEntityIds);
if (selectFields) parts.push(`$select=${selectFields}`);
}
if (opts.filter) if (typeof opts.filter === "string") parts.push(`$filter=${opts.filter}`);
else {
const match = buildQuery({ filter: opts.filter }).match(FILTER_QUERY_REGEX);
if (match) parts.push(`$filter=${match[1]}`);
}
if (opts.orderBy) {
const orderByValue = Array.isArray(opts.orderBy) ? opts.orderBy.join(",") : String(opts.orderBy);
parts.push(`$orderby=${orderByValue}`);
}
if (opts.top !== void 0) parts.push(`$top=${opts.top}`);
if (opts.skip !== void 0) parts.push(`$skip=${opts.skip}`);
if (opts.expand && typeof opts.expand === "string") parts.push(`$expand=${opts.expand}`);
return parts;
}
};
//#endregion
//#region src/client/builders/query-string-builder.ts
/**
* Builds OData query string for $select and $expand parameters.
* Used by both QueryBuilder and RecordBuilder to eliminate duplication.
*
* @param config - Configuration object
* @returns Query string starting with ? or empty string if no parameters
*/
function buildSelectExpandQueryString(config) {
const parts = [];
const expandBuilder = new ExpandBuilder(config.useEntityIds, config.logger);
if (config.selectedFields && config.selectedFields.length > 0) {
const selectString = formatSelectFields(config.selectedFields, config.table, config.useEntityIds);
if (selectString) parts.push(`$select=${selectString}`);
}
const expandString = expandBuilder.buildExpandString(config.expandConfigs);
if (expandString) parts.push(`$expand=${expandString}`);
return parts.length > 0 ? `?${parts.join("&")}` : "";
}
//#endregion
//#region src/client/builders/read-builder-state.ts
function createInitialQueryReadBuilderState() {
return {
queryOptions: {},
expandConfigs: [],
singleMode: false,
isCountMode: false,
includeCountMode: false
};
}
function cloneQueryReadBuilderState(state, changes) {
let fieldMapping = state.fieldMapping ? { ...state.fieldMapping } : void 0;
if ("fieldMapping" in (changes ?? {})) fieldMapping = changes?.fieldMapping ? { ...changes.fieldMapping } : void 0;
return {
...state,
...changes,
queryOptions: {
...state.queryOptions,
...changes?.queryOptions ?? {}
},
expandConfigs: changes?.expandConfigs ? [...changes.expandConfigs] : [...state.expandConfigs],
fieldMapping
};
}
function createInitialRecordReadBuilderState() {
return { expandConfigs: [] };
}
function cloneRecordReadBuilderState(state, changes) {
let selectedFields = state.selectedFields ? [...state.selectedFields] : void 0;
if ("selectedFields" in (changes ?? {})) selectedFields = changes?.selectedFields ? [...changes.selectedFields] : void 0;
let fieldMapping = state.fieldMapping ? { ...state.fieldMapping } : void 0;
if ("fieldMapping" in (changes ?? {})) fieldMapping = changes?.fieldMapping ? { ...changes.fieldMapping } : void 0;
return {
...state,
...changes,
selectedFields,
expandConfigs: changes?.expandConfigs ? [...changes.expandConfigs] : [...state.expandConfigs],
fieldMapping
};
}
//#endregion
//#region src/validation.ts
/**
* Validates and transforms input data for insert/update operations.
* Applies input validators (writeValidators) to transform user input to database format.
* Fields without input validators are passed through unchanged.
*
* @param data - The input data to validate and transform
* @param inputSchema - Optional schema containing input validators for each field
* @returns Transformed data ready to send to the server
* @throws ValidationError if any field fails validation
*/
async function validateAndTransformInput(data, inputSchema) {
if (!inputSchema) return data;
const transformedData = { ...data };
const allIssues = [];
const failedFields = [];
for (const [fieldName, fieldSchema] of Object.entries(inputSchema)) {
if (!fieldSchema) continue;
if (fieldName in data) {
const inputValue = data[fieldName];
try {
let result = fieldSchema["~standard"].validate(inputValue);
if (result instanceof Promise) result = await result;
if (result.issues) {
for (const issue of result.issues) allIssues.push({
...issue,
path: issue.path ? [fieldName, ...issue.path] : [fieldName]
});
failedFields.push(fieldName);
continue;
}
transformedData[fieldName] = result.value;
} catch (error) {
if (error instanceof ValidationError) 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]
});
failedFields.push(fieldName);
}
}
}
if (allIssues.length > 0) throw new ValidationError(`Input validation failed for field${failedFields.length > 1 ? "s" : ""} '${failedFields.join("', '")}'`, allIssues, { field: failedFields[0] });
return transformedData;
}
/**
* Validates a single record against a schema, only validating selected fields.
* Also validates expanded relations if expandConfigs are provided.
*/
async function validateRecord(record, schema, selectedFields, expandConfigs, includeSpecialColumns) {
const { "@id": id, "@editLink": editLink, ...rest } = record;
const metadata = {};
if (id) metadata["@id"] = id;
if (editLink) metadata["@editLink"] = editLink;
if (!schema) {
const { ROWID, ROWMODID, ...restWithoutSystemFields } = rest;
const specialColumns = {};
if (includeSpecialColumns) {
if (ROWID !== void 0) specialColumns.ROWID = ROWID;
if (ROWMODID !== void 0) specialColumns.ROWMODID = ROWMODID;
}
return {
valid: true,
data: {
...restWithoutSystemFields,
...specialColumns,
...metadata
}
};
}
const { ROWID, ROWMODID, ...restWithoutSystemFields } = rest;
const specialColumns = {};
if (includeSpecialColumns) {
if (ROWID !== void 0) specialColumns.ROWID = ROWID;
if (ROWMODID !== void 0) specialColumns.ROWMODID = ROWMODID;
}
if (selectedFields && selectedFields.length > 0) {
const validatedRecord = {};
const allIssues = [];
const failedFields = [];
for (const field of selectedFields) {
const fieldName = String(field);
const fieldSchema = schema[fieldName];
if (fieldSchema) {
const input = rest[fieldName];
try {
let result = fieldSchema["~standard"].validate(input);
if (result instanceof Promise) result = await result;
if (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 (originalError instanceof ValidationError) for (const issue of originalError.issues) allIssues.push({
...issue,
path: issue.path ? [fieldName, ...issue.path] : [fieldName]
});
else allIssues.push({
message: originalError instanceof Error ? originalError.message : String(originalError),
path: [fieldName]
});
failedFields.push(fieldName);
}
} else if (fieldName === "ROWID" || fieldName === "ROWMODID") {
if (fieldName === "ROWID" && ROWID !== void 0) validatedRecord[fieldName] = ROWID;
else if (fieldName === "ROWMODID" && ROWMODID !== void 0) validatedRecord[fieldName] = ROWMODID;
} else validatedRecord[fieldName] = rest[fieldName];
}
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
})
};
if (expandConfigs && expandConfigs.length > 0) for (const expandConfig of expandConfigs) {
const expandValue = rest[expandConfig.relation];
if (expandValue === void 0) {
if (Array.isArray(rest.error) && rest.error.length > 0) {
const errorDetail = rest.error[0]?.error;
if (errorDetail?.message) {
const errorMessage = errorDetail.message;
if (errorMessage.toLowerCase().includes(expandConfig.relation.toLowerCase()) || expandConfig.selectedFields?.some((field) => errorMessage.toLowerCase().includes(field.toLowerCase()))) return {
valid: false,
error: new ValidationError(`Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`, [], { field: expandConfig.relation })
};
}
}
} else if (Array.isArray(expandValue)) {
const validatedExpandedItems = [];
for (let i = 0; i < expandValue.length; i++) {
const item = expandValue[i];
const itemValidation = await validateRecord(item, expandConfig.targetSchema, expandConfig.selectedFields, expandConfig.nestedExpands, includeSpecialColumns);
if (!itemValidation.valid) return {
valid: false,
error: new ValidationError(`Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`, itemValidation.error.issues, {
field: expandConfig.relation,
cause: itemValidation.error.cause
})
};
validatedExpandedItems.push(itemValidation.data);
}
validatedRecord[expandConfig.relation] = validatedExpandedItems;
} else {
const itemValidation = await validateRecord(expandValue, expandConfig.targetSchema, expandConfig.selectedFields, expandConfig.nestedExpands, includeSpecialColumns);
if (!itemValidation.valid) return {
valid: false,
error: new ValidationError(`Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`, itemValidation.error.issues, {
field: expandConfig.relation,
cause: itemValidation.error.cause
})
};
validatedRecord[expandConfig.relation] = itemValidation.data;
}
}
return {
valid: true,
data: {
...validatedRecord,
...specialColumns,
...metadata
}
};
}
const validatedRecord = { ...restWithoutSystemFields };
const allIssues = [];
const failedFields = [];
for (const [fieldName, fieldSchema] of Object.entries(schema)) {
if (!fieldSchema) continue;
const input = rest[fieldName];
try {
let result = fieldSchema["~standard"].validate(input);
if (result instanceof Promise) result = await result;
if (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 (originalError instanceof ValidationError) for (const issue of originalError.issues) allIssues.push({
...issue,
path: issue.path ? [fieldName, ...issue.path] : [fieldName]
});
else allIssues.push({
message: originalError instanceof Error ? originalError.message : String(originalError),
path: [fieldName]
});
failedFields.push(fieldName);
}
}
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
})
};
if (expandConfigs && expandConfigs.length > 0) for (const expandConfig of expandConfigs) {
const expandValue = rest[expandConfig.relation];
if (expandValue === void 0) {
if (Array.isArray(rest.error) && rest.error.length > 0) {
const errorDetail = rest.error[0]?.error;
if (errorDetail?.message) {
const errorMessage = errorDetail.message;
if (errorMessage.toLowerCase().includes(expandConfig.relation.toLowerCase()) || expandConfig.selectedFields?.some((field) => errorMessage.toLowerCase().includes(field.toLowerCase()))) return {
valid: false,
error: new ValidationError(`Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`, [], { field: expandConfig.relation })
};
}
}
} else if (Array.isArray(expandValue)) {
const validatedExpandedItems = [];
for (let i = 0; i < expandValue.length; i++) {
const item = expandValue[i];
const itemValidation = await validateRecord(item, expandConfig.targetSchema, expandConfig.selectedFields, expandConfig.nestedExpands, includeSpecialColumns);
if (!itemValidation.valid) return {
valid: false,
error: new ValidationError(`Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`, itemValidation.error.issues, {
field: expandConfig.relation,
cause: itemValidation.error.cause
})
};
validatedExpandedItems.push(itemValidation.data);
}
validatedRecord[expandConfig.relation] = validatedExpandedItems;
} else {
const itemValidation = await validateRecord(expandValue, expandConfig.targetSchema, expandConfig.selectedFields, expandConfig.nestedExpands, includeSpecialColumns);
if (!itemValidation.valid) return {
valid: false,
error: new ValidationError(`Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`, itemValidation.error.issues, {
field: expandConfig.relation,
cause: itemValidation.error.cause
})
};
validatedRecord[expandConfig.relation] = itemValidation.data;
}
}
return {
valid: true,
data: {
...validatedRecord,
...specialColumns,
...metadata
}
};
}
/**
* Validates a list response against a schema.
*/
async function validateListResponse(response, schema, selectedFields, expandConfigs, includeSpecialColumns) {
if (!response || typeof response !== "object") return {
valid: false,
error: new ResponseStructureError("an object", response)
};
const { "@context": context, value, ..._rest } = response;
if (!Array.isArray(value)) return {
valid: false,
error: new ResponseStructureError("'value' property to be an array", value)
};
const validatedRecords = [];
for (const record of value) {
const validation = await validateRecord(record, schema, selectedFields, expandConfigs, includeSpecialColumns);
if (!validation.valid) return {
valid: false,
error: validation.error
};
validatedRecords.push(validation.data);
}
return {
valid: true,
data: validatedRecords
};
}
/**
* Validates a single record response against a schema.
*/
async function validateSingleResponse(response, schema, selectedFields, expandConfigs, mode = "maybe", includeSpecialColumns) {
if (response.value && Array.isArray(response.value) && response.value.length > 1) return {
valid: false,
error: new RecordCountMismatchError(mode === "exact" ? "one" : "at-most-one", response.value.length)
};
if (!response || response.value && response.value.length === 0) {
if (mode === "exact") return {
valid: false,
error: new RecordCountMismatchError("one", 0)
};
return {
valid: true,
data: null
};
}
const validation = await validateRecord(response.value?.[0] ?? response, schema, selectedFields, expandConfigs, includeSpecialColumns);
if (!validation.valid) return validation;
return {
valid: true,
data: validation.data
};
}
//#endregion
//#region src/client/builders/response-processor.ts
/**
* Processes OData response with transformation and validation.
* Shared by QueryBuilder and RecordBuilder.
*/
async function processODataResponse(rawResponse, config) {
const { table, schema, singleMode, selectedFields, expandValidationConfigs, skipValidation, useEntityIds, includeSpecialColumns, fieldMapping } = config;
let response = rawResponse;
if (table && useEntityIds) response = transformResponseFields(response, table, expandValidationConfigs);
if (skipValidation) {
const result = extractRecords(response, singleMode);
if (result.data && fieldMapping && Object.keys(fieldMapping).length > 0) {
if (result.error) return {
data: void 0,
error: result.error
};
return {
data: renameFieldsInResponse(result.data, fieldMapping),
error: void 0
};
}
return result;
}
if (singleMode !== false) {
const validation = await validateSingleResponse(response, schema, selectedFields, expandValidationConfigs, singleMode, includeSpecialColumns);
if (!validation.valid) return {
data: void 0,
error: validation.error
};
if (fieldMapping && Object.keys(fieldMapping).length > 0) return {
data: renameFieldsInResponse(validation.data, fieldMapping),
error: void 0
};
return {
data: validation.data,
error: void 0
};
}
const validation = await validateListResponse(response, schema, selectedFields, expandValidationConfigs, includeSpecialColumns);
if (!validation.valid) return {
data: void 0,
error: validation.error
};
if (fieldMapping && Object.keys(fieldMapping).length > 0) return {
data: renameFieldsInResponse(validation.data, fieldMapping),
error: void 0
};
return {
data: validation.data,
error: void 0
};
}
/**
* Extracts records from response without validation.
*/
function extractRecords(response, singleMode) {
if (singleMode === false) return {
data: response.value ?? [],
error: void 0
};
const records = response.value ?? [response];
const count = Array.isArray(records) ? records.length : 1;
if (count > 1) return {
data: void 0,
error: new RecordCountMismatchError(singleMode === "exact" ? "one" : "at-most-one", count)
};
if (count === 0) {
if (singleMode === "exact") return {
data: void 0,
error: new RecordCountMismatchError("one", 0)
};
return {
data: null,
error: void 0
};
}
return {
data: Array.isArray(records) ? records[0] : records,
error: void 0
};
}
/**
* Gets schema from a table occurrence, excluding container fields.
* Container fields are never returned in regular responses (only via getSingleField).
*/
function getSchemaFromTable(table) {
if (!table) return;
const baseTableConfig = getBaseTableConfig(table);
const containerFields = baseTableConfig.containerFields || [];
const schema = { ...baseTableConfig.schema };
for (const containerField of containerFields) delete schema[containerField];
return schema;
}
/**
* Renames fields in response data according to the field mapping.
* Used when select() is called with renamed fields (e.g., { userEmail: users.email }).
*/
function renameFieldsInResponse(data, fieldMapping) {
if (!data || typeof data !== "object") return data;
if (Array.isArray(data)) return data.map((item) => renameFieldsInResponse(item, fieldMapping));
if ("value" in data && Array.isArray(data.value)) return {
...data,
value: data.value.map((item) => renameFieldsInResponse(item, fieldMapping))
};
const renamed = {};
for (const [key, value] of Object.entries(data)) {
const outputKey = fieldMapping[key];
if (outputKey) renamed[outputKey] = value;
else renamed[key] = value;
}
return renamed;
}
/**
* Processes query response with expand configs.
* This is a convenience wrapper that builds validation configs from expand configs.
*/
async function processQueryResponse(response, config) {
const { occurrence, singleMode, queryOptions, expandConfigs, skipValidation, useEntityIds, includeSpecialColumns, includeCount, fieldMapping, logger } = config;
const expandValidationConfigs = new ExpandBuilder(useEntityIds ?? false, logger).buildValidationConfigs(expandConfigs);
let selectedFields;
if (queryOptions.select) selectedFields = Array.isArray(queryOptions.select) ? queryOptions.select.map(String) : [String(queryOptions.select)];
let processedResponse = await processODataResponse(response, {
table: occurrence,
schema: getSchemaFromTable(occurrence),
singleMode,
selectedFields,
expandValidationConfigs,
skipValidation,
useEntityIds,
includeSpecialColumns
});
if (processedResponse.data && fieldMapping && Object.keys(fieldMapping).length > 0) processedResponse = {
...processedResponse,
data: renameFieldsInResponse(processedResponse.data, fieldMapping)
};
if (includeCount) {
if (processedResponse.error) return {
data: void 0,
error: processedResponse.error
};
if (singleMode !== false) return {
data: void 0,
error: new ResponseStructureError("list response for count-enabled query", response)
};
const rawCount = response?.["@odata.count"];
let parsedCount = NaN;
if (typeof rawCount === "number") parsedCount = rawCount;
else if (typeof rawCount === "string" && rawCount.trim() !== "") parsedCount = Number(rawCount);
if (!Number.isFinite(parsedCount)) return {
data: void 0,
error: new ResponseStructureError("response with valid @odata.count", response)
};
return {
data: {
records: processedResponse.data,
count: parsedCount
},
error: void 0
};
}
return processedResponse;
}
/**
* Processes record response by delegating to the canonical query processor.
* Record reads are query reads with singleMode fixed to "exact".
*/
async function processRecordResponse(response, config) {
return 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
});
}
//#endregion
//#region src/client/builders/select-mixin.ts
/**
* Processes select() calls with field renaming support.
* Validates columns belong to the correct table and builds field mapping for renamed fields.
* Used by both QueryBuilder and RecordBuilder to eliminate duplication.
*
* @param fields - Object mapping output keys to column references
* @param tableName - Expected table name for validation
* @returns Object with selectedFields array and fieldMapping for renamed fields
*/
function processSelectWithRenames(fields, tableName, logger) {
const selectedFields = [];
const fieldMapping = {};
for (const [outputKey, column] of Object.entries(fields)) {
if (!isColumn(column)) throw new Error(`select() expects column references, but got: ${typeof column}`);
if (column.tableName !== tableName) logger.warn(`Column ${column.toString()} is from table "${column.tableName}", but query is for table "${tableName}"`);
const fieldName = column.fieldName;
selectedFields.push(fieldName);
if (fieldName !== outputKey) fieldMapping[fieldName] = outputKey;
}
return {
selectedFields,
fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : {}
};
}
//#endregion
//#region src/client/sanitize-json.ts
/**
* FileMaker OData API sometimes returns invalid JSON containing unquoted `?`
* characters as field values (e.g., `"fieldName": ?`), which causes JSON.parse()
* to fail. This module provides utilities to sanitize such responses before parsing.
*/
/**
* Sanitizes FileMaker OData JSON responses by replacing unquoted `?` values with `null`.
*
* FileMaker uses `?` to represent undefined/null values in its OData responses,
* but this is not valid JSON. This function converts those to proper `null` values.
*
* The regex uses two patterns:
* 1. `/:\s*\?(?=\s*[,}\]])/g` - for values in objects (after `:`)
* 2. `/(?<=[\[,])\s*\?(?=\s*[,\]])/g` - for values in arrays (after `[` or `,`)
*
* @param text - The raw response text from FileMaker OData API
* @returns Sanitized JSON string with `?` values replaced by `null`
*
* @example
* sanitizeFileMakerJson('{"field1": "valid", "field2": ?, "field3": null}')
* // Returns: '{"field1": "valid", "field2": null, "field3": null}'
*/
function sanitizeFileMakerJson(text) {
let result = text.replace(/:\s*\?(?=\s*[,}\]])/g, ": null");
result = result.replace(/(?<=[[,])\s*\?(?=\s*[,\]])/g, " null");
return result;
}
/**
* Safely parses a Response body as JSON, handling FileMaker's invalid JSON responses.
*
* This function reads the response as text first, sanitizes any invalid `?` values,
* and then parses the sanitized JSON. This approach handles the case where FileMaker
* returns a Content-Type of application/json but the body contains invalid JSON.
*
* @param response - The fetch Response object
* @returns Parsed JSON data
* @throws ResponseParseError if the JSON is still invalid after sanitization (includes sanitized text for debugging)
*/
async function safeJsonParse(response) {
const sanitized = sanitizeFileMakerJson(await response.text());
try {
return JSON.parse(sanitized);
} catch (err) {
throw new ResponseParseError(response.url, `Failed to parse response as JSON: ${err instanceof Error ? err.message : "Unknown error"}`, {
rawText: sanitized,
cause: err instanceof Error ? err : void 0
});
}
}
//#endregion
//#region src/client/error-parser.ts
/**
* Parses an error response and returns an appropriate error object.
* This helper is used by builder processResponse methods to handle error responses
* consistently, particularly important for batch operations where errors need to be
* properly parsed from the response body.
*
* @param response - The Response object (may be from batch or direct request)
* @param url - The URL that was requested (for error context)
* @returns An appropriate error object (ODataError, SchemaLockedError, or HTTPError)
*/
async function parseErrorResponse(response, url) {
let errorBody;
try {
if (response.headers.get("content-type")?.includes("application/json")) errorBody = await safeJsonParse(response);
} catch {}
if (errorBody?.error) {
const errorCode = errorBody.error.code;
const errorMessage = errorBody.error.message || response.statusText;
if (errorCode === "303" || errorCode === 303) return new SchemaLockedError(url, errorMessage, errorBody.error);
return new ODataError(url, errorMessage, String(errorCode), errorBody.error);
}
return new HTTPError(url, response.status, response.statusText, errorBody);
}
//#endregion
//#region src/client/query/url-builder.ts
/**
* Builds OData query URLs for different navigation modes.
* Handles:
* - Record navigation: /database/sourceTable('recordId')/relation
* - Entity set navigation: /database/sourceTable/relation
* - Count endpoint: /database/tableId/$count
* - Standard queries: /database/tableId
*/
var QueryUrlBuilder = class {
databaseName;
occurrence;
useEntityIds;
constructor(databaseName, occurrence, useEntityIds) {
this.databaseName = databaseName;
this.occurrence = occurrence;
this.useEntityIds = useEntityIds;
}
/**
* Builds the full URL for a query request.
*
* @param queryString - The OData query string (e.g., "?$filter=...&$select=...")
* @param options - Options including whether this is a count query, useEntityIds override, and navigation config
*/
build(queryString, options) {
return `/${this.databaseName}${this.buildPath(queryString, options)}`;
}
/**
* Builds a query string path (without database prefix) for getQueryString().
* Used when the full URL is not needed.
*/
buildPath(queryString, options) {
const effectiveUseEntityIds = options?.useEntityIds ?? this.useEntityIds;
const navigation = options?.navigation;
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds);
const suffix = options?.isCount ? "/$count" : "";
if (navigation?.recordLocator !== void 0 && navigation?.relation) {
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 { recordLocator } = navigation;
if (recordLocator === void 0) throw new Error("recordLocator is required for record navigation");
const base = baseRelation ? buildRecordPath(`${sourceTable}/${baseRelation}`, recordLocator) : buildRecordPath(sourceTable, recordLocator);
return queryString ? `/${base}/${relation}${suffix}${queryString}` : `/${base}/${relation}${suffix}`;
}
if (navigation?.relation) {
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}${suffix}${queryString}` : `/${base}/${relation}${suffix}`;
}
return queryString ? `/${tableId}${suffix}${queryString}` : `/${tableId}${suffix}`;
}
/**
* Build URL for record operations (single record by ID).
* Used by RecordBuilder to build URLs like /database/table('id').
*
* @param recordLocator - The record locator
* @param queryString - The OData query string (e.g., "?$select=...")
* @param options - Options including operation type and useEntityIds override
*/
buildRecordUrl(recordLocator, queryString, options) {
const effectiveUseEntityIds = options?.useEntityIds ?? this.useEntityIds;
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds);
let url;
if (options?.isNavigateFromEntitySet && options.navigateSourceTableName && options.navigateRelation) url = `/${this.databaseName}/${buildRecordPath(`${options.navigateSourceTableName}/${options.navigateRelation}`, recordLocator)}`;
else url = `/${this.databaseName}/${buildRecordPath(tableId, recordLocator)}`;
if (options?.operation === "getSingleField" && options.operationParam) url += `/${options.operationParam}`;
return url + queryString;
}
};
//#endregion
//#region src/client/count-builder.ts
function normalizeCountBuildError(error) {
if (isFMODataError(error)) return error;
if (error instanceof Error) return new BuilderInvariantError("CountBuilder.execute", error.message, { cause: error });
return new BuilderInvariantError("CountBuilder.execute", String(error));
}
var CountBuilder = class {
occurrence;
layer;
config;
urlBuilder;
filterExpression;
queryOptions = {};
navigationConfig;
constructor(config) {
this.occurrence = config.occurrence;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
this.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds);
}
set navigation(navigation) {
this.navigationConfig = navigation;
}
where(expression) {
if (typeof expression === "string") {
this.filterExpression = void 0;
this.queryOptions.filter = expression;
return this;
}
this.filterExpression = expression;
this.queryOptions.filter = void 0;
return this;
}
buildQueryString(useEntityIds) {
const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds;
const queryOptions = { ...this.queryOptions };
if (this.filterExpression) queryOptions.filter = this.filterExpression.toODataFilter(finalUseEntityIds);
queryOptions.count = void 0;
queryOptions.select = void 0;
queryOptions.expand = void 0;
queryOptions.top = void 0;
queryOptions.skip = void 0;
queryOptions.orderBy = void 0;
return buildQuery(queryOptions);
}
parseCountValue(raw) {
let count = NaN;
if (typeof raw === "number") count = raw;
else if (typeof raw === "string" && raw.trim() !== "") count = Number(raw);
return Number.isFinite(count) ? count : new ResponseStructureError("numeric count response", raw);
}
execute(options) {
const mergedOptions = mergeExecuteOptions(options, this.config.useEntityIds);
let queryString;
let url;
try {
queryString = this.buildQueryString(mergedOptions.useEntityIds);
url = this.urlBuilder.build(queryString, {
isCount: true,
useEntityIds: mergedOptions.useEntityIds,
navigation: this.navigationConfig
});
} catch (error) {
return Promise.resolve({
data: void 0,
error: normalizeCountBuildError(error)
});
}
const pipeline = requestFromService(url, mergedOptions).pipe(Effect.flatMap((data) => {
const parsed = this.parseCountValue(data);
return parsed instanceof ResponseStructureError ? Effect.fail(parsed) : Effect.succeed(parsed);
}));
return runLayerResult(this.layer, pipeline, "fmodata.query.count", { "fmodata.table": getTableName(this.occurrence) });
}
getQueryString(options) {
const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds;
const queryString = this.buildQueryString(useEntityIds);
return this.urlBuilder.buildPath(queryString, {
isCount: true,
useEntityIds,
navigation: this.navigationConfig
});
}
getRequestConfig() {
const queryString = this.buildQueryString(this.config.useEntityIds);
return {
method: "GET",
url: this.urlBuilder.build(queryString, {
isCount: true,
useEntityIds: this.config.useEntityIds,
navigation: this.navigationConfig
})
};
}
toRequest(baseUrl, options) {
return createODataRequest(baseUrl, this.getRequestConfig(), {
...options,
normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName
});
}
async processResponse(response, _options) {
if (!response.ok) return {
data: void 0,
error: await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${getTableName(this.occurrence)}/$count`)
};
const raw = await response.text();
const parsed = this.parseCountValue(raw);
if (parsed instanceof ResponseStructureError) return {
data: void 0,
error: parsed
};
return {
data: parsed,
error: void 0
};
}
};
//#endregion
//#region src/orm/operators.ts
/**
* FilterExpression represents a filter condition that can be used in where() clauses.
* Internal representation of operator expressions that get converted to OData filter syntax.
*/
var FilterExpression = class FilterExpression {
operator;
operands;
constructor(operator, operands) {
this.operator = operator;
this.operands = operands;
}
/**
* Convert this expression to OData filter syntax.
* @internal Used by QueryBuilder
*/
toODataFilter(useEntityIds) {
switch (this.operator) {
case "eq": return this._binaryOp("eq", useEntityIds);
case "ne": return this._binaryOp("ne", useEntityIds);
case "gt": return this._binaryOp("gt", useEntityIds);
case "gte": return this._binaryOp("ge", useEntityIds);
case "lt": return this._binaryOp("lt", useEntityIds);
case "lte": return this._binaryOp("le", useEntityIds);
case "in": return this._inOp(useEntityIds);
case "notIn": return this._notInOp(useEntityIds);
case "contains": return this._functionOp("contains", useEntityIds);
case "startsWith": return this._functionOp("startswith", useEntityIds);
case "endsWith": return this._functionOp("endswith", useEntityIds);
case "matchesPattern": return this._functionOp("matchesPattern", useEntityIds);
case "isNull": return this._isNullOp(useEntityIds);
case "isNotNull": return this._isNotNullOp(useEntityIds);
case "and": return this._logicalOp("and", useEntityIds);
case "or": return this._logicalOp("or", useEntityIds);
case "not": return this._notOp(useEntityIds);
default: throw new Error(`Unknown operator: ${this.operator}`);
}
}
_binaryOp(op, useEntityIds) {
const [left, right] = this.operands;
let columnForValue;
if (isColumn(left) && !isColumn(right)) columnForValue = left;
else if (isColumn(right) && !isColumn(left)) columnForValue = right;
else columnForValue = void 0;
return `${this._operandToString(left, useEntityIds, columnForValue)} ${op} ${this._operandToString(right, useEntityIds, columnForValue)}`;
}
_functionOp(fnName, useEntityIds) {
const [column, value] = this.operands;
const columnInstance = isColumn(column) ? column : void 0;
return `${fnName}(${this._operandToString(column, useEntityIds)}, ${this._operandToString(value, useEntityIds, columnInstance)})`;
}
_inOp(useEntityIds) {
const [column, values] = this.operands;
const columnInstance = isColumn(column) ? column : void 0;
return `${this._operandToString(column, useEntityIds)} in (${values.map((v) => this._operandToString(v, useEntityIds, columnInstance)).join(", ")})`;
}
_notInOp(useEntityIds) {
const [column, values] = this.operands;
const columnInstance = isColumn(column) ? column : void 0;
return `not (${this._operandToString(column, useEntityIds)} in (${values.map((v) => this._operandToString(v, useEntityIds, columnInstance)).join(", ")}))`;
}
_isNullOp(useEntityIds) {
const [column] = this.operands;
return `${this._operandToString(column, useEntityIds)} eq null`;
}
_isNotNullOp(useEntityIds) {
const [column] = this.operands;
return `${this._operandToString(column, useEntityIds)} ne null`;
}
_logicalOp(op, useEntityIds) {
return this.operands.map((expr) => {
if (expr instanceof FilterExpression) {
const innerExpr = expr.toODataFilter(useEntityIds);
if (expr.operator === "and" || expr.operator === "or") return `(${innerExpr})`;
return innerExpr;
}
throw new Error("Logical operators require FilterExpression operands");
}).join(` ${op} `);
}
_notOp(useEntityIds) {
const [expr] = this.operands;
if (expr instanceof FilterExpression) return `not (${expr.toODataFilter(useEntityIds)})`;
throw new Error("NOT operator requires a FilterExpression operand");
}
_formatTemporalValue(value, fieldType) {
if (!(value instanceof Date)) return String(value);
if (fieldType === "date") return value.toISOString().slice(0, 10);
if (fieldType === "time") return value.toISOString().slice(11, 19);
return value.toISOString();
}
_operandToString(operand, useEntityIds, column) {
if (isColumnFunction(operand)) return operand.toFilterString(useEntityIds);
if (isColumn(operand)) {
const fieldIdentifier = operand.getFieldIdentifier(useEntityIds);
return needsFieldQuoting(fieldIdentifier) ? `"${fieldIdentifier}"` : fieldIdentifier;
}
let value = operand;
if (column?.inputValidator) try {
const result = column.inputValidator["~standard"].validate(value);
if (result instanceof Promise) value = operand;
else if ("issues" in result && result.issues) value = operand;
else if ("value" in result) value = result.value;
} catch (_error) {
value = operand;
}
const ft = column?.fieldType;
if (ft === "date" || ft === "time" || ft === "timestamp") return this._formatTemporalValue(value, ft);
if (typeof value === "string") return `'${value.replace(/'/g, "''")}'`;
if (value === null || value === void 0) return "null";
if (value instanceof Date) return value.toISOString();
if (typeof value === "object") {
const valueType = value?.constructor?.name ?? "Object";
throw new Error(`Unsupported filter operand: received ${valueType}. Pass a table column or a primitive filter value.`);
}
return String(value);
}
};
/**
* OrderByExpression represents a sort order specification for a column.
* Used in orderBy() clauses to provide type-safe sorting with direction.
*/
var OrderByExpression = class {
column;
direction;
constructor(column, direction) {
this.column = column;
this.direction = direction;
}
};
/**
* Type guard to check if a value is an OrderByExpression instance.
*/
function isOrderByExpression(value) {
return value instanceof OrderByExpression;
}
//#endregion
//#region src/client/query/query-builder.ts
function normalizeQueryBuildError(error) {
if (isFMODataError(error)) return error;
if (error instanceof Error) return new BuilderInvariantError("QueryBuilder.execute", error.message, { cause: error });
return new BuilderInvariantError("QueryBuilder.execute", String(error));
}
var QueryBuilder = class QueryBuilder {
readState = createInitialQueryReadBuilderState();
occurrence;
expandBuilder;
urlBuilder;
layer;
config;
logger;
get queryOptions() {
return this.readState.queryOptions;
}
set queryOptions(queryOptions) {
this.readState = cloneQueryReadBuilderState(this.readState, { queryOptions });
}
get expandConfigs() {
return this.readState.expandConfigs;
}
set expandConfigs(expandConfigs) {
this.readState = cloneQueryReadBuilderState(this.readState, { expandConfigs });
}
get singleMode() {
return this.readState.singleMode;
}
set singleMode(singleMode) {
this.readState = cloneQueryReadBuilderState(this.readState, { singleMode });
}
get isCountMode() {
return this.readState.isCountMode;
}
set isCountMode(isCountMode) {
this.readState = cloneQueryReadBuilderState(this.readState, { isCountMode });
}
get includeCountMode() {
return this.readState.includeCountMode;
}
set includeCountMode(includeCountMode) {
this.readState = cloneQueryReadBuilderState(this.readState, { includeCountMode });
}
get fieldMapping() {
return this.readState.fieldMapping;
}
set fieldMapping(fieldMapping) {
this.readState = cloneQueryReadBuilderState(this.readState, { fieldMapping });
}
get systemColumns() {
return this.readState.systemColumns;
}
set systemColumns(systemColumns) {
this.readState = cloneQueryReadBuilderState(this.readState, { systemColumns });
}
get navigation() {
return this.readState.navigation;
}
set navigation(navigation) {
this.setNavigation(navigation);
}
constructor(config) {
this.occurrence = config.occurrence;
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);
}
/**
* Helper to merge database-level useEntityIds and includeSpecialColumns with per-request options
*/
mergeExecuteOptions(options) {
return {
...mergeExecuteOptions(options, this.config.useEntityIds),
includeSpecialColumns: options?.includeSpecialColumns ?? this.config.includeSpecialColumns
};
}
patchQueryOptions(patch) {
this.readState = cloneQueryReadBuilderState(this.readState, { queryOptions: patch });
}
setFilterExpression(expression) {
this.readState = cloneQueryReadBuilderState(this.readState, { filterExpression: expression });
}
setNavigation(navigation) {
this.readState = cloneQueryReadBuilderState(this.readState, { navigation });
}
/**
* Creates a new QueryBuilder with modified configuration.
* Used by single(), maybeSingle(), count(), and select() to create new instances.
*/
cloneWithChanges(changes) {
const newBuilder = new QueryBuilder({
occurrence: this.occurrence,
layer: this.layer
});
newBuilder.readState = cloneQueryReadBuilderState(this.readState, {
queryOptions: changes.queryOptions,
expandConfigs: this.readState.expandConfigs,
singleMode: changes.singleMode ?? this.readState.singleMode,
isCountMode: changes.isCountMode ?? this.readState.isCountMode,
includeCountMode: changes.includeCountMode ?? this.readState.includeCountMode,
fieldMapping: "fieldMapping" in changes ? changes.fieldMapping : this.readState.fieldMapping,
systemColumns: changes.systemColumns !== void 0 ? changes.systemColumns : this.readState.systemColumns,
navigation: this.readState.navigation
});
newBuilder.urlBuilder = new QueryUrlBuilder(this.config.databaseName, this.occurrence, this.config.useEntityIds);
return newBuilder;
}
select(fields, systemColumns) {
if (fields === "all") return this.cloneWithChanges({
queryOptions: { select: void 0 },
fieldMapping: void 0,
systemColumns: void 0
});
const { selectedFields, fieldMapping } = processSelectWithRenames(fields, getTableName(this.occurrence), this.logger);
const finalSelectedFields = [...selectedFields];
if (systemColumns?.ROWID) finalSelectedFields.push("ROWID");
if (systemColumns?.ROWMODID) finalSelectedFields.push("ROWMODID");
return this.cloneWithChanges({
selectedFields: fields,
queryOptions: { select: finalSelectedFields },
fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : void 0,
systemColumns
});
}
/**
* Filter results using operator expressions (new ORM-style API).
* Supports eq, gt, lt, and, or, etc. operators with Column references.
* Also supports raw OData filter strings as an escape hatch.
*
* @example
* .where(eq(users.hobby, "reading"))
* .where(and(eq(users.active, true), gt(users.age, 18)))
* .where("status eq 'active'") // Raw OData string escape hatch
*/
where(expression) {
if (typeof expression === "string") {
this.setFilterExpression(void 0);
this.patchQueryOptions({ filter: expression });
return this;
}
this.setFilterExpression(expression);
this.patchQueryOptions({ filter: void 0 });
return this;
}
/**
* Specify the sort order for query results.
*
* @example Single field (ascending by default)
* ```ts
* .orderBy("name")
* .orderBy(users.name) // Column reference
* .orderBy(asc(users.name)) // Explicit ascending
* ```
*
* @example Single field with explicit direction
* ```ts
* .orderBy(["name", "desc"])
* .orderBy([users.name, "desc"]) // Column reference
* .orderBy(desc(users.name)) // Explicit descending
* ```
*
* @example Multiple fields with directions
* ```ts
* .orderBy([["name", "asc"], ["createdAt", "desc"]])
* .orderBy([[users.name, "asc"], [users.createdAt, "desc"]]) // Column references
* .orderBy(users.name, desc(users.age)) // Variadic with helpers
* ```
*/
orderBy(...orderByArgs) {
const tableName = getTableName(this.occurrence);
if (orderByArgs.length > 1) {
const orderByParts = orderByArgs.map((arg) => {
if (isOrderByExpression(arg)) {
if (arg.column.tableName !== tableName) this.logger.warn(`Column ${arg.column.toString()} is from table "${arg.column.tableName}", but query is for table "${tableName}"`);
const fieldName = arg.column.fieldName;
return `${this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName} ${arg.direction}`;
}
if (isColumn(arg)) {
if (arg.tableName !== tableName) this.logger.warn(`Column ${arg.toString()} is from table "${arg.tableName}", but query is for table "${tableName}"`);
const fieldName = arg.fieldName;
return this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName;
}
throw new Error("Variadic orderBy() only accepts Column or OrderByExpression arguments");
});
this.patchQueryOptions({ orderBy: orderByParts });
return this;
}
const orderBy = orderByArgs[0];
if (isOrderByExpression(orderBy)) {
if (orderBy.column.tableName !== tableName) this.logger.warn(`Column ${orderBy.column.toString()} is from table "${orderBy.column.tableName}", but query is for table "${tableName}"`);
const fieldName = orderBy.column.fieldName;
const transformedField = this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName;
this.patchQueryOptions({ orderBy: `${transformedField} ${orderBy.direction}` });
return this;
}
if (isColumn(orderBy)) {
if (orderBy.tableName !== tableName) this.logger.warn(`Column ${orderBy.toString()} is from table "${orderBy.tableName}", but query is for table "${tableName}"`);
const fieldName = orderBy.fieldName;
this.patchQueryOptions({ orderBy: this.occurrence ? transformOrderByField(fieldName, this.occurrence) : fieldName });
return this;
}
if (this.occurrence && orderBy) if (Array.isArray(orderBy)) if (orderBy.length === 2 && (typeof orderBy[0] === "string" || isColumn(orderBy[0])) && (orderBy[1] === "asc" || orderBy[1] === "desc")) {
const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0];
const direction = orderBy[1];
this.patchQueryOptions({ orderBy: `${transformOrderByField(field, this.occurrence)} ${direction}` });
} else this.patchQueryOptions({ orderBy: orderBy.map(([fieldOrCol, direction]) => {
const field = isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol);
return `${this.occurrence ? transformOrderByField(field, this.occurrence) : field} ${direction}`;
}) });
else this.patchQueryOptions({ orderBy: transformOrderByField(String(orderBy), this.occurrence) });
else if (Array.isArray(orderBy)) if (orderBy.length === 2 && (typeof orderBy[0] === "string" || isColumn(orderBy[0])) && (orderBy[1] === "asc" || orderBy[1] === "desc")) {
const field = isColumn(orderBy[0]) ? orderBy[0].fieldName : orderBy[0];
const direction = orderBy[1];
this.patchQueryOptions({ orderBy: `${field} ${direction}` });
} else this.patchQueryOptions({ orderBy: orderBy.map(([fieldOrCol, direction]) => {
return `${isColumn(fieldOrCol) ? fieldOrCol.fieldName : String(fieldOrCol)} ${direction}`;
}) });
else this.patchQueryOptions({ orderBy });
return this;
}
top(count) {
this.patchQueryOptions({ top: count });
return this;
}
skip(count) {
this.patchQueryOptions({ skip: count });
return this;
}
expand(targetTable, callback) {
const expandConfig = this.expandBuilder.processExpand(targetTable, this.occurrence, callback, () => new QueryBuilder({
occurrence: targetTable,
layer: this.layer
}));
this.readState = cloneQueryReadBuilderState(this.readState, { expandConfigs: [...this.readState.expandConfigs, expandConfig] });
return this;
}
single() {
if (this.readState.includeCountMode) throw new BuilderInvariantError("QueryBuilder.single", "count-enabled list queries cannot use single()");
return this.cloneWithChanges({ singleMode: "exact" });
}
maybeSingle() {
if (this.readState.includeCountMode) throw new BuilderInvariantError("QueryBuilder.maybeSingle", "count-enabled list queries cannot use maybeSingle()");
return this.cloneWithChanges({ singleMode: "maybe" });
}
count() {
if (this.readState.singleMode !== false) throw new BuilderInvariantError("QueryBuilder.count", "single() and maybeSingle() cannot be combined with count()");
return this.cloneWithChanges({
includeCountMode: true,
queryOptions: { count: true }
});
}
/**
* Builds the OData query string from current query options and expand configs.
*/
buildQueryString(includeSpecialColumns, useEntityIds) {
const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds;
const queryOptionsWithoutExpandAndSelect = { ...this.readState.queryOptions };
if (this.readState.filterExpression) queryOptionsWithoutExpandAndSelect.filter = this.readState.filterExpression.toODataFilter(finalUseEntityIds);
const originalSelect = queryOptionsWithoutExpandAndSelect.select;
queryOptionsWithoutExpandAndSelect.expand = void 0;
queryOptionsWithoutExpandAndSelect.select = void 0;
let queryString = buildQuery(queryOptionsWithoutExpandAndSelect);
let selectArray;
if (originalSelect) selectArray = Array.isArray(originalSelect) ? originalSelect.map(String) : [String(originalSelect)];
const finalIncludeSpecialColumns = includeSpecialColumns ?? this.config.includeSpecialColumns;
const selectExpandString = buildSelectExpandQueryString({
selectedFields: selectArray,
expandConfigs: this.readState.expandConfigs,
table: this.occurrence,
useEntityIds: finalUseEntityIds,
logger: this.logger,
includeSpecialColumns: finalIncludeSpecialColumns
});
if (selectExpandString) {
const params = selectExpandString.startsWith("?") ? selectExpandString.slice(1) : selectExpandString;
const separator = queryString.includes("?") ? "&" : "?";
queryString = `${queryString}${separator}${params}`;
}
return queryString;
}
execute(options) {
const mergedOptions = this.mergeExecuteOptions(options);
let queryString;
try {
queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds);
} catch (error) {
return Promise.resolve({
data: void 0,
error: normalizeQueryBuildError(error)
});
}
if (this.readState.isCountMode) {
let url;
try {
url = this.urlBuilder.build(queryString, {
isCount: true,
useEntityIds: mergedOptions.useEntityIds,
navigation: this.readState.navigation
});
} catch (error) {
return Promise.resolve({
data: void 0,
error: normalizeQueryBuildError(error)
});
}
const pipeline = requestFromService(url, mergedOptions).pipe(Effect.map((data) => {
return typeof data === "string" ? Number(data) : data;
}));
return runLayerResult(this.layer, pipeline, "fmodata.query.count", { "fmodata.table": getTableName(this.occurrence) });
}
let url;
try {
url = this.urlBuilder.build(queryString, {
isCount: this.readState.isCountMode,
useEntityIds: mergedOptions.useEntityIds,
navigation: this.readState.navigation
});
} catch (error) {
return Promise.resolve({
data: void 0,
error: normalizeQueryBuildError(error)
});
}
const pipeline = requestFromService(url, mergedOptions).pipe(Effect.flatMap((data) => Effect.tryPromise({
try: () => processQueryResponse(data, {
occurrence: this.occurrence,
singleMode: this.readState.singleMode,
queryOptions: this.readState.queryOptions,
expandConfigs: this.readState.expandConfigs,
skipValidation: options?.skipValidation,
useEntityIds: mergedOptions.useEntityIds,
includeSpecialColumns: mergedOptions.includeSpecialColumns,
includeCount: this.readState.includeCountMode,
fieldMapping: this.readState.fieldMapping,
logger: this.logger
}),
catch: (e) => e instanceof Error ? e : new Error(String(e))
})), Effect.flatMap((result) => result.error ? Effect.fail(result.error) : Effect.succeed(result.data)));
return runLayerResult(this.layer, pipeline, this.readState.singleMode ? "fmodata.query.single" : "fmodata.query.list", { "fmodata.table": getTableName(this.occurrence) });
}
getQueryString(options) {
const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds;
const queryString = this.buildQueryString(void 0, useEntityIds);
return this.urlBuilder.buildPath(queryString, {
useEntityIds,
navigation: this.readState.navigation
});
}
getRequestConfig() {
const queryString = this.buildQueryString();
return {
method: "GET",
url: this.urlBuilder.build(queryString, {
isCount: this.readState.isCountMode,
useEntityIds: this.config.useEntityIds,
navigation: this.readState.navigation
})
};
}
toRequest(baseUrl, options) {
return createODataRequest(baseUrl, this.getRequestConfig(), {
...options,
normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName
});
}
async processResponse(response, options) {
if (!response.ok) return {
data: void 0,
error: await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${getTableName(this.occurrence)}`)
};
if (response.status === 204) {
if (this.readState.singleMode !== false) {
if (this.readState.singleMode === "maybe") return {
data: null,
error: void 0
};
return {
data: void 0,
error: new RecordCountMismatchError("one", 0)
};
}
return {
data: [],
error: void 0
};
}
let rawData;
try {
rawData = await safeJsonParse(response);
} catch (err) {
if (err instanceof SyntaxError && response.status === 204) return {
data: [],
error: void 0
};
return {
data: void 0,
error: {
name: "ResponseParseError",
message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
timestamp: /* @__PURE__ */ new Date()
}
};
}
if (!rawData) return {
data: void 0,
error: {
name: "ResponseError",
message: "Response body was empty or null",
timestamp: /* @__PURE__ */ new Date()
}
};
const mergedOptions = this.mergeExecuteOptions(options);
this.readState.queryOptions.select;
return processQueryResponse(rawData, {
occurrence: this.occurrence,
singleMode: this.readState.singleMode,
queryOptions: this.readState.queryOptions,
expandConfigs: this.readState.expandConfigs,
skipValidation: options?.skipValidation,
useEntityIds: mergedOptions.useEntityIds,
includeSpecialColumns: mergedOptions.includeSpecialColumns,
includeCount: this.readState.includeCountMode,
fieldMapping: this.readState.fieldMapping,
logger: this.logger
});
}
};
//#endregion
//#region src/client/delete-builder.ts
/**
* Initial delete builder returned from EntitySet.delete()
* Requires calling .byId() or .where() before .execute() is available
*/
var DeleteBuilder = class {
table;
layer;
config;
constructor(config) {
this.table = config.occurrence;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
}
/**
* Delete a single record by ID
*/
byId(id) {
return new ExecutableDeleteBuilder({
occurrence: this.table,
layer: this.layer,
mode: "byId",
recordLocator: id
});
}
/**
* Delete a single record by ROWID
*/
byRowId(rowId) {
return new ExecutableDeleteBuilder({
occurrence: this.table,
layer: this.layer,
mode: "byId",
recordLocator: { ROWID: rowId }
});
}
/**
* Delete records matching a filter query
* @param fn Callback that receives a QueryBuilder for building the filter
*/
where(fn) {
const configuredBuilder = fn(new QueryBuilder({
occurrence: this.table,
layer: this.layer
}));
return new ExecutableDeleteBuilder({
occurrence: this.table,
layer: this.layer,
mode: "byFilter",
queryBuilder: configuredBuilder
});
}
};
/**
* Executable delete builder - has execute() method
* Returned after calling .byId() or .where()
*/
var ExecutableDeleteBuilder = class {
table;
mode;
recordLocator;
queryBuilder;
layer;
config;
constructor(config) {
this.table = config.occurrence;
this.layer = config.layer;
this.mode = config.mode;
this.recordLocator = config.recordLocator;
this.queryBuilder = config.queryBuilder;
this.config = createClientRuntime(this.layer).config;
}
execute(options) {
const mergedOptions = mergeMutationExecuteOptions(options, this.config.useEntityIds, this.config.includeSpecialColumns);
const { method: _method, body: _body, ...requestOptions } = mergedOptions;
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,
recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds,
builderName: "ExecutableDeleteBuilder"
});
const pipeline = Effect.gen(this, function* () {
return { deletedCount: extractAffectedRows(yield* requestFromService(url, {
...requestOptions,
method: "DELETE"
}), void 0, 0, "deletedCount") };
});
return runLayerResult(this.layer, pipeline, "fmodata.delete", { "fmodata.table": getTableName(this.table) });
}
getRequestConfig() {
const tableId = resolveMutationTableId(this.table, this.config.useEntityIds, "ExecutableDeleteBuilder");
return {
method: "DELETE",
url: buildMutationUrl({
databaseName: this.config.databaseName,
tableId,
tableName: getTableName(this.table),
mode: this.mode,
recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds: this.config.useEntityIds,
builderName: "ExecutableDeleteBuilder"
})
};
}
toRequest(baseUrl, options) {
const config = this.getRequestConfig();
const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName })}`;
return new Request(fullUrl, {
method: config.method,
headers: { Accept: getAcceptHeader(options?.includeODataAnnotations) }
});
}
async processResponse(response, _options) {
if (!response.ok) {
const tableName = getTableName(this.table);
return {
data: void 0,
error: await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`)
};
}
const text = await response.text();
if (!text || text.trim() === "") return {
data: { deletedCount: extractAffectedRows(void 0, response.headers, 1, "deletedCount") },
error: void 0
};
return {
data: { deletedCount: extractAffectedRows(JSON.parse(text), response.headers, 0, "deletedCount") },
error: void 0
};
}
};
//#endregion
//#region src/client/insert-builder.ts
var InsertBuilder = class {
table;
data;
returnPreference;
layer;
config;
constructor(config) {
this.table = config.occurrence;
this.layer = config.layer;
this.data = config.data;
this.returnPreference = config.returnPreference || "representation";
const runtime = createClientRuntime(this.layer);
this.config = runtime.config;
}
/**
* Helper to merge database-level useEntityIds with per-request options
*/
mergeExecuteOptions(options) {
return mergeMutationExecuteOptions(options, this.config.useEntityIds, this.config.includeSpecialColumns);
}
/**
* Parse ROWID from Location header
* Expected formats:
* - contacts(ROWID=4583)
* - contacts('some-uuid')
*/
parseLocationHeader(locationHeader) {
return parseRowIdFromLocationHeader(locationHeader);
}
/**
* Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
* @param useEntityIds - Optional override for entity ID usage
*/
getTableId(useEntityIds) {
if (!this.table) throw new BuilderInvariantError("InsertBuilder", "table occurrence is required");
return resolveMutationTableId(this.table, useEntityIds ?? this.config.useEntityIds, "InsertBuilder");
}
/**
* Builds the schema for validation, excluding container fields.
*/
getValidationSchema() {
if (!this.table) return;
const baseTableConfig = getBaseTableConfig(this.table);
const containerFields = baseTableConfig.containerFields || [];
const schema = { ...baseTableConfig.schema };
for (const containerField of containerFields) delete schema[containerField];
return schema;
}
execute(options) {
const mergedOptions = this.mergeExecuteOptions(options);
const { method: _method, headers: callerHeaders, body: _body, ...requestOptions } = mergedOptions;
const tableId = this.getTableId(mergedOptions.useEntityIds);
const url = `/${this.config.databaseName}/${tableId}`;
const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds;
const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns;
const canonicalHeaders = new Headers(callerHeaders || {});
const preferHeader = mergePreferHeaderValues(this.returnPreference === "minimal" ? "return=minimal" : "return=representation", shouldUseIds ? "fmodata.entity-ids" : void 0, includeSpecialColumns ? "fmodata.include-specialcolumns" : void 0, canonicalHeaders.get("Prefer") ?? void 0);
canonicalHeaders.set("Content-Type", "application/json");
if (preferHeader) canonicalHeaders.set("Prefer", preferHeader);
else canonicalHeaders.delete("Prefer");
const pipeline = Effect.gen(this, function* () {
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)));
}
const transformedData = this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData;
const responseData = yield* requestFromService(url, {
...requestOptions,
method: "POST",
headers: canonicalHeaders,
body: JSON.stringify(transformedData)
});
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"));
return { ROWID: this.parseLocationHeader(responseData._location) };
}
let response = responseData;
if (this.table && shouldUseIds) response = transformResponseFields(response, this.table, void 0);
const schema = this.getValidationSchema();
const validated = yield* fromValidation(() => validateSingleResponse(response, schema, void 0, void 0, "exact", includeSpecialColumns));
if (validated === null) return yield* Effect.fail(new BuilderInvariantError("InsertBuilder.execute", "insert operation returned null response"));
return validated;
});
return runLayerResult(this.layer, pipeline, "fmodata.insert", this.table ? { "fmodata.table": getTableName(this.table) } : void 0);
}
getRequestConfig() {
const tableId = this.getTableId(this.config.useEntityIds);
const transformedData = this.table && this.config.useEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data;
return {
method: "POST",
url: `/${this.config.databaseName}/${tableId}`,
body: JSON.stringify(transformedData)
};
}
toRequest(baseUrl, options) {
const config = this.getRequestConfig();
const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName })}`;
const preferHeader = mergePreferHeaderValues(this.returnPreference === "minimal" ? "return=minimal" : "return=representation", options?.useEntityIds ?? this.config.useEntityIds ? "fmodata.entity-ids" : void 0, options?.includeSpecialColumns ?? this.config.includeSpecialColumns ? "fmodata.include-specialcolumns" : void 0);
return new Request(fullUrl, {
method: config.method,
headers: {
"Content-Type": "application/json",
Accept: getAcceptHeader(options?.includeODataAnnotations),
...preferHeader ? { Prefer: preferHeader } : {}
},
body: config.body
});
}
async processResponse(response, options) {
if (!response.ok) {
const tableName = this.table ? getTableName(this.table) : "unknown";
return {
data: void 0,
error: await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`)
};
}
if (response.status === 204) {
if (this.returnPreference === "minimal") {
const locationHeader = getLocationHeader(response.headers);
return {
data: { ROWID: locationHeader ? this.parseLocationHeader(locationHeader) : -1 },
error: void 0
};
}
return {
data: {},
error: void 0
};
}
if (this.returnPreference === "minimal") {
const locationHeader = getLocationHeader(response.headers);
return {
data: { ROWID: locationHeader ? this.parseLocationHeader(locationHeader) : -1 },
error: void 0
};
}
let rawResponse;
try {
rawResponse = await safeJsonParse(response);
} catch (err) {
if (response.status === 204) return {
data: {},
error: void 0
};
return {
data: void 0,
error: {
name: "ResponseParseError",
message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
timestamp: /* @__PURE__ */ new Date()
}
};
}
this.data;
if (this.table) {
const inputSchema = getBaseTableConfig(this.table).inputSchema;
try {
await validateAndTransformInput(this.data, inputSchema);
} catch (error) {
return {
data: void 0,
error: error instanceof Error ? error : new BuilderInvariantError("InsertBuilder.processResponse", String(error))
};
}
}
const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds;
const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns;
let transformedResponse = rawResponse;
if (this.table && shouldUseIds) transformedResponse = transformResponseFields(rawResponse, this.table, void 0);
let schema;
if (this.table) {
const baseTableConfig = getBaseTableConfig(this.table);
const containerFields = baseTableConfig.containerFields || [];
schema = { ...baseTableConfig.schema };
for (const containerField of containerFields) delete schema[containerField];
}
const validation = await validateSingleResponse(transformedResponse, schema, void 0, void 0, "exact", includeSpecialColumns);
if (!validation.valid) return {
data: void 0,
error: validation.error
};
if (validation.data === null) return {
data: void 0,
error: new BuilderInvariantError("InsertBuilder.processResponse", "insert operation returned null response")
};
return {
data: validation.data,
error: void 0
};
}
};
//#endregion
//#region src/client/record-builder.ts
var RecordBuilder = class RecordBuilder {
table;
recordLocator;
operation;
operationParam;
operationColumn;
isNavigateFromEntitySet;
navigateRelation;
navigateRelationEntityId;
navigateSourceTableName;
navigateSourceTableEntityId;
readState = createInitialRecordReadBuilderState();
layer;
config;
logger;
get selectedFields() {
return this.readState.selectedFields;
}
set selectedFields(selectedFields) {
this.readState = cloneRecordReadBuilderState(this.readState, { selectedFields });
}
get expandConfigs() {
return this.readState.expandConfigs;
}
set expandConfigs(expandConfigs) {
this.readState = cloneRecordReadBuilderState(this.readState, { expandConfigs });
}
get fieldMapping() {
return this.readState.fieldMapping;
}
set fieldMapping(fieldMapping) {
this.readState = cloneRecordReadBuilderState(this.readState, { fieldMapping });
}
get systemColumns() {
return this.readState.systemColumns;
}
set systemColumns(systemColumns) {
this.readState = cloneRecordReadBuilderState(this.readState, { systemColumns });
}
constructor(config) {
this.table = config.occurrence;
this.recordLocator = config.recordLocator;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
this.logger = runtime.logger;
}
/**
* Helper to merge database-level useEntityIds and includeSpecialColumns with per-request options
*/
mergeExecuteOptions(options) {
return {
...mergeExecuteOptions(options, this.config.useEntityIds),
includeSpecialColumns: options?.includeSpecialColumns ?? this.config.includeSpecialColumns
};
}
/**
* Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
* @param useEntityIds - Optional override for entity ID usage
*/
getTableId(useEntityIds) {
if (!this.table) throw new BuilderInvariantError("RecordBuilder", "table occurrence is required");
return resolveTableId(this.table, getTableName(this.table), useEntityIds ?? this.config.useEntityIds);
}
/**
* Creates a new RecordBuilder with modified configuration.
* Used by select() to create new instances.
*/
cloneWithChanges(changes) {
const newBuilder = new RecordBuilder({
occurrence: this.table,
layer: this.layer,
recordLocator: this.recordLocator
});
const mutableBuilder = newBuilder;
mutableBuilder.readState = cloneRecordReadBuilderState(this.readState, {
selectedFields: "selectedFields" in changes ? changes.selectedFields : this.selectedFields,
fieldMapping: "fieldMapping" in changes ? changes.fieldMapping : this.fieldMapping,
systemColumns: changes.systemColumns !== void 0 ? changes.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;
return newBuilder;
}
getSingleField(column) {
const tableName = getTableName(this.table);
if (!column.isFromTable(tableName)) throw new BuilderInvariantError("RecordBuilder.getSingleField", `column ${column.toString()} is not from table ${tableName}`);
const newBuilder = new RecordBuilder({
occurrence: this.table,
layer: this.layer,
recordLocator: this.recordLocator
});
const mutableBuilder = newBuilder;
mutableBuilder.operation = "getSingleField";
mutableBuilder.operationColumn = column;
mutableBuilder.isNavigateFromEntitySet = this.isNavigateFromEntitySet;
mutableBuilder.navigateRelation = this.navigateRelation;
mutableBuilder.navigateSourceTableName = this.navigateSourceTableName;
return newBuilder;
}
select(fields, systemColumns) {
if (fields === "all") return this.cloneWithChanges({
selectedFields: void 0,
fieldMapping: void 0,
systemColumns: void 0
});
const { selectedFields, fieldMapping } = processSelectWithRenames(fields, getTableName(this.table), this.logger);
const finalSelectedFields = [...selectedFields];
if (systemColumns?.ROWID) finalSelectedFields.push("ROWID");
if (systemColumns?.ROWMODID) finalSelectedFields.push("ROWMODID");
return this.cloneWithChanges({
selectedFields: finalSelectedFields,
fieldMapping: Object.keys(fieldMapping).length > 0 ? fieldMapping : void 0,
systemColumns
});
}
/**
* Expand a navigation property to include related records.
* Supports nested select, filter, orderBy, and expand operations.
*
* @example
* ```typescript
* // Simple expand with FMTable object
* const contact = await db.from(contacts).get("uuid").expand(users).execute();
*
* // Expand with select
* const contact = await db.from(contacts).get("uuid")
* .expand(users, b => b.select({ username: users.username, email: users.email }))
* .execute();
* ```
*/
expand(targetTable, callback) {
const newBuilder = new RecordBuilder({
occurrence: this.table,
layer: this.layer,
recordLocator: this.recordLocator
});
const mutableBuilder = newBuilder;
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;
const expandConfig = new ExpandBuilder(this.config.useEntityIds, this.logger).processExpand(targetTable, this.table ?? void 0, callback, () => new QueryBuilder({
occurrence: targetTable,
layer: this.layer
}));
mutableBuilder.readState = cloneRecordReadBuilderState(mutableBuilder.readState, { expandConfigs: [...this.expandConfigs, expandConfig] });
return newBuilder;
}
navigate(targetTable) {
const relationName = getTableName(targetTable);
if (this.table) {
const navigationPaths = getNavigationPaths(this.table);
if (navigationPaths && !navigationPaths.includes(relationName)) this.logger.warn(`Cannot navigate to "${relationName}". Valid navigation paths: ${navigationPaths.length > 0 ? navigationPaths.join(", ") : "none"}`);
}
const builder = new QueryBuilder({
occurrence: targetTable,
layer: this.layer
});
const relationEntityId = isUsingEntityIds(targetTable) ? resolveTableId(targetTable, relationName, true) : relationName;
let sourceTableName;
let sourceTableEntityId;
let baseRelation;
let baseRelationEntityId;
if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
sourceTableName = this.navigateSourceTableName;
sourceTableEntityId = this.navigateSourceTableEntityId ?? sourceTableName;
baseRelation = this.navigateRelation;
baseRelationEntityId = this.navigateRelationEntityId ?? baseRelation;
} else {
if (!this.table) throw new BuilderInvariantError("RecordBuilder.navigate", "table occurrence is required for navigation");
sourceTableName = getTableName(this.table);
sourceTableEntityId = isUsingEntityIds(this.table) ? resolveTableId(this.table, sourceTableName, true) : sourceTableName;
}
builder.navigation = {
recordLocator: this.recordLocator,
relation: relationName,
relationEntityId,
sourceTableName,
sourceTableEntityId,
baseRelation,
baseRelationEntityId
};
return builder;
}
/**
* Builds the complete query string including $select and $expand parameters.
*/
buildQueryString(includeSpecialColumns, useEntityIds) {
const finalIncludeSpecialColumns = includeSpecialColumns ?? this.config.includeSpecialColumns;
const finalUseEntityIds = useEntityIds ?? this.config.useEntityIds;
return buildSelectExpandQueryString({
selectedFields: this.selectedFields,
expandConfigs: this.expandConfigs,
table: this.table,
useEntityIds: finalUseEntityIds,
logger: this.logger,
includeSpecialColumns: finalIncludeSpecialColumns
});
}
buildRecordResourcePath(useEntityIds) {
if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) return `/${buildRecordPath(`${useEntityIds ? this.navigateSourceTableEntityId ?? this.navigateSourceTableName : this.navigateSourceTableName}/${useEntityIds ? this.navigateRelationEntityId ?? this.navigateRelation : this.navigateRelation}`, this.recordLocator)}`;
return `/${buildRecordPath(this.getTableId(useEntityIds), this.recordLocator)}`;
}
execute(options) {
const mergedOptions = this.mergeExecuteOptions(options);
let url = `/${this.config.databaseName}${this.buildRecordResourcePath(mergedOptions.useEntityIds ?? this.config.useEntityIds)}`;
if (this.operation === "getSingleField" && this.operationColumn) url += `/${this.operationColumn.getFieldIdentifier(mergedOptions.useEntityIds)}`;
else if (this.operation === "getSingleField" && this.operationParam) url += `/${this.operationParam}`;
else {
const queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds);
url += queryString;
}
const pipeline = Effect.gen(this, function* () {
const response = yield* requestFromService(url, {
method: "GET",
...mergedOptions
});
if (this.operation === "getSingleField") return response.value;
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))
});
if (result.error) return yield* Effect.fail(result.error);
return result.data;
});
return runLayerResult(this.layer, pipeline, "fmodata.record.get", { "fmodata.table": getTableName(this.table) });
}
getRequestConfig() {
let url = `/${this.config.databaseName}${this.buildRecordResourcePath(this.config.useEntityIds)}`;
if (this.operation === "getSingleField" && this.operationColumn) url += `/${this.operationColumn.getFieldIdentifier(this.config.useEntityIds)}`;
else if (this.operation === "getSingleField" && this.operationParam) url += `/${this.operationParam}`;
else {
const queryString = this.buildQueryString();
url += queryString;
}
return {
method: "GET",
url
};
}
/**
* Returns the query string for this record builder (for testing purposes).
*/
getQueryString(options) {
const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds;
const path = this.buildRecordResourcePath(useEntityIds);
if (this.operation === "getSingleField" && this.operationColumn) return `${path}/${this.operationColumn.getFieldIdentifier(useEntityIds)}`;
if (this.operation === "getSingleField" && this.operationParam) return `${path}/${this.operationParam}`;
return `${path}${this.buildQueryString(void 0, useEntityIds)}`;
}
toRequest(baseUrl, options) {
return createODataRequest(baseUrl, this.getRequestConfig(), {
...options,
normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName
});
}
async processResponse(response, options) {
if (!response.ok) {
const tableName = this.table ? getTableName(this.table) : "unknown";
return {
data: void 0,
error: await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`)
};
}
const rawResponse = await safeJsonParse(response);
if (this.operation === "getSingleField") return {
data: rawResponse.value,
error: void 0
};
const mergedOptions = this.mergeExecuteOptions(options);
return processRecordResponse(rawResponse, {
table: this.table,
selectedFields: this.selectedFields,
expandConfigs: this.expandConfigs,
skipValidation: options?.skipValidation,
useEntityIds: mergedOptions.useEntityIds,
includeSpecialColumns: mergedOptions.includeSpecialColumns,
fieldMapping: this.fieldMapping,
logger: this.logger
});
}
};
//#endregion
//#region src/client/update-builder.ts
/**
* Initial update builder returned from EntitySet.update(data)
* Requires calling .byId() or .where() before .execute() is available
*/
var UpdateBuilder = class {
table;
data;
returnPreference;
layer;
config;
constructor(config) {
this.table = config.occurrence;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.data = config.data;
this.returnPreference = config.returnPreference;
this.config = runtime.config;
}
/**
* Update a single record by ID
* Returns updated count by default, or full record if returnFullRecord was set to true
*/
byId(id) {
return new ExecutableUpdateBuilder({
occurrence: this.table,
layer: this.layer,
data: this.data,
mode: "byId",
recordLocator: id,
returnPreference: this.returnPreference
});
}
/**
* Update a single record by ROWID
*/
byRowId(rowId) {
return new ExecutableUpdateBuilder({
occurrence: this.table,
layer: this.layer,
data: this.data,
mode: "byId",
recordLocator: { ROWID: rowId },
returnPreference: this.returnPreference
});
}
/**
* Update records matching a filter query
* Returns updated count by default, or full record if returnFullRecord was set to true
* @param fn Callback that receives a QueryBuilder for building the filter
*/
where(fn) {
const configuredBuilder = fn(new QueryBuilder({
occurrence: this.table,
layer: this.layer
}));
return new ExecutableUpdateBuilder({
occurrence: this.table,
layer: this.layer,
data: this.data,
mode: "byFilter",
queryBuilder: configuredBuilder,
returnPreference: this.returnPreference
});
}
};
/**
* Executable update builder - has execute() method
* Returned after calling .byId() or .where()
* Can return either updated count or full record based on returnFullRecord option
*/
var ExecutableUpdateBuilder = class {
table;
data;
mode;
recordLocator;
queryBuilder;
returnPreference;
layer;
config;
constructor(config) {
this.table = config.occurrence;
this.layer = config.layer;
this.data = config.data;
this.mode = config.mode;
this.recordLocator = config.recordLocator;
this.queryBuilder = config.queryBuilder;
this.returnPreference = config.returnPreference;
const runtime = createClientRuntime(this.layer);
this.config = runtime.config;
}
execute(options) {
const mergedOptions = mergeMutationExecuteOptions(options, this.config.useEntityIds, this.config.includeSpecialColumns);
const { method: _method, body: _body, headers: callerHeaders, ...requestOptions } = mergedOptions;
const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds;
const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns;
const tableId = resolveMutationTableId(this.table, shouldUseIds, "ExecutableUpdateBuilder");
const url = buildMutationUrl({
databaseName: this.config.databaseName,
tableId,
tableName: getTableName(this.table),
mode: this.mode,
recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds: shouldUseIds,
builderName: "ExecutableUpdateBuilder"
});
const requestHeaders = new Headers(callerHeaders || {});
const preferHeader = mergePreferHeaderValues(this.returnPreference === "representation" ? "return=representation" : void 0, shouldUseIds ? "fmodata.entity-ids" : void 0, includeSpecialColumns ? "fmodata.include-specialcolumns" : void 0, requestHeaders.get("Prefer") ?? void 0);
requestHeaders.set("Content-Type", "application/json");
if (preferHeader) requestHeaders.set("Prefer", preferHeader);
else requestHeaders.delete("Prefer");
const pipeline = Effect.gen(this, function* () {
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)));
}
const transformedData = this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData;
const response = yield* requestFromService(url, {
...requestOptions,
method: "PATCH",
headers: requestHeaders,
body: JSON.stringify(transformedData)
});
if (this.returnPreference === "representation") return response;
return { updatedCount: extractAffectedRows(response, void 0, 0, "updatedCount") };
});
return runLayerResult(this.layer, pipeline, "fmodata.update", { "fmodata.table": getTableName(this.table) });
}
getRequestConfig() {
const tableId = resolveMutationTableId(this.table, this.config.useEntityIds, "ExecutableUpdateBuilder");
const transformedData = this.table && this.config.useEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data;
return {
method: "PATCH",
url: buildMutationUrl({
databaseName: this.config.databaseName,
tableId,
tableName: getTableName(this.table),
mode: this.mode,
recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds: this.config.useEntityIds,
builderName: "ExecutableUpdateBuilder"
}),
body: JSON.stringify(transformedData)
};
}
toRequest(baseUrl, options) {
const config = this.getRequestConfig();
const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, { normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName })}`;
const preferHeader = mergePreferHeaderValues(this.returnPreference === "representation" ? "return=representation" : void 0, options?.useEntityIds ?? this.config.useEntityIds ? "fmodata.entity-ids" : void 0, options?.includeSpecialColumns ?? this.config.includeSpecialColumns ? "fmodata.include-specialcolumns" : void 0);
return new Request(fullUrl, {
method: config.method,
headers: {
"Content-Type": "application/json",
Accept: getAcceptHeader(options?.includeODataAnnotations),
...preferHeader ? { Prefer: preferHeader } : {}
},
body: config.body
});
}
async processResponse(response, options) {
if (!response.ok) {
const tableName = getTableName(this.table);
return {
data: void 0,
error: await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`)
};
}
const text = await response.text();
if (!text || text.trim() === "") return {
data: { updatedCount: extractAffectedRows(void 0, response.headers, 1, "updatedCount") },
error: void 0
};
const rawResponse = JSON.parse(text);
this.data;
if (this.table) {
const inputSchema = getBaseTableConfig(this.table).inputSchema;
try {
await validateAndTransformInput(this.data, inputSchema);
} catch (error) {
return {
data: void 0,
error: error instanceof Error ? error : new BuilderInvariantError("ExecutableUpdateBuilder.processResponse", String(error))
};
}
}
if (this.returnPreference === "representation") {
const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds;
const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns;
let transformedResponse = rawResponse;
if (this.table && shouldUseIds) transformedResponse = transformResponseFields(rawResponse, this.table, void 0);
const validation = await validateSingleResponse(transformedResponse, getBaseTableConfig(this.table).schema, void 0, void 0, "exact", includeSpecialColumns);
if (!validation.valid) return {
data: void 0,
error: validation.error
};
if (validation.data === null) return {
data: void 0,
error: new BuilderInvariantError("ExecutableUpdateBuilder.processResponse", "update operation returned null response")
};
return {
data: validation.data,
error: void 0
};
}
return {
data: { updatedCount: extractAffectedRows(rawResponse, response.headers, 0, "updatedCount") },
error: void 0
};
}
};
//#endregion
//#region src/client/entity-set.ts
var EntitySet = class EntitySet {
occurrence;
layer;
config;
logger;
database;
isNavigateFromEntitySet;
navigateRelation;
navigateRelationEntityId;
navigateSourceTableName;
navigateSourceTableEntityId;
navigateBasePath;
navigateBasePathEntityId;
constructor(config) {
this.occurrence = config.occurrence;
this.database = config.database;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
this.logger = runtime.logger;
}
static create(config) {
return new EntitySet({
occurrence: config.occurrence,
layer: config.layer,
database: config.database
});
}
applyNavigationContext(builder) {
if (this.isNavigateFromEntitySet && this.navigateRelation && this.navigateSourceTableName) builder.navigation = {
relation: this.navigateRelation,
relationEntityId: this.navigateRelationEntityId,
sourceTableName: this.navigateSourceTableName,
sourceTableEntityId: this.navigateSourceTableEntityId,
basePath: this.navigateBasePath,
basePathEntityId: this.navigateBasePathEntityId
};
return builder;
}
list() {
const builder = new QueryBuilder({
occurrence: this.occurrence,
layer: this.layer
});
if (this.occurrence) {
const defaultSelectValue = getDefaultSelect(this.occurrence);
getTableSchema(this.occurrence);
if (defaultSelectValue === "schema") {
const allColumns = getTableColumns(this.occurrence);
return this.applyNavigationContext(this.config.includeSpecialColumns ? builder.select(allColumns, {
ROWID: true,
ROWMODID: true
}) : builder.select(allColumns)).top(1e3);
}
if (typeof defaultSelectValue === "object") return this.applyNavigationContext(builder.select(defaultSelectValue)).top(1e3);
}
return this.applyNavigationContext(builder).top(1e3);
}
count() {
const builder = new CountBuilder({
occurrence: this.occurrence,
layer: this.layer
});
return this.applyNavigationContext(builder);
}
get(locator) {
const builder = new RecordBuilder({
occurrence: this.occurrence,
layer: this.layer,
recordLocator: locator
});
if (this.occurrence) {
const defaultSelectValue = getDefaultSelect(this.occurrence);
getTableSchema(this.occurrence);
if (defaultSelectValue === "schema") {
const allColumns = getTableColumns(this.occurrence);
return this.applyNavigationContext(this.config.includeSpecialColumns ? builder.select(allColumns, {
ROWID: true,
ROWMODID: true
}) : builder.select(allColumns));
}
if (typeof defaultSelectValue === "object" && defaultSelectValue !== null && !Array.isArray(defaultSelectValue)) return this.applyNavigationContext(builder.select(defaultSelectValue));
}
return this.applyNavigationContext(builder);
}
insert(data, options) {
const returnPreference = options?.returnFullRecord === false ? "minimal" : "representation";
return new InsertBuilder({
occurrence: this.occurrence,
layer: this.layer,
data,
returnPreference
});
}
update(data, options) {
const returnPreference = options?.returnFullRecord === true ? "representation" : "minimal";
return new UpdateBuilder({
occurrence: this.occurrence,
layer: this.layer,
data,
returnPreference
});
}
delete() {
return new DeleteBuilder({
occurrence: this.occurrence,
layer: this.layer
});
}
navigate(targetTable) {
let relationName;
relationName = getTableName(targetTable);
if (this.occurrence && FMTable.Symbol.NavigationPaths in this.occurrence) {
const navigationPaths = this.occurrence[FMTable.Symbol.NavigationPaths];
if (navigationPaths && !navigationPaths.includes(relationName)) this.logger.warn(`Cannot navigate to "${relationName}". Valid navigation paths: ${navigationPaths.length > 0 ? navigationPaths.join(", ") : "none"}`);
}
const entitySet = new EntitySet({
occurrence: targetTable,
layer: this.layer,
database: this.database
});
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;
entitySet.isNavigateFromEntitySet = true;
entitySet.navigateRelation = relationName;
entitySet.navigateRelationEntityId = relationEntityId;
if (this.isNavigateFromEntitySet && this.navigateBasePath) {
entitySet.navigateBasePath = `${this.navigateBasePath}/${this.navigateRelation}`;
entitySet.navigateBasePathEntityId = `${this.navigateBasePathEntityId ?? this.navigateBasePath}/${this.navigateRelationEntityId ?? this.navigateRelation}`;
entitySet.navigateSourceTableName = this.navigateSourceTableName;
entitySet.navigateSourceTableEntityId = this.navigateSourceTableEntityId;
} else if (this.isNavigateFromEntitySet && this.navigateRelation) {
entitySet.navigateBasePath = `${this.navigateSourceTableName}/${this.navigateRelation}`;
entitySet.navigateBasePathEntityId = `${this.navigateSourceTableEntityId ?? this.navigateSourceTableName}/${this.navigateRelationEntityId ?? this.navigateRelation}`;
entitySet.navigateSourceTableName = this.navigateSourceTableName;
entitySet.navigateSourceTableEntityId = this.navigateSourceTableEntityId;
} else {
entitySet.navigateSourceTableName = sourceTableName;
entitySet.navigateSourceTableEntityId = sourceTableEntityId;
}
return entitySet;
}
};
//#endregion
//#region src/client/schema-manager.ts
var SchemaManager = class SchemaManager {
layer;
config;
constructor(layer) {
const runtime = createClientRuntime(layer);
this.layer = runtime.layer;
this.config = runtime.config;
}
createTable(tableName, fields, options) {
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
});
});
return runLayerOrThrow(this.layer, pipeline, "fmodata.schema.createTable");
}
addFields(tableName, fields, options) {
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
});
});
return runLayerOrThrow(this.layer, pipeline, "fmodata.schema.addFields");
}
async deleteTable(tableName, options) {
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}`, {
method: "DELETE",
...options
});
});
await runLayerOrThrow(this.layer, pipeline, "fmodata.schema.deleteTable");
}
async deleteField(tableName, fieldName, options) {
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}/${fieldName}`, {
method: "DELETE",
...options
});
});
await runLayerOrThrow(this.layer, pipeline, "fmodata.schema.deleteField");
}
createIndex(tableName, fieldName, options) {
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Indexes/${tableName}`, {
method: "POST",
body: JSON.stringify({ indexName: fieldName }),
...options
});
});
return runLayerOrThrow(this.layer, pipeline, "fmodata.schema.createIndex");
}
async deleteIndex(tableName, fieldName, options) {
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Indexes/${tableName}/${fieldName}`, {
method: "DELETE",
...options
});
});
await runLayerOrThrow(this.layer, pipeline, "fmodata.schema.deleteIndex");
}
static compileFieldDefinition(field) {
let type = field.type;
const repetitions = field.repetitions;
if (field.type === "string") {
type = "varchar";
const stringField = field;
if (stringField.maxLength !== void 0) type += `(${stringField.maxLength})`;
}
if (repetitions !== void 0) type += `[${repetitions}]`;
const result = {
name: field.name,
type
};
if (field.nullable !== void 0) result.nullable = field.nullable;
if (field.primary !== void 0) result.primary = field.primary;
if (field.unique !== void 0) result.unique = field.unique;
if (field.global !== void 0) result.global = field.global;
if (field.type === "string") {
const stringField = field;
if (stringField.default !== void 0) result.default = stringField.default;
} else if (field.type === "date") {
const dateField = field;
if (dateField.default !== void 0) result.default = dateField.default;
} else if (field.type === "time") {
const timeField = field;
if (timeField.default !== void 0) result.default = timeField.default;
} else if (field.type === "timestamp") {
const timestampField = field;
if (timestampField.default !== void 0) result.default = timestampField.default;
} else if (field.type === "container") {
const containerField = field;
if (containerField.externalSecurePath !== void 0) result.externalSecurePath = containerField.externalSecurePath;
}
return result;
}
};
//#endregion
//#region src/client/webhook-builder.ts
var WebhookManager = class {
layer;
config;
constructor(layer) {
const runtime = createClientRuntime(layer);
this.layer = runtime.layer;
this.config = runtime.config;
}
/**
* Adds a new webhook to the database.
* @param webhook - The webhook configuration object
* @param webhook.webhook - The webhook URL to call
* @param webhook.tableName - The FMTable instance for the table to monitor
* @param webhook.headers - Optional custom headers to include in webhook requests
* @param webhook.notifySchemaChanges - Whether to notify on schema changes
* @param webhook.select - Optional field selection (string or array of Column references)
* @param webhook.filter - Optional filter (string or FilterExpression)
* @returns Promise resolving to the created webhook data with ID
* @example
* ```ts
* const result = await db.webhook.add({
* webhook: "https://example.com/webhook",
* tableName: contactsTable,
* headers: { "X-Custom-Header": "value" },
* });
* // result.webhookResult.webhookID contains the new webhook ID
* ```
* @example
* ```ts
* // Using filter expressions and column arrays (same DX as query builder)
* const result = await db.webhook.add({
* webhook: "https://example.com/webhook",
* tableName: contacts,
* filter: eq(contacts.name, "John"),
* select: [contacts.name, contacts.PrimaryKey],
* });
* ```
*/
add(webhook, options) {
const tableName = getTableName(webhook.tableName);
const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds ?? false;
let filter;
if (webhook.filter !== void 0) if (webhook.filter instanceof FilterExpression) filter = webhook.filter.toODataFilter(useEntityIds);
else filter = webhook.filter;
let select;
if (webhook.select !== void 0) if (Array.isArray(webhook.select)) select = formatSelectFields(webhook.select.map((item) => {
if (isColumn(item)) return item.getFieldIdentifier(useEntityIds);
return String(item);
}), webhook.tableName, useEntityIds);
else select = webhook.select;
const requestBody = {
webhook: webhook.webhook,
tableName
};
if (webhook.headers !== void 0) requestBody.headers = webhook.headers;
if (webhook.notifySchemaChanges !== void 0) requestBody.notifySchemaChanges = webhook.notifySchemaChanges;
if (select !== void 0) requestBody.select = select;
if (filter !== void 0) requestBody.filter = filter;
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/Webhook.Add`, {
...options,
method: "POST",
body: JSON.stringify(requestBody),
databaseNameNormalizationMode: "ensureExtension"
});
});
return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.add");
}
/**
* Deletes a webhook by ID.
* @param webhookId - The ID of the webhook to delete
* @returns Promise that resolves when the webhook is deleted
* @example
* ```ts
* await db.webhook.remove(1);
* ```
*/
async remove(webhookId, options) {
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/Webhook.Delete(${webhookId})`, {
...options,
method: "POST",
databaseNameNormalizationMode: "ensureExtension"
});
});
await runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.remove");
}
/**
* Gets a webhook by ID.
* @param webhookId - The ID of the webhook to retrieve
* @returns Promise resolving to the webhook data
* @example
* ```ts
* const webhook = await db.webhook.get(1);
* // webhook.webhookID, webhook.tableName, webhook.webhook, etc.
* ```
*/
get(webhookId, options) {
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/Webhook.Get(${webhookId})`, options);
});
return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.get");
}
/**
* Lists all webhooks.
* @returns Promise resolving to webhook list response with status and webhooks array
* @example
* ```ts
* const result = await db.webhook.list();
* // result.status contains the status
* // result.webhooks contains the array of webhooks
* ```
*/
list(options) {
const pipeline = Effect.gen(this, function* () {
return yield* requestFromService(`/${this.config.databaseName}/Webhook.GetAll`, {
...options,
databaseNameNormalizationMode: "ensureExtension"
});
});
return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.list");
}
/**
* Invokes a webhook by ID, optionally for specific row IDs.
* @param webhookId - The ID of the webhook to invoke
* @param options - Optional configuration
* @param options.rowIDs - Array of row IDs to trigger the webhook for
* @returns Promise resolving to the invocation result (type unknown until API behavior is confirmed)
* @example
* ```ts
* // Invoke for all rows
* await db.webhook.invoke(1);
*
* // Invoke for specific rows
* await db.webhook.invoke(1, { rowIDs: [63, 61] });
* ```
*/
invoke(webhookId, options, executeOptions) {
const body = {};
if (options?.rowIDs !== void 0) body.rowIDs = options.rowIDs;
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) : void 0,
...executeOptions
});
});
return runLayerOrThrow(this.layer, pipeline, "fmodata.webhook.invoke");
}
};
//#endregion
//#region src/client/database.ts
var Database = class {
schema;
webhook;
databaseName;
_normalizeDatabaseName;
_useEntityIds;
_includeSpecialColumns;
/** @internal Database-scoped Effect Layer for dependency injection */
_layer;
constructor(databaseName, context, config) {
this.databaseName = databaseName;
this._normalizeDatabaseName = config?.normalizeDatabaseName ?? true;
this._useEntityIds = config?.useEntityIds ?? false;
this._includeSpecialColumns = config?.includeSpecialColumns ?? false;
const baseLayer = context._getLayer?.();
if (baseLayer) this._layer = createDatabaseLayer(baseLayer, {
databaseName: this.databaseName,
normalizeDatabaseName: this._normalizeDatabaseName,
useEntityIds: this._useEntityIds,
includeSpecialColumns: this._includeSpecialColumns
});
else throw new BuilderInvariantError("Database", "ExecutionContext must implement _getLayer() for dependency injection");
this.schema = new SchemaManager(this._layer);
this.webhook = new WebhookManager(this._layer);
}
/**
* @internal Used by adapter packages to access the database filename.
*/
get _getDatabaseName() {
return this.databaseName;
}
/**
* @internal Used by EntitySet to access database configuration
*/
get _getUseEntityIds() {
return this._useEntityIds;
}
/**
* @internal Used by EntitySet to access database configuration
*/
get _getNormalizeDatabaseName() {
return this._normalizeDatabaseName;
}
/**
* @internal Used by EntitySet to access database configuration
*/
get _getIncludeSpecialColumns() {
return this._includeSpecialColumns;
}
/**
* @internal Used by adapter packages for raw OData requests.
* Makes requests through the Effect DI layer.
*/
_makeRequest(path, options) {
const pipeline = requestFromService(`/${this.databaseName}${path}`, options);
return runLayerResult(this._layer, pipeline);
}
from(table) {
let useEntityIds = this._useEntityIds;
if (Object.hasOwn(table, FMTable.Symbol.UseEntityIds)) {
const tableUseEntityIds = table[FMTable.Symbol.UseEntityIds];
if (typeof tableUseEntityIds === "boolean") useEntityIds = tableUseEntityIds;
}
return new EntitySet({
occurrence: table,
layer: useEntityIds !== this._useEntityIds ? createDatabaseLayer(this._layer, {
databaseName: this.databaseName,
normalizeDatabaseName: this._normalizeDatabaseName,
useEntityIds,
includeSpecialColumns: this._includeSpecialColumns
}) : this._layer,
database: this
});
}
async getMetadata(args) {
let url = `/${this.databaseName}/$metadata`;
if (args?.tableName) url = `/${this.databaseName}/$metadata%23${args.tableName}`;
const headers = { Accept: args?.format === "xml" ? "application/xml" : "application/json" };
if (args?.reduceAnnotations) headers.Prefer = "include-annotations=\"-*\"";
const pipeline = requestFromService(url, { headers });
const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.metadata");
if (args?.format === "xml") return data;
const metadataMap = data;
const metadata = metadataMap[this.databaseName] ?? metadataMap[stripFmp12Extension(this.databaseName)];
if (!metadata) throw new MetadataNotFoundError(this.databaseName);
return metadata;
}
/**
* Lists all available tables (entity sets) in this database.
* @returns Promise resolving to an array of table names
*/
async listTableNames() {
const pipeline = requestFromService(`/${this.databaseName}`);
const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.listTableNames");
if (data.value && Array.isArray(data.value)) return data.value.map((item) => item.name);
return [];
}
/**
* Executes a FileMaker script.
* @param scriptName - The name of the script to execute (must be valid according to OData rules)
* @param options - Optional script parameter and result schema
* @returns Promise resolving to script execution result
*/
async runScript(scriptName, options) {
const body = {};
if (options?.scriptParam !== void 0) body.scriptParameterValue = options.scriptParam;
const pipeline = requestFromService(`/${this.databaseName}/Script.${scriptName}`, {
method: "POST",
body: Object.keys(body).length > 0 ? JSON.stringify(body) : void 0
});
const response = await runLayerOrThrow(this._layer, pipeline, "fmodata.runScript");
if (options?.resultSchema && response.scriptResult !== void 0) {
const validationResult = options.resultSchema["~standard"].validate(response.scriptResult.resultParameter);
const validated = validationResult instanceof Promise ? await validationResult : validationResult;
if (validated.issues) throw new SchemaValidationFailedError("Database.runScript", JSON.stringify(validated.issues), { issues: validated.issues });
return {
resultCode: response.scriptResult.code,
result: validated.value
};
}
return {
resultCode: response.scriptResult.code,
result: response.scriptResult.resultParameter
};
}
/**
* Create a batch operation builder that allows multiple queries to be executed together
* in a single atomic request. All operations succeed or fail together (transactional).
*
* @param builders - Array of executable query builders to batch
* @returns A BatchBuilder that can be executed
* @example
* ```ts
* const result = await db.batch([
* db.from('contacts').list().top(5),
* db.from('users').list().top(5),
* db.from('contacts').insert({ name: 'John' })
* ]).execute();
*
* if (result.data) {
* const [contacts, users, insertResult] = result.data;
* }
* ```
*/
batch(builders) {
return new BatchBuilder(builders, this._layer);
}
};
//#endregion
//#region src/client/filemaker-odata.ts
const TRAILING_SLASH_REGEX = /\/+$/;
var FMServerConnection = class {
fetchClient;
serverUrl;
auth;
normalizeDatabaseName = true;
useEntityIds = false;
includeSpecialColumns = false;
logger;
clarisIdAuthManager;
hasWarnedAboutOttoDatabaseNormalization = false;
/** @internal Stored so credential-override flows can inherit non-auth config. */
_fetchClientOptions;
constructor(config) {
this.logger = createLogger(config.logger);
this._fetchClientOptions = config.fetchClientOptions;
this.fetchClient = createClient({
retries: 0,
...config.fetchClientOptions
});
const url = new URL(config.serverUrl);
if (url.protocol !== "https:") url.protocol = "https:";
url.pathname = url.pathname.replace(TRAILING_SLASH_REGEX, "");
this.serverUrl = url.toString().replace(TRAILING_SLASH_REGEX, "");
this.auth = config.auth;
this.clarisIdAuthManager = "clarisId" in config.auth ? new ClarisIdAuthManager({
username: config.auth.clarisId.username,
password: config.auth.clarisId.password
}) : null;
}
/**
* @internal
* Sets whether to use FileMaker entity IDs (FMFID/FMTID) in requests
*/
_setUseEntityIds(useEntityIds) {
this.useEntityIds = useEntityIds;
}
/**
* @internal
* Gets whether to use FileMaker entity IDs (FMFID/FMTID) in requests
*/
_getUseEntityIds() {
return this.useEntityIds;
}
/**
* @internal
* Sets whether to include special columns (ROWID and ROWMODID) in requests
*/
_setIncludeSpecialColumns(includeSpecialColumns) {
this.includeSpecialColumns = includeSpecialColumns;
}
/**
* @internal
* Gets whether to include special columns (ROWID and ROWMODID) in requests
*/
_getIncludeSpecialColumns() {
return this.includeSpecialColumns;
}
/**
* @internal
* Gets the base URL for OData requests
*/
_getBaseUrl() {
return `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`;
}
_getAuthorizationHeader(fetchHandler) {
if ("apiKey" in this.auth) return Promise.resolve(`Bearer ${this.auth.apiKey}`);
if ("clarisId" in this.auth) {
if (!this.clarisIdAuthManager) throw new Error("Claris ID auth manager was not initialized");
return this.clarisIdAuthManager.getAuthorizationHeader(fetchHandler);
}
return Promise.resolve(`Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`);
}
/**
* @internal
* Gets the logger instance
*/
_getLogger() {
return this.logger;
}
/**
* @internal
* Returns the Effect Layer for this connection, composing HttpClient, ODataConfig, and ODataLogger services.
*/
_getLayer() {
const httpLayer = Layer.succeed(HttpClient, { request: (url, options) => this._makeRequestEffect(url, options) });
const configLayer = Layer.succeed(ODataConfig, {
baseUrl: this._getBaseUrl(),
databaseName: "",
normalizeDatabaseName: this.normalizeDatabaseName,
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.
*/
_classifyError(err, fullUrl) {
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.
*/
_parseHttpError(resp, fullUrl, errorBody) {
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.
*/
_checkEmbeddedODataError(data, fullUrl) {
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);
}
}
/**
* @internal
* Builds the Effect pipeline for an HTTP request.
* Each step in the pipeline is a discrete Effect, enabling composable error handling.
*/
_makeRequestEffect(url, options) {
const logger = this._getLogger();
const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`;
const normalizeDatabaseName = options?.normalizeDatabaseName ?? this.normalizeDatabaseName;
if ("apiKey" in this.auth && normalizeDatabaseName === false && !this.hasWarnedAboutOttoDatabaseNormalization) {
logger.warn("normalizeDatabaseName=false cannot disable filename normalization with Otto auth; FileMaker Server normalizes it automatically.");
this.hasWarnedAboutOttoDatabaseNormalization = true;
}
const normalizedUrl = normalizeDatabasePath(url, {
normalizeDatabaseName,
mode: options?.databaseNameNormalizationMode
});
const fullUrl = baseUrl + normalizedUrl;
const useEntityIds = options?.useEntityIds ?? this.useEntityIds;
const includeSpecialColumns = options?.includeSpecialColumns ?? this.includeSpecialColumns;
const includeODataAnnotations = options?.includeODataAnnotations;
const preferValues = [];
if (useEntityIds) preferValues.push("fmodata.entity-ids");
if (includeSpecialColumns) preferValues.push("fmodata.include-specialcolumns");
const fetchHandler = options?.fetchHandler ?? this._fetchClientOptions?.fetchHandler;
const { headers: _headers, fetchHandler: _fetchHandler, ...restOptions } = options || {};
const buildHeaders = async () => {
const headers = new Headers(options?.headers);
headers.set("Authorization", await this._getAuthorizationHeader(fetchHandler));
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (!headers.has("Accept")) headers.set("Accept", getAcceptHeader(includeODataAnnotations));
const mergedPrefer = mergePreferHeaderValues(preferValues.length > 0 ? preferValues.join(", ") : void 0, headers.get("Prefer") ?? void 0);
if (mergedPrefer) headers.set("Prefer", mergedPrefer);
else headers.delete("Prefer");
return headers;
};
const clientToUse = fetchHandler ? createClient({
retries: 0,
fetchHandler
}) : this.fetchClient;
const pipeline = Effect.tryPromise({
try: async () => {
const headers = await buildHeaders();
const { authorization: _authorization, ...loggableHeaders } = Object.fromEntries(headers.entries());
logger.debug("Request headers:", loggableHeaders);
const finalOptions = {
...restOptions,
headers
};
return clientToUse(fullUrl, finalOptions);
},
catch: (err) => this._classifyError(err, fullUrl)
}).pipe(Effect.tap((resp) => Effect.sync(() => logger.debug(`${restOptions.method ?? "GET"} ${resp.status} ${fullUrl}`))), Effect.flatMap((resp) => {
if (!resp.ok) return Effect.tryPromise({
try: async () => {
let errorBody;
try {
if (resp.headers.get("content-type")?.includes("application/json")) errorBody = await safeJsonParse(resp);
} catch {}
return errorBody;
},
catch: () => new HTTPError(fullUrl, resp.status, resp.statusText)
}).pipe(Effect.flatMap((errorBody) => Effect.fail(this._parseHttpError(resp, fullUrl, errorBody))));
const affectedRows = resp.headers.get("fmodata.affected_rows");
if (affectedRows !== null) return Effect.succeed(Number.parseInt(affectedRows, 10));
if (resp.status === 204) {
const locationHeader = resp.headers?.get?.("Location") || resp.headers?.get?.("location");
if (locationHeader) return Effect.succeed({ _location: locationHeader });
return Effect.succeed(0);
}
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);
}));
return Effect.tryPromise({
try: () => resp.text(),
catch: (err) => this._classifyError(err, fullUrl)
}).pipe(Effect.map((text) => text));
}));
const retryPolicy = options?.retryPolicy;
const method = (restOptions.method ?? "GET").toUpperCase();
return withSpan(retryPolicy && (method === "GET" || method === "HEAD" || method === "OPTIONS" || method === "PUT") ? withRetryPolicy(pipeline, retryPolicy) : pipeline, "fmodata.request", {
"fmodata.url": normalizedUrl,
"fmodata.method": method
});
}
/**
* @internal
*/
_makeRequest(url, options) {
return runAsResult(this._makeRequestEffect(url, options));
}
database(name, config) {
return new Database(name, this, config);
}
/**
* Lists all available databases from the FileMaker OData service.
* @returns Promise resolving to an array of database names
*/
async listDatabaseNames() {
const result = await this._makeRequest("/$metadata", { headers: { Accept: "application/json" } });
if (result.error) throw result.error;
if (result.data.value && Array.isArray(result.data.value)) return result.data.value.map((item) => item.name);
return [];
}
};
//#endregion
//#region src/cli/utils/connection.ts
const ENV_NAMES = {
server: "FM_SERVER",
db: "FM_DATABASE",
username: "FM_USERNAME",
password: "FM_PASSWORD",
clarisIdUsername: "CLARIS_ID_USERNAME",
clarisIdPassword: "CLARIS_ID_PASSWORD",
apiKey: "OTTO_API_KEY"
};
function buildConnection(opts) {
const server = opts.server ?? process.env[ENV_NAMES.server];
const database = opts.database ?? process.env[ENV_NAMES.db];
const apiKey = opts.apiKey ?? process.env[ENV_NAMES.apiKey];
const username = opts.username ?? process.env[ENV_NAMES.username];
const password = opts.password ?? process.env[ENV_NAMES.password];
const clarisIdUsername = opts.clarisIdUsername ?? process.env[ENV_NAMES.clarisIdUsername];
const clarisIdPassword = opts.clarisIdPassword ?? process.env[ENV_NAMES.clarisIdPassword];
if (!server) throw new Error(`Missing required: --server or ${ENV_NAMES.server} environment variable`);
if (!database) throw new Error(`Missing required: --database or ${ENV_NAMES.db} environment variable`);
if (!(apiKey || clarisIdUsername || username)) throw new Error(`Missing required auth: --api-key (${ENV_NAMES.apiKey}), --claris-id-username (${ENV_NAMES.clarisIdUsername}), or --username (${ENV_NAMES.username})`);
if (!apiKey && clarisIdUsername && !clarisIdPassword) throw new Error(`Missing required: --claris-id-password (${ENV_NAMES.clarisIdPassword}) when using Claris ID auth`);
if (!apiKey && username && !password) throw new Error(`Missing required: --password (${ENV_NAMES.password}) when using username auth`);
let auth;
if (apiKey) auth = { apiKey };
else if (clarisIdUsername) auth = { clarisId: {
username: clarisIdUsername,
password: clarisIdPassword
} };
else auth = {
username,
password
};
const connection = new FMServerConnection({
serverUrl: server,
auth
});
return {
connection,
db: connection.database(database)
};
}
//#endregion
//#region src/cli/utils/errors.ts
function handleCliError(err) {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`Error: ${message}\n`);
process.exit(1);
}
//#endregion
//#region src/cli/utils/output.ts
function printResult(data, opts) {
if (opts.pretty) printTable(data);
else console.log(JSON.stringify(data, null, 2));
}
function printTable(data) {
if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object" && data[0] !== null) {
const keys = Object.keys(data[0]);
const table = new Table({ head: keys });
for (const row of data) table.push(keys.map((k) => String(row[k] ?? "")));
console.log(table.toString());
return;
}
if (Array.isArray(data) && data.length > 0) {
const table = new Table({ head: ["Value"] });
for (const value of data) table.push([String(value ?? "")]);
console.log(table.toString());
return;
}
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
const table = new Table({ head: ["Key", "Value"] });
for (const [key, value] of Object.entries(data)) table.push([key, typeof value === "object" ? JSON.stringify(value) : String(value ?? "")]);
console.log(table.toString());
return;
}
console.log(JSON.stringify(data, null, 2));
}
//#endregion
//#region src/cli/commands/metadata.ts
function isEntityType(value) {
return value.$Kind === "EntityType";
}
function isFieldMetadata(value) {
return value !== null && typeof value === "object" && "$Type" in value;
}
function getEntityFieldEntries(entityType) {
return Object.entries(entityType).filter((entry) => !entry[0].startsWith("$") && isFieldMetadata(entry[1]));
}
function makeMetadataCommand() {
const metadata = new Command("metadata").description("FileMaker OData metadata operations");
metadata.command("get").description("Get OData metadata for the database").option("--format <format>", "Output format: json or xml", "json").option("--table <table>", "Filter metadata to a specific table").action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
let result;
if (opts.format === "xml") result = await db.getMetadata({
format: "xml",
tableName: opts.table
});
else result = await db.getMetadata({
format: "json",
tableName: opts.table
});
printResult(result, { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
metadata.command("tables").description("List all table names in the database").action(async (_opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
printResult(await db.listTableNames(), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
metadata.command("fields").description("List field names for a specific table").requiredOption("--table <table>", "Table name").option("--details", "Include field metadata details", false).action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
const metadataResult = await db.getMetadata({ tableName: opts.table });
const entityType = Object.values(metadataResult).find(isEntityType);
if (!entityType) throw new Error(`No entity metadata found for table: ${opts.table}`);
const fields = getEntityFieldEntries(entityType);
if (opts.details) {
printResult(fields.map(([fieldName, fieldMeta]) => {
const { $Type, $Nullable, ...rest } = fieldMeta;
return {
name: fieldName,
type: $Type,
nullable: $Nullable,
...rest
};
}), { pretty: globalOpts.pretty ?? false });
return;
}
printResult(fields.map(([fieldName]) => fieldName), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
return metadata;
}
//#endregion
//#region src/cli/commands/query.ts
function buildQueryString(params) {
const parts = [];
if (params.top !== void 0) parts.push(`$top=${encodeURIComponent(String(params.top))}`);
if (params.skip !== void 0) parts.push(`$skip=${encodeURIComponent(String(params.skip))}`);
if (params.select) parts.push(`$select=${encodeURIComponent(params.select)}`);
if (params.where) parts.push(`$filter=${encodeURIComponent(params.where)}`);
if (params.orderBy) {
const orderStr = params.orderBy.split(",").map((part) => {
const [field, dir] = part.trim().split(":");
return dir ? `${field} ${dir}` : field;
}).join(",");
parts.push(`$orderby=${encodeURIComponent(orderStr)}`);
}
return parts.length > 0 ? `?${parts.join("&")}` : "";
}
function makeRecordsCommand() {
const query = new Command("records").description("FileMaker record operations (list, insert, update, delete)");
query.command("list").description("List records from a table").requiredOption("--table <name>", "Table name").option("--top <n>", "Max records to return", Number).option("--skip <n>", "Records to skip", Number).option("--select <fields>", "Comma-separated field names").option("--where <expr>", "OData filter expression").option("--order-by <field>", "Order by field (format: field:asc|desc, or comma-separated)").action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
const qs = buildQueryString({
top: opts.top,
skip: opts.skip,
select: opts.select,
where: opts.where,
orderBy: opts.orderBy
});
const table = encodeURIComponent(opts.table);
const result = await db._makeRequest(`/${table}${qs}`);
if (result.error) throw result.error;
printResult(result.data.value ?? result.data, { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
query.command("insert").description("Insert a record into a table").requiredOption("--table <name>", "Table name").requiredOption("--data <json>", "Record data as JSON object").action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
let data;
try {
const parsed = JSON.parse(opts.data);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid");
data = parsed;
} catch {
throw new Error("--data must be a valid JSON object");
}
const table = encodeURIComponent(opts.table);
const result = await db._makeRequest(`/${table}`, {
method: "POST",
body: JSON.stringify(data)
});
if (result.error) throw result.error;
printResult(result.data, { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
query.command("update").description("Update records in a table").requiredOption("--table <name>", "Table name").requiredOption("--data <json>", "Update data as JSON object").option("--where <expr>", "OData filter expression").option("--confirm", "Execute without --where (affects all records)").action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
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"
}, { pretty: globalOpts.pretty ?? false });
return;
}
const { db } = buildConnection(globalOpts);
let data;
try {
const parsed = JSON.parse(opts.data);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("invalid");
data = parsed;
} catch {
throw new Error("--data must be a valid JSON object");
}
const qs = buildQueryString({ where: opts.where });
const table = encodeURIComponent(opts.table);
const result = await db._makeRequest(`/${table}${qs}`, {
method: "PATCH",
body: JSON.stringify(data)
});
if (result.error) throw result.error;
printResult(result.data, { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
query.command("delete").description("Delete records from a table").requiredOption("--table <name>", "Table name").option("--where <expr>", "OData filter expression").option("--confirm", "Execute without --where (affects all records)").action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
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"
}, { pretty: globalOpts.pretty ?? false });
return;
}
const { db } = buildConnection(globalOpts);
const qs = buildQueryString({ where: opts.where });
const table = encodeURIComponent(opts.table);
const result = await db._makeRequest(`/${table}${qs}`, { method: "DELETE" });
if (result.error) throw result.error;
printResult(result.data, { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
return query;
}
//#endregion
//#region src/cli/commands/schema.ts
function makeSchemaCommand() {
const schema = new Command("schema").description("FileMaker schema modification operations");
schema.command("list-tables").description("List all tables in the database").action(async (_opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
printResult(await db.listTableNames(), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
schema.command("create-table").description("Create a new table (requires --confirm to execute; dry-run by default)").requiredOption("--name <name>", "Table name").requiredOption("--fields <json>", "Fields definition as JSON array").option("--confirm", "Execute the operation (without this flag, shows what would be created)").action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
let fields;
try {
const parsed = JSON.parse(opts.fields);
if (!Array.isArray(parsed)) throw new Error("invalid");
fields = parsed;
} catch {
throw new Error("--fields must be a valid JSON array");
}
if (!opts.confirm) {
printResult({
dryRun: true,
action: "create-table",
tableName: opts.name,
fields
}, { pretty: globalOpts.pretty ?? false });
return;
}
const { db } = buildConnection(globalOpts);
printResult(await db.schema.createTable(opts.name, fields), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
schema.command("add-fields").description("Add fields to an existing table (requires --confirm to execute; dry-run by default)").requiredOption("--table <name>", "Table name").requiredOption("--fields <json>", "Fields to add as JSON array").option("--confirm", "Execute the operation (without this flag, shows what would be added)").action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
let fields;
try {
const parsed = JSON.parse(opts.fields);
if (!Array.isArray(parsed)) throw new Error("invalid");
fields = parsed;
} catch {
throw new Error("--fields must be a valid JSON array");
}
if (!opts.confirm) {
printResult({
dryRun: true,
action: "add-fields",
tableName: opts.table,
fields
}, { pretty: globalOpts.pretty ?? false });
return;
}
const { db } = buildConnection(globalOpts);
printResult(await db.schema.addFields(opts.table, fields), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
return schema;
}
//#endregion
//#region src/cli/commands/script.ts
function makeScriptCommand() {
const script = new Command("script").description("FileMaker script operations");
script.command("run <scriptName>").description("Run a FileMaker script").option("--param <json>", "Script parameter as JSON string or plain value").action(async (scriptName, opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
let scriptParam;
if (opts.param !== void 0) try {
scriptParam = JSON.parse(opts.param);
} catch {
scriptParam = opts.param;
}
printResult(await db.runScript(scriptName, { scriptParam }), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
return script;
}
//#endregion
//#region src/cli/commands/webhook.ts
const WEBHOOK_ID_RE = /^\d+$/;
function parseWebhookId(id) {
if (!WEBHOOK_ID_RE.test(id)) throw new Error(`Invalid webhook ID: "${id}" — must be a positive integer`);
return Number(id);
}
function makeWebhookCommand() {
const webhook = new Command("webhook").description("FileMaker webhook operations");
webhook.command("list").description("List all webhooks").action(async (_opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
printResult(await db.webhook.list(), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
webhook.command("get <id>").description("Get a webhook by ID").action(async (id, _opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const parsedId = parseWebhookId(id);
const { db } = buildConnection(globalOpts);
printResult(await db.webhook.get(parsedId), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
webhook.command("add").description("Add a new webhook").requiredOption("--table <name>", "Table to monitor").requiredOption("--url <url>", "Webhook URL to call").option("--select <fields>", "Comma-separated field names to include").option("--header <kv>", "Header in key=value format (repeatable)", (val, acc) => {
acc.push(val);
return acc;
}, []).action(async (opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const { db } = buildConnection(globalOpts);
const headers = {};
for (const h of opts.header) {
const eqIdx = h.indexOf("=");
if (eqIdx === -1) throw new Error(`Invalid header format (expected key=value): ${h}`);
headers[h.slice(0, eqIdx)] = h.slice(eqIdx + 1);
}
const tableProxy = { [FMTable.Symbol.Name]: opts.table };
const webhookPayload = {
webhook: opts.url,
tableName: tableProxy
};
if (Object.keys(headers).length > 0) webhookPayload.headers = headers;
if (opts.select) webhookPayload.select = opts.select;
printResult(await db.webhook.add(webhookPayload), { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
webhook.command("remove <id>").description("Remove a webhook by ID").action(async (id, _opts, cmd) => {
const globalOpts = cmd.parent?.parent?.opts();
try {
const parsedId = parseWebhookId(id);
const { db } = buildConnection(globalOpts);
await db.webhook.remove(parsedId);
printResult({
removed: true,
id: parsedId
}, { pretty: globalOpts.pretty ?? false });
} catch (err) {
handleCliError(err);
}
});
return webhook;
}
//#endregion
//#region src/cli/index.ts
const { version } = createRequire(import.meta.url)("../../package.json");
const program = new Command();
program.name("fmodata").description("FileMaker OData CLI — query, script, webhook, metadata, and schema operations").version(version).option("--server <url>", `FM server URL [env: ${ENV_NAMES.server}]`).option("--database <name>", `FM database name [env: ${ENV_NAMES.db}]`).option("--username <user>", `FM username [env: ${ENV_NAMES.username}]`).option("--password <pass>", `FM password [env: ${ENV_NAMES.password}]`).option("--claris-id-username <user>", `Claris ID username [env: ${ENV_NAMES.clarisIdUsername}]`).option("--claris-id-password <pass>", `Claris ID password [env: ${ENV_NAMES.clarisIdPassword}]`).option("--api-key <key>", `OttoFMS API key [env: ${ENV_NAMES.apiKey}]`).option("--pretty", "Output as table (default: JSON)", false);
program.addCommand(makeRecordsCommand());
program.addCommand(makeScriptCommand());
program.addCommand(makeWebhookCommand());
program.addCommand(makeMetadataCommand());
program.addCommand(makeSchemaCommand());
program.exitOverride();
try {
await program.parseAsync(process.argv);
} catch (err) {
if (err && typeof err === "object" && "code" in err) {
const code = err.code;
if (code === "commander.helpDisplayed" || code === "commander.version") process.exit(0);
}
handleCliError(err);
}
//#endregion
export {};
//# sourceMappingURL=index.js.map