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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .codex/environments/environment.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "proofkit"

[setup]
script = "pnpm i"
19 changes: 18 additions & 1 deletion packages/fmodata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@
"default": "./dist/esm/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/esm/testing.d.ts",
"default": "./dist/esm/testing.js"
}
},
"./services": {
"import": {
"types": "./dist/esm/services.d.ts",
"default": "./dist/esm/services.js"
}
},
"./types": {
"import": {
"types": "./dist/esm/types.d.ts",
"default": "./dist/esm/types.js"
}
},
"./package.json": "./package.json"
},
"scripts": {
Expand Down Expand Up @@ -48,7 +66,6 @@
"dotenv": "^16.6.1",
"effect": "^3.20.0",
"es-toolkit": "^1.43.0",
"neverthrow": "^8.2.0",
"odata-query": "^8.0.7"
},
"peerDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/fmodata/skills/fmodata-client/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: >
timestampField containerField calcField listField query builder execute() filter operators eq ne gt
gte lt lte contains startsWith endsWith matchesPattern inArray notInArray isNull isNotNull and or
not tolower toupper trim CRUD insert update delete byId where navigate expand relationships batch
Result error handling neverthrow pattern FMODataError HTTPError ODataError ValidationError
Result error handling Effect.ts pattern FMODataError HTTPError ODataError ValidationError
BatchTruncatedError entity IDs FMTID FMFID defaultSelect readValidator writeValidator orderBy asc
desc top skip single maybeSingle count getSingleField FileMaker OData API schema management
webhooks getTableColumns select("all")
Expand Down
16 changes: 14 additions & 2 deletions packages/fmodata/src/cli/commands/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,13 @@ export function makeRecordsCommand(): Command {
try {
if (!(opts.where || opts.confirm)) {
printResult(
{ dryRun: true, action: "update", table: opts.table, affectsAllRows: true, hint: "Add --where to filter or --confirm to update all records" },
{
dryRun: true,
action: "update",
table: opts.table,
affectsAllRows: true,
hint: "Add --where to filter or --confirm to update all records",
},
{ pretty: globalOpts.pretty ?? false },
);
return;
Expand Down Expand Up @@ -159,7 +165,13 @@ export function makeRecordsCommand(): Command {
try {
if (!(opts.where || opts.confirm)) {
printResult(
{ dryRun: true, action: "delete", table: opts.table, affectsAllRows: true, hint: "Add --where to filter or --confirm to delete all records" },
{
dryRun: true,
action: "delete",
table: opts.table,
affectsAllRows: true,
hint: "Add --where to filter or --confirm to delete all records",
},
{ pretty: globalOpts.pretty ?? false },
);
return;
Expand Down
187 changes: 69 additions & 118 deletions packages/fmodata/src/client/batch-builder.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Effect } from "effect";
import { requestFromService, runLayerResult } from "../effect";
import type { FMODataErrorType } from "../errors";
import { BatchTruncatedError } from "../errors";
import type { FMODataLayer, ODataConfig } from "../services";
import type {
BatchItemResult,
BatchResult,
ExecutableBuilder,
ExecuteMethodOptions,
ExecuteOptions,
ExecutionContext,
Result,
} from "../types";
import { formatBatchRequestFromNative, type ParsedBatchResponse, parseBatchResponse } from "./batch-request";
import { createClientRuntime } from "./runtime";

/**
* Helper type to extract result types from a tuple of ExecutableBuilders.
Expand Down Expand Up @@ -64,23 +68,23 @@ function parsedToResponse(parsed: ParsedBatchResponse): Response {
export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExecutableBuilder result type
private readonly builders: ExecutableBuilder<any>[];
private readonly databaseName: string;
private readonly context: ExecutionContext;
private readonly layer: FMODataLayer;
private readonly config: ODataConfig;

constructor(builders: Builders, databaseName: string, context: ExecutionContext) {
constructor(builders: Builders, layer: FMODataLayer) {
// Convert readonly tuple to mutable array for dynamic additions
this.builders = [...builders];
// Store original tuple for type preservation
this.databaseName = databaseName;
this.context = context;
const runtime = createClientRuntime(layer);
this.layer = runtime.layer;
this.config = runtime.config;
}

/**
* 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 This BatchBuilder for method chaining
* @returns A BatchBuilder typed with the appended request result
* @example
* ```ts
* const batch = db.batch([]);
Expand All @@ -89,9 +93,9 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
* const result = await batch.execute();
* ```
*/
addRequest<T>(builder: ExecutableBuilder<T>): this {
addRequest<T>(builder: ExecutableBuilder<T>): BatchBuilder<[...Builders, ExecutableBuilder<T>]> {
this.builders.push(builder);
return this;
return this as unknown as BatchBuilder<[...Builders, ExecutableBuilder<T>]>;
}

/**
Expand All @@ -100,19 +104,15 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
*/
// biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
getRequestConfig(): { method: string; url: string; body?: any } {
// Note: This method is kept for compatibility but batch operations
// should use execute() directly which handles the full Request/Response flow
return {
method: "POST",
url: `/${this.databaseName}/$batch`,
url: `/${this.config.databaseName}/$batch`,
body: undefined, // Body is constructed in execute()
};
}

toRequest(baseUrl: string, _options?: ExecuteOptions): Request {
// Batch operations are not designed to be nested, but we provide
// a basic implementation for interface compliance
const fullUrl = `${baseUrl}/${this.databaseName}/$batch`;
const fullUrl = `${baseUrl}/${this.config.databaseName}/$batch`;
return new Request(fullUrl, {
method: "POST",
headers: {
Expand All @@ -124,8 +124,6 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {

// biome-ignore lint/suspicious/noExplicitAny: Generic return type for interface compliance
processResponse(_response: Response, _options?: ExecuteOptions): Promise<Result<any>> {
// This should not typically be called for batch operations
// as they handle their own response processing
return Promise.resolve({
data: undefined,
error: {
Expand All @@ -137,6 +135,28 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
});
}

/**
* Creates a failed BatchResult where all operations are marked as failed with the given error.
*/
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type
private failAllResults(error: any): BatchResult<ExtractTupleTypes<Builders>> {
const errorCount = this.builders.length;
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type
const results: BatchItemResult<any>[] = this.builders.map(() => ({
data: undefined,
error,
status: 0,
}));
return {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
results: results as any,
successCount: 0,
errorCount,
truncated: false,
firstErrorIndex: errorCount > 0 ? 0 : null,
};
}

/**
* Execute the batch operation.
*
Expand All @@ -146,41 +166,25 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
async execute<EO extends ExecuteOptions>(
options?: ExecuteMethodOptions<EO>,
): Promise<BatchResult<ExtractTupleTypes<Builders>>> {
const baseUrl = this.context._getBaseUrl?.();
const baseUrl = this.config.baseUrl;
if (!baseUrl) {
// Return BatchResult with all operations marked as failed
const errorCount = this.builders.length;
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type
const results: BatchItemResult<any>[] = this.builders.map((_, _i) => ({
data: undefined,
error: {
name: "ConfigurationError",
message: "Base URL not available - execution context must implement _getBaseUrl()",
timestamp: new Date(),
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object
} as any,
status: 0,
}));

return {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
results: results as any,
successCount: 0,
errorCount,
truncated: false,
firstErrorIndex: 0,
};
return this.failAllResults({
name: "ConfigurationError",
message: "Base URL not available in ODataConfig",
timestamp: new Date(),
});
}

try {
// Convert builders to native Request objects
const pipeline = Effect.gen(this, function* () {
// Step 1: Convert builders to Request objects and format batch
const requests: Request[] = this.builders.map((builder) => builder.toRequest(baseUrl, options));
const { body, boundary } = yield* Effect.tryPromise({
try: () => formatBatchRequestFromNative(requests, baseUrl),
catch: (e) => e as FMODataErrorType,
});

// Format batch request (automatically groups mutations into changesets)
const { body, boundary } = await formatBatchRequestFromNative(requests, baseUrl);

// Execute the batch request
const response = await this.context._makeRequest<string>(`/${this.databaseName}/$batch`, {
// Step 2: Execute the batch HTTP request via DI
const responseData = yield* requestFromService<string>(`/${this.config.databaseName}/$batch`, {
...options,
method: "POST",
headers: {
Expand All @@ -191,53 +195,25 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
body,
});

if (response.error) {
// Return BatchResult with all operations marked as failed
const errorCount = this.builders.length;
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type
const results: BatchItemResult<any>[] = this.builders.map((_, _i) => ({
data: undefined,
error: response.error,
status: 0,
}));

return {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
results: results as any,
successCount: 0,
errorCount,
truncated: false,
firstErrorIndex: 0,
};
}

// Extract the actual boundary from the response
// FileMaker uses its own boundary, not the one we sent
const firstLine = response.data.split("\r\n")[0] || response.data.split("\n")[0] || "";
// Step 3: Parse multipart response
const firstLine = responseData.split("\r\n")[0] || responseData.split("\n")[0] || "";
const actualBoundary = firstLine.startsWith("--") ? firstLine.substring(2) : boundary;

// Parse the multipart response
const contentTypeHeader = `multipart/mixed; boundary=${actualBoundary}`;
const parsedResponses = parseBatchResponse(response.data, contentTypeHeader);

// Process each response using the corresponding builder
// Build BatchResult with per-item results
type _ResultTuple = ExtractTupleTypes<Builders>;
const parsedResponses = parseBatchResponse(responseData, contentTypeHeader);

// Step 4: Process each response using the corresponding builder
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type
const results: BatchItemResult<any>[] = [];
let successCount = 0;
let errorCount = 0;
let firstErrorIndex: number | null = null;
const truncated = parsedResponses.length < this.builders.length;

// Process builders sequentially to preserve tuple order
for (let i = 0; i < this.builders.length; i++) {
const builder = this.builders[i];
const parsed = parsedResponses[i];

if (!parsed) {
// Truncated - operation never executed
const failedAtIndex = firstErrorIndex ?? i;
results.push({
data: undefined,
Expand All @@ -249,7 +225,6 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
}

if (!builder) {
// Should not happen, but handle gracefully
results.push({
data: undefined,
error: {
Expand All @@ -267,28 +242,20 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
continue;
}

// Convert parsed response to native Response
const nativeResponse = parsedToResponse(parsed);

// Let the builder process its own response
const result = await builder.processResponse(nativeResponse, options);
const result = yield* Effect.tryPromise({
try: () => builder.processResponse(nativeResponse, options),
catch: (e) => e as FMODataErrorType,
});

if (result.error) {
results.push({
data: undefined,
error: result.error,
status: parsed.status,
});
results.push({ data: undefined, error: result.error, status: parsed.status });
errorCount++;
if (firstErrorIndex === null) {
firstErrorIndex = i;
}
} else {
results.push({
data: result.data,
error: undefined,
status: parsed.status,
});
results.push({ data: result.data, error: undefined, status: parsed.status });
successCount++;
}
}
Expand All @@ -300,30 +267,14 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
errorCount,
truncated,
firstErrorIndex,
};
} catch (err) {
// On exception, return a BatchResult with all operations marked as failed
const errorCount = this.builders.length;
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any result type
const results: BatchItemResult<any>[] = this.builders.map((_, _i) => ({
data: undefined,
error: {
name: "BatchError",
message: err instanceof Error ? err.message : "Unknown error",
timestamp: new Date(),
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for error object
} as any,
status: 0,
}));
} as BatchResult<ExtractTupleTypes<Builders>>;
});

return {
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for complex generic return type
results: results as any,
successCount: 0,
errorCount,
truncated: false,
firstErrorIndex: 0,
};
// For batch, errors at the transport level fail all operations
const result = await runLayerResult(this.layer, pipeline, "fmodata.batch");
if (result.error) {
return this.failAllResults(result.error);
}
return result.data;
}
}
Loading
Loading