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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/migrate-effect-v4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@proofkit/fmodata": minor
---

Migrate to Effect v4, updating services, generators, error handling, and retry schedules.
Custom Effect layers must use v4 services and APIs.
2 changes: 1 addition & 1 deletion packages/fmodata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"cli-table3": "^0.6.5",
"commander": "^14.0.2",
"dotenv": "^16.6.1",
"effect": "^3.20.0",
"effect": "^4.0.0",
"es-toolkit": "^1.43.0",
"odata-query": "^8.0.7"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/fmodata/src/client/batch-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export class BatchBuilder<Builders extends readonly ExecutableBuilder<any>[]> {
});
}

const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: 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({
Expand Down
2 changes: 1 addition & 1 deletion packages/fmodata/src/client/delete-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export class ExecutableDeleteBuilder<Occ extends FMTable<any, any>>
builderName: "ExecutableDeleteBuilder",
});

const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
// Make DELETE request via DI
const response = yield* requestFromService(url, {
...requestOptions,
Expand Down
2 changes: 1 addition & 1 deletion packages/fmodata/src/client/insert-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export class InsertBuilder<
canonicalHeaders.delete("Prefer");
}

const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
// Step 1: Validate input
let validatedData = this.data;
if (this.table) {
Expand Down
2 changes: 1 addition & 1 deletion packages/fmodata/src/client/record-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ export class RecordBuilder<
const queryString = this.buildQueryString(mergedOptions.includeSpecialColumns, mergedOptions.useEntityIds);
url += queryString;
}
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
// Make GET request via DI
// biome-ignore lint/suspicious/noExplicitAny: Dynamic response type from OData API
const response = yield* requestFromService<any>(url, {
Expand Down
12 changes: 6 additions & 6 deletions packages/fmodata/src/client/schema-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class SchemaManager {
}

createTable(tableName: string, fields: Field[], options?: RequestInit & FFetchOptions): Promise<TableDefinition> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService<TableDefinition>(`/${this.config.databaseName}/FileMaker_Tables`, {
method: "POST",
body: JSON.stringify({
Expand All @@ -82,7 +82,7 @@ export class SchemaManager {
}

addFields(tableName: string, fields: Field[], options?: RequestInit & FFetchOptions): Promise<TableDefinition> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService<TableDefinition>(`/${this.config.databaseName}/FileMaker_Tables/${tableName}`, {
method: "PATCH",
body: JSON.stringify({
Expand All @@ -96,7 +96,7 @@ export class SchemaManager {
}

async deleteTable(tableName: string, options?: RequestInit & FFetchOptions): Promise<void> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}`, {
method: "DELETE",
...options,
Expand All @@ -107,7 +107,7 @@ export class SchemaManager {
}

async deleteField(tableName: string, fieldName: string, options?: RequestInit & FFetchOptions): Promise<void> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}/${fieldName}`, {
method: "DELETE",
...options,
Expand All @@ -122,7 +122,7 @@ export class SchemaManager {
fieldName: string,
options?: RequestInit & FFetchOptions,
): Promise<{ indexName: string }> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService<{ indexName: string }>(
`/${this.config.databaseName}/FileMaker_Indexes/${tableName}`,
{
Expand All @@ -137,7 +137,7 @@ export class SchemaManager {
}

async deleteIndex(tableName: string, fieldName: string, options?: RequestInit & FFetchOptions): Promise<void> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Indexes/${tableName}/${fieldName}`, {
method: "DELETE",
...options,
Expand Down
2 changes: 1 addition & 1 deletion packages/fmodata/src/client/update-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export class ExecutableUpdateBuilder<
requestHeaders.delete("Prefer");
}

const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
// Step 1: Validate input
let validatedData = this.data;
if (this.table) {
Expand Down
10 changes: 5 additions & 5 deletions packages/fmodata/src/client/webhook-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export class WebhookManager {
requestBody.filter = filter;
}

const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService<WebhookAddResponse>(`/${this.config.databaseName}/Webhook.Add`, {
...options,
method: "POST",
Expand All @@ -173,7 +173,7 @@ export class WebhookManager {
* ```
*/
async remove(webhookId: number, options?: ExecuteMethodOptions): Promise<void> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService(`/${this.config.databaseName}/Webhook.Delete(${webhookId})`, {
...options,
method: "POST",
Expand All @@ -195,7 +195,7 @@ export class WebhookManager {
* ```
*/
get(webhookId: number, options?: ExecuteMethodOptions): Promise<WebhookInfo> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService<WebhookInfo>(`/${this.config.databaseName}/Webhook.Get(${webhookId})`, options);
});

Expand All @@ -213,7 +213,7 @@ export class WebhookManager {
* ```
*/
list(options?: ExecuteMethodOptions): Promise<WebhookListResponse> {
const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService<WebhookListResponse>(`/${this.config.databaseName}/Webhook.GetAll`, {
...options,
databaseNameNormalizationMode: "ensureExtension",
Expand Down Expand Up @@ -244,7 +244,7 @@ export class WebhookManager {
body.rowIDs = options.rowIDs;
}

const pipeline = Effect.gen(this, function* () {
const pipeline = Effect.gen({ self: this }, function* () {
return yield* requestFromService<unknown>(`/${this.config.databaseName}/Webhook.Invoke(${webhookId})`, {
method: "POST",
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
Expand Down
7 changes: 3 additions & 4 deletions packages/fmodata/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function runAsResult<T>(effect: Effect.Effect<T, FMODataErrorType>): Prom
return Effect.runPromise(
effect.pipe(
Effect.map((data): Result<T> => ({ data, error: undefined })),
Effect.catchAll((error) => Effect.succeed<Result<T>>({ data: undefined, error })),
Effect.catch((error) => Effect.succeed<Result<T>>({ data: undefined, error })),
),
).catch((defect) => ({
data: undefined,
Expand Down Expand Up @@ -169,9 +169,8 @@ export function buildRetrySchedule(policy: RetryPolicy) {
const base = Schedule.exponential(baseDelay);
const withJitter = useJitter ? Schedule.jittered(base) : base;

return withJitter.pipe(
Schedule.intersect(Schedule.recurs(maxRetries)),
Schedule.whileInput((error: FMODataErrorType) => isTransientError(error)),
return Schedule.max([withJitter, Schedule.recurs(maxRetries)]).pipe(
Schedule.while(({ input }: Schedule.Metadata<unknown, FMODataErrorType>) => isTransientError(input)),
);
}

Expand Down
6 changes: 3 additions & 3 deletions packages/fmodata/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface HttpClient {
) => Effect.Effect<T, FMODataErrorType>;
}

export const HttpClient = Context.GenericTag<HttpClient>("@proofkit/fmodata/HttpClient");
export const HttpClient = Context.Service<HttpClient>("@proofkit/fmodata/HttpClient");

// --- ODataConfig Service ---

Expand All @@ -47,15 +47,15 @@ export interface ODataConfig {
readonly includeSpecialColumns: boolean;
}

export const ODataConfig = Context.GenericTag<ODataConfig>("@proofkit/fmodata/ODataConfig");
export const ODataConfig = Context.Service<ODataConfig>("@proofkit/fmodata/ODataConfig");

// --- ODataLogger Service ---

export interface ODataLogger {
readonly logger: InternalLogger;
}

export const ODataLogger = Context.GenericTag<ODataLogger>("@proofkit/fmodata/ODataLogger");
export const ODataLogger = Context.Service<ODataLogger>("@proofkit/fmodata/ODataLogger");

// --- Combined layer type ---

Expand Down
34 changes: 33 additions & 1 deletion packages/fmodata/tests/effect-layer-execution.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { HTTPError } from "@proofkit/fmodata";
import { requestFromService, runLayerOrThrow, runLayerResult } from "@proofkit/fmodata/effect";
import { HttpClient, ODataConfig, ODataLogger } from "@proofkit/fmodata/services";
import {
createDatabaseLayer,
extractConfigFromLayer,
HttpClient,
ODataConfig,
ODataLogger,
} from "@proofkit/fmodata/services";
import { Effect, Layer } from "effect";
import { describe, expect, it } from "vitest";

Expand All @@ -24,6 +30,32 @@ const baseConfig = {
};

describe("effect layer execution helpers", () => {
it("overrides database config while preserving HTTP and logger services", async () => {
const httpClient = { request: () => Effect.fail(new HTTPError("/unused", 500, "Unused")) };
const baseLayer = Layer.mergeAll(
Layer.succeed(HttpClient, httpClient),
Layer.succeed(ODataConfig, baseConfig),
Layer.succeed(ODataLogger, { logger }),
);
const overrides = {
databaseName: "other_db",
normalizeDatabaseName: false,
useEntityIds: true,
includeSpecialColumns: true,
};
const layer = createDatabaseLayer(baseLayer, overrides);

expect(extractConfigFromLayer(layer)).toEqual({ config: { ...baseConfig, ...overrides }, logger });
const services = await runLayerResult(
layer,
Effect.gen(function* () {
return { client: yield* HttpClient, logger: (yield* ODataLogger).logger };
}),
);
expect(services.data?.client).toBe(httpClient);
expect(services.data?.logger).toBe(logger);
});

it("maps successful layered execution to Result", async () => {
const layer = Layer.mergeAll(
Layer.succeed(HttpClient, {
Expand Down
79 changes: 79 additions & 0 deletions packages/fmodata/tests/effect-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { HTTPError, SchemaLockedError } from "@proofkit/fmodata";
import { runAsResult, withRetryPolicy } from "@proofkit/fmodata/effect";
import { BuilderInvariantError } from "@proofkit/fmodata/errors";
import { Clock, Effect, Fiber } from "effect";
import { TestClock } from "effect/testing";
import { describe, expect, it } from "vitest";

describe("Effect retry policies", () => {
it.each([0, 2, undefined])("limits retries to %s, defaulting to three", async (maxRetries) => {
const error = new HTTPError("/retry", 503, "Unavailable");
let attempts = 0;
const request = Effect.suspend(() => {
attempts += 1;
return Effect.fail(error);
});

const result = await runAsResult(withRetryPolicy(request, { maxRetries, baseDelay: 0 }));

expect(attempts).toBe((maxRetries ?? 3) + 1);
expect(result.error).toBe(error);
});

it("does not retry permanent errors", async () => {
const error = new HTTPError("/retry", 400, "Bad Request");
let attempts = 0;
const request = Effect.suspend(() => {
attempts += 1;
return Effect.fail(error);
});

const result = await runAsResult(withRetryPolicy(request, { baseDelay: 0 }));

expect(attempts).toBe(1);
expect(result.error).toBe(error);
});

it("retries schema locks with exponential backoff until success", async () => {
const attemptTimes: number[] = [];
const request = Effect.gen(function* () {
attemptTimes.push(yield* Clock.currentTimeMillis);
if (attemptTimes.length < 4) {
return yield* Effect.fail(new SchemaLockedError("/retry", "Locked"));
}
return "ok";
});
const program = Effect.gen(function* () {
const fiber = yield* withRetryPolicy(request, { baseDelay: 100, jitter: false }).pipe(Effect.forkChild);
yield* TestClock.adjust("700 millis");
return yield* Fiber.join(fiber);
});

const result = await Effect.runPromise(Effect.provide(program, TestClock.layer()));

expect(result).toBe("ok");
expect(attemptTimes).toEqual([0, 100, 300, 700]);
});

it("does not retry when no policy is configured", async () => {
const error = new HTTPError("/retry", 503, "Unavailable");
let attempts = 0;
const request = Effect.suspend(() => {
attempts += 1;
return Effect.fail(error);
});

const result = await runAsResult(withRetryPolicy(request));

expect(attempts).toBe(1);
expect(result.error).toBe(error);
});

it("converts unexpected defects to builder errors", async () => {
const result = await runAsResult(Effect.die(new Error("unexpected")));

expect(result.data).toBeUndefined();
expect(result.error).toBeInstanceOf(BuilderInvariantError);
expect(result.error?.message).toContain("unexpected");
});
});
Loading
Loading