From 368778e9d99045cc73f7871b25c162376a81b740 Mon Sep 17 00:00:00 2001 From: Silas <37158449+eluce2@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:36:37 -0500 Subject: [PATCH] feat(fmodata): migrate to Effect v4 --- .changeset/migrate-effect-v4.md | 6 ++ packages/fmodata/package.json | 2 +- packages/fmodata/src/client/batch-builder.ts | 2 +- packages/fmodata/src/client/delete-builder.ts | 2 +- packages/fmodata/src/client/insert-builder.ts | 2 +- packages/fmodata/src/client/record-builder.ts | 2 +- packages/fmodata/src/client/schema-manager.ts | 12 +-- packages/fmodata/src/client/update-builder.ts | 2 +- .../fmodata/src/client/webhook-builder.ts | 10 +-- packages/fmodata/src/effect.ts | 7 +- packages/fmodata/src/services.ts | 6 +- .../tests/effect-layer-execution.test.ts | 34 +++++++- packages/fmodata/tests/effect-retry.test.ts | 79 +++++++++++++++++++ pnpm-lock.yaml | 36 +++------ 14 files changed, 151 insertions(+), 51 deletions(-) create mode 100644 .changeset/migrate-effect-v4.md create mode 100644 packages/fmodata/tests/effect-retry.test.ts diff --git a/.changeset/migrate-effect-v4.md b/.changeset/migrate-effect-v4.md new file mode 100644 index 00000000..62a8e49e --- /dev/null +++ b/.changeset/migrate-effect-v4.md @@ -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. diff --git a/packages/fmodata/package.json b/packages/fmodata/package.json index f6e178a5..2520be6d 100644 --- a/packages/fmodata/package.json +++ b/packages/fmodata/package.json @@ -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" }, diff --git a/packages/fmodata/src/client/batch-builder.ts b/packages/fmodata/src/client/batch-builder.ts index 71da2a8b..37762775 100644 --- a/packages/fmodata/src/client/batch-builder.ts +++ b/packages/fmodata/src/client/batch-builder.ts @@ -182,7 +182,7 @@ export class BatchBuilder[]> { }); } - 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({ diff --git a/packages/fmodata/src/client/delete-builder.ts b/packages/fmodata/src/client/delete-builder.ts index ccde014c..508e9e08 100644 --- a/packages/fmodata/src/client/delete-builder.ts +++ b/packages/fmodata/src/client/delete-builder.ts @@ -135,7 +135,7 @@ export class ExecutableDeleteBuilder> 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, diff --git a/packages/fmodata/src/client/insert-builder.ts b/packages/fmodata/src/client/insert-builder.ts index d6c84c50..a79bd6da 100644 --- a/packages/fmodata/src/client/insert-builder.ts +++ b/packages/fmodata/src/client/insert-builder.ts @@ -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) { diff --git a/packages/fmodata/src/client/record-builder.ts b/packages/fmodata/src/client/record-builder.ts index 8026e1fc..6ad84167 100644 --- a/packages/fmodata/src/client/record-builder.ts +++ b/packages/fmodata/src/client/record-builder.ts @@ -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(url, { diff --git a/packages/fmodata/src/client/schema-manager.ts b/packages/fmodata/src/client/schema-manager.ts index 3e5a424c..5dcb7635 100644 --- a/packages/fmodata/src/client/schema-manager.ts +++ b/packages/fmodata/src/client/schema-manager.ts @@ -67,7 +67,7 @@ export class SchemaManager { } createTable(tableName: string, fields: Field[], options?: RequestInit & FFetchOptions): Promise { - const pipeline = Effect.gen(this, function* () { + const pipeline = Effect.gen({ self: this }, function* () { return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables`, { method: "POST", body: JSON.stringify({ @@ -82,7 +82,7 @@ export class SchemaManager { } addFields(tableName: string, fields: Field[], options?: RequestInit & FFetchOptions): Promise { - const pipeline = Effect.gen(this, function* () { + const pipeline = Effect.gen({ self: this }, function* () { return yield* requestFromService(`/${this.config.databaseName}/FileMaker_Tables/${tableName}`, { method: "PATCH", body: JSON.stringify({ @@ -96,7 +96,7 @@ export class SchemaManager { } async deleteTable(tableName: string, options?: RequestInit & FFetchOptions): Promise { - 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, @@ -107,7 +107,7 @@ export class SchemaManager { } async deleteField(tableName: string, fieldName: string, options?: RequestInit & FFetchOptions): Promise { - 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, @@ -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}`, { @@ -137,7 +137,7 @@ export class SchemaManager { } async deleteIndex(tableName: string, fieldName: string, options?: RequestInit & FFetchOptions): Promise { - 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, diff --git a/packages/fmodata/src/client/update-builder.ts b/packages/fmodata/src/client/update-builder.ts index 817e4c93..4b67ec03 100644 --- a/packages/fmodata/src/client/update-builder.ts +++ b/packages/fmodata/src/client/update-builder.ts @@ -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) { diff --git a/packages/fmodata/src/client/webhook-builder.ts b/packages/fmodata/src/client/webhook-builder.ts index b334c336..0e1d671f 100644 --- a/packages/fmodata/src/client/webhook-builder.ts +++ b/packages/fmodata/src/client/webhook-builder.ts @@ -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(`/${this.config.databaseName}/Webhook.Add`, { ...options, method: "POST", @@ -173,7 +173,7 @@ export class WebhookManager { * ``` */ async remove(webhookId: number, options?: ExecuteMethodOptions): Promise { - 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", @@ -195,7 +195,7 @@ export class WebhookManager { * ``` */ get(webhookId: number, options?: ExecuteMethodOptions): Promise { - const pipeline = Effect.gen(this, function* () { + const pipeline = Effect.gen({ self: this }, function* () { return yield* requestFromService(`/${this.config.databaseName}/Webhook.Get(${webhookId})`, options); }); @@ -213,7 +213,7 @@ export class WebhookManager { * ``` */ list(options?: ExecuteMethodOptions): Promise { - const pipeline = Effect.gen(this, function* () { + const pipeline = Effect.gen({ self: this }, function* () { return yield* requestFromService(`/${this.config.databaseName}/Webhook.GetAll`, { ...options, databaseNameNormalizationMode: "ensureExtension", @@ -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(`/${this.config.databaseName}/Webhook.Invoke(${webhookId})`, { method: "POST", body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined, diff --git a/packages/fmodata/src/effect.ts b/packages/fmodata/src/effect.ts index a07ae416..01afc4a7 100644 --- a/packages/fmodata/src/effect.ts +++ b/packages/fmodata/src/effect.ts @@ -70,7 +70,7 @@ export function runAsResult(effect: Effect.Effect): Prom return Effect.runPromise( effect.pipe( Effect.map((data): Result => ({ data, error: undefined })), - Effect.catchAll((error) => Effect.succeed>({ data: undefined, error })), + Effect.catch((error) => Effect.succeed>({ data: undefined, error })), ), ).catch((defect) => ({ data: undefined, @@ -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) => isTransientError(input)), ); } diff --git a/packages/fmodata/src/services.ts b/packages/fmodata/src/services.ts index 3bd8698a..a521f466 100644 --- a/packages/fmodata/src/services.ts +++ b/packages/fmodata/src/services.ts @@ -35,7 +35,7 @@ export interface HttpClient { ) => Effect.Effect; } -export const HttpClient = Context.GenericTag("@proofkit/fmodata/HttpClient"); +export const HttpClient = Context.Service("@proofkit/fmodata/HttpClient"); // --- ODataConfig Service --- @@ -47,7 +47,7 @@ export interface ODataConfig { readonly includeSpecialColumns: boolean; } -export const ODataConfig = Context.GenericTag("@proofkit/fmodata/ODataConfig"); +export const ODataConfig = Context.Service("@proofkit/fmodata/ODataConfig"); // --- ODataLogger Service --- @@ -55,7 +55,7 @@ export interface ODataLogger { readonly logger: InternalLogger; } -export const ODataLogger = Context.GenericTag("@proofkit/fmodata/ODataLogger"); +export const ODataLogger = Context.Service("@proofkit/fmodata/ODataLogger"); // --- Combined layer type --- diff --git a/packages/fmodata/tests/effect-layer-execution.test.ts b/packages/fmodata/tests/effect-layer-execution.test.ts index ee5b4b81..c16fe412 100644 --- a/packages/fmodata/tests/effect-layer-execution.test.ts +++ b/packages/fmodata/tests/effect-layer-execution.test.ts @@ -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"; @@ -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, { diff --git a/packages/fmodata/tests/effect-retry.test.ts b/packages/fmodata/tests/effect-retry.test.ts new file mode 100644 index 00000000..76d950fc --- /dev/null +++ b/packages/fmodata/tests/effect-retry.test.ts @@ -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"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac9e7be0..aefa62ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,7 +51,7 @@ importers: version: 5.9.3 ultracite: specifier: 7.0.8 - version: 7.0.8(effect@3.20.0)(typescript@5.9.3) + version: 7.0.8(effect@4.0.0)(typescript@5.9.3) varlock: specifier: ^0.6.4 version: 0.6.4 @@ -353,8 +353,8 @@ importers: specifier: ^16.6.1 version: 16.6.1 effect: - specifier: ^3.20.0 - version: 3.20.0 + specifier: ^4.0.0 + version: 4.0.0 es-toolkit: specifier: ^1.43.0 version: 1.43.0 @@ -4757,8 +4757,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@3.20.0: - resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + effect@4.0.0: + resolution: {integrity: sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ==} electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -4955,10 +4955,6 @@ packages: fast-base64-decode@1.0.0: resolution: {integrity: sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -6615,9 +6611,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - qified@0.9.0: resolution: {integrity: sha512-4q61YgkHbY6gmwkqm0BsxyLDO3UYdrdiJTJ7JiaZb3xpW1duxn135SB7KqUEkCiuu5O4W+TtwEWP2VjmSRanvA==} engines: {node: '>=20'} @@ -11919,10 +11912,7 @@ snapshots: ee-first@1.1.1: {} - effect@3.20.0: - dependencies: - '@standard-schema/spec': 1.1.0 - fast-check: 3.23.2 + effect@4.0.0: {} electron-to-chromium@1.5.267: {} @@ -12218,10 +12208,6 @@ snapshots: fast-base64-decode@1.0.0: {} - fast-check@3.23.2: - dependencies: - pure-rand: 6.1.0 - fast-deep-equal@3.1.3: {} fast-equals@4.0.3: {} @@ -14351,8 +14337,6 @@ snapshots: punycode@2.3.1: {} - pure-rand@6.1.0: {} - qified@0.9.0: dependencies: hookified: 2.1.0 @@ -15279,12 +15263,12 @@ snapshots: trough@2.2.0: {} - trpc-cli@0.12.2(@trpc/server@11.8.1(typescript@5.9.3))(effect@3.20.0)(zod@4.3.6): + trpc-cli@0.12.2(@trpc/server@11.8.1(typescript@5.9.3))(effect@4.0.0)(zod@4.3.6): dependencies: commander: 14.0.2 optionalDependencies: '@trpc/server': 11.8.1(typescript@5.9.3) - effect: 3.20.0 + effect: 4.0.0 zod: 4.3.6 ts-morph@18.0.0: @@ -15399,7 +15383,7 @@ snapshots: ufo@1.6.2: {} - ultracite@7.0.8(effect@3.20.0)(typescript@5.9.3): + ultracite@7.0.8(effect@4.0.0)(typescript@5.9.3): dependencies: '@clack/prompts': 0.11.0 '@trpc/server': 11.8.1(typescript@5.9.3) @@ -15408,7 +15392,7 @@ snapshots: jsonc-parser: 3.3.1 nypm: 0.6.2 oxlint: 1.39.0 - trpc-cli: 0.12.2(@trpc/server@11.8.1(typescript@5.9.3))(effect@3.20.0)(zod@4.3.6) + trpc-cli: 0.12.2(@trpc/server@11.8.1(typescript@5.9.3))(effect@4.0.0)(zod@4.3.6) zod: 4.3.6 transitivePeerDependencies: - '@orpc/server'